From 5f517a3b2e0e86cceb6b9b86a27bde8dde2f290b Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 13:52:26 +0200 Subject: [PATCH] fix(encode): make 8-bit the default H.264 output The default H.264 branch pushed -profile:v high10 -pix_fmt yuv420p10le with no gate at all, so every render without --hardware-acceleration produced a High 10 file. QuickTime and Safari refuse those outright, and a user reported exactly that: a file the tool had just written and they could not open. The default is now -profile:v high -pix_fmt yuv420p. The old encode is still reachable as --codec h264_10bit, which is the same libx264 call with the same crf and preset. Making it a codec name rather than a new flag means it travels the plumbing that already exists: codec: &str already reaches ffmpeg_args from nine call sites, so nothing needed a new parameter. check_codec's allow-list and containers_for both had to learn the name -- the first rejected it before ffmpeg_args ever saw it, which is how the first attempt silently produced an 8-bit file for --codec h264_10bit. Verified end to end with ffprobe: the default gives h264/High/yuv420p, --codec h264_10bit gives h264/High 10/yuv420p10le. The docs claimed 10-bit as the default in five places -- README, CLAUDE, SKILL, and rules/gradient-quality.md -- and recommended ProRes for dark gradients without mentioning that 10-bit H.264 was already on. All four now say which of the two trades away playback and why. Refs #435 --- README.md | 5 +- crates/rustmotion-core/src/error.rs | 6 +- crates/rustmotion/CLAUDE.md | 2 +- crates/rustmotion/skills/SKILL.md | 4 +- .../skills/rules/gradient-quality.md | 11 +++- .../rustmotion/src/cli/commands/validation.rs | 28 ++++++++- crates/rustmotion/src/encode/video/ffmpeg.rs | 58 ++++++++++++++++++- crates/rustmotion/src/encode/video/formats.rs | 2 +- 8 files changed, 104 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fd29a7fa..494dab1a 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-core/src/error.rs b/crates/rustmotion-core/src/error.rs index 99851823..61fd0fd5 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 d2274b4e..40faf9c8 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 31e07190..8d3b8346 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 6f970ea3..1590329a 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/src/cli/commands/validation.rs b/crates/rustmotion/src/cli/commands/validation.rs index a7a31e3c..4e4482cf 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 be6863ad..1234181b 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 4d7765a5..f205567b 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"]),