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
22 changes: 12 additions & 10 deletions crates/rustmotion/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,7 @@ Each scene is an **implicit flex container** at video dimensions. All children p

**IMPORTANT:** Every scene SHOULD include `"layout": {"align_items": "center", "justify_content": "center"}` for centered composition. Without this, content aligns to the top-left corner.

**`layout` options:** `direction` (column/row), `gap`, `align_items` (start/center/end/stretch), `justify_content` (start/center/end/space_between/space_around/space_evenly), `padding`
**`layout` options:** `direction` (column/row), `gap`, `align_items` (start/center/end/stretch), `justify_content` (start/center/end/space_between/space_around/space_evenly), `padding` (f32 only — unlike `style.padding`, `layout.padding` does not accept the `{top,right,bottom,left}` object form)

#### Layout Strategy: Prefer Flex/Grid — Absolute is a last resort

Expand Down Expand Up @@ -1254,7 +1254,7 @@ Each dimension (`width`/`height` in `style`) can be a number or `"auto"`.
| ------------------------ | ----------- | ---------- |
| `display` | enum | `"flex"` — `"flex"` or `"grid"` |
| `background` | string | `null` |
| `border-radius` | f32 | `12.0` |
| `border-radius` | f32 | `null` — sharp corners; `card`/`flex`/etc. are the same component as `div` and carry no special default either |
| `border` | object | `null` — `{ "color": "#E5E7EB", "width": 1 }` |
| `box-shadow` | array | `null` — `[{ "color": "#00000040", "offset-x": 0, "offset-y": 4, "blur": 12 }]` (kebab-case keys, always an array — see [rules/component-field-placement.md](rules/component-field-placement.md)) |
| `padding` | f32 or obj | `null` |
Expand Down Expand Up @@ -1807,7 +1807,7 @@ The `timeline` field — a **root field**, sibling of `style`, not nested inside
}
```

**Animatable properties:** `opacity`, `translate_x`, `translate_y`, `scale_x`, `scale_y`, `scale` (both axes), `rotation`, `blur`, `color`, `rotate_x`, `rotate_y`, `perspective`
**Animatable properties:** `opacity`, `translate_x`, `translate_y`, `scale.x`, `scale.y`, `scale` (both axes), `rotation`, `blur`, `color`, `rotate_x`, `rotate_y`, `perspective`

**3D keyframe properties:**
- `rotate_x` — Rotation around X axis in degrees (tilts forward/backward)
Expand Down Expand Up @@ -1920,7 +1920,7 @@ Orbit creates continuous circular or elliptical motion with pseudo-3D depth simu

```bash
# Render a scenario file to MP4
rustmotion render scenario.json -o output.mp4
rustmotion render -f scenario.json -o output.mp4

# Render from inline JSON
rustmotion render --json '{ ... }' -o output.mp4
Expand All @@ -1936,24 +1936,26 @@ rustmotion validate -f scenario.json --lenient # warnings only
rustmotion schema

# Show scenario info
rustmotion info scenario.json
rustmotion info -f scenario.json

# Render a single frame (0-indexed) as PNG
rustmotion render scenario.json -o frame.png --frame 0
rustmotion render -f scenario.json -o frame.png --frame 0

# Render with specific codec/format
rustmotion render scenario.json -o output.webm --codec vp9 --format webm
rustmotion render -f scenario.json -o output.webm --codec vp9 --format webm

# Render as GIF
rustmotion render scenario.json -o output.gif --format gif
rustmotion render -f scenario.json -o output.gif --format gif

# Render as PNG sequence
rustmotion render scenario.json -o frames/ --format png-seq
rustmotion render -f scenario.json -o frames/ --format png-seq

# Machine-readable output
rustmotion render scenario.json -o output.mp4 --output-format json
rustmotion render -f scenario.json -o output.mp4 --output-format json
```

`render` and `info` only accept the path via `-f`/`--file` — there is no positional form; passing a bare path errors with `unexpected argument`.

---

### Pre-Delivery Checklist
Expand Down
122 changes: 119 additions & 3 deletions crates/rustmotion/src/cli/commands/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use rustmotion::components::box_builder::{
use rustmotion::components::intrinsic::{
CaptionIntrinsic, GradientTextIntrinsic, RichTextIntrinsic, TableIntrinsic, TextIntrinsic,
};
use rustmotion::components::{ChildComponent, Component};
use rustmotion::components::{Arrow, ChildComponent, Component, Connector, Line};
use rustmotion::core::css::style::{
CssStyle, Position, TransformFn, TransformOrigin, WhiteSpace, MIN_LEGIBLE_FONT_RATIO,
TEXT_AUTOFIT_MIN_FONT_PX,
Expand Down Expand Up @@ -160,7 +160,7 @@ fn walk(
Some(l) => l,
None => continue,
};
let raw_bbox = bbox_of(layout);
let raw_bbox = component_bbox(&child.component, layout);
let own_bound = if box_node.css.position == Some(Position::Absolute) {
None
} else {
Expand Down Expand Up @@ -229,6 +229,84 @@ fn bbox_of(layout: &BoxLayout) -> BBox {
}
}

const ARROW_HEAD_BBOX_PADDING: f32 = 16.0;

fn endpoint_extent(component: &Component) -> Option<(f32, f32, f32, f32, f32)> {
match component {
Component::Line(Line {
x1,
y1,
x2,
y2,
width,
..
}) => Some((
x1.min(*x2),
y1.min(*y2),
x1.max(*x2),
y1.max(*y2),
width.max(0.0) / 2.0,
)),
Component::Arrow(Arrow {
x1,
y1,
x2,
y2,
cp,
cp1,
cp2,
width,
arrow_size,
..
}) => {
let mut min_x = x1.min(*x2);
let mut max_x = x1.max(*x2);
let mut min_y = y1.min(*y2);
let mut max_y = y1.max(*y2);
for p in [cp.as_ref(), cp1.as_ref(), cp2.as_ref()]
.into_iter()
.flatten()
{
min_x = min_x.min(p.x);
max_x = max_x.max(p.x);
min_y = min_y.min(p.y);
max_y = max_y.max(p.y);
}
let pad = width.max(0.0) / 2.0 + ARROW_HEAD_BBOX_PADDING + arrow_size.max(0.0);
Some((min_x, min_y, max_x, max_y, pad))
}
Component::Connector(Connector {
from,
to,
width,
arrow_size,
..
}) => {
let pad = width.max(0.0) / 2.0 + ARROW_HEAD_BBOX_PADDING + arrow_size.max(0.0);
Some((
from.x.min(to.x),
from.y.min(to.y),
from.x.max(to.x),
from.y.max(to.y),
pad,
))
}
_ => None,
}
}

fn component_bbox(component: &Component, layout: &BoxLayout) -> BBox {
match endpoint_extent(component) {
Some((min_x, min_y, max_x, max_y, pad)) => BBox {
x: layout.x + min_x - pad,
y: layout.y + min_y - pad,
w: (max_x - min_x) + pad * 2.0,
h: (max_y - min_y) + pad * 2.0,
},
None => bbox_of(layout),
}
}

fn is_exempted(c: &Component) -> bool {
matches!(
c,
Expand Down Expand Up @@ -935,7 +1013,7 @@ fn walk_anim(
Some(effects) => resolve_props_for_effects(&effects, local_time, scene_duration),
None => AnimatedProperties::default(),
};
let raw_bbox = bbox_of(layout);
let raw_bbox = component_bbox(&child.component, layout);
let mut transformed = apply_static_node_transform(&raw_bbox, &box_node.css, viewport_f);
if let Some(overshoot) = props
.char_animation
Expand Down Expand Up @@ -3116,6 +3194,44 @@ mod tests {
violations
);
}

#[test]
fn line_bbox_honours_x1_y1_not_just_the_node_position() {
let json = r##"{"video":{"width":1920,"height":1080,"fps":30,"background":"#000000"},
"scenes":[{"duration":1.0,"children":[
{"type":"line","position":"absolute","x":0,"y":0,"x1":960,"y1":100,"x2":960,"y2":1280,"color":"#FFFFFF","width":4}]}]}"##;
let scenario = parse(json);
let violations = validate_geometry(&scenario);
let v = violations
.iter()
.find(|v| v.component == "line" && v.kind == ViolationKind::ViewportOverflow)
.unwrap_or_else(|| {
panic!("expected a ViewportOverflow for the line: {:?}", violations)
});
assert_eq!(v.axis, Axis::Y);
assert_eq!(
(v.bbox.x, v.bbox.y, v.bbox.w, v.bbox.h),
(958.0, 98.0, 4.0, 1184.0),
"bbox must be anchored at x1/y1 (960, 100), not at the node's own x/y (0, 0): {:?}",
v.bbox
);
}

#[test]
fn line_with_negative_x1_that_pokes_off_the_left_edge_is_caught() {
let json = r##"{"video":{"width":1920,"height":1080,"fps":30,"background":"#000000"},
"scenes":[{"duration":1.0,"children":[
{"type":"line","position":"absolute","x":0,"y":0,"x1":-500,"y1":100,"x2":0,"y2":100,"color":"#FFFFFF","width":4}]}]}"##;
let scenario = parse(json);
let violations = validate_geometry(&scenario);
assert!(
violations
.iter()
.any(|v| v.component == "line" && v.kind == ViolationKind::ViewportOverflow && v.axis == Axis::X),
"a line whose x1 pokes past x=0 must be reported even though its own box (x=0) does not: {:?}",
violations
);
}
}

#[cfg(test)]
Expand Down
81 changes: 76 additions & 5 deletions crates/rustmotion/src/cli/commands/info.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
use rustmotion::components::intrinsic::{GradientTextIntrinsic, TextIntrinsic};
use rustmotion::components::{ChildComponent, Component};
use rustmotion::core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
use rustmotion::encode::build_frame_tasks;
use rustmotion::engine::animator::spring_rest_time;
use rustmotion::engine::render::deserialize_children;
use rustmotion::error::Result;
use rustmotion::loader::load_input;
use rustmotion::schema::{self, AnimationEffect, ResolvedScenario, SpringConfig};
use std::path::PathBuf;

fn rendered_duration_and_frames(scenario: &ResolvedScenario) -> (f64, u32) {
let fps = scenario.video.fps as f64;
let total_frames = build_frame_tasks(scenario).len() as u32;
let total_duration = if fps > 0.0 {
total_frames as f64 / fps
} else {
0.0
};
(total_duration, total_frames)
}

pub fn cmd_info(input: &PathBuf) -> Result<()> {
let scenario = load_input(input)?;
let fps = scenario.video.fps;
let all_scenes: Vec<_> = scenario.all_scenes().collect();
let total_duration: f64 = all_scenes.iter().map(|s| s.duration).sum();
let total_frames: u32 = all_scenes
.iter()
.map(|s| (s.duration * fps as f64).round() as u32)
.sum();
let (total_duration, total_frames) = rendered_duration_and_frames(&scenario);

let total_layers: usize = all_scenes.iter().map(|s| s.children.len()).sum();

Expand Down Expand Up @@ -697,3 +705,66 @@ mod spring_report_tests {
assert!(out.is_empty(), "unexpected spring reports: {out:?}");
}
}

#[cfg(test)]
mod duration_tests {
use super::*;

fn load(json: serde_json::Value) -> ResolvedScenario {
rustmotion::loader::load_scenario_from_source(None, Some(&json.to_string()))
.expect("scenario must load and validate structurally")
}

#[test]
fn a_v1_transition_shortens_the_rendered_total_the_way_the_encoder_sees_it() {
let json = serde_json::json!({
"video": { "width": 320, "height": 180, "fps": 30, "background": "#000000" },
"scenes": [
{ "duration": 1.0, "children": [] },
{
"duration": 1.0,
"transition": { "type": "iris", "duration": 0.6 },
"children": []
}
]
});
let scenario = load(json);
let (duration, frames) = rendered_duration_and_frames(&scenario);
assert_eq!(
frames, 42,
"two 1.0s scenes with a 0.6s transition must render 42 frames, not \
60 = sum(scene.duration) * fps: got {frames}"
);
assert!(
(duration - 1.4).abs() < 1e-9,
"expected 1.4s to match the frame count, got {duration}"
);
}

#[test]
fn v2_at_placement_reports_the_overlapped_total_not_the_sum_of_durations() {
let json = serde_json::json!({
"version": "1.0",
"timing": "v2",
"video": { "width": 320, "height": 180, "fps": 30, "background": "#000000" },
"composition": [{
"type": "slide",
"scenes": [
{ "at": 0, "duration": 2.0, "children": [] },
{ "at": 1.0, "duration": 2.0, "children": [] }
]
}]
});
let scenario = load(json);
let (duration, frames) = rendered_duration_and_frames(&scenario);
assert_eq!(
frames, 90,
"at:0/at:1.0 over 2.0s scenes must report the 90-frame overlapped total \
(at_last + duration_last), not 120 = sum(scene.duration) * fps: got {frames}"
);
assert!(
(duration - 3.0).abs() < 1e-9,
"expected 3.0s to match the frame count, got {duration}"
);
}
}
Loading