Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions .claude/skills/rustmotion/rules/geometry-safety.md
Original file line number Diff line number Diff line change
@@ -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`)

Expand Down Expand Up @@ -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.

Expand Down
66 changes: 66 additions & 0 deletions .claude/skills/rustmotion/rules/motion-path.md
Original file line number Diff line number Diff line change
@@ -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`.
140 changes: 140 additions & 0 deletions .claude/skills/rustmotion/rules/templates-and-iteration.md
Original file line number Diff line number Diff line change
@@ -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é |
25 changes: 25 additions & 0 deletions .claude/skills/rustmotion/rules/timeline-sequencing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 26 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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`

Expand Down
Loading