Un agent écrit un effet visuel en WGSL. Le compositeur l'applique en une passe sur l'image composée, en preview comme à l'export. L'effet vit dans le document comme une région de la timeline : on peut le relire, le régler, le déplacer et le supprimer, que l'on soit l'utilisateur ou un agent.
Ce document fixe le modèle, le contrat, le DSL, le rendu et l'interface, puis découpe les PR.
Il part du spike Windows (branche worktree-custom-shader-fx-spike, commits 9cb30018d et
42728e335).
Ce que le spike a établi
- Coût. +0,04 ms par frame en 1080p, +0,12 ms en 4K, pour un effet lourd.
- Export. 520 ms sans effet, 523 ms avec, pour 180 frames.
- Preview. Le rendu tient la cadence de la source, avec ou sans effet.
- SSOT. Entre la vue live et le MP4, l'écart reste au niveau du bruit du codec.
- Boucle d'agent. À partir du prompt « effet ripple lors du clic », un agent a écrit un effet correct en deux itérations, par les seuls outils MCP.
- Le compositeur reste la seule source de pixels. Un effet est une passe de
compose_frame, que la preview et l'export appellent tous les deux. Il n'existe pas de chemin de rendu propre aux effets. - Le document ne stocke que la source. Le WGSL, son nom, sa description, ses paramètres et les valeurs posées. Tout binaire compilé en est dérivé, et n'est jamais écrit dans le projet.
- Un effet est une fonction pure de l'image composée, du temps, du curseur et de ses paramètres. Pas d'état d'une frame à l'autre.
- Le rendu ne compile jamais. Le code est compilé à l'écriture, puis à la pose de la scène, sur un thread de fond.
- Ce que seul le compositeur sait entre dans le contrat. La place de l'écran, de la webcam et du curseur dans la sortie, après zoom, layout et tilt : un effet ne peut pas la recalculer. Le prompt du ripple l'a montré, il ne pouvait pas s'écrire sans.
- L'agent passe par le DSL. Il appelle des outils de document. Il n'écrit jamais le JSON du projet.
- Pour tout le monde. Aucun réglage, aucune condition ne réserve la fonctionnalité à certains utilisateurs.
- La définition (
shaderEffects) porte le code, son nom, sa description et ses paramètres déclarés. Elle dit ce qu'est l'effet. - La région (
shaderEffectRanges) pose une définition sur la timeline, avec ses valeurs de paramètres. Elle dit où l'effet s'applique et comment il est réglé.
La séparation sert trois choses :
- réutiliser un effet à plusieurs endroits sans dupliquer son code ;
- régler une région (
values) sans réécrire de code ; - garder léger le snapshot de l'agent, où le code n'apparaît qu'une fois par définition.
Deux tableaux de premier rang dans documentSchemaShape
(src/lib/ai-edition/schema/index.ts), à côté de zoomRanges :
const paramName = z.string().regex(/^[a-z][a-z0-9_]{0,31}$/);
const shaderEffectParamSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("number"), name: paramName, label: z.string().max(40),
default: z.number(), min: z.number(), max: z.number() }),
z.object({ type: z.literal("toggle"), name: paramName, label: z.string().max(40),
default: z.boolean() }),
z.object({ type: z.literal("color"), name: paramName, label: z.string().max(40),
default: hexColor }),
z.object({ type: z.literal("point"), name: paramName, label: z.string().max(40),
default: z.object({ x: z.number().min(0).max(1), y: z.number().min(0).max(1) }) }),
]);
export const shaderEffectSchema = z.object({
id: z.string().min(1), // "fx_…"
name: z.string().min(1).max(60), // ce que la pilule affiche
description: z.string().max(500).default(""), // pour l'agent suivant : ce que l'effet fait
contract: z.number().int().positive(), // pas un littéral : voir C.5
code: z.string().min(1).max(65_536), // plafond technique, pas un budget (C.7)
params: z.array(shaderEffectParamSchema).max(32).default([]),
origin: z.enum(["agent", "user"]).default("agent"),
cost: z.object({ tier, frameReads }).optional(), // estimation du compositeur, en cache
}).superRefine(/* noms uniques, min ≤ default ≤ max */);
export const shaderEffectRangeSchema = endGteStart(z.object({
id: z.string().min(1), // "fxr_…", un par fragment
startMs: z.number().nonnegative(), // cache dérivé de l'ancre, comme les zooms
endMs: z.number().nonnegative(),
...clipAnchorShape,
effectId: z.string().min(1),
values: z.record(z.string(), shaderEffectValueSchema).default({}),
target: z.enum(["image", "text", "both"]).default("image"),
}), "endMs", "startMs");
// documentSchemaShape
shaderEffects: z.array(shaderEffectSchema).optional(),
shaderEffectRanges: z.array(shaderEffectRangeSchema).optional(),valuesne porte que ce qui diffère dudefaultde la définition. Une valeur hors bornes est ramenée dans ses bornes à l'écriture (les outils) et à la lecture (la scène), sans erreur. Le parse ne le peut pas : les bornes sont dans la définition, pas dans la région.- Les
labelsont écrits par l'agent, dans la langue de l'utilisateur, et affichés tels quels. Ils ne passent pas par l'i18n : c'est du contenu, pas de l'interface. - Une pilule, ce sont les fragments identiques et contigus (
regionIdentityKey), comme pour les zooms. Il n'y a pas de clé de groupe : une région coupée par un trim ou une frontière de clip reste une seule pilule, et sa progression (ctx.region) court sur la pilule entière (B.5). costest l'estimation du compositeur (C.7), mise en cache quand le code a été vérifié. Elle est dérivée, jamais une entrée : un code modifié la recalcule.- Une définition que plus aucune région n'utilise est retirée par l'édition qui a retiré sa
dernière région (
removeRegion,mapAllRegionCollections). Le document ne garde pas de code mort. - Les
maxdu schéma sont des plafonds techniques, pas des budgets. Ils protègent le document (64 Ko de code, 32 paramètres) et sont loin au-dessus de tout effet raisonnable. Ce que l'agent doit viser, ce sont les paliers de C.7.
Une région d'effet est une région de plus, pas un cas à part. Elle passe par tout le pipeline des modificateurs (timeline-model.md), et suit donc les clips quand on les déplace, les coupe ou les supprime. Le spike la stockait en ms brutes, sans ancre : elle restait en place pendant que les clips bougeaient.
RegionKindgagne"shaderEffect". Les sites qui switchent dessus sont listés dans editor-shell.md, « Adding a region kind » (ses numéros de ligne sont périmés). Les principaux :- le crochet des éditions de clips :
mapAllRegionCollections(document/timeline.ts), par lequel passent moveClip, duplicateClip, setClipSourceRange, removeClip, replaceTimeline et l'insertion ; - les listes codées en dur :
anchoredRegionsOf, etremoveRegion/EDIT_REGIONS, dont les types rendent un oubli bloquant à la compilation ; - côté agent :
ModifierKind,modifierIds,modifierIdsOfet la recherche d'id deremoveModifier(agent-tools.ts) ; - côté éditeur :
removeRegions(useTimeline.ts),regionClipboard.ts, le copier-coller et la touche Suppr deNewEditorShell.tsx.
- le crochet des éditions de clips :
- L'identité des pilules (
regionIdentityKey) compte tous les champs sauf la position et la provenance. Deux régions contiguës du même effet, aux mêmesvalues, se fondent donc en une pilule, comme deux zooms identiques. - Un effet à la fois (K.6). Deux régions d'effet ne se chevauchent pas : les outils refusent
un chevauchement, avec la règle des autres régions (
refuseIfBlocked). Pour combiner deux rendus au même instant, l'agent les écrit dans un seul shader. - Après une édition de clip qui ferait se chevaucher deux régions, le rendu applique les deux, dans l'ordre du tableau. Pas de réparation automatique : deux effets superposés ne cassent rien.
- Champs additifs et optionnels, sans montée de
schemaVersion. C'est la convention devocabulary:.optional(), etz.objectqui ignore les clés inconnues. Un document écrit avant cette fonctionnalité s'ouvre tel quel, un document sans effet n'en porte aucune trace, et tout lecteur prend une liste absente pour vide. - Le prix de ce choix. Une build antérieure qui ouvre puis sauvegarde un projet perd ses effets en silence. Voir K, question 1.
- Pas dans les style presets. Un preset porte le look du projet, jamais son contenu
(
stylePresets.ts), et un effet est posé sur des moments précis du contenu. openscreen infocompte les effets avec les zooms et les trims (documentInfoFields,projectCommands.ts).
buildSceneDescription projette les régions comme les zooms (projectRegionsToSource). Il
ajoute à chaque fragment ce que le compositeur ne peut pas recalculer :
"shaderEffects": [{
"clipIndex": 0, "startSec": 1.0, "endSec": 5.0,
"programmeStartSec": 1.0, "programmeEndSec": 5.0,
"target": "image",
"contract": 1, "code": "fn effect(uv: vec2<f32>) -> vec4<f32> { … }",
"params": [ { "name": "strength", "type": "number" } ],
"values": { "strength": 1.0 }
}]programmeStart/EndSecest l'étendue de la pilule entière en temps programme. Le compositeur en tirectx.regionsans rien savoir des trims.valuesest déjà normalisé : une couleur devient 4 floats, un toggle 0 ou 1, un point 2 floats, et une valeur absente prend sondefault.- Le packing dans l'uniform se fait côté Rust, au même endroit que la struct WGSL générée. Le layout n'a qu'une source.
- Les fragments sous un trim (
underTrim) restent dans la scène, comme pour les autres régions. Ils se dessinent quand la tête de lecture est posée sur le trim, et l'export ne les rencontre jamais.
Une seule fonction, appelée pour chaque pixel de la sortie :
fn effect(uv: vec2<f32>) -> vec4<f32>uv va de (0, 0) en haut à gauche à (1, 1) en bas à droite de la sortie. La fonction rend la
couleur finale du pixel. Le reste du module (bindings, entrées, @vertex, @fragment) est écrit
par le compositeur et placé APRÈS le code de l'agent : en WGSL, l'ordre des déclarations est
libre, et les erreurs pointent ainsi les lignes que l'agent a écrites.
| Nom | Type | Sens |
|---|---|---|
frame(uv) |
vec4<f32> |
L'image composée en ce point (D.1 dit ce qu'elle contient). Échantillonnage bilinéaire, bords répétés. |
ctx.resolution |
vec2<f32> |
Taille de la sortie, en px. |
ctx.aspect |
f32 |
Largeur sur hauteur de la sortie, pour les distances circulaires en uv. |
ctx.time |
f32 |
Secondes de programme, continues à travers coupes, clips et vitesses. |
ctx.region |
vec4<f32> |
x : progression de 0 à 1 sur la pilule entière. y : sa durée en s. z : secondes depuis son début. |
ctx.cursor |
vec4<f32> |
xy : la pointe en uv de sortie. z : 1 si elle est à l'écran, même quand le curseur est masqué. |
ctx.clicks[i] |
vec4<f32> |
Les 4 derniers clics des 2 dernières secondes, du plus récent au plus ancien. xy : le point du clic en uv de sortie. z : secondes écoulées depuis. w : 1 si l'entrée est occupée. |
ctx.footage |
vec4<f32> |
Le rect de l'enregistrement en uv de sortie (x, y, w, h), après zoom et layout. Sous un tilt 3D, c'est sa boîte englobante. |
ctx.camera |
vec4<f32> |
Le rect de la webcam en uv de sortie. Taille nulle sans caméra. |
ctx.footage_aspect |
f32 |
Largeur sur hauteur de l'enregistrement dans son propre plan, pour les distances circulaires sur lui. |
ctx.footage_corners, ctx.footage_warp |
Ce que lisent les deux fonctions suivantes : les coins TL, TR, BR, BL du métrage en uv de sortie, et son warp (1 homographie, 0 bilinéaire). | |
fx_to_footage(uv) |
vec2<f32> |
Le point du plan de l'enregistrement sous uv : (0, 0) son coin haut-gauche, (1, 1) le bas-droit, prolongé au-delà de ses bords. Loin hors de 0..1 là où aucun point du plan ne tombe. |
fx_from_footage(p) |
vec2<f32> |
L'inverse : où tombe en uv de sortie le point p du plan. |
params.<nom> |
selon le type | Les paramètres déclarés par l'effet (C.3). |
fx_px(n) |
f32 |
n pixels d'une sortie 1080p, en fraction de la hauteur. |
fx_hash(p) |
f32 |
Hasard déterministe dans [0, 1) pour un vec2<u32>, identique sur tous les GPU (PCG). |
- Tout ce qui est en uv de sortie est déjà passé par le zoom, le layout, le cadre et le
tilt. Ces valeurs viennent de
plan_frameeteffect_pointer(frame_geometry.rs), que les trois backends partagent. - Le plan de l'enregistrement suit le warp que le compositeur dessine : bilinéaire sous un
angle fixe, homographie sous la caméra réelle (
FootageQuad,quad_inversedes shaders). L'effet choisit son repère : l'image de sortie, ou ce plan. Le contrat ne décide pas lequel convient à quel effet. - Le curseur masqué (« Afficher le curseur » coupé, l'auto-hide, le
hideCursord'un zoom) n'est plus dessiné, maisctx.cursoretctx.clicksrestent ceux de l'enregistrement : un halo suit toujours la souris, une onde part toujours du clic, et un effet peut remplacer le curseur par son propre dessin.ctx.cursor.zne vaut 0 que si l'enregistrement a caché la pointe ou si elle sort du cadre (un zoom serré) ; les clics d'avant restent. - Le point d'un clic est la pointe à l'instant du clic, au même endroit que l'impact intégré (mode 16).
- Le curseur dessiné passe par le lissage et peut traîner un instant derrière ce point quand la souris arrive pile au moment du clic. Ce cas est rare sur une vraie prise.
- Le spike exposait un seul clic (
ctx.click). Avec quatre, deux clics rapprochés gardent chacun leur onde. - Rien d'autre n'est lié. Une variable
@group/@bindingdans le code de l'agent est refusée, avec sa ligne : aucun backend ne la lierait.
Un effet déclare ses paramètres. Le compositeur en génère une struct FxParams liée en uniform,
et le code lit params.radius, jamais param(2). Un agent qui relit l'effet six mois plus tard
comprend ce qu'il lit, et l'inspecteur sait quel contrôle afficher.
type |
WGSL | Dans le document | Contrôle de l'inspecteur |
|---|---|---|---|
number |
f32 |
nombre, entre min et max |
curseur |
toggle |
f32 (0 ou 1) |
booléen | interrupteur |
color |
vec4<f32> (RGBA, 0 à 1) |
#rrggbb ou #rrggbbaa |
sélecteur de couleur |
point |
vec2<f32> (uv de sortie) |
{ x, y } |
deux curseurs |
- Huit paramètres au plus, recommandé. Au-delà, l'inspecteur devient une liste que personne ne règle. Ce n'est pas un refus : le plafond technique est à 32.
- Les noms sont des identifiants WGSL en
snake_case, uniques, hors mots réservés. - Le layout uniform est calculé côté Rust avec les règles d'alignement de WGSL :
f32sur 4,vec2sur 8,vec4sur 16. Il produit le même ordre et les mêmes trous que la struct générée, et un test compare les offsets de naga à ceux du packer. - Un paramètre doit se voir. La description de l'outil exige qu'il change visiblement l'effet sur toute sa plage. Un contrôle qui ne fait rien à l'écran est exactement ce que l'inspecteur doit éviter.
- Pure. Le résultat ne dépend que de
uv, deframe(), dectxet deparams. Pas d'état d'une frame à l'autre, pas d'horloge, pas de compteur de frames. La preview saute d'une frame à l'autre au seek, l'export les rend dans l'ordre, et les deux doivent donner le même pixel. - Indépendante de la résolution. La preview rend plus petit que l'export. Distances en uv ou
en
fx_px, jamais en px bruts. - Hasard par
fx_hash. Le classiquefract(sin(x) * 43758.5453)donne des valeurs différentes d'un GPU à l'autre. - Boucles à borne littérale. Toute boucle s'arrête sur un compteur comparé à un littéral
(
for (var i = 0; i < 16; i++)). Une boucle dont la borne n'est pas un littéral est refusée. C'est la seule règle ferme du contrat, et ce n'est pas un budget : une boucle qui ne finit pas bloque le GPU, le TDR de Windows le réinitialise, et l'app perd son device sans retour (G.2). La valeur de la borne, elle, est libre : elle compte dans le palier de coût (C.7). - Un plafond de sécurité sur le travail par pixel : 4 096 itérations, toutes boucles imbriquées comprises. L'ordre de grandeur visé est le seuil du TDR (2 s par frame) sur un GPU intégré en 1080p ; c'est une estimation, que la PR 1 mesure et ajuste. Un flou de 64 taps en fait 64, un raymarching de 64 pas sur 8 objets 512.
- Alpha. Pour les cibles
imageetboth, la valeur rendue remplace le pixel ; les encodeurs ignorent l'alpha, et rendreframe(uv).asuffit. Pour la cibletext,frame()rend le calque des textes en alpha prémultiplié, transparent hors du texte, et l'effet doit rendre de l'alpha prémultiplié lui aussi (D.1).
Chaque définition porte contract: 1.
- Le prélude d'une version livrée ne change plus. Une entrée nouvelle s'ajoute à la fin de
FxContext: le code existant ne la lit pas et compile à l'identique. - Un changement de sens crée la version 2, et le compositeur garde le prélude de la 1.
- Un document qui porte une version inconnue (écrit par une build plus récente) garde ses régions, sans les appliquer.
Le ripple du spike, réécrit pour le contrat v1 :
// Une onde part de chaque clic, réfracte l'image et laisse deux ou trois vagues derrière elle.
fn ripple(uv: vec2<f32>, click: vec4<f32>) -> vec2<f32> {
if (click.w < 0.5 || click.z > params.duration) { return vec2<f32>(0.0); }
let t = click.z / params.duration;
let aspect = ctx.resolution.x / ctx.resolution.y;
let d = (uv - click.xy) * vec2<f32>(aspect, 1.0);
let r = length(d);
let x = params.radius * (1.0 - pow(1.0 - t, 3.0)) - r;
let env = smoothstep(-0.008, 0.0, x) * exp(-max(x, 0.0) / 0.07);
let wave = sin(x / 0.03 * 6.2831853) * env * (1.0 - t) * (1.0 - t) * params.strength;
let dir = select(vec2<f32>(0.0), d / r, r > 1e-5);
return dir * wave * 0.01 / vec2<f32>(aspect, 1.0);
}
fn effect(uv: vec2<f32>) -> vec4<f32> {
var offset = vec2<f32>(0.0);
for (var i = 0; i < 4; i++) { offset += ripple(uv, ctx.clicks[i]); }
return frame(uv - offset);
}Avec params : strength (number, 0 à 2, 1), radius (number, 0,05 à 0,5, 0,2), duration
(number, 0,3 à 2 s, 0,9).
Le coût d'un effet est estimé depuis son code, sans le faire tourner. Il ne dépend donc pas de la machine : le même code reçoit le même palier partout, et un effet haut de gamme n'est jamais refusé parce que la machine qui l'écrit est modeste.
- L'estimation se fait sur l'IR de naga. Elle compte les lectures de
frame()par pixel, multipliées par la borne des boucles qui les contiennent ; les fonctions appelées sont dépliées. Les lectures de texture dominent le coût mesuré dans le spike : c'est la bande passante qui paie. - Les paliers, en lectures par pixel :
| Palier | Lectures | Exemples | Recommandation |
|---|---|---|---|
| Léger | ≤ 8 | vignette, étalonnage, ripple à 4 clics | Partout, y compris sur toute la vidéo. |
| Moyen | ≤ 32 | aberration chromatique échantillonnée, petit flou, glitch | Sur des régions de quelques dizaines de secondes. |
| Lourd | ≤ 128 | gros flou, bokeh | Sur des régions courtes : une transition, un moment fort. |
| Très lourd | au-delà | raymarching, simulations | Quelques secondes. L'export reste juste, mais plus lent ; la preview peut ralentir sur un petit GPU. |
- Mesuré sur la RTX 4070 Ti (
shader_fx_tier_calibration) : environ 4 µs par lecture en 1080p et 17 µs en 4K, au-dessus d'un fixe de 0,03 ms. Un effet léger coûte au plus 0,06 ms par frame en 1080p, un moyen 0,14 ms, un lourd 0,54 ms, 512 lectures 2,1 ms. Le détail est dans rendering-performance.md. - Mesuré sur lavapipe (le repli CPU de Linux, 16 threads) : environ 1,4 ms par lecture en 1080p, 350 fois la RTX. Un effet léger y ajoute jusqu'à 11 ms à une frame qui en coûte 7,5 ; un lourd, 234 ms. L'ordre des paliers tient, mais sans GPU même un effet léger double le temps d'export.
- Mesuré sur un Apple M1 (GPU intégré, Metal, même banc) : environ 0,12 ms par lecture en
1080p et 0,5 ms en 4K, au-dessus d'un fixe de 0,3 ms. Un effet léger coûte au plus 1,3 ms par
frame en 1080p, un moyen 4,3 ms, un lourd 16 ms, 512 lectures 49 ms : trente fois la RTX. Les
lectures y dominent aussi : sans les
sin/cosdu banc, 128 lectures coûtent encore 10,5 ms. - Le Radeon 610M des mesures Linux reste à mesurer. La description de l'outil donnera les paliers en ms sur la RTX et sur le M1, pour que l'agent raisonne en temps et non en lectures.
- Rien n'est refusé au coût. Le palier et sa recommandation reviennent à l'agent (E.3), qui décide, et ils restent lisibles dans le snapshot (E.2).
compose_frame dessine, dans l'ordre :
- le fond ;
- le métrage et le cadre ;
- les flous de confidentialité ;
- le curseur ;
- la webcam ;
- les annotations et les sous-titres.
Une région choisit sa cible (target). Sans demande explicite, c'est image.
image: l'image sans les textes. La passe s'insère avant les annotations et les sous-titres. Un effet déforme l'écran, la webcam, le curseur et le fond, jamais le texte que l'utilisateur a posé pour être lu.text: le calque posé sur l'image, c'est-à-dire les annotations de tout type (textes, images, flèches) et les sous-titres. Un effettexttouche donc aussi une flèche ou un logo : les séparer casserait leur ordre de superposition (zIndex). Ce calque est dessiné à part, transparent, l'effet s'y applique, puis le calque est composé par-dessus l'image (« over » prémultiplié). L'image dessous reste intacte. Le calque n'existe que si une régiontextcouvre la frame.both: l'image finale, textes compris, par exemple pour un fondu au noir qui doit couvrir les sous-titres. La passe vient après tout.
Les flous de confidentialité restent sûrs. L'effet ne voit que des pixels déjà floutés et ne peut rien y retrouver : il n'existe aucun chemin vers l'image d'avant le flou.
- Le document n'en tient qu'un à un instant donné (B.3). Une scène qui en superposerait les appliquerait dans l'ordre du tableau.
- Le coût. Un effet fait une copie du RT dans
ann_copyet une passe plein cadre. - Les cibles s'enchaînent dans l'ordre du dessin : les effets
image, puis les textes et leurs effetstext, puis les effetsboth. Un effet ne voit que ce qui est dessiné avant lui.
- Un seul
compose_frame. La preview (live.rs), l'export MP4 et l'export GIF (walk_composited_timeline,timeline_walk.rs) l'appellent tous. Les effets apparaissent donc partout, sans code propre à chaque chemin. - Une passe par frame de sortie.
compose_frame_mbn'a aucun appelant, et le flou de mouvement est calculé par calque (mb_taps).
Il y a deux compilations, à deux moments :
- À l'écriture. Quand un outil pose ou change un code (
addEffect,setEffectDefinition), le processus principal appellecheckShaderEffect(code, params)de l'addon.- La compilation est réelle, pour le backend de la machine, en plus de la validation naga. « Accepté » veut donc dire « se dessinera ».
- Elle tourne sur le pool de libuv (une
AsyncTask) : la compilation Metal d'un code neuf prend 120 ms, et jusqu'à 375 ms pour la première du processus.runDocumentToolattend la réponse, puis la passe à l'exécuteur des outils, qui reste synchrone. - Le coût est estimé depuis le code (C.7), sans rendu ni mesure sur la machine.
- L'erreur revient à l'agent avec la ligne et la colonne de SON code.
- À la pose de la scène (
Compositor::set_scene). Chaque code nouveau part compiler sur un thread de fond.- La preview saute l'effet le temps de la compilation, puis recompose la frame en pause
(
shader_fx_landed). Environ 10 ms avec FXC ; avec Metal, 120 ms pour une source neuve et jusqu'à 375 ms pour la première du processus (H.3). - L'export attend le binaire, et ne rend jamais une frame sans son effet. C'est le mode par défaut du compositeur : seule la vue live le coupe.
- La preview saute l'effet le temps de la compilation, puis recompose la frame en pause
(
Le cache est en mémoire, par compositeur.
- La clé est le hash de (contrat, layout des paramètres, code).
- L'éviction est LRU au-delà de 64 entrées : un agent qui itère en crée une par version.
- Pas de cache disque en v1. 10 ms par effet ne le justifient pas, et Metal comme Mesa gardent
déjà leur propre cache de shaders : sur le M1, une source déjà vue se recompile en 0,4 ms dans le
même processus, en 3 ms dans un autre. L'app ne passe d'ailleurs aujourd'hui aucun dossier
inscriptible au natif : il faudrait une variable d'environnement sur le modèle de
OPENSCREEN_FONTS_DIR.
Une seule source WGSL, traduite par naga au moment de compiler.
| Windows | macOS | Linux | |
|---|---|---|---|
| Traduction | naga vers HLSL SM 5.0 | naga vers MSL | aucune : WGSL tel quel |
| Compilation | D3DCompile (FXC) vers DXBC, puis CreateVertexShader/CreatePixelShader |
new_library_with_source, puis make_pipeline(…, Blend::Replace) |
create_shader_module, puis create_render_pipeline |
| Thread de fond | naga et FXC (fait) | naga, bibliothèque et pipeline, sous autoreleasepool (fait) |
module et pipeline (fait) |
| Copie du RT | CopySubresourceRegion vers ann_copy (fait) |
blit vers ann_copy dans le command buffer de la frame (fait) |
copy_texture_to_texture vers le niveau 0 d'ann_copy (fait) |
| Insertion | fin de compose_frame (fait) |
fin de compose_frame, avant submit (fait) |
image entre la passe caméra et l'ann-pass, text et both après (fait) |
| Uniforms | Map(WRITE_DISCARD) (fait) |
set_fragment_bytes (fait) |
create_buffer_init(UNIFORM) par passe (fait) |
macOS en détail
- Activer
msl-out. Le dépôt l'obtient aujourd'hui par unification des features de wgpu ; le déclarer explicitement souscfg(target_os = "macos"). - Pas d'objet sampler. Les shaders Metal du dépôt utilisent des
constexpr sampler: passer le sampler eninline_samplersde naga (BindSamplerTarget::Inline). - Lier les bindings explicitement. Remplir
per_entry_point_mappourfx_fs: texture, sampler, buffer. Sinonfake_missing_bindingsémet[[user(fake0)]], qui n'est pas du MSL valide. - Lire les noms de fonctions dans
TranslationInfo.entry_point_names. - Le thread de fond.
Device,LibraryetRenderPipelineStatede metal-rs sontSendetSync, etlive.rsdéplace déjà unGpuvers un thread de préchargement. - Pas de garde de boucle. naga borne sinon chaque boucle par une lecture
volatileà chaque tour (force_loop_bounding). La validation a déjà prouvé que toute boucle finit (C.4) : la garde coûtait 7 à 10 % de la passe sur le M1. - Le calque des textes.
draw_annotationsouvre sa propre passe : elle prend désormais sa cible, le RT ou le calque. Le calque est effacé dans une passe à part, puisquedraw_annotationsn'en ouvre aucune quand rien n'est visible, puis posé sur le RT parpipeline_fs_tex(« over » prémultiplié). - La passe charge sa cible au lieu de l'ignorer : un effet peut faire
discard, et le pixel doit alors rester celui de la composition.
Linux en détail
- Un bind group layout explicite, jamais
layout: None. Sinon un effet qui n'appelle pasframe(), ou ne lit pasctx, perd ces bindings, et le bind group échoue à la validation. - Le thread de fond.
wgpu::DeviceestSend,SyncetClone: le pipeline peut se construire hors du thread de rendu. - Valider avant wgpu. Aucun
on_uncaptured_errorn'est posé, donc une erreur de validation wgpu fait paniquer. La pré-validation (shader_fx::analyze) en protège : naga 24, celui de wgpu 24, et le refus de tout binding que le prélude ne déclare pas. Si wgpu panique malgré tout, seul le thread de compilation meurt, et l'effet ne se dessine pas (D.6). - « Accepté » sous Linux.
check_for_agenttraduit l'effet en SPIR-V par naga, comme wgpu le fera. La compilation par le pilote Vulkan n'a lieu qu'àset_scene, faute de device dans l'outil. - Le calque des textes est une texture RGBA8 transparente, posée sur le RT par la passe qui
recopie déjà
accum(pipeline_copy, « over » prémultiplié).
naga reste en 24. À partir de la 25, sa sortie HLSL déclare les samplers dans un tas D3D12
(register(s0, space0)), que FXC refuse en ps_5_0 (erreur X3721). Le test
a_valid_effect_becomes_hlsl_that_d3d11_accepts garde cette frontière : il doit passer avant tout
bump de naga ou de wgpu.
Ce cas arrive avec un document écrit ailleurs, une version de contrat inconnue ou un compilateur qui diffère d'une machine à l'autre.
- La région ne se dessine pas, et la frame reste celle de la composition.
- L'erreur est écrite une fois dans le journal natif (
[shader-fx]) et remonte à l'inspecteur (F.2). - L'export ne s'arrête pas. Un effet manquant vaut mieux qu'un export perdu, et l'agent ou l'utilisateur a déjà vu l'erreur à l'écriture.
Les effets passent par les outils de document, comme le reste : TOOL_ARG_SCHEMAS,
TOOL_DESCRIPTIONS, executeAgentTool. Le serveur MCP les sert sans rien de plus, et l'agent
intégré les reçoit par le même tableau.
| Outil | Arguments | Effet |
|---|---|---|
addEffect |
startSec, endSec (secondes de règle), effect ou effectId, values?, target? |
Pose une région. Avec effect (name, description, code, params?), crée d'abord la définition. Avec effectId, réutilise une définition du projet. |
setEffect |
effectRegionId, startSec?, endSec?, values?, target? |
Change une région. Seuls les champs passés changent. |
setEffectDefinition |
effectId, name?, description?, code?, params? |
Change une définition, donc toutes les régions qui l'utilisent. Un code neuf est compilé avant d'être accepté. Les values d'un paramètre retiré tombent, un paramètre nouveau prend son default. |
getEffect |
effectId |
Lecture : la définition entière, code compris. |
removeModifier |
id |
Retire une région d'effet, comme un zoom. |
- Un seul
add. Écrire puis poser un effet est le cas courant : un appel, pas deux.effectIdsert à réutiliser. - Pas de
removeEffect. La définition part avec sa dernière région (B.2). - Les noms suivent le DSL :
add*, puisset*qui ne touche que les champs passés, puisremoveModifierpour retirer. Pas de neutralisation (force à 0, durée nulle) à la place d'un retrait. - Le placement suit les autres régions :
anchorForAgent/landingOf. Le modèle estaddCameraFullscreen. Une étendue qui ne couvre aucun clip est refusée (coversNoClip), et une région qui en heurterait une autre aussi : un effet à la fois (refuseIfBlocked).
getCurrentDocument ajoute deux listes, sans le code, qui coûterait cher en tokens à chaque
lecture :
"effects": [
{ "id": "fxr_…", "effectId": "fx_…", "name": "Ripple au clic",
"startSec": 0, "endSec": 6, "target": "image", "values": { "strength": 1 } }
],
"effectDefinitions": [
{ "effectId": "fx_…", "name": "Ripple au clic",
"description": "Une onde part de chaque clic et réfracte l'image.",
"contract": 1, "lines": 22, "regions": 1,
"cost": { "tier": "light", "frameReads": 4 },
"params": [ { "name": "strength", "type": "number", "label": "Force",
"default": 1, "min": 0, "max": 2 } ] }
]- Les pilules sont coalescées comme les autres régions (
coalesceForAgent) : un id par pilule, celui de son premier fragment. - Le code se lit par
getEffect, quand l'agent veut le modifier. descriptionest écrite par l'agent et lue par l'agent suivant. La description de l'outil l'exige : ce que l'effet fait, en une phrase, pas comment il le fait.costest l'estimation mise en cache dans la définition.nullsignifie que rien n'a pu vérifier le code quand il a été écrit.timeBaseNotenomme les effets parmi les régions en secondes de règle.
- Accepté : l'id, l'étendue posée (
landingReport, comme les autres régions : fragments, bornes rognées), et le coût : le palier de l'effet, et la recommandation du palier quand la région la dépasse. Par exemple : « lourd sur 45 s : pour une région longue, vise moyen ». - Refusé à la compilation : l'enveloppe
{ error }, avec l'erreur de naga ou du compilateur du backend, lignes et colonnes de SON code. Le document ne change pas. - Refusé pour une boucle sans borne littérale, ou au-delà du plafond de sécurité de C.4, avec la ligne de la boucle. C'est le seul refus qui ne vient pas du compilateur.
- Sans validateur branché (un harnais de test), le code est stocké et
validated: falsele dit.
Avant, getFrames (media-tools.ts) ne montrait que le dernier export : vérifier un effet
coûtait un export complet. Sur un projet de dix minutes, la boucle de l'agent devenait trop
lente.
getFrames rend désormais depuis le document quand l'export manque ou est périmé (PR 5).
- Avec un export à jour, l'outil lit toujours ce fichier.
- Sinon, le processus principal construit les clips et la scène comme l'export
(
buildNativeClipList,buildSceneDescription). Il passe les secondes de règle en indices de frame, sur la grille à 60 i/s deexportVideo, et demande ces frames à l'addon (renderFrames). - L'addon rend par la marche de l'export,
timeline_walk::render_frames: mêmes clips, mêmes vitesses, même frame source à chaque instant. Les frames non demandées ne sont pas composées. Le décodeur ne lit que ce qui mène à chaque frame demandée : depuis la précédente quand elle est à moins de 2 s, sinon depuis une image clé. - Un saut de plus de 2 s repart d'une image clé (
catch_up).seek_torend la première frame à ou après le point visé ; on recule donc ce point jusqu'à tomber avant l'instant voulu, puis on avance comme l'export : la frame montrée est la dernière dont le pts le précède. - Le compositeur attend la compilation des effets, comme celui de l'export.
- Les images sont des JPEG de 1 280 px de large au plus, comme les frames d'un export.
- Prouvé au pixel par
tests/render_frames_match_export.rs: une frame rendue seule est identique, octet pour octet, à la même frame de la marche complète. La source est une mire dont chaque frame change, sur une grille à 24 i/s qui tombe entre ses frames. Un seek droit sur l'instant visé échoue à ce test. - Ce n'est pas la vue live que le spike pilotait (
presentTime,readFrame) : elle saute un effet qui compile encore, et il faut la sonder en boucle.
TOOL_DESCRIPTIONS.addEffectporte le contrat court : la signature, les entrées de C.2, les règles de C.4, les paliers de C.7 en ms, les trois cibles et le défautimage, l'exigence d'un paramètre visible, et la vérification avecgetFrames.- La ressource MCP
openscreen://effects/contractporte le contrat complet et deux exemples : le ripple au clic et une vignette qui respire.- Ce serait la première ressource du serveur, qui n'en a aucune aujourd'hui.
- Pourquoi une ressource : le contrat est trop long pour une description d'outil, et il
évolue avec
contract.
- Le prompt de base (
BASE_SYSTEM_PROMPT) gagne deux choses :- une ligne dans la carte des intentions : un effet visuel que les réglages n'offrent pas se
fait avec
addEffect; - les effets dans la ligne des bases de temps.
- une ligne dans la carte des intentions : un effet visuel que les réglages n'offrent pas se
fait avec
- Le skill
openscreen-demogagne une entrée « effets » à son étape 6.
Il a les mêmes outils et le même validateur que le serveur MCP : handlers.ts passe
checkShaderEffect: (code, params) => loudnessService.checkShaderEffect(code, params) à
runChat, qui le transmet par ChatRunEnv et InvokeArgs jusqu'à buildTools.
Un scénario, effect-ripple-on-click, sur une mire avec trois clics :
- Attendu : un
addEffectdont le code litctx.clicks, une région qui couvre chaque clic et la demi-seconde qui suit, et la cibleimage, puisque rien ne demande les textes. - Pas exigé : lire la piste du curseur. Le compositeur donne déjà les clics placés sur la sortie, c'est tout l'intérêt du contrat (C.2).
documentInvariants valide les deux nouvelles listes : chaque région pointe une définition
existante, et ses values sont dans les bornes. Hors ligne, rien ne compile le code : le résultat
dit validated: false, et les checks lisent le code comme du texte.
- Où. Une seule rangée « Effets », sous la piste Full Camera : les effets ne se chevauchent
pas (K.6). Elle n'apparaît que si le projet a un effet, puisqu'aucun bouton n'en ajoute, et
la timeline grandit alors de sa hauteur (
EFFECT_LANE_EXPANSION_PX). - La pilule porte le nom de la définition. La glisser, l'étirer, la sélectionner et la
supprimer se font comme pour un zoom (
selectPill,startPillDrag,removeRegions). - L'annulation passe par l'historique habituel (
saveDocument({ history: true })). Une édition d'agent reste un seul pas d'annulation par tour (agentDocumentApply.ts). - Pas de bouton « ajouter un effet » en v1. Un effet naît d'un agent : le chat intégré ou un client MCP. Un bouton qui ouvrirait un panneau vide n'aurait rien à proposer. La bibliothèque d'effets (J) lui donnera un contenu.
- Le copier-coller (
regionClipboard) copie la région avec sa définition. Collée dans un autre projet, la définition y est recréée. - Le site (
website/scripts/gen-recreation.mjs) recopieLANE_ORDERà la main. La nouvelle piste n'y casse rien, et la recréation ne la montrera pas tant qu'on ne l'y ajoute pas.
SelectionPane (FloatingInspector.tsx) gagne une branche shaderEffect. Elle contient, dans
l'ordre :
- le nom de l'effet, sans phrase d'explication sous les contrôles ;
- un contrôle par paramètre déclaré, généré depuis
params(curseur, interrupteur, couleur ou point), qui écritvaluessur la région ; - la cible : « Image », « Textes » ou « Les deux » (
target) ; - le palier de coût, en un mot (léger, moyen, lourd, très lourd), sans phrase ;
- le code, replié par défaut, en lecture seule, avec un bouton copier ; l'édition du code passe par l'agent en v1 ;
- supprimer, comme pour les autres régions.
Un effet qui ne compile pas affiche l'erreur du compilateur dans l'inspecteur. C'est l'exception admise aux phrases sous les réglages : sans elle, la pilule existerait sans rien faire, et ce serait une vraie surprise.
Les libellés fixes vont dans editor.json, pour les 15 locales : inspector.effect* (cible,
coût et ses quatre paliers, code, erreur) et regionClipboard.pasteEffectOverlap. La pilule
et l'en-tête de l'inspecteur portent le nom de l'effet, que l'agent écrit : la piste n'a pas de
libellé propre.
localeParity.test.ts et npm run i18n:check vérifient leur parité. Les noms et libellés
écrits par l'agent n'y passent pas.
- Un seul chemin de pixels. La preview et l'export appellent le même
compose_frame, avec la même scène :buildSceneDescriptionsert aux deux. - Une seule horloge.
ctx.timeest le temps programme, etctx.regionvient de l'étendue de la pilule en temps programme. Aucun des deux ne lit le compteur de frames. - Un effet pur. C'est le contrat C.4. La validation fait respecter ce qui se vérifie statiquement (boucles, taille), et la description de l'outil donne le reste à l'agent comme règle d'écriture.
- Jamais de placeholder à l'export. Le mode bloquant est le défaut du compositeur.
- À résolution égale. La preview rend plus petit que l'export. Un effet qui suit C.4 donne la même image, mais pas les mêmes pixels, puisqu'il n'y en a pas le même nombre. C'est déjà vrai de tout le reste du rendu.
Mesure du spike. Écart entre la frame de la vue live et celle du MP4 exporté, décodé en BT.709 : 2,2 à 2,6 / 255, dans la région d'effet comme hors d'elle. C'est le bruit du codec.
Le seul vrai risque d'un code écrit par un agent est une boucle qui ne finit pas. Le TDR de
Windows réinitialise alors le GPU, et l'app perd son device. Rien ne gère ce cas dans le dépôt :
le thread de rendu s'arrête sur son erreur (fatal, live.rs) et ne redémarre pas.
La protection est statique, posée à la validation : la borne littérale de toute boucle et le plafond de 4 096 itérations par pixel (C.4). Ce n'est pas un budget de coût, qui n'existe qu'en paliers indicatifs (C.7). Récupérer un device perdu est un chantier à part : il concerne toute l'app, pas les effets.
Un projet porte le code de ses effets. Ouvrir le projet de quelqu'un d'autre compile donc du code qu'on n'a pas écrit.
- Ce code ne tourne que sur le GPU, sans accès aux fichiers, au réseau ni à la mémoire du processus.
- Les garde-fous de G.2 s'appliquent aussi à l'ouverture. Un effet refusé reste dans le document, et sa région ne se dessine pas.
- Une région inactive ne coûte rien. La passe n'existe que si une région couvre la frame.
- Le coût d'un effet se lit en paliers (C.7), estimés depuis le code et identiques sur toutes les machines. L'agent les connaît et suit leurs recommandations ; rien n'est refusé au coût.
- Une cible
textajoute un calque transparent et sa composition, seulement aux frames qu'elle couvre.
| Mesure | Résultat |
|---|---|
| Compilation (naga + FXC) d'un effet de 60 lignes | 10 ms, sur un thread de fond |
| Passe d'un effet lourd, 1080p | +0,04 ms par frame |
| Passe d'un effet lourd, 4K | +0,12 ms par frame |
| Export de 180 frames en 1752×1080 : sans effet, avec effet, avec effet lourd | 520, 523 et 521 ms |
| Preview en lecture, source à 30 i/s | 30 i/s avec et sans effet |
Mesuré à la passe Linux (lavapipe sous WSL, 16 threads) : effet prêt en 18 ms après set_scene,
module et pipeline compris ; les paliers en C.7.
À mesurer à l'implémentation :
- le Radeon 610M des mesures Linux.
| Mesure | Résultat |
|---|---|
| Compilation (naga + Metal + pipeline) d'une source neuve, le ripple de C.6 | 120 ms en médiane, 160 à 375 ms pour la première du processus, sur le thread de fond |
| Recompilation d'une source que Metal a déjà vue | 0,4 ms dans le même processus, 3 ms dans un autre |
addEffect accepté, compilation réelle comprise (CLI mcp) |
152 ms ; refusé à la ligne de l'agent en 6 ms |
| Passe d'un effet de 1 lecture, 1080p et 4K | +0,41 ms et +1,2 ms par frame |
| Passe d'un effet de 128 lectures, 1080p et 4K | +16 ms et +63 ms par frame |
| Export de 180 frames en 1752×1080 : sans effet, ripple, effet lourd du spike | 2 469, 2 428 et 2 375 ms : dans le bruit, l'export est limité par l'encodeur |
| Preview en lecture, source à 30 i/s | 29,7 i/s avec et sans effet |
| Vue live contre MP4 (BT.709), image entière | 3,1 / 255 avec effet, 3,0 sans |
| Vue live contre MP4, sur les pixels où l'effet agit | 13 à 16 / 255 avec effet, 14 à 16 sans effet sur les mêmes pixels : le bruit du codec sur des bords nets |
La compilation, dix fois plus lente qu'avec FXC, reste hors du rendu. Elle tourne aussi à
l'écriture (checkShaderEffect), d'où le pool de libuv plutôt que le thread principal (D.4).
Le détail est dans rendering-performance.md.
| Niveau | Ce qu'il couvre |
|---|---|
Rust, shader_fx.rs |
Prélude et struct générée ; packer de paramètres contre les offsets de naga ; borne littérale et plafond d'itérations ; estimation du coût et paliers (boucles imbriquées, fonctions dépliées) ; erreurs aux lignes de l'agent ; sélection des régions (fenêtre, underTrim) ; ctx.region sur une pilule coupée. |
| Rust, par backend | Une passe repeint la frame dans sa région et nulle part ailleurs ; les trois cibles (text laisse l'image intacte, image laisse les textes intacts) ; preview non bloquante puis shader_fx_landed ; effet cassé = frame intacte. Windows (fait), macOS (fait, sur le runner CI, avec en plus une vraie annotation entre une cible image et une cible text), Linux sur lavapipe. |
Rust, frame_geometry.rs |
ctx.clicks, ctx.footage, ctx.camera en uv de sortie, sous zoom et sous tilt. |
| TS, schéma | Parse, bornes des values, définition orpheline retirée, document ancien inchangé. |
| TS, timeline | La région suit moveClip, removeClip, replaceTimeline et les trims (timelineMap.test.ts, timeline.test.ts). |
| TS, outils | addEffect avec effect puis avec effectId ; setEffect ; setEffectDefinition ; getEffect ; removeModifier ; les refus (compilation, boucle sans borne, chevauchement, aucun clip) ; le palier et sa recommandation dans le retour ; le snapshot. |
| TS, scène | Projection, programmeStart/EndSec d'une pilule coupée, normalisation des values. |
| Banc | Les deux scénarios de E.7. |
| Bout en bout | La boucle du spike par la CLI mcp : prompt, addEffect, getFrames, puis comparaison de la vue live avec l'export. Une ligne dans le journal e2e. |
- Effets par calque (l'écran seul, la webcam seule). La passe agit sur l'image composée.
- Effets temporels (traînées, persistance). Ils prendraient les frames précédentes comme entrées explicites, jamais un état gardé ; ce n'est pas fait.
- Transitions entre deux clips. Elles demandent deux images en entrée.
- L'édition du code dans l'interface. En v1, le code se lit, se copie, et s'écrit par l'agent.
- La bibliothèque d'effets partagée entre projets, avec un bouton « ajouter » qui la propose.
- Une poignée sur la preview pour les paramètres
point. - Le cache disque des binaires compilés.
- La récupération d'un device GPU perdu, un chantier qui concerne toute l'app.
- Champs additifs, sans montée de
schemaVersion. CommeaudioTracks. Une build antérieure qui sauvegarde le projet perd ses effets : c'est accepté. - Pas de budget ferme, des paliers indicatifs (C.7). Ils sont estimés depuis le code, donc indépendants de la machine. L'agent les connaît, avec leurs recommandations. Un budget lié à la machine aurait refusé des effets haut de gamme sur une machine modeste, alors que l'export, lui, les rend très bien.
- Pas de limites fermes non plus. Paramètres et code ont des recommandations, et des plafonds techniques loin au-dessus (B.2). Seule reste ferme la borne littérale des boucles, avec son plafond de 4 096 itérations par pixel : une protection contre le blocage du GPU, pas une limite de créativité.
- Pas de bouton « ajouter un effet » en v1. Les effets naissent des agents.
- Trois cibles :
image,text,both. Sans demande explicite, l'effet viseimage, sous les textes. - Un effet à la fois (décidé après les premières PR). Un effet est écrit sur mesure pour ce que l'utilisateur demande : deux rendus au même instant s'écrivent dans un seul shader, au lieu de s'empiler. Les lanes 0 à 7 des premières PR sont retirées du document, des outils et du compositeur, et la timeline n'a qu'une rangée d'effets.
Le spike n'est pas fusionné tel quel : les PR 1 et 2 reprennent son code, et il est stocké
ailleurs (legacyEditor, sans ancre).
- Contrat v1 et passe Windows (Rust).
shader_fx.rs: structFxParamsgénérée et packer ;ctx.region,ctx.clicks,ctx.footage,ctx.camera,fx_px,fx_hash; borne littérale des boucles et plafond d'itérations ; estimation du coût et paliers, calibrés en ms sur le GPU intégré de référence.check_shader_effectcompile pour le backend et rend le palier.scene.rslit la nouvelle forme ; la passe Windows gère les trois cibles (calque de textes à part pourtext) et l'éviction LRU.
- Modèle de données et DSL (TS).
- Schéma (B.2) et
RegionKinddans tout le pipeline (B.3) ; - outils (E.1), snapshot (E.2), projection de scène (B.5) ;
- validateur branché sur l'agent intégré (E.6) ;
- descriptions, prompt de base, ressource MCP du contrat, skill,
openscreen info, banc.
- Schéma (B.2) et
- Passe macOS. naga
msl-out, samplers inline, bindings explicites, thread de fond, tests sur le runner CI. - Passe Linux. WGSL direct, bind group layout explicite, thread de fond, test lavapipe.
getFramesdepuis le document. Rendu de frames sans export quand celui-ci manque ou est périmé (E.4).- Interface. Piste, inspecteur, copier-coller, i18n.
La PR 2 dépend de la 1. Les PR 3, 4 et 5 dépendent de la 1 et peuvent avancer en parallèle. La PR 6 dépend de la 2.
Chaque PR met à jour la documentation qu'elle rend fausse, dans le même changement :
native-compositor.md;document-model.mdettimeline-model.md;editor-shell.md, dont la checklist « Adding a region kind » a ses numéros de ligne à rafraîchir ;ai-agent.md, dont le tableau d'outils annonce encore « 26 tools » ;mcp-server.md;- une ligne de decisions.md : WGSL et naga 24, compilation hors du rendu, effet pur ;
- et, à la PR 6, une page de
website/docs/.