Skip to content

chantier: expression parity with hand-written motion code #326

Description

@LeadcodeDev

Two short 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 are better than anything this engine has produced. Rebuilding the first one as a scenario, at the maximum the engine currently allows, measures the gap exactly.

Most of the result is in Rustmotion's favour. The rebuild renders 15 s at 1920x1080 in 7.5 s, against roughly eight minutes for the SVG reel through headless Chromium and 600 PNG captures. It is 42 nodes where examples/ferriskey-launch-60s.json is 233 across eight levels of nesting, so the component catalogue is not what forces the slide-deck shape. The geometry validator caught the one real defect in it, which is the class of defect the SVG reel's author states he could not detect at all.

Seven things could not be expressed.

# Wall Measured cost
1 A cut cannot land on a beat Transitions are subtracted from the timeline. Six scenes declaring 15.0 s render 13.5 s, and the last cut lands 1.15 s early.
2 No sound AudioTrack requires a src. An LLM can write a score; it cannot hand over a WAV.
3 No reference between nodes Eight lines joining a logo to orbiting badges: abandoned.
4 A glyph is not addressable A ? detaching from the end of a typed sentence: abandoned.
5 No measurement inside a text run Caret head, per-letter advance, gradient span. Four of the reel's fifteen measureText calls; flexbox covers the other eleven better than manual measurement does.
6 No arithmetic 316 lines of Python to emit the JSON. Every position frozen as a literal.
7 No global shake 155 sampled camera keyframes. GSAP writes the same thing as {x: 7, duration: .035, repeat: 11, yoyo: true}.

The rendering backend is not the subject. One reel is immediate-mode Canvas, the other a declarative tree with tweens on a timeline. Opposite paradigms, and they hit the same seven walls. What both have and this engine does not is a single absolute timeline, arithmetic in the authoring layer, and a synthesised score sharing a clock with the cuts.

Verified on main at 23f4d6a: build_frame_tasks subtracts transition frames from the scene frames rather than overlapping them; geometry.rs iterates view.scenes and never samples a transition frame; there is no expression evaluator, no node reference, no vars and no bpm anywhere in the schema. ClipPath::Path, mix-blend-mode and text.caret already exist and are not part of this work.

Scope

Goal:         A scenario written by an LLM reaches what the same LLM writes
              with Canvas or with SVG + GSAP, and needs no generator script.
Invariants:   - A scenario with no `at`, no `bpm`, no expression and no
                reference renders exactly as it does today.
              - Two renders of the same file produce the same frames and the
                same audio. No global RNG state.
              - Geometry validation runs after static folding and covers
                transition frames.
Out of scope: An imperative drawing mode. A scripting runtime. Any change to
              the studio's data model. New chart types, new components.
Acceptance:   - The 15 s reel rebuilds with no external generator and all
                seven walls gone
              - Declared 15.0 s renders 15.0 s, cuts on the beat grid
              - The scenario carries its own soundtrack, with no audio file
              - <= 15% render-time regression on examples/mega-showcase.json
Verify (exit): cargo fmt --all --check
               cargo clippy --workspace --all-targets -- -D warnings
               cargo test --workspace

Workstreams

Batch 1 has no dependency between its four members and can run at once.

Batch Issue Walls
1 #336 absolute timeline and beat grid 1
1 #327 arithmetic expressions for scenario values 6
1 #333 shrink what the generator reads first (phase A)
1 #334 a contact sheet
2 #328 node references and text measurement 3, 4, 5
2 #330 repeat, yoyo, steps easing, declarative shake 7
2 #331 a synthesised soundtrack 2
3 #329 animated variables
3 #332 deeper drawing primitives
3 #334 transition sampling
4 #333 deprecate the frozen compositions (phase B)
4 #335 the timing gate and a migration command

#329 carries no wall of its own and is the one that makes the rest compose: it is the mechanism the SVG reel's author describes as animating a plain object and mapping it to attributes every frame. Without it, derived motion stays out of reach whatever the other issues land.


Orchestration state

Branch chantier/expression-parity, cut from main at 23f4d6a. This half of the issue is the single source of truth for who writes what, written for a session with no shared memory.

Status

Issue Mission Status
#336 Absolute timeline, beat grid, transitions as overlap integrated
#327 Arithmetic expressions integrated
#333 Shrink the generator surface (phase A) integrated
#334 Contact sheet (rustmotion sheet) integrated
#330 repeat, yoyo, steps, declarative shake integrated
#328 Node references and text measurement integrated
#329 Animated variables integrated
#338 A field can hold an expression integrated
— Wiring: shake on the camera, per-frame dependency graph integrated
— Join: composed per-frame scope (clock + vars + node refs) integrated
#331 Synthesised soundtrack integrated
#333 Deprecate the frozen compositions (phase B) integrated
#332 Deeper drawing primitives in-flight
#334 Transition sampling, audio measurement in-flight
#335 Timing gate, migration, the 11 remaining deprecations pending

Verified end to end, by rebuilding and measuring rather than on a report's word: a six-scene file declaring 15.0 s renders 13.5 s under timing: v1 and 15.0 s under v2; a for-each over eight computed cosines places eight labels on an ellipse with no generator script; "opacity": "= $fade" over an animated variable renders 000000 → 197f19 → 33ff33; a 59-event synthesised score muxes at peak -0.147 dB with two renders byte-identical.

What this cost that the decomposition did not predict

Five of the workstreams' blocked deliverables 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 keep three agents from colliding is what made five of them stop at the fence.

Three independent workstreams then hit a second shared trap: cli/commands/validation.rs carries its own load pipeline, separate from loader.rs. Static folding, the variable guard and the synth were each wired once, worked in tests, and did nothing through the CLI until wired a second time there. A fourth and fifth pipeline turned up in include.rs. A feature in this codebase is not done when loader.rs knows about it.

Frozen contracts

Decided before parallelising, read-only for every sub-agent. A workstream that needs one of these reshaped stops and reports rather than changing it.

TimePoint — produced by #336, consumed by #330, #331, #329

// crates/rustmotion-core/src/schema/time.rs
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TimeCtx {
    pub bpm: Option<f64>,
    pub beat_offset: f64,
    pub scene_start: f64,
}

#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum TimeError {
    #[error("beat unit in `{0}` but the scenario declares no bpm")]
    NoBpm(String),
    #[error("cannot parse time `{0}`")]
    Unparseable(String),
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum TimePoint {
    Seconds(f64),
    Spec(String),
}

impl TimePoint {
    pub fn resolve_relative(&self, ctx: &TimeCtx) -> Result<f64, TimeError>;
    pub fn resolve_absolute(&self, ctx: &TimeCtx) -> Result<f64, TimeError>;
    pub fn is_absolute(&self) -> bool;
}

Spec grammar: optional leading @ for absolute, then terms joined by + or -, each <number><unit> with unit s, ms or b. Beats resolve as beat_offset + n * 60/bpm and error NoBpm without a bpm. Must parse: "2.5s", "8b", "120ms", "8b+120ms", "@8b", "@2.2s-1b".

Expr and Scope — produced by #327, consumed by #328, #329, #332

// crates/rustmotion-core/src/expr/mod.rs
pub trait Scope {
    fn var(&self, name: &str) -> Option<f64>;
    fn node_prop(&self, id: &str, prop: &str) -> Option<f64> { let _ = (id, prop); None }
}

pub enum ExprError { Parse { src, reason }, UnknownIdent(String), Arity { name, expected, got }, TooDeep(usize) }

pub struct Expr { /* private */ }
impl Expr {
    pub fn parse(src: &str) -> Result<Self, ExprError>;
    pub fn eval(&self, scope: &dyn Scope) -> Result<f64, ExprError>;
    pub fn free_vars(&self) -> Vec<String>;
    pub fn is_static(&self) -> bool;
}

#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Computed<T> { Literal(T), Expr(String) }

#328 implements node_prop, #329 implements var for animated variables. Neither reshapes the trait.

The component three-class split — decided, not revisable by a sub-agent

Seventeen algorithms stay in Rust with their docs: chart, codeblock, terminal, qr_code, dot_map, treemap, heatmap, table, lottie, video, gif, caption, waveform, audio_spectrum, mockup, image, particle.

Fourteen primitives stay and get emphasis: text, rich_text, gradient_text, shape, svg, icon, line, arrow, connector, div, card, flex, grid, positioned, cursor, pointer.

Twenty-seven frozen compositions stop being presented to the generator and are deprecated in phase B: stat, badge, gauge, sparkline, progress, counter, number_wheel, kbd, tooltip, list, stepper, comparison, countdown, pill_nav, notification, avatar, avatar_group, rating, switch, slider, skeleton, tag_cloud, callout, divider, success_check, timeline, marquee.

File partition

Owned, batch 1

Issue Files
#336 rustmotion-core/src/schema/time.rs (new), schema/scenario.rs, schema/mod.rs, rustmotion/src/encode/video/tasks.rs
#327 rustmotion-core/src/expr/** (new), rustmotion-core/src/lib.rs, rustmotion/src/loader.rs
#333 crates/rustmotion/skills/**, new files under examples/ only
#334 rustmotion/src/cli/commands/sheet.rs (new), cli/commands/mod.rs, cli/mod.rs

crates/rustmotion/src/tests.rs is append-only for every workstream: nobody edits or deletes an existing test.

Orchestrator-owned

Cargo.toml at every level, CLAUDE.md, this file, and any existing file under examples/. A sub-agent needing a dependency reports the exact line rather than editing a manifest.

Batch 1's module-registration points turned out disjoint, so each was assigned to its single writer rather than reserved. This is a deliberate departure from reserving every convergence point: in Rust a module that is not registered does not compile, so reserving them would have left three agents unable to build. Later batches must re-check this before reusing the pattern.

Regenerated at integration

Cargo.lock — rebuilt by cargo check --workspace, never hand-edited or merged.

Per-workstream environment

cargo holds a lock on the target directory, so concurrent builds in one checkout serialize at the toolchain level whatever the file partition says. Each agent gets its own:

Issue CARGO_TARGET_DIR
#336 /tmp/rm-ws336
#327 /tmp/rm-ws327
#333 /tmp/rm-ws333
#334 /tmp/rm-ws334

Batch 1 runs concurrently in a shared checkout, so no sub-agent runs the full workspace suite: a full run there tests a tree carrying siblings' unfinished work. Sub-agents stay scoped to their own crates; only the orchestrator's post-integration run counts.

Decision log

The rendering backend is not the subject. One reference reel is immediate-mode Canvas, the other a declarative SVG tree with tweens on a timeline. Opposite paradigms, same seven walls. An imperative drawing mode was considered and rejected: it lifts the ceiling and takes the studio with it, and the studio is the product's reason to exist.

A2 over a smaller fix. Correcting only the transition accounting was on the table. It fixes the measured symptom and leaves the cause: the video's rhythm stays a sum of durations, so nothing can be placed at an absolute time across a cut. The absolute timeline exposes to the author what the engine already does internally through FrameTask and scenario_time.

Animated variables are not folded into the timeline work. They need the expression engine, which does not exist yet. Folding them in would have made #336 large and delayed the only change that shows immediately.

The twenty-seven do not become a shipped library. A library is still a default that anchors, and a generator fills stat rather than designing one. A component defined inside a scenario beats a global one because art direction is per video, and components + for-each already provide that. What ships instead is worked examples: an example teaches composition, a library teaches filling in blanks. This writes off recent careful work on success_check, number_wheel and pointer.

Deprecation, never deletion. The twenty-seven keep working. Removing an enum variant from a published crate breaks every existing scenario, rustmotion-html and the studio, so removal is a major version with rustmotion migrate behind it.

The skill lives at crates/rustmotion/skills/. .claude/skills/rustmotion is a symlink to it, and git tracks the crates/ path: 59 files there against 3 under .claude/. The first briefing for #333 named the symlink and forbade writing under crates/, which is a contradiction the agent had to work around silently. Every later briefing names the real path.

Sub-agents run no state-changing git command. No checkout, reset, stash, commit, branch or push. The first run of batch 1 lost every edit to a tracked file when the working copy was switched to another branch from a GUI while four agents were writing. New files survived; modifications did not. Read-only git is still fine.

The chantier branch has its own remote ref. It was cut with git checkout -b … origin/main, which set its upstream to origin/main and left it sitting on main's tip with no commits of its own — a bare git push would have gone to main. It is now pushed as origin/chantier/expression-parity and tracks that.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions