diff --git a/README.md b/README.md index fd29a7f..494dab1 100644 --- a/README.md +++ b/README.md @@ -2028,7 +2028,8 @@ Renders multiple sub-frames and composites them for physically-correct motion bl | Format | Command | Requires | |---|---|---| -| **MP4 (H.264 10-bit)** | `rustmotion render in.json -o out.mp4` | ffmpeg (auto-detected) | +| **MP4 (H.264 8-bit)** | `rustmotion render in.json -o out.mp4` | ffmpeg (auto-detected) | +| **MP4 (H.264 10-bit)** | `rustmotion render in.json --codec h264_10bit -o out.mp4` | ffmpeg | | **MP4 (H.264 8-bit)** | `rustmotion render in.json -o out.mp4` | Built-in (fallback without ffmpeg) | | **MP4 (H.265)** | `rustmotion render in.json -o out.mp4 --codec h265` | ffmpeg | | **WebM (VP9)** | `rustmotion render in.json -o out.webm --codec vp9` | ffmpeg | @@ -2039,7 +2040,7 @@ Renders multiple sub-frames and composites them for physically-correct motion bl Transparency is supported with `--transparent` for PNG sequences, WebM (VP9), and ProRes 4444. -> **Gradient quality:** When ffmpeg is available, H.264 uses 10-bit color depth (`yuv420p10le`, `high10` profile) which greatly reduces banding on dark gradients. For maximum quality, use `--codec prores`. The built-in openh264 encoder (fallback without ffmpeg) outputs 8-bit only. +> **Gradient quality:** H.264 output is 8-bit (`yuv420p`, `high` profile) so it plays everywhere, QuickTime and Safari included. `--codec h264_10bit` gives 10-bit (`yuv420p10le`, `high10`), which greatly reduces banding on dark gradients — at the cost of those two players refusing the file. For maximum quality, use `--codec prores`. The built-in openh264 encoder (fallback without ffmpeg) outputs 8-bit only. --- diff --git a/crates/rustmotion-components/src/cursor.rs b/crates/rustmotion-components/src/cursor.rs index a5ecd7f..14ef404 100644 --- a/crates/rustmotion-components/src/cursor.rs +++ b/crates/rustmotion-components/src/cursor.rs @@ -12,12 +12,21 @@ use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; /// A cursor waypoint with position and time. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct CursorWaypoint { - /// Time in seconds when the cursor reaches this point (and clicks). + /// Time in seconds when the cursor reaches this point. pub time: f64, /// X position relative to the cursor's origin. pub x: f32, /// Y position relative to the cursor's origin. pub y: f32, + /// Whether arriving here is a click. Defaults to `true`, which is what + /// every waypoint did before this field existed. Set it to `false` for a + /// point the pointer only travels through. + #[serde(default = "default_waypoint_click")] + pub click: bool, +} + +fn default_waypoint_click() -> bool { + true } /// A blinking cursor component (vertical bar) with optional motion path and click events. diff --git a/crates/rustmotion-components/src/pointer.rs b/crates/rustmotion-components/src/pointer.rs index 2e57cd4..f815b60 100644 --- a/crates/rustmotion-components/src/pointer.rs +++ b/crates/rustmotion-components/src/pointer.rs @@ -140,7 +140,11 @@ impl Pointer { if self.path.is_empty() { self.click_at.clone() } else { - self.path.iter().map(|w| w.time).collect() + self.path + .iter() + .filter(|w| w.click) + .map(|w| w.time) + .collect() } } @@ -187,6 +191,24 @@ impl Pointer { path.close(); } + fn add_smooth_contour(path: &mut PathBuilder, points: &[(f32, f32)], size: f32) { + let n = points.len(); + if n < 3 { + Self::add_contour(path, points, size); + return; + } + let at = |i: usize| (points[i % n].0 * size, points[i % n].1 * size); + let midpoint = |a: (f32, f32), b: (f32, f32)| ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5); + + path.move_to(midpoint(at(0), at(1))); + for i in 1..=n { + let control = at(i); + let end = midpoint(at(i), at(i + 1)); + path.quad_to(control, end); + } + path.close(); + } + fn arrow_path(size: f32) -> Path { const OUTLINE: [(f32, f32); 7] = [ (0.0, 0.0), @@ -202,28 +224,52 @@ impl Pointer { path.detach() } - const HAND_FIST: [(f32, f32); 8] = [ - (0.08, 0.38), - (0.30, 0.30), - (0.54, 0.36), - (0.62, 0.58), - (0.54, 0.82), - (0.30, 0.92), - (0.12, 0.82), - (0.04, 0.58), + const HAND_OUTLINE: [(f32, f32); 16] = [ + (0.00, 0.00), + (0.22, 0.00), + (0.26, 0.34), + (0.38, 0.30), + (0.48, 0.36), + (0.58, 0.34), + (0.66, 0.46), + (0.68, 0.64), + (0.58, 0.86), + (0.36, 0.96), + (0.16, 0.90), + (0.06, 0.72), + (0.00, 0.58), + (0.03, 0.46), + (0.12, 0.42), + (0.04, 0.34), + ]; + + const FIST_OUTLINE: [(f32, f32); 15] = [ + (0.18, 0.22), + (0.30, 0.12), + (0.40, 0.20), + (0.50, 0.14), + (0.60, 0.24), + (0.66, 0.38), + (0.66, 0.60), + (0.56, 0.80), + (0.38, 0.90), + (0.20, 0.84), + (0.10, 0.68), + (0.02, 0.58), + (0.04, 0.44), + (0.14, 0.40), + (0.12, 0.30), ]; fn hand_path(size: f32) -> Path { - const FINGER: [(f32, f32); 3] = [(0.00, 0.00), (0.40, 0.06), (0.10, 0.30)]; let mut path = PathBuilder::new(); - Self::add_contour(&mut path, &FINGER, size); - Self::add_contour(&mut path, &Self::HAND_FIST, size); + Self::add_smooth_contour(&mut path, &Self::HAND_OUTLINE, size); path.detach() } fn grab_path(size: f32) -> Path { let mut path = PathBuilder::new(); - Self::add_contour(&mut path, &Self::HAND_FIST, size); + Self::add_smooth_contour(&mut path, &Self::FIST_OUTLINE, size); path.detach() } @@ -235,6 +281,17 @@ impl Pointer { } } + fn unfilled_contrast_color(&self, outline: &str) -> Option<&'static str> { + if self.tone != PointerTone::Outline || self.outline_color.is_some() { + return None; + } + let (_, _, _, alpha) = parse_hex_color(outline); + if alpha == 0 { + return None; + } + Some("#111827") + } + fn active_glyph(&self, click: Option) -> PointerGlyph { match click { Some(_) => self.click_glyph.unwrap_or(self.glyph), @@ -286,9 +343,10 @@ impl Painter for Pointer { } let path = Self::glyph_path(self.active_glyph(click), self.size); + let outline_width = (self.size * 0.07).max(1.0); let mut outline_paint = paint_from_hex(&outline); outline_paint.set_style(PaintStyle::Stroke); - outline_paint.set_stroke_width((self.size * 0.07).max(1.0)); + outline_paint.set_stroke_width(outline_width); outline_paint.set_stroke_join(skia_safe::PaintJoin::Round); outline_paint.set_anti_alias(true); @@ -296,6 +354,15 @@ impl Painter for Pointer { fill_paint.set_style(PaintStyle::Fill); fill_paint.set_anti_alias(true); + if let Some(contrast) = self.unfilled_contrast_color(&outline) { + let mut contrast_paint = paint_from_hex(contrast); + contrast_paint.set_style(PaintStyle::Stroke); + contrast_paint.set_stroke_width(outline_width * 1.5); + contrast_paint.set_stroke_join(skia_safe::PaintJoin::Round); + contrast_paint.set_anti_alias(true); + canvas.draw_path(&path, &contrast_paint); + } + canvas.draw_path(&path, &fill_paint); canvas.draw_path(&path, &outline_paint); @@ -562,6 +629,85 @@ mod tests { (0.08 * size, 0.05 * size) } + #[test] + fn a_waypoint_can_be_passed_through_without_clicking() { + let p = pointer(serde_json::json!({ + "size": 120, + "click_duration": 0.4, + "path": [ + { "time": 0.0, "x": 0.0, "y": 0.0 }, + { "time": 1.0, "x": 200.0, "y": 0.0, "click": false }, + { "time": 2.0, "x": 200.0, "y": 200.0 } + ] + })); + assert_eq!( + p.click_times(), + vec![0.0, 2.0], + "a pointer must be able to travel through an intermediate point without clicking \ + on arrival — it clicked at every waypoint, with no way to opt out" + ); + } + + #[test] + fn a_waypoint_clicks_by_default_so_an_existing_scenario_is_unchanged() { + let p = pointer(serde_json::json!({ + "size": 120, + "path": [ + { "time": 0.0, "x": 0.0, "y": 0.0 }, + { "time": 1.0, "x": 200.0, "y": 0.0 } + ] + })); + assert_eq!(p.click_times(), vec![0.0, 1.0]); + } + + #[test] + fn opting_every_waypoint_out_leaves_a_pointer_that_travels_and_never_clicks() { + let p = pointer(serde_json::json!({ + "size": 120, + "click_at": [3.0], + "path": [ + { "time": 0.0, "x": 0.0, "y": 0.0, "click": false }, + { "time": 1.0, "x": 200.0, "y": 0.0, "click": false } + ] + })); + assert!( + p.click_times().is_empty(), + "click_at stays ignored while a path is present, which is what \ + clicks_come_from_the_waypoints_when_a_path_is_given pins; a pointer that should \ + click somewhere says so on the waypoint" + ); + } + + #[test] + fn an_outline_pointer_carries_a_dark_contour_so_it_reads_on_a_light_frame() { + let p = pointer(serde_json::json!({ "size": 200, "tone": "outline" })); + const W: i32 = 300; + const H: i32 = 300; + let mut frame = render( + &p, + W, + H, + 0.0, + skia_safe::Color::from_argb(255, 255, 255, 255), + ); + + let mut darkest = 255u8; + for y in 0..H { + for x in 0..W { + let (r, g, b, a) = pixel(&mut frame, W, H, x as f32, y as f32); + if a > 200 { + darkest = darkest.min(r.max(g).max(b)); + } + } + } + assert!( + darkest < 100, + "tone: outline is a white stroke on a transparent fill, so on a white frame it was \ + invisible while its own documentation promised it reads on any background; \ + darkest painted channel was {darkest}" + ); + } + #[test] fn finger_probe_is_inside_the_open_hand_but_outside_the_closed_fist() { const SIZE: f32 = 200.0; diff --git a/crates/rustmotion-core/src/error.rs b/crates/rustmotion-core/src/error.rs index 9985182..61fd0fd 100644 --- a/crates/rustmotion-core/src/error.rs +++ b/crates/rustmotion-core/src/error.rs @@ -324,7 +324,11 @@ pub enum RustmotionError { #[error("Invalid CRF value {value}: must be between 0 and 51")] InvalidCrf { value: u8 }, - #[error("Unknown codec '{codec}'. Supported: h264, h265, vp9, prores")] + #[error( + "Unknown codec '{codec}'. Supported: h264, h264_10bit, h265, vp9, prores\n\ + h264 is 8-bit and plays everywhere; h264_10bit trades that for banding-free \ + dark gradients." + )] UnknownCodec { codec: String }, #[error("Path is not valid UTF-8: '{path}'")] diff --git a/crates/rustmotion/CLAUDE.md b/crates/rustmotion/CLAUDE.md index d2274b4..40faf9c 100644 --- a/crates/rustmotion/CLAUDE.md +++ b/crates/rustmotion/CLAUDE.md @@ -52,7 +52,7 @@ without the flag. ## Encodage -- ffmpeg est auto-détecté et utilisé par défaut (10-bit H.264, meilleure qualité sur les gradients sombres) +- ffmpeg is auto-detected and used by default. H.264 output is **8-bit** (`yuv420p`, `high` profile), which QuickTime and Safari play; `--codec h264_10bit` trades that for `yuv420p10le`/`high10`, which is banding-free on dark gradients and refused by both players. - `--hardware-acceleration` sonde `ffmpeg -encoders` et bascule sur VideoToolbox/NVENC/QSV/AMF si la machine en offre un. Indisponible → message explicite et repli logiciel, jamais de bascule silencieuse. Le CRF n'a pas de sens sur la plupart des encodeurs matériels : le passer avec l'accélération produit un avertissement. - `--frames a-b` rend une plage de frames en segment autonome, avec **sa** tranche d'audio (les pistes ne repartent pas de zéro). `rustmotion concat seg1.mp4 seg2.mp4 -o out.mp4` les recolle via le concat demuxer de ffmpeg. C'est la brique d'un rendu distribué. - Sans ffmpeg, le fallback openh264 intégré encode en 8-bit diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 31e0719..8d3b834 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -609,10 +609,10 @@ These four are the worked reference for [rules/composition-recipes.md](rules/com | `height` | u32 | required | Video height in pixels. **Must be even for H.264.** | | `fps` | u32 | `30` | Frames per second | | `background` | string | `"#000000"` | Default background color (hex `#RRGGBB` or `#RRGGBBAA`) | -| `codec` | string | `null` | `"h264"` (10-bit), `"h265"`, `"vp9"`, `"prores"` | +| `codec` | string | `null` | `"h264"` (8-bit), `"h264_10bit"`, `"h265"`, `"vp9"`, `"prores"` | | `crf` | u8 | `23` | Constant Rate Factor (0-51, lower = better quality) | -> **Encoding note:** H.264 outputs 10-bit (`yuv420p10le`) by default when ffmpeg is available, which reduces color banding on dark gradients. For best quality on gradient-heavy videos, use `--codec prores` (lossless). +> **Encoding note:** H.264 outputs 8-bit (`yuv420p`) by default, which plays in QuickTime and Safari. `--codec h264_10bit` gives `yuv420p10le` and reduces banding on dark gradients, at the cost of those two players refusing the file. For best quality on gradient-heavy videos, use `--codec prores` (lossless). #### `audio` (optional array) diff --git a/crates/rustmotion/skills/rules/gradient-quality.md b/crates/rustmotion/skills/rules/gradient-quality.md index 6f970ea..1590329 100644 --- a/crates/rustmotion/skills/rules/gradient-quality.md +++ b/crates/rustmotion/skills/rules/gradient-quality.md @@ -4,17 +4,22 @@ Dark gradients are prone to color banding (visible steps instead of smooth trans 1. **Linear color space interpolation** for animated background gradients (smoother dark tones) 2. **Subdivided color stops** (16 intermediate stops between each color pair) -3. **10-bit H.264** encoding (`yuv420p10le`, `high10` profile) when ffmpeg is available +3. **Optional 10-bit H.264** encoding (`yuv420p10le`, `high10` profile) behind `--codec h264_10bit` 4. **Dithering** enabled on all gradient paints ## Encoding recommendations | Scenario | Recommendation | |---|---| -| Dark gradient backgrounds | Use `--codec prores` for best quality | -| General use | Default H.264 10-bit (requires ffmpeg) | +| Dark gradient backgrounds | `--codec prores` for best quality, or `--codec h264_10bit` for a small file | +| General use | Default H.264, 8-bit, plays everywhere | | No ffmpeg available | Built-in openh264 (8-bit, may show banding on dark gradients) | +10-bit is not the default, and the reason is not quality. `yuv420p10le` in the +`high10` profile is refused outright by QuickTime and by Safari, so a file that +looked better was a file a lot of people could not open. `--codec h264_10bit` is +the same encode it used to do, asked for on purpose. + **GOOD:** Use `gradient_type: "radial"` with at least 3 colors for smooth transitions: ```json { diff --git a/crates/rustmotion/skills/rules/pointer-walkthrough.md b/crates/rustmotion/skills/rules/pointer-walkthrough.md index 9fd8834..de7d628 100644 --- a/crates/rustmotion/skills/rules/pointer-walkthrough.md +++ b/crates/rustmotion/skills/rules/pointer-walkthrough.md @@ -31,7 +31,7 @@ For a product demo or an agent walkthrough — the arrow that moves to a control | `tone` | `light` (white arrow, dark outline), `dark`, or `outline` (transparent fill, white outline) | | `color` / `outline_color` | Override `tone` | | `click_ring` | `subtle` / `standard` / `bold` / `none` | -| `path` | Waypoints `{time, x, y}` — the pointer **clicks on arrival** at each one | +| `path` | Waypoints `{time, x, y, click?}` — the pointer clicks on arrival unless the waypoint says `"click": false` | | `click_at` | Clicks for a stationary pointer. **Ignored if `path` is present** | | `click_duration` | Duration of the click, *and* the pause on the waypoint before moving on | | `path_easing` | `ease_in_out` (default), `linear`, `ease_out`, `step` | @@ -77,3 +77,31 @@ The default `glyph` is the classic arrow. `hand` draws an open hand pointing wit `click_glyph` only applies **for the duration of the click** — the same window `click_progress` already computes from `path`'s arrival times or `click_at`. There is no second clock: a hand with no `click_glyph` set still dips on click (the existing scale animation), it just never swaps shape. `grab` with no `glyph: "hand"` is legal but unusual — nothing ever shows it, since the resting glyph never becomes it outside a click. Because the hotspot is defined per glyph rather than being the geometric centre of its bounding box, switching `glyph` (or swapping to `click_glyph` mid-click) never moves the point a waypoint is aimed at — only the drawn shape around that point changes. + +## Travelling through a point without clicking + +A waypoint clicks on arrival, which is what you want for the control the +walkthrough is about and wrong for the corner it rounds on the way there. Set +`"click": false` on the ones that are only a path: + +```json +"path": [ + { "time": 0.0, "x": 120, "y": 620 }, + { "time": 0.6, "x": 500, "y": 620, "click": false }, + { "time": 1.2, "x": 500, "y": 240 } +] +``` + +The default is `true`, so a scenario written before this field existed is +unchanged. `click_at` stays ignored while a `path` is present — a pointer that +should click somewhere says so on the waypoint, not beside it. + +## `tone: "outline"` reads on a light frame too + +The unfilled tone is a white stroke over a transparent fill. On a white frame +that was invisible, although the tone exists precisely so the pointer can sit on +any background without a filled shape competing with what it points at. It now +carries a dark contour under the white one, so the glyph reads either way. + +Setting `outline_color` yourself turns the contour off — you have said what the +edge should be, and a second edge under it would be a surprise. diff --git a/crates/rustmotion/src/cli/commands/validation.rs b/crates/rustmotion/src/cli/commands/validation.rs index a7a31e3..4e4482c 100644 --- a/crates/rustmotion/src/cli/commands/validation.rs +++ b/crates/rustmotion/src/cli/commands/validation.rs @@ -274,7 +274,7 @@ pub fn transitions_with_nothing_to_come_from(scenario: &ResolvedScenario) -> Vec pub fn check_codec(codec: Option<&str>) -> Result<()> { if let Some(c) = codec { - let allowed = ["h264", "h265", "vp9", "prores"]; + let allowed = ["h264", "h264_10bit", "h265", "vp9", "prores"]; if !allowed.contains(&c) { return Err(RustmotionError::UnknownCodec { codec: c.to_string(), @@ -751,3 +751,29 @@ mod check_crf_tests { assert!(check_crf(Some(52), false).is_err()); } } + +#[cfg(test)] +mod codec_name_tests { + use super::check_codec; + + #[test] + fn the_ten_bit_h264_name_is_accepted() { + assert!( + check_codec(Some("h264_10bit")).is_ok(), + "ffmpeg_args knows this name; rejecting it here made the opt-in unreachable from \ + the command line" + ); + } + + #[test] + fn every_name_ffmpeg_args_branches_on_is_accepted_here() { + for name in ["h264", "h264_10bit", "h265", "vp9", "prores"] { + assert!(check_codec(Some(name)).is_ok(), "{name} must be reachable"); + } + } + + #[test] + fn a_name_nothing_branches_on_is_still_refused() { + assert!(check_codec(Some("h266")).is_err()); + } +} diff --git a/crates/rustmotion/src/encode/video/ffmpeg.rs b/crates/rustmotion/src/encode/video/ffmpeg.rs index be6863a..1234181 100644 --- a/crates/rustmotion/src/encode/video/ffmpeg.rs +++ b/crates/rustmotion/src/encode/video/ffmpeg.rs @@ -181,7 +181,7 @@ fn ffmpeg_args( &mut args, ); } - _ => { + "h264_10bit" => { push( &[ "-c:v", @@ -198,6 +198,23 @@ fn ffmpeg_args( &mut args, ); } + _ => { + push( + &[ + "-c:v", + "libx264", + "-crf", + &crf, + "-preset", + "medium", + "-profile:v", + "high", + "-pix_fmt", + "yuv420p", + ], + &mut args, + ); + } } } @@ -788,6 +805,45 @@ mod tests { } } + fn value_after(args: &[String], flag: &str) -> Option { + let at = args.iter().position(|s| s == flag)?; + args.get(at + 1).cloned() + } + + #[test] + fn the_default_h264_output_is_eight_bit() { + let args = ffmpeg_args(320, 240, 30, "h264", 23, false, None, None, "o.mp4"); + assert_eq!( + value_after(&args, "-pix_fmt").as_deref(), + Some("yuv420p"), + "High 10 is what QuickTime and Safari refuse to play, and it was the default" + ); + assert_eq!(value_after(&args, "-profile:v").as_deref(), Some("high")); + } + + #[test] + fn ten_bit_is_still_reachable_by_asking_for_it() { + let args = ffmpeg_args(320, 240, 30, "h264_10bit", 23, false, None, None, "o.mp4"); + assert_eq!( + value_after(&args, "-pix_fmt").as_deref(), + Some("yuv420p10le"), + "a dark gradient still needs 10-bit, so it stays available" + ); + assert_eq!(value_after(&args, "-profile:v").as_deref(), Some("high10")); + assert!(args.iter().any(|s| s == "libx264"), "still H.264"); + } + + #[test] + fn an_unknown_codec_name_falls_back_to_the_playable_default() { + let args = ffmpeg_args(320, 240, 30, "not-a-codec", 23, false, None, None, "o.mp4"); + assert_eq!( + value_after(&args, "-pix_fmt").as_deref(), + Some("yuv420p"), + "the catch-all arm is what an unrecognised --codec lands in, so it must be the \ + playable one" + ); + } + #[test] fn a_silent_scenario_declares_a_single_input_and_no_audio_codec() { let args = ffmpeg_args(320, 240, 30, "h264", 23, false, None, None, "o.mp4"); diff --git a/crates/rustmotion/src/encode/video/formats.rs b/crates/rustmotion/src/encode/video/formats.rs index 4d7765a..f205567 100644 --- a/crates/rustmotion/src/encode/video/formats.rs +++ b/crates/rustmotion/src/encode/video/formats.rs @@ -590,7 +590,7 @@ mod tests { fn containers_for(codec: &str) -> Option<&'static [&'static str]> { match codec { - "h264" => Some(&["mp4", "mov", "mkv"]), + "h264" | "h264_10bit" => Some(&["mp4", "mov", "mkv"]), "h265" | "hevc" => Some(&["mp4", "mov", "mkv"]), "vp9" => Some(&["webm", "mkv"]), "prores" => Some(&["mov", "mkv"]),