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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

---

Expand Down
6 changes: 5 additions & 1 deletion crates/rustmotion-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}'")]
Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/rustmotion/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
11 changes: 8 additions & 3 deletions crates/rustmotion/skills/rules/gradient-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
28 changes: 27 additions & 1 deletion crates/rustmotion/src/cli/commands/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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());
}
}
58 changes: 57 additions & 1 deletion crates/rustmotion/src/encode/video/ffmpeg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ fn ffmpeg_args(
&mut args,
);
}
_ => {
"h264_10bit" => {
push(
&[
"-c:v",
Expand All @@ -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,
);
}
}
}

Expand Down Expand Up @@ -788,6 +805,45 @@ mod tests {
}
}

fn value_after(args: &[String], flag: &str) -> Option<String> {
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");
Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion/src/encode/video/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down
Loading