diff --git a/.claude/skills/rustmotion/rules/geometry-safety.md b/.claude/skills/rustmotion/rules/geometry-safety.md index 83eb529f..0601fc3a 100644 --- a/.claude/skills/rustmotion/rules/geometry-safety.md +++ b/.claude/skills/rustmotion/rules/geometry-safety.md @@ -1,6 +1,6 @@ # Rule: Keep Content Inside the Viewport -No textual content may bleed out of the device viewport. The renderer enforces this through three opt-in mechanisms, all checked by `rustmotion validate`. +No textual content may bleed out of the device viewport. The renderer enforces this through four opt-in mechanisms, all checked by `rustmotion validate`. ## 1. Text wrapping (`style.white-space`) @@ -29,7 +29,26 @@ When you give a `codeblock` or `terminal` a fixed `size` smaller than its natura } ``` -## 3. Container `style.overflow` +## 3. Text shrink-to-fit (`style.text-autofit`) + +`text` and `gradient_text` accept `text-autofit: true`, which reduces their font size until the content fits the resolved box — width, and height when taffy resolves one. + +```json +{ "type": "text", "content": "A headline too long for its box", + "style": { "width": "320px", "height": "90px", "font-size": 120, "text-autofit": true } } +``` + +Use it when the copy is data-driven and you cannot know in advance whether it fits — a label coming from a `for-each`, a headline injected through a variable. Do **not** reach for it to paper over a layout you can simply size correctly: shrinking is a fallback, not a design. + +Three things to know: + +- **It has a floor.** Shrinking stops at a calibrated legibility threshold and never goes below it. If the text still does not fit at the floor, the geometry violation is **still reported** — `text-autofit` narrows that failure class, it does not silence it. +- **`white-space` still decides whether the text wraps**; `text-autofit` only decides at what size. They compose. +- **Only these two components implement it.** Declaring it on a `caption`, a `codeblock` or a `table` is inert — those painters never read it. + +On a canvas taller than 1080, `validate` warns that autofit may shrink below the legibility floor for that frame height. That warning is about the *rendered* size, not the declared one. + +## 4. Container `style.overflow` CSS-like semantics: `visible` (default) lets children bleed; `hidden` clips at the parent box. The validator only fails when content escapes the **viewport**, not a `visible` parent — a badge sticking out of a card is legal. diff --git a/.claude/skills/rustmotion/rules/motion-path.md b/.claude/skills/rustmotion/rules/motion-path.md new file mode 100644 index 00000000..8e481ec6 --- /dev/null +++ b/.claude/skills/rustmotion/rules/motion-path.md @@ -0,0 +1,66 @@ +# Rule: Motion Path + +Pour faire suivre une trajectoire à un composant — une courbe, un arc, un tracé en S — utilise l'effet d'animation `motion_path` plutôt que d'empiler des `translate` successifs dans une `timeline`. + +## La forme + +```json +{ + "type": "shape", + "shape": "circle", + "fill": "#F68F2B", + "position": "absolute", + "x": 160, + "y": 700, + "style": { + "width": "56px", + "height": "56px", + "animation": [{ + "name": "motion_path", + "path": "M0,0 C300,-320 750,-320 1100,-60", + "delay": 0.2, + "duration": 2.4, + "orient": true, + "orient_offset": 90, + "easing": "ease_in_out" + }] + } +} +``` + +| Champ | Rôle | +|---|---| +| `path` | Données de chemin SVG (`M`/`L`/`H`/`V`/`C`/`S`/`Q`/`T`/`A`/`Z`) — **la même syntaxe** que `shape: { "type": "path", "data": ... }` | +| `delay`, `duration` | Fenêtre temporelle, comme tout autre effet | +| `loop` | Reprend au début à la fin du parcours | +| `orient` | Oriente le composant selon la tangente | +| `orient_offset` | Correction d'angle, en degrés | +| `easing` | Appliqué à la progression **le long du chemin**. Défaut linéaire = vitesse constante sur la courbe | + +## Les coordonnées sont des deltas + +Le chemin est relatif à la position que le layout aurait donnée au composant. `M0,0` est donc son point de repos, pas le coin du device — même convention qu'`orbit`. + +Concrètement : positionne le composant normalement (`x`/`y`, ou le flux), puis décris la trajectoire **depuis là**. + +## `orient_offset` n'est pas décoratif + +Un composant est orienté selon la tangente, et la tangente pointe dans le sens du parcours. Si ton visuel pointe naturellement vers le haut — une flèche, une icône de fusée, un curseur — il apparaîtra tourné de 90° sur un chemin horizontal. `orient_offset: 90` le corrige. + +Vérifie l'orientation au repos de ton visuel avant de conclure que `orient` est cassé. + +## Le validateur voit la trajectoire + +La position résout en `transform`, donc `rustmotion validate --strict-anim` détecte un composant qui sort du cadre en suivant sa courbe, et nomme l'instant : + +``` +bbox: [2046, 700] -> [2102, 756] (viewport: 1920x1080) +hint: at t=1.70s (57% of scene), animation transforms (tx=1886, ty=0, …) + push the bbox out of the viewport +``` + +C'est la raison de préférer `motion_path` à une position calculée à la main : une trajectoire écrite en dur dans des keyframes reste vérifiable, mais tu perds l'orientation automatique et la vitesse constante le long de la courbe. + +## Cas dégénérés + +Un chemin vide ou impossible à parser est **rejeté au chargement**. Un chemin d'un seul point, ou de longueur nulle, tient la position avec une rotation nulle. Une `duration` négative ou nulle est rejetée par `validate`. Aucun de ces cas ne produit de `NaN`. diff --git a/.claude/skills/rustmotion/rules/templates-and-iteration.md b/.claude/skills/rustmotion/rules/templates-and-iteration.md new file mode 100644 index 00000000..c4add167 --- /dev/null +++ b/.claude/skills/rustmotion/rules/templates-and-iteration.md @@ -0,0 +1,140 @@ +# Rule: Templates & Iteration + +Ne duplique pas un sous-arbre. Si dix cartes ne diffèrent que par leurs données, écris-en une et itère. + +C'est le mode d'échec le plus fréquent de la génération : dix copies écrites à la main, dont l'une finit par diverger sur une couleur, une `font-size` ou un `position` oublié. + +## `for-each` — répéter un sous-arbre + +Utilisable dans n'importe quel tableau `children`. + +```json +{ + "for-each": [ + { "label": "Revenue", "value": 1250, "accent": "#22C55E" }, + { "label": "Users", "value": 340, "accent": "#3B82F6" } + ], + "template": { + "type": "card", + "style": { "width": "300px", "height": "160px", "background": "#111827" }, + "children": [ + { "type": "text", "content": "$label", "style": { "color": "$accent" } } + ] + } +} +``` + +Chaque élément du tableau **lie ses propres champs directement** : `$label`, `$value`, `$accent`. Il n'y a pas d'accès par chemin pointé — écrire `$item.label` ne fonctionne pas. + +Deux liaisons sont fournies en plus : + +| Liaison | Contenu | +|---|---| +| `$index` | La position dans le tableau, à partir de 0 | +| `$item` | L'élément entier, pour le transmettre tel quel | + +Une donnée portant explicitement le nom `index` ou `item` gagne toujours sur la liaison intégrée. + +Le `template` peut être un objet unique **ou un tableau** — dans ce cas ses éléments sont insérés comme des frères, pas imbriqués. + +## `components` + `use` — définir une fois, instancier partout + +Bloc racine, à côté de `scenes` / `composition` : + +```json +{ + "components": { + "stat_card": { + "params": { + "label": { "type": "string" }, + "value": { "type": "number", "default": 0 }, + "accent": { "type": "string", "default": "#6366F1" } + }, + "template": { + "type": "card", + "children": [ + { "type": "text", "content": "$label", "style": { "color": "$accent" } } + ] + } + } + } +} +``` + +`params` a exactement la forme de `config` : `type`, `default`, `description`. **Omettre `default` rend le paramètre requis** — l'instancier sans le fournir est une erreur nommée, pas un défaut silencieux. + +Instanciation : + +```json +{ "use": "stat_card", "props": { "label": "Revenue", "value": 1250 } } +``` + +La clé s'appelle **`props`**, pas `config`. Ce n'est pas une inconsistance : la substitution de variables saute délibérément tout objet portant la clé `config`, pour protéger le bloc de déclarations racine. Utiliser ce nom ici laisserait tout `for-each` imbriqué dans un `use` silencieusement non substitué. + +## Les deux se composent + +C'est la forme la plus utile — une définition, une liste de données : + +```json +{ + "for-each": [ + { "label": "Revenue", "value": 1250, "accent": "#22C55E" }, + { "label": "Users", "value": 340, "accent": "#3B82F6" }, + { "label": "Growth", "value": 8, "accent": "#F59E0B" } + ], + "template": { + "use": "stat_card", + "props": { "label": "$label", "value": "$value", "accent": "$accent" } + } +} +``` + +## Le piège : un champ omis dans un élément + +**Chaque élément d'un `for-each` doit fournir tous les champs que le template référence.** + +Ceci ne fonctionne pas : + +```json +"for-each": [ + { "label": "Revenue", "accent": "#22C55E" }, + { "label": "Users" } +], +"template": { "use": "stat_card", "props": { "label": "$label", "accent": "$accent" } } +``` + +Le second élément n'a pas d'`accent`, donc `$accent` reste littéral et le composant reçoit la chaîne `"$accent"` — pas son `default`. Un `default` de `params` s'applique quand `props` **omet la clé**, pas quand `props` transmet une liaison non résolue. + +Ce n'est pas silencieux : la validation émet un avertissement de variable non résolue, et le composant qui consomme la valeur échoue à son tour (ici, `color '$accent' is not a recognized CSS color`). Mais corrige la donnée plutôt que le symptôme — remplis le champ dans chaque élément : + +```json +"for-each": [ + { "label": "Revenue", "accent": "#22C55E" }, + { "label": "Users", "accent": "#6366F1" } +] +``` + +## Ordre des passes, et ce qu'il autorise + +Substitution des variables → expansion des directives → `include`, appliqué par document. + +- **Tu peux** itérer sur un tableau venu d'une variable `config` ou de `--var` : la substitution tourne avant l'expansion. +- **Tu ne peux pas** instancier un composant défini dans un fichier inclus. `components` est strictement local au fichier qui le déclare, comme `config`. Dans les deux sens, c'est une erreur nommée, jamais une portée silencieusement fausse. + +## Ce que ça coûte + +**`--fix` refuse de réécrire un scénario qui utilise ces directives.** Les chemins de violation portent des index post-expansion ; une itération sur dix éléments décale de neuf tout ce qui suit, donc `--fix` patcherait le mauvais nœud. Il refuse plutôt que de corriger à côté — exactement comme pour `include`. + +Tu peux toujours valider (`rustmotion validate` voit l'arbre expansé, donc la géométrie est vérifiée sur ce qui sera réellement rendu). Seule la réécriture automatique est indisponible. + +## Erreurs nommées + +Aucune de ces situations ne passe en silence : + +| Situation | Diagnostic | +|---|---| +| Cycle entre composants | La chaîne complète (`a -> b -> a`), jamais un débordement de pile | +| `for-each` sur autre chose qu'un tableau | Ce qui a été trouvé à la place, avec un indice si ça ressemble à un `$var` non résolu | +| `use` d'un composant inconnu | Le nom manquant | +| Paramètre requis absent | Le nom du paramètre | +| Clé de `props` non déclarée | Le nom de la clé | diff --git a/.claude/skills/rustmotion/rules/timeline-sequencing.md b/.claude/skills/rustmotion/rules/timeline-sequencing.md index 350e03c6..e4a73885 100644 --- a/.claude/skills/rustmotion/rules/timeline-sequencing.md +++ b/.claude/skills/rustmotion/rules/timeline-sequencing.md @@ -106,3 +106,28 @@ For items entering one by one without exits, use increasing `delay` on sibling e { "type": "text", "content": "Second", "style": { "animation": [{ "name": "fade_in_up", "delay": 0.2, "duration": 0.6 }] } }, { "type": "text", "content": "Third", "style": { "animation": [{ "name": "fade_in_up", "delay": 0.4, "duration": 0.6 }] } } ``` + +## Ce que `style.transition` lisse réellement + +`style.transition` fait interpoler les changements posés par un `timeline` — mais **seulement pour certaines propriétés**. Toutes les autres sautent à l'instant du pas. + +Interpolées aujourd'hui : + +| Propriété | Restriction | +|---|---| +| `opacity` | — | +| `color` | sur `text` / `counter` | +| `background` | couleur solide uniquement | +| `border-radius` | rayon uniforme, en px absolus | + +Tout le reste saute. Ce n'est pas silencieux : dès que `style.transition` est posé, `rustmotion validate` inspecte les diffs entre états de `timeline` et **nomme chaque propriété qui ne sera pas lissée**, avec la raison. + +Trois raisons distinctes, et le message le dit : + +- **Propriété de layout** (`width`, `height`, `margin`, `padding`, `gap`, `font-size`, `top`/`left`…) — interpoler demanderait de relancer le layout à chaque frame échantillonnée. Le message suggère l'alternative : `transform: translate` ou `scale`, qui sont côté peinture et s'interpolent, elles. +- **Propriété discrète** (`display`, `position`, `overflow`, `font-weight`, `text-align`…) — il n'existe aucune valeur intermédiaire. Le saut est le comportement CSS attendu, pas une limite de rustmotion, et le message le précise pour t'éviter de chercher un bug. +- **Peinture non supportée** (`transform`, `box-shadow`, `filter`, `clip-path`…) — continue en principe, pas encore implémenté. + +Les unités relatives (`%`, `em`, `rem`, `vw`, `vh`) et les rayons par coin sont refusés à l'interpolation et signalés : avant le layout, ces unités n'ont pas de base fiable. + +**En pratique :** pour animer une taille ou une position, préfère `transform` à `width`/`top`. C'est ce que le validateur te dira, et c'est aussi ce qui coûte le moins cher à rendre. diff --git a/CLAUDE.md b/CLAUDE.md index 50b85c90..75e41d23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,28 +9,49 @@ Tout JSON de scénario généré doit être validé avec `rustmotion validate` a ## Sécurité géométrique (viewport) -Aucun contenu textuel ne doit dépasser du device. Trois propriétés contrôlent ce comportement : +Aucun contenu textuel ne doit dépasser du device. Quatre propriétés contrôlent ce comportement : - `style.white-space` (default `normal`, donc wrap actif) sur `text` : le texte wrap sur la largeur du parent par défaut. `white-space: "nowrap"` (ou `"pre"`) est légitime uniquement si un `max-width` fini + `font-size` raisonnable garantissent que la ligne tient. Le validateur émet `unwrappable_text_overflow` sinon. Il n'existe pas de champ `style.wrap` — c'est un vocabulaire hérité de l'ancien modèle de style, supprimé de `CssStyle`. Voir [rules/geometry-safety.md](.claude/skills/rustmotion/rules/geometry-safety.md). - `auto_scroll` (default `true`) sur `codeblock` et `terminal` : quand le contenu dépasse la hauteur du `size`, le moteur scrolle (clip + translate) sans réduire la `font-size`. `auto_scroll: false` → `auto_scroll_disabled_overflow`. +- `style.text-autofit` (default absent) sur `text` et `gradient_text` : réduit la `font-size` jusqu'à ce que le contenu tienne dans sa boîte. À réserver au texte piloté par des données, dont on ne peut pas connaître la longueur à l'avance — pas pour compenser une mise en page qu'on peut simplement dimensionner. Le rétrécissement s'arrête à un plancher de lisibilité calibré ; si ça ne suffit pas, **la violation est toujours signalée**. Seuls ces deux composants l'implémentent : le déclarer ailleurs est inerte. - `style.overflow` (default `visible`) sur les conteneurs : sémantique CSS. `hidden` clippe au bord du parent. Le validateur ne se plaint que si le contenu sort du **viewport**, pas d'un parent `visible`. `marquee` et `cursor` sont exemptés (leur rôle est de bleed). CLI : - `rustmotion validate -f file.json` — schema + geometry -- `--fix` — auto-fix sûr : `auto_scroll: true` sur `auto_scroll_disabled_overflow`, et retrait de `style.white-space` sur `unwrappable_text_overflow` (retour au défaut `normal`, donc au wrapping). Les débordements de viewport et de boîte ne sont jamais corrigés automatiquement : ils demandent un arbitrage de mise en page. +- `--fix` — auto-fix sûr : `auto_scroll: true` sur `auto_scroll_disabled_overflow`, retrait de `style.white-space` sur `unwrappable_text_overflow` (retour au wrapping), et `text-autofit: true` sur `content_overflows_box` pour `text`/`gradient_text`. Les débordements de viewport restent non corrigés : ils demandent un arbitrage de mise en page. `--fix` **refuse** d'écrire sur un scénario templaté, utilisant `include`, ou utilisant `for-each`/`use` — les index de chemin ne correspondraient plus à la source. - `--report r.json` — rapport JSON -- `--strict-anim` — vérification frame par frame ; ajoute la détection `animated_text_overflow` (transform animé qui sort du viewport à un instant échantillonné) +- `--strict-anim` — vérification frame par frame ; ajoute la détection `animated_text_overflow` (transform animé qui sort du viewport à un instant échantillonné). L'échantillonnage s'arrête à `scene.freeze_at`, puisque rien n'est rendu au-delà. - `--strict-attrs` — promeut en erreurs les attributs inconnus (détection schéma + did-you-mean, activée par défaut en warnings) - `--lenient` — warnings au lieu d'errors ## Encodage - ffmpeg est auto-détecté et utilisé par défaut (10-bit H.264, meilleure qualité sur les gradients sombres) +- `--hardware-acceleration` sonde `ffmpeg -encoders` et bascule sur VideoToolbox/NVENC/QSV/AMF si la machine en offre un. Indisponible → message explicite et repli logiciel, jamais de bascule silencieuse. Le CRF n'a pas de sens sur la plupart des encodeurs matériels : le passer avec l'accélération produit un avertissement. +- `--frames a-b` rend une plage de frames en segment autonome, avec **sa** tranche d'audio (les pistes ne repartent pas de zéro). `rustmotion concat seg1.mp4 seg2.mp4 -o out.mp4` les recolle via le concat demuxer de ffmpeg. C'est la brique d'un rendu distribué. - Sans ffmpeg, le fallback openh264 intégré encode en 8-bit - Pour les vidéos avec des gradients sombres, recommander `--codec prores` pour une qualité maximale +## Factorisation : `components`, `for-each`, `use` + +Ne duplique pas un sous-arbre. Si dix cartes ne diffèrent que par leurs données, écris-en une et itère — c'est le mode d'échec le plus fréquent de la génération, chaque copie étant une occasion de diverger. + +```json +"components": { "stat_card": { "params": { "label": { "type": "string" } }, "template": { … } } }, +"children": [{ + "for-each": [ { "label": "Revenue" }, { "label": "Users" } ], + "template": { "use": "stat_card", "props": { "label": "$label" } } +}] +``` + +Chaque élément du `for-each` lie ses champs directement (`$label`), plus `$index` et `$item`. `params` a la forme de `config` ; omettre `default` rend le paramètre requis. La clé d'overrides est **`props`**, pas `config` — ce nom-là est réservé et serait sauté par la substitution. + +`components` est local au fichier qui le déclare. On peut itérer sur un tableau venu d'une variable ; on ne peut pas instancier un composant défini dans un fichier inclus. Toute erreur — cycle, tableau manquant, composant inconnu, paramètre absent — est nommée et située. Voir [rules/templates-and-iteration.md](.claude/skills/rustmotion/rules/templates-and-iteration.md). + +> `--fix` refuse de réécrire un scénario qui utilise ces directives : les index de chemin ne correspondent plus à la source. `validate` fonctionne normalement, sur l'arbre expansé. + ## Composition : `scenes` vs `composition` (vues `slide` / `world`) Un scénario est soit une liste plate `scenes` (racine) — implicitement enveloppée dans une seule vue `slide` — soit un `composition: [...]` explicite, un tableau de **vues** typées `"slide"` ou `"world"`. Les deux sont mutuellement exclusifs (`CompositionAndScenesConflict` si les deux sont présents). @@ -90,6 +111,8 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en ### Diagrammes `arrow`, `connector`, `timeline`, `line` +> Pour faire suivre une trajectoire à un composant, utilise l'effet d'animation `motion_path` (données de chemin SVG, orientation optionnelle selon la tangente) plutôt que d'empiler des `translate`. Voir [rules/motion-path.md](.claude/skills/rustmotion/rules/motion-path.md). + ### Média `mockup`, `lottie`, `cursor`, `particle`, `qr_code`