chantier: expression parity — close the seven walls between a generated scenario and hand-written motion code - #340
Merged
Merged
Conversation
`build_frame_tasks` built a flat Vec<FrameTask> where the index was the frame number, and a transition's frames replaced scene frames instead of overlapping them. Six scenes declaring 15.0s with five transitions rendered 13.5s, and every cut after the first drifted: -0.30, -0.60, -0.90, -1.15s. At 115 BPM the last of those is more than two beats, so a rhythmic edit was impossible without compensating each duration by hand, and changing one transition re-timed everything after it. A scene now declares `at`, in seconds or in beats against the scenario's own `bpm` and `beat_offset`. A transition is a declared overlap: scene i-1 keeps rendering through it, frozen on its last frame or still animating, and the total stops shrinking. Behind `"timing": "v2"`. Absent, the old accounting is byte-identical and `render` says so once. `snap: "beat"` rounds every resolved time onto the grid, which is the cheapest way to make a generated edit rhythmic without the generator having to think about it. An unresolvable `at` is a hard error, not a warning: a typo that leaves validation green and silently changes the edit is the failure class this whole chantier exists to remove. Grammar is checked at deserialization, where no bpm is needed; resolution is checked at validation, where it is.
Rebuilding a reference reel as a scenario needed 316 lines of Python to emit the JSON, because a scenario cannot compute. Every position ended a frozen literal: unreadable in the studio, uneditable by hand, and severed from the intent that produced it. Eight badges on a circle, twenty-four facets shaded by their angle to a light, an element floating on a sine — each is one line in the reference reels and was impossible here. A value prefixed with `=` is an expression. The grammar is deliberately not Turing-complete: no loops, no user functions, no recursion, and a nesting cap, so evaluation stays bounded and a hostile expression fails at parse rather than hanging a render. `rand` and `noise` are pure functions of their arguments with no global state, so two renders of a file match. Two tiers. An expression whose free variables are all load-time-known is folded to a literal during resolution and costs nothing per frame; that tier alone removes the generator script. Everything else compiles once to a stack machine and evaluates per frame. The fold had to be wired five times, not once. `loader.rs` has three entry points, `cli/commands/validation.rs` carries its own pipeline that `validate` and `render` actually use, and `include.rs` resolves each included file independently. A feature is not done in this codebase when `loader.rs` knows about it. `find_unresolved` now skips `=`-prefixed strings rather than carrying a list of reserved names: the scan's job is "did a $var fail to resolve", and an expression is a different sub-language with its own resolver and its own more precise error. A name list would need updating every time this layer grows; recognising the syntax cannot drift.
…frame `Computed<T>` was defined and used by exactly zero fields, so a scenario writing `"opacity": "= $keyDraw"` died at deserialization with `invalid type: string, expected f32` before any per-frame machinery ran. Static folding worked only because it rewrites the JSON to a literal before `CssStyle` ever deserializes; the dynamic tier had nowhere to land. Three landed workstreams resolved correct values into a scope no field could read from. Retyping every style field to `Computed<f32>` would ripple through every consumer of every property. Instead a `= ...` string is lifted out of the JSON before the typed field sees it, parsed once, and re-applied each frame through `apply_animated_props` — the same override path animations already use, rather than a second one beside it. `vars` completes it: a scalar declared once, animated on the absolute timeline, read by any expression. That is the mechanism the reference reel's author describes as animating a plain object and mapping it to attributes every frame, and it is what makes derived motion expressible at all — a hand-rolled 3D wireframe, a float on a sine, a counter driving a typewriter. `Expr::is_static()` cannot see `vars` and never could: it special-cases four fixed names, so `$keyDraw` looks like a constant and would fold into an "unknown identifier" error. `dynamic_names` is the mechanism that stops it, and the loader guard consults it in every fold site.
…glyphs
Eight lines joining a logo to badges orbiting around it were abandoned
when this chantier's gap was measured: a line's endpoint cannot follow a
node whose position is animated. A `?` detaching from the end of a typed
sentence went the same way, because nothing could ask where that sentence
ends.
Most of this is exposure rather than new geometry. Glyph positions were
already computed at paint time and discarded; text measurement already
existed behind an intrinsic. What was missing is a way to name a node and
read it.
A node takes an `id`; an expression reads `node("id", "prop")` across four
families — geometry after layout, the transform resolved this frame, text
measurement, and glyph positions.
The ordering is the whole difficulty. Resolving in tree order rather than
topological order yields values one frame stale, which is invisible in
playback and only shows if someone pauses on the wrong frame. A fresh
ResolvedFrame is built every frame and filled in DepGraph::order(), so
staleness is structural rather than a caller's discipline, and reading a
node before its turn fails loudly instead of returning zero.
Known limit, documented where it bites: a direct reference resolves, a
transitive chain C->B->A does not. Closing it needs per-id re-resolution
walking the graph rather than a second whole-tree build.
Reproducing the reference reel's camera shake took 155 sampled camera
keyframes, generated by a Python loop evaluating a damped sine and pasted
into the JSON. GSAP writes the same motion as `{x: 7, duration: .035,
repeat: 11, yoyo: true}`. `repeat` was a bool with no count, there was no
`yoyo`, and no `steps` easing, so a blinking caret and an oscillating
offset were both spelled out keyframe by keyframe.
`Scene.shake` takes a list of impacts whose times are TimePoints, so they
are written on the same beat grid as the cuts. The model is a decaying
spiral rather than a decaying line — x and y 90 degrees out of phase —
because that reads as a shake and not as a bounce, and `decay` and
`frequency` are knobs an author can reason about. The formula is on the
type, since tuning a number whose meaning is hidden is guesswork.
Additive over `camera`, so a pan and a shake coexist instead of fighting
over one keyframe track. A scene declaring `shake` with no `camera` block
needs a neutral camera to ride on, or the most natural authoring shape —
just an impact, no pan — would silently do nothing.
`repeat: true` deserializes and behaves exactly as before, proven two ways
rather than argued: the old formula kept inline as a reference across
fourteen sample points, and a JSON-level round trip through the single
`loop` key.
`AudioTrack` required a `src`, so every generated scenario was silent — not one of the eleven example files had any audio at all. A language model can write a score and cannot hand over a WAV. Both reference reels synthesise their entire soundtrack from oscillators, noise and filters, with no audio file anywhere. Voices, a score and a master bus, rendered offline to an f32 buffer at 48kHz and muxed through the existing path beside file-based tracks rather than replacing them. Every time in a score is a TimePoint, so `every: "1b"` means one beat of the scenario's own grid. That sharing is the point: a score that merely plays alongside the picture is worth a fraction of one whose impacts and cuts resolve through the same clock. A cut declared at 8b and a hit declared at 8b land on the same instant by construction, not by tuning. The limiter is on by default. The author of one reference reel found his first mix clipping at 0 dB only by reading ffmpeg output afterwards, which is the loop this removes. `every` and `offset` are durations, not instants, so they resolve against a context with `beat_offset` zeroed — a beat's length is set by bpm, and folding the grid's anchor into an interval would stretch every repeat whenever a scenario moved its origin.
Asked how he verified a motion reel he had generated, the author of one reference reel was direct: he cannot watch it move or hear it. He captured stills at chosen instants, assembled contact sheets by hand, and found a misplaced hexagon and a clipped subtitle that way. His audio he judged only through peak and RMS numbers read afterwards. One blind spot he names outright: "the transitions, I never saw them play." This engine had the same one. `geometry.rs` iterated `view.scenes` and sampled within each scene's own duration, so transition frames — separate FrameTask variants, and under v2 a window that extends past a scene's own end — were checked by nobody. The composite of two frame buffers was unvalidated. `rustmotion sheet` renders a contact sheet in one command, each cell stamped with its instant, because an unlabelled grid cannot locate a defect in time. `--strict-anim` now walks frame tasks rather than scenes. `--report` carries peak, RMS and true peak, per beat where a bpm is declared, so a clipping soundtrack is a named violation found at validation rather than a discovery made later with another tool. `rustmotion migrate` converts a scenario to v2, compensating each scene's duration by its own transition so the render is frame-identical, and verifies that by loading both files through the same scheduler `render` uses rather than re-deriving the arithmetic a second time. None of this gives a generator better sight. It gives it numbers in place of the sense it does not have, which is the only thing that can be given.
`card`, `flex`, `grid`, `div`, `container` and `positioned` were six names for the same thing. All six had an empty `paint_content` — every decoration a card was supposed to carry is painted by `paint_pass` from `CssStyle`, identically for any node in the tree — and five of the six had the same seven fields, character for character. `container` was already a serde alias of `div`; the others should have been. `flex` additionally declared a `FlexSize` struct referenced nowhere in the workspace. One variant now, six accepted spellings. Nothing breaks: the 272 occurrences across `examples/` keep validating and render byte-identical, verified over nineteen sampled frames of the two files that lean on them hardest. `type` stays required. A typeless object meaning "container" would read as simpler and would turn a typo — `"tpye": "text"` — into a silently laid-out empty box instead of a named error. This chantier's subject is failures that pass validation; it does not add one to save a word. The default-display heuristic could not survive as a variant match, since the alias used is erased after deserialization. It now keys on whether `grid-template-columns` is set, which reproduces the old behaviour for every valid grid scenario and for all card/flex/div usage. `positioned` loses an implicit block default it never relied on in practice.
Three thousand lines of engine for three shapes a scenario can now describe itself. A terminal is a title bar over monospace lines under a typewriter. A toast is a card that slides in and out on start_at/end_at. Both were compositions wearing a type name. Codeblock was the one that looked irreducible: syntax highlighting needs the grammars of a hundred languages, and that is real. The argument fails one level up. Whoever writes the scenario is a language model that already knows those grammars, and can emit a coloured span per token directly. The grammar belongs in the generator, not in the binary every user of this crate links. `syntect` went with it, and it turned out to be declared in three manifests rather than the two that used it — dead weight in `rustmotion` and `rustmotion-core` for as long as it had been there. `similar` was the same story, reached only by codeblock's diff mode. `fancy-regex` and `plist` followed transitively: eleven crates out of the tree, on a project whose users link it as a library. `check_auto_scroll` and `ViolationKind::AutoScrollDisabledOverflow` went too. No component carries an `auto_scroll` field any more, so the variant could never be constructed again, and validator machinery that describes a feature nobody can use is the defect this chantier keeps finding.
Across the eleven files in `examples/`, five component types carried 82% of roughly 690 instances. Twenty-seven types appeared exactly once in the whole repository. `dark-premium.json` used four types, `1600-style.json` nine; `mega-showcase.json` used fifty-four, and its job is to demonstrate the catalogue. The only file that used the catalogue was the one that exists to show it. A component that adds nothing a primitive already does is a name to learn for no capability gained. Thirty-four of them are `card` plus `text` plus `shape` plus an animation, and a component defined inside a scenario beats a global one, because art direction is per video and `components` with `for-each` already provides exactly that. Seven of the thirty-four were classified as irreducible algorithms before this chantier and reclassified after it, because the chantier moved the line. A five-bar chart with proportional heights and an index-staggered grow-in is now a `for-each` and one expression. `particle` is `rand(seed, $i)` and a sine, which is its definition. That call was wrong when it was made and the evidence for changing it is a rendered frame, not an argument. Nothing is removed. Deprecating a struct in Rust also deprecates every field read on it, so a crate-root allow silences the engine's own dispatch tables, and three narrow allows cover the CLI's per-type lookups — on a function or a match arm, never a file, except `text_sizes`, whose entire purpose is that per-type table. Each note names the composition that replaces it rather than saying "deprecated", because a deprecation that does not say what to write instead just moves the problem to the reader.
The skill presented sixty components across fifty-five rule files, most of them orientated at UI widgets. A generator handed that catalogue assembles cards — 233 layout nodes across eight levels of nesting in a sixty-second example, against 42 nodes for the same subject written as a film. Thirty-four widgets leave the catalogue; three are gone from the engine entirely. Half the rule files existed only to document a removed component's quirks and go with them, and `composition-recipes.md` replaces them with the recipe each one is superseded by. A library of the thirty-four was considered and rejected. A library is still a default that anchors, and a generator fills `stat` rather than designing one. What ships instead is worked example scenarios: an example teaches composition, a library teaches filling in blanks. The container section now teaches one word. The entry document said `div` is layout and `card` is the same with decoration expected, which was never true in the code and is now not true in the schema either. This writes off recent careful work on several components. That is the cost of the decision, not an argument against it.
Four new scenarios show what replaces the widgets that left the catalogue: a KPI row, a pill row, animated progress bars and a step flow, each one component defined once and instantiated through `for-each`. Four existing ones were rewritten rather than deleted when codeblock, terminal and notification went, so the corpus proves the recipes work instead of a rules file promising they do. Two real defects surfaced only in the rendered frames, not in validation. A tokeniser regex with no catch-all silently dropped the characters it did not recognise, so a sentence rendered with its full stop missing. And a lone `=` token, from tokenising `width="1920"`, was read as an expression by the loader this chantier added, which is a collision worth knowing about for anyone emitting code as data.
`apply_post_effects` gained the scene-local instant it renders, which only `Flash` reads — every other effect is time-invariant and depends on the frame index alone. The call sites and their tests follow. Two audio error variants for the synth path, sitting in `error.rs`'s existing per-feature sectioning.
Module declarations added by hand across thirteen workstreams landed out of order. CI runs fmt first, so this is the difference between a green pipeline and a red one on the first push.
`chunks_exact_to_as_chunks` landed in clippy 1.98. CI pins the stable channel as of 2026-09-22; this machine was on 1.97.1 from July, so the lint existed in the pipeline and not in the local check that cleared the branch. Three sites: the synth's stereo-pair assertion, the audio report's PCM decode, and the flash post-effect's pixel walk. Worth recording rather than fixing silently: a green local clippy on an older toolchain is not evidence about CI. The version gap is the check, not the command.
This was referenced Sep 26, 2026
This was referenced Sep 26, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #327, #328, #329, #330, #331, #332, #333, #334, #335, #336, #338. Refs #326.
Two motion reels were generated by an LLM with no access to Rustmotion — one on Canvas 2D, one on an SVG tree driven by a GSAP timeline and a Web Audio score. Both were better than anything this engine had produced. Rebuilding the first as a scenario, at the maximum the engine then allowed, measured the gap in numbers rather than impressions.
Most of that measurement was in Rustmotion's favour. It renders 15 s at 1920×1080 in 7.5 s, against roughly eight minutes for the SVG reel through headless Chromium and 600 PNG captures. Its geometry validator caught the one real defect in the rebuild — the exact class of defect the SVG reel's author states he had no way to detect. None of that was the problem.
Seven things could not be expressed at all, and this branch closes all seven.
atin beats, transitions as overlap, declared 15.0 s renders 15.0 sAudioTrackneeded asrc; none of the 11 examples had audionode("id", "prop")across four families, resolved in topological order?detaching from a typed sentence: abandoned{at, amplitude}pairsThis is too large to review in one sitting, and that is worth saying plainly. 20 000 insertions across 166 files. Reviewing it commit by commit is realistic: each of the fourteen carries its own reasoning, the alternative it rejected, and what it could not verify. The split is by subsystem rather than by workstream, because thirteen agents worked in parallel and files like
lib.rscarry four of them at once.What the decomposition did not predict, and what it cost
Five of five blocked deliverables across the chantier were the same thing: wiring. The partition was drawn by mechanism, and every mechanism's wiring lands on one shared surface — the per-frame render path. Fencing it off to stop three agents colliding is what made five of them stop at the fence.
Three independent workstreams then hit a second shared trap.
cli/commands/validation.rscarries its own load pipeline, separate fromloader.rs, andinclude.rscarries a third. Static folding, the variable guard and the synthesised audio were each wired once, passed their own tests, and did nothing through the actual CLI until wired a second time. A fourth and fifth pipeline turned up the same way.A feature is not done in this codebase when
loader.rsknows about it. That is the most reusable thing this branch learned, and it will cost the next feature too unless it is written down.Three findings this branch does not fix
gradient_textignorestext-alignand draws every line from x=0. Verified still reproducing on this branch: same box, same property,textcentres andgradient_textdoes not.style.clip-pathis declared, exported in the schema with seven variants, and read by nothing. Verified on this branch by sampling pixels: a circle withinset{right:120}is unclipped at both probes. Present since the CSS renderer rework in May. This is the one drawing capability the reference reels use that the engine has no path to.examples/ferriskey-presentation.jsonoverflows by 4px, pre-existing on main.#157 and #337 are the same component, and are plausibly two symptoms of one measurement or placement fault in
gradient_text.Known limits, named rather than buried
A
node()reference resolves one hop; a chain C→B→A does not. A referencing node with noidof its own is invisible to the dependency graph, at validate and at render alike.resolve_node_referencesis wired for slide views, not world views.{"type":"grid"}with neitherdisplaynorgrid-template-columnsloses its dedicated error, since the container merge erases which spelling was written — no example uses it. Expressions have two dialects:= expron a numeric property,${expr}inside a string, because a path is text with syntax around it and a number is not.Verification
Verified by rebuilding and measuring rather than on a report's word: six scenes declaring 15.0 s render 13.5 s under v1 and 15.0 s under v2; a
for-eachover eight computed cosines places eight labels on an ellipse with no generator script;"opacity": "= $fade"over an animated variable renders black → mid → full across three sampled instants; a 59-event synthesised score muxes at peak −0.147 dB with two renders byte-identical; 272 container instances across the corpus render byte-identical over nineteen sampled frames after the six-into-one merge.syntect,similar,fancy-regexandplistleave the tree withcodeblock. Two of the three manifests declaringsyntectnever imported it.