Skip to content

fix(animation): make the animation knobs actually reach the solver - #158

Merged
LeadcodeDev merged 1 commit into
chantier/audit-remediationfrom
fix/animation-dead-knobs
Aug 9, 2026
Merged

fix(animation): make the animation knobs actually reach the solver#158
LeadcodeDev merged 1 commit into
chantier/audit-remediationfrom
fix/animation-dead-knobs

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Round 4, vague A — lot animation, 8 constats vérifiés.

Six des huit sont des boutons morts : un champ que l'auteur pose, que le schéma accepte, que validate déclare bon, et que le moteur ignore. Personne ne reçoit d'erreur ; la vidéo est simplement dépourvue de l'animation demandée. Pour un outil piloté par génération, c'est le pire mode de défaillance qui soit — la boucle de correction ne se referme jamais.

Constat Sévérité Mesure réelle en phase rouge
amplitude de float_3d jetée High amplitude: 60 demandé, -12 délivré (le défaut). La recette de parallaxe documentée ne produisait aucune parallaxe.
pulse/float/shake/spin ignorent delay/duration High Avec delay: 1.0, à t=0,5 s — donc censés être au repos : shake -10, float -10, pulse 1.05, spin 180.
"loop": true inerte High À t=2,5 s au-delà du dernier keyframe : opacité 1 (figée) au lieu de ≈0,5 (wrap).
--strict-anim ignore time_scale/time_offset High Violation signalée at t=0.13s sur un nœud réglé 5–7 s avant tout échantillon : un instant jamais rendu.
Budget de complétion Medium start_at: 1.5, delay: 0, duration: 1.0 dans une scène de 2 s → "animation finishes at 2.50s". Elle finit à 1,0 s.
Composition sum vs last-wins Medium Deux keyframes sur la même propriété : combined=120 vs B seul=20 selon qui porte le délai.
Spring : NaN et divergence Medium mass=0NaN. stiffness=0NaN. damping=-20NaN à t=5 s. Zéro validation.
Typos dans style.animation[*] Medium {"name":"fade_in_up","duratoin":0.6}errors: [].

Le constat 6 est un choix de sémantique, pas une correction mécanique

Deux animations keyframes sur la même propriété donnaient soit une somme, soit un écrasement, selon qu'un delay était posé — le delay aiguillait l'effet vers l'un de deux seaux résolus séparément puis fusionnés additivement.

Sémantique retenue : le dernier effet déclaré dans style.animation gagne, la règle de cascade CSS, indépendamment du delay. C'est déjà le comportement intra-seau existant, donc le cas majoritaire (delay = 0) ne bouge pas ; seul le cas inter-seaux change, et il était de toute façon accidentel.

Le spring est traité aux deux bouts

Un NaN qui atteint le layout contamine l'arbre entier. Le solveur plafonne donc ses entrées, et validate rejette les configurations invalides en amont. Corriger seulement le solveur aurait laissé passer une configuration qui n'a aucun sens ; corriger seulement la validation aurait laissé render produire des NaN.

Conséquence assumée de deny_unknown_fields

À signaler explicitement, parce que ce n'est pas neutre. deserialize_children (render/scene.rs:534) saute un enfant qu'il ne sait pas parser. Avec deny_unknown_fields sur les neuf structs de config d'effet, une clé mal orthographiée cesse de produire une animation aux valeurs par défaut et se met à retirer le composant, avec un avertissement sur stderr.

C'est le contrat que CssStyle porte depuis toujours — c'est exactement pourquoi margin-top supprime un composant, cf. PR #156. Cette PR étend une politique existante, elle n'en invente pas une. Et validate, obligatoire selon CLAUDE.md, l'attrape en amont.

Si tu préfères l'inverse — détection côté validateur sans durcir serde — c'est faisable, mais ça demande une liste de champs connus maintenue à la main en plus des structs. Je n'ai pas pris cette décision seul : dis-le et je bascule.

Reste ouvert

schema/animation.rs était hors périmètre de l'agent. Les clés à l'intérieur d'un keyframes[*] (types Animation / Keyframe) restent non couvertes. Le patch est trivial (deny_unknown_fields sur les deux structs, vérifié innocent sur les 8 exemples) et revient au lot SERDE de la vague B, à qui ce fichier appartient.

Vérification

cargo test --workspace sur cette branche seule : intégralement vert, 20 cibles.

Eight confirmed audit findings. Six of them are dead knobs: a field the
author sets, the schema accepts, `validate` calls clean, and the engine
ignores. Nobody gets an error; the video simply lacks the animation that was
asked for. For a tool driven by generated JSON that is the worst failure
mode there is, because the correction loop never closes.

- `float_3d`'s `amplitude` never reached `PresetConfig`: `AnimationTiming`
  had no such field and the only converter wrote `None`. Every `float_3d`
  moved by the 12px default, so the documented parallax recipe produced no
  parallax. Measured: 60 requested, -12 delivered.
- `pulse` / `float` / `shake` / `spin` built keyframes at the literal times
  0.0 / 0.25 / 0.5 / 1.0 and never read `delay` or `duration`. With
  `delay: 1.0`, all four were already mid-animation at t=0.5s.
- `"loop": true` was inert on keyframe effects and `tilt_in`: the resolver
  was handed `None` for the preset config, fell back to `repeat: false`,
  and never called `loop_time`.
- `--strict-anim` resolved effects at global scene time while the engine
  resolves at remapped local time, so it flagged violations at instants
  that are never rendered — and missed real ones. It now reads the
  `time_params` the builder already computes.
- The completion budget added `start_at` to `delay + duration`, which the
  engine does not do: since PR #27 `start_at` gates visibility only and
  `delay` is absolute scene time. A 1s animation at `start_at: 1.5` in a 2s
  scene was reported as overrunning.
- Two keyframe animations on the same property summed or overwrote each
  other depending on whether one carried a `delay` — a field with nothing
  to do with composition, routing effects into two separately-resolved
  buckets. Now a single bucket with one rule: last declared wins, the CSS
  cascade rule, which was already the behaviour within a bucket.
- The spring solver returned NaN for `mass: 0` or `stiffness: 0` and
  diverged on negative damping, with no validation anywhere. A NaN reaching
  layout contaminates the whole tree. Both ends are handled: the solver
  floors its inputs, and `validate` now rejects the configs outright.
- Unknown keys inside `style.animation[*]` were never reported.

That last one has a deliberate consequence worth stating: `deny_unknown_fields`
on the nine effect-config structs means a typo now fails deserialization, and
`deserialize_children` skips a child it cannot parse. A misspelled key stops
producing a default-valued animation and starts removing the component, with
a stderr warning. That is the same contract `CssStyle` has carried all along
— it is why `margin-top` drops a component — so this extends an existing
policy rather than inventing one, and `validate` catches it first.

Tests: full workspace green on this branch alone.
@LeadcodeDev LeadcodeDev added the bug Something isn't working label Aug 9, 2026
@LeadcodeDev LeadcodeDev self-assigned this Aug 9, 2026
@LeadcodeDev
LeadcodeDev merged commit e6decfb into chantier/audit-remediation Aug 9, 2026
3 checks passed
LeadcodeDev added a commit that referenced this pull request Aug 9, 2026
…161)

Nine confirmed audit findings on the JSON contract. Each is a silent sink: a
legitimate value — usually the one CSS would have you write — swallowed
without a word, producing a wrong render instead of an error. An LLM does not
read serde's output; it sets a field, nothing objects, and it concludes the
field was honoured.

- `AnimatedBackground`'s hand-written `Deserialize` had three sinks, not the
  two the audit named. An unknown `preset` fell through `_ =>` to
  `gradient_shift` with no colors; `zones` parsed with
  `.ok().unwrap_or_default()`; and — the third, found by reading the
  function — `colors` and `gradient_type` did the same *even with a
  correctly spelled preset*. The symptom is the worst available: an entirely
  black video, no diagnostic. A correctly spelled `heropattern` in the flat
  legacy form also fell through to `gradient_shift`. Every branch now routes
  through a typed struct with error propagation.
- `Edges` accepted any object at all: `{"padding": {"horizontal": 20}}`
  deserialized to zero on all four sides. `CssStyle`'s `deny_unknown_fields`
  gave the illusion of protection, but the enum one level down had four
  defaulted fields and no guard of its own.
- `border-radius` per-corner was the only composite in `CssStyle` using
  snake_case. The kebab form every neighbour uses failed, fell to another
  variant, and produced radius 0. Both spellings now work, unknown ones are
  reported.
- `width: "max-content"` was unreachable: `Length`'s own string catch-all
  absorbed it before the `Keyword` variant was tried, so the box collapsed
  to 0. `LineHeight` had the identical defect with `"normal"` — found by
  sweeping the other fifteen untagged enums in the repo, which is now the
  complete list: no catch-all-before-specific ordering remains.
- Animation `property` was a free `String` on three types, so an unknown or
  wrongly-cased name made the animation inert. Now constrained at the schema
  layer only — the solver is untouched — with a did-you-mean when the sole
  difference is the naming convention.
- The exported JSON schema declared `background` invalid on `Scene` and
  `View`, because `deny_unknown_fields` emits `additionalProperties: false`
  and `background` was `schemars(skip)`. The repo's own examples failed
  against the schema the generators consume: 31 violations across 6 files.
- A literal `$` in any string was fatal if and only if the document happened
  to contain a `config` block — a price, or `$PATH` in a terminal, blocked
  by an unrelated key elsewhere. The scan now always runs, and warns rather
  than rejecting: a declared variable can never survive `merge_variables`
  unresolved, so anything the scan still finds is by definition outside the
  declared set. An override naming an undeclared variable stays a hard
  error.
- `PositionMode::Named` accepted any string while only `"absolute"` does
  anything, so `"position": "relative"` — legitimate CSS — dropped `x`/`y`
  in silence. It warns now, deduplicated per distinct value: `prepare_scene`
  re-runs this `Deserialize` over the whole tree once per frame, so an
  unguarded warning would print over a thousand times on a 1200-frame
  render.

Also completes PR #158's hardening at the level below: `deny_unknown_fields`
on `Animation` and `Keyframe`, the keys inside a `keyframes[*]`.

Tests: full workspace green on this branch alone; every example still
validates, and now also validates against the exported schema.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant