<!-- oxframe-agents {{VERSION}}: generated by `oxframe init`, refreshed by `oxframe agents`. Delete this line to keep your own edits. -->
# Instructions for AI coding agents
This is an **oxframe** project: procedural animation in Rust. Scenes are
`async fn`s; every `.await` on an animation takes time.
This project uses **oxframe {{SDK_VERSION}}**. Use the docs of exactly this
version — the API changes between versions:
- Guide: https://oxframe.dev/docs/{{DOCS_SERIES}}
- Docs for models: https://oxframe.dev/docs/{{DOCS_SERIES}}/llms.txt and https://oxframe.dev/docs/{{DOCS_SERIES}}/llms-full.txt
- API reference: https://docs.rs/oxframe/{{SDK_VERSION}}/oxframe/
## Layout
- `src/main.rs` — scenes (`#[oxframe::scene] async fn name(view: View)`) and `main()` with `Project::new(..).scene(..).main()`.
- `src/<scene>.meta.json` — markers, eases, tunables and transition length edited in the editor. **Do not hand-edit unless asked; never delete keys.**
- `oxframe.toml` — settings for the `oxframe` CLI. `assets/` — images, audio.
## Commands (run from the project root)
| Goal | Command |
|---|---|
| **Check a change** (compile, then look) | `oxframe check` → `out/check/storyboard.png` and `out/check/report.json`. Exit 1 if any issue is an error. |
| Scene tree at one time | `oxframe inspect --at 2.5` (JSON: kind, name, bounds, text, `file:line`; `transition` with kind and progress while one is playing). `--graph` adds `passes`: vector, then composite |
| One full-size still | `oxframe snapshot --at 1.5` → prints the PNG path |
| Scene length, markers | `cargo run -q -- info` (JSON) |
| Final video | `oxframe render` → `out/<project>.mp4` (needs ffmpeg) |
| A ready-made, checked block | `oxframe add` lists them; `oxframe add stat-counter` writes `src/blocks/stat_counter.rs` and `mod blocks;`; call `blocks::stat_counter(view, 1280.0, "stars").await` |
Add `--json` to any command for one JSON object on stdout (`ok`, `exit`, and
on a compile error `diagnostics` plus `hints`). `oxframe mcp` is an MCP server
on stdio with tools `check`, `storyboard`, `render_frame`, `inspect`,
`render_graph` (the render passes at one time), `list_components`,
`docs_search` and `render`.
`oxframe dev` is an interactive editor server for humans; do not run it in an
agent session (it never exits).
## Model in five lines
1. `let s = view.add(Rect::new().size(200.0).x(-300.0).fill(color!("#e13238")));` — builder sets the start; `add` returns a `Copy` handle.
2. `s.x().to(300.0, 1.0).await;` — animate a property (target, seconds) and wait.
3. `all((a, b, c)).await` — run together (a tuple, or `all(items.iter().map(|x| x.fade_in()))`); `stagger(0.1, &items, |x| x.pop_in()).await` — one after another; `wait_for(secs)`; `spawn(fut)` for background. Never `Box::pin` or `BoxFuture`.
4. Properties are `Signal<T>`: `.get()`, `.set(v)`, `.to(v, secs).ease(e)`, `.bind(move || ..)`.
5. Coordinates: origin at the center, y down, default canvas 1920×1080 at 60 fps; rotation in degrees. Prefer theme tokens (`Accent`, `.display()`, `.padding(Md)`, `Normal`) over hardcoded colors and sizes. Place with `.next_to(other, Side::Bottom)`, `.align_to(other, Edge::Top)`, `.to_edge(Edge::Top)`, `.center_in(other)` and `.width(pct(80))` instead of pixel guesses. `Project::aspects([Landscape, Portrait, Square])` and `view.is_portrait()` cover 16:9, 9:16 and 1:1. `Project::theme(...)` or `[theme]` in `oxframe.toml` restyles the whole video.
## Transitions
`transition(Transition::slide(Dir::Left)).await` at the start of a scene (not the first) blends the previous scene's last frame over this one for the theme's normal duration. Also `fade`, `wipe(Dir::…)`, `zoom_in`, `zoom_out`, `.duration(0.6)`, `.ease(...)`, `.overlap()`, and `Transition::custom(|t| Blend { ... })`. Nodes added before the call are visible during the blend.
## Camera
For a big diagram or a close look at a detail: build it inside
`view.add(Camera::new())`, then `cam.fit(node, padding, secs)`,
`cam.center_on(node, secs)`, `cam.zoom().to(2.0, secs)`, `cam.reset(secs)`.
Titles added to `view` stay on screen.
## Components
Prefer these over drawing the block yourself. Each has `enter()`, `next()` and `exit()`.
- `TitleCard::new("Binary search").subtitle("O(log n)")` — title, then the subtitle on `next`.
- `ChapterMarker::new("1 Search")` — label on the top safe edge.
- `BulletList::new(["Take the middle", "Compare", "Drop half"])` — `next` reveals one row.
- `CodeExplainer::new("lo + (hi - lo) / 2").step(1, 1, "the middle")` — `next` focuses that line range.
- `FlowDiagram::new().edge("Client", "API").edge("API", "DB")` — laid out in layers; `.down()` stacks them. `next` highlights the next node.
- `Callout::new(node).text("the middle")` — arrow and caption above `node`.
- `LowerThird::new("Ada").role("walks the half")` — name plate on the bottom safe edge.
- `EndCard::new("That's the search").note("Repeat from the top")` — closing card.
See `.claude/skills/oxframe/reference.md` for the full API cheat sheet.
## Voice
`voice("one two").start()` speaks now. `line.at_word("two").await` waits for that word; `line.await` waits until the end. Await the line, or `oxframe check` errors because it runs past the scene. A line is `.priority()`, so it ducks a soundtrack built with `.duck_under_voice()`. Pass a `TtsProvider` with `Project::tts`. Without one, the length is estimated. A real recording is cached under `tts/`; commit that folder so CI renders it without an API key. `oxframe render --subtitles out.srt` writes the lines (`.vtt` by extension).
## Video
Needs `features = ["video"]` on `oxframe` and ffmpeg. `let demo = view.add(Video::new().file("assets/demo.mp4").width(pct(70)).radius(24.0));` then `demo.play()` (returns at once), `demo.pause()`, `demo.seek(2.0)`; `demo.playback_rate().to(0.25, 0.5)` and `demo.time()` are signals. Sound only at rate 1; `.muted(true)` drops it. Not in the browser playground.
## 3D
Needs `features = ["3d"]`. `let world = Scene3d::new(); let cube = world.add(Mesh::cube().color(Accent));` then `view.add(View3d::new(&world).size(640.0, 400.0))` — the view is a 2D node. `cube.rotation().to(Vec3::new(0.0, 360.0, 0.0), 3.0)` (degrees), `world.camera().position()`, `world.light(Light::directional((-2.0, -4.0, -1.0)))`. For a 2D label on a 3D point use `let to_view = frame.projector();` inside `bind` (not `frame.project`). Y is up, shapes are 1 unit. No shadows.
## Presentations
`slide_break().await` is a stop for `oxframe present` (a render plays through, so put `wait_for(1.5).await` before each break or `check` reports `too_fast`); every scene ends with a stop, so don't fade out the last point — the next scene's transition does. `notes("…")` are speaker notes for the part up to the next stop. `oxframe render --slides talk.pdf` writes a page per stop.
## Matrices, vectors, tables
`view.add(Matrix::new([[1, 2], [3, 4]]).font_size(64.0))`; cells are `Txt`: `m.cell(r, c)`, `m.row(r)`, `m.column(c)`; `m.row_frame(r)` / `column_frame(c)` / `cell_frame(r, c)` are hidden outlines to `.fade_in()`. On `Axes`: `axes.vector(x, y)` (tip `v.to(x, y, secs)`, `.label("v")`) and `axes.apply_matrix([[a, b], [c, d]], secs)` bends the grid and everything on it. `Table::new(["A", "B"]).row(["x", "1"])`, then `t.insert_row(i, [..])`, `t.remove_row(i)`, `t.sort_by_column(c, secs)` — await them.
## Icons
Needs `features = ["icons"]` on `oxframe`. `view.add(icon!("lucide:server").size(64.0).color(Accent))`; sets are `lucide:`, `tabler:` (`-filled` for filled) and `simple:` (brand logos). A wrong name fails to compile and lists the closest names. The handle's `color()` and `stroke_width()` are signals; `paths()` for `draw_on`. In a diagram: `FlowDiagram::new().edge("api", "db").icon("db", icon!("lucide:database"))`, then `flow.icon("db")`.
## Nodes of your own
Needs `features = ["unstable-node-api"]` on the `oxframe` dependency.
```rust
#[derive(Node)]
pub struct Gauge { #[signal] value: f64, #[signal] size: f64 }
impl Draw for Gauge {
fn draw(&self, ctx: &mut DrawCtx) { ctx.arc((0.0, 0.0), self.size / 2.0, 135.0, 270.0 * self.value).stroke(Accent, 16.0); }
fn size(&self) -> (f64, f64) { (self.size, self.size) }
}
let g = view.add(Gauge::new().size(200.0)); // GaugeHandle; g.value() is a Signal
```
Draw only from the fields and `ctx.time()`. To combine existing nodes, write a function that adds them to a `Group` instead.
## Charts
```rust
let axes = view.add(Axes::new().x_range(-5.0..5.0).y_range(-2.0..2.0));
let wave = axes.plot(|x| x.sin()).stroke(Accent);
wave.draw_on().await;
axes.x_range().to(-10.0..10.0, 1.0).await; // the curve, grid and labels follow
```
`axes.point_on(&wave, x_signal)` rides a dot on the curve; `wave.to_fn(|x| x.cos(), 1.0)` morphs it. Bars: `axes.bars([0.0, 0.0]).labels(["a", "b"])`, then `bars.value(0).to(3.2, 1.0)`, x range `-0.5..n - 0.5`. `NumberLine::new(0.0..10.0)` is one axis. No pie chart: use `Custom`.
## Shaders
```rust
let sea = view.add(shaders::waves().amp(0.05).deep(Accent).foam(Surface)
.width(pct(100)).height(pct(40)).to_edge(Edge::Bottom));
sea.amp().to(0.12, 1.2).await;
```
`shaders::waves()` is a node. `.amp()`, `.speed()`, `.deep()` and `.foam()` are signals. A theme role such as `Accent` follows the theme. The node is a box and has no children; put a title on the view.
`Shader::source(wgsl)` and `Shader::file(path)` take WGSL with `struct Params`, the uniform `p`, and `fn shade`. oxframe adds `ox` and `ShadeIn`. `.uniform("amp", 0.3)` sets a field. A name or type that is not in `Params` fails `check`. A file is read on every draw, so saving it updates the scene without a rebuild. The waves preset is painted. A custom `shade` is type-checked on every draw.
```rust
let lens = view.add(shaders::lens_drops().wetness(0.0).fullscreen()); // add it last
lens.wetness().to(1.0, 0.4).await; // a splash
let rings = sea.effect(shaders::ripple()); // rings on a node
rings.strength().to(0.0, 1.0).await;
transition(Transition::shader(shaders::water_wipe())).await; // next scene
```
Pick a preset before writing WGSL:
| You want | Use |
| --- | --- |
| A sea or a moving water background | `shaders::waves()`, a box node |
| Spray, sparks, rain | `Particles::new().seed(7)` with `.burst()`, or `Custom` with `ctx.rng(seed)` |
| A wet camera lens | `shaders::lens_drops()`, added last, `.fullscreen()` |
| Rings on water, text or an image | `node.effect(shaders::ripple())` |
| A scene change through water | `Transition::shader(shaders::water_wipe())` |
| Anything else | `Custom`; your own WGSL is checked, not run |
Make an event (a splash, a swell) a signal change at that moment, such as `lens.wetness().to(1.0, 0.4)`, not a formula of `ox.time`: the timeline and `check` see signals. `lens_drops` bends what was drawn before it. `node.effect(..)` changes a node's own pixels; `ripple` moves colors and keeps the outline, so a flat fill shows nothing. Never put either inside a group with opacity below 1, a blur, a clip, a blend, a shadow or another effect: `check` reports `input_cycle` and skips the shader. In `water_wipe`, add the next scene's background before the `transition` call.
## Rules
- **Deterministic**: never use `std::time`, `Instant`, `rand::thread_rng` or other entropy; use `random()` / `random_range(a, b)` (seeded by `Project::seed`). Avoid iterating `HashMap` to decide what to draw; use `Vec`/`BTreeMap`.
- Names for `wait_until`, `tune!`, `ease!` are `[a-z0-9_-]+` (checked at compile time for the macros).
- Colors: `color!("#rrggbb")` (compile-time checked) or `Color::rgb(r, g, b)` with 0..1 floats.
- Keep each scene a straight story: prefer `all(..)`/`stagger(..)` and the helpers `fade_in()`, `fade_out()`, `pop_in()`, `slide_in(Side::Left)`, `draw_on()` (timed by the theme; `.duration(s)` to change) over manual tweens and frame loops; `next_frame()` only for per-frame logic.
- After each visual change run `oxframe check`, open `out/check/storyboard.png` and read `report.json`, fix what it reports, and run it again. Do not report success while it exits non-zero.
- Name nodes that matter in the story: `.name("title")`. Without a name, check and inspect label the node by the `add` call site (`src/main.rs:42`).
## Common compile errors
- *"closure may outlive the current function"* in `bind`: clone the signals first and use `move`:
`let (ax, bx) = (a.x(), b.x()); label.x().bind(move || (ax.get() + bx.get()) / 2.0);`
- *"`()` is not an animation"*: a closure ending in `;` returns nothing. Drop the `;` (`|r| r.fade_in()`), or return `async move { a.await; b.await; }` for several steps.
- `Signal::new(0.0)` ambiguous float: write `0.0f64`.
- `all` of a few different animations takes a **tuple** — note the double parentheses: `all((a, b))`.