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
1 change: 1 addition & 0 deletions crates/rustmotion/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ command.
## Encodage

- 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.
- `--codec h265` muxes into mp4/mov with `-tag:v hvc1`. ffmpeg writes HEVC as `hev1` unless told otherwise, and AVFoundation — QuickTime, Safari, `avconvert` — opens nothing but `hvc1`, so the default produced a file that encoded correctly and would not play. mkv carries no such tag, and the hardware encoders go through the same muxer, so they get it too.
- `--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
131 changes: 131 additions & 0 deletions crates/rustmotion/src/encode/video/ffmpeg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ fn probe_ffmpeg_encoders() -> HashSet<String> {
}
}

fn hevc_sample_entry_tag(codec: &str, output_path: &str) -> Option<&'static str> {
let container = std::path::Path::new(output_path)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
match (codec, container.as_deref()) {
("h265" | "hevc", Some("mp4" | "mov")) => Some("hvc1"),
_ => None,
}
}

#[allow(clippy::too_many_arguments)]
fn ffmpeg_args(
width: u32,
Expand Down Expand Up @@ -218,6 +229,10 @@ fn ffmpeg_args(
}
}

if let Some(tag) = hevc_sample_entry_tag(codec, output_path) {
push(&["-tag:v", tag], &mut args);
}

if audio_input.is_some() {
push(&["-c:a", "aac", "-b:a", "128k"], &mut args);
}
Expand Down Expand Up @@ -925,6 +940,54 @@ mod tests {
assert!(with_none.iter().any(|s| s == "-crf"));
}

#[test]
fn hevc_into_an_apple_container_is_tagged_hvc1() {
for codec in ["h265", "hevc"] {
for (path, hw) in [
("o.mp4", None),
("o.mov", None),
("o.MP4", None),
("o.partial.mp4", None),
("o.mp4", Some("hevc_videotoolbox")),
("o.mp4", Some("hevc_nvenc")),
("o.mov", Some("hevc_qsv")),
("o.mp4", Some("hevc_amf")),
] {
let args = ffmpeg_args(320, 240, 30, codec, 23, false, hw, None, path);
assert_eq!(
value_after(&args, "-tag:v").as_deref(),
Some("hvc1"),
"{codec} -> {path} (hw {hw:?}): the muxer writes hev1 unless told, and \
AVFoundation opens nothing but hvc1"
);
}
}
}

#[test]
fn hevc_into_matroska_carries_no_sample_entry_tag() {
for path in ["o.mkv", "o"] {
let args = ffmpeg_args(320, 240, 30, "h265", 23, false, None, None, path);
assert!(
!args.iter().any(|s| s == "-tag:v"),
"{path} is not an ISO-BMFF container, so the tag does not apply: {args:?}"
);
}
}

#[test]
fn no_other_codec_is_retagged() {
for codec in ["h264", "h264_10bit", "vp9", "prores", "not-a-codec"] {
for path in ["o.mp4", "o.mov"] {
let args = ffmpeg_args(320, 240, 30, codec, 23, false, None, None, path);
assert!(
!args.iter().any(|s| s == "-tag:v"),
"{codec} already muxes under a tag its players accept: {args:?}"
);
}
}
}

#[test]
fn selection_is_a_noop_when_not_requested() {
assert_eq!(
Expand Down Expand Up @@ -1040,6 +1103,27 @@ Encoders:
.unwrap_or(false)
}

fn ffprobe_video_codec_tag(path: &str) -> Option<String> {
let out = std::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_tag_string",
"-of",
"default=noprint_wrappers=1:nokey=1",
path,
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}

fn ffprobe_stream_duration(path: &str, selector: &str) -> Option<f64> {
let out = std::process::Command::new("ffprobe")
.args([
Expand Down Expand Up @@ -1156,6 +1240,53 @@ Encoders:
let _ = std::fs::remove_file(&out);
}

#[test]
fn a_rendered_hevc_mp4_carries_the_hvc1_tag_on_disk() {
if !ffmpeg_on_path() || !ffprobe_on_path() {
eprintln!("a_rendered_hevc_mp4_carries_the_hvc1_tag_on_disk: ffmpeg/ffprobe not found — skipping");
return;
}
if !super::probe_ffmpeg_encoders().contains("libx265") {
eprintln!("a_rendered_hevc_mp4_carries_the_hvc1_tag_on_disk: this ffmpeg has no libx265 — skipping");
return;
}

let json = r#"{"video": {"width": 64, "height": 64, "fps": 10},
"scenes": [{"duration": 0.5, "children": []}]}"#;
let scenario = crate::loader::load_scenario_from_source(None, Some(json)).expect("load");

let out = std::env::temp_dir().join(format!(
"rm_hvc1_tag_{}_{}.mp4",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&out);

super::encode_with_ffmpeg(
&scenario,
out.to_str().unwrap(),
true,
"h265",
None,
false,
None,
)
.expect("an h265 render must succeed");

let tag = ffprobe_video_codec_tag(out.to_str().unwrap())
.expect("ffprobe must report a video codec tag");
assert_eq!(
tag, "hvc1",
"hev1 is what the muxer writes by default and what QuickTime, Safari and every \
other AVFoundation player refuse to open"
);

let _ = std::fs::remove_file(&out);
}

#[test]
fn hardware_probe_reports_what_this_machine_actually_offers() {
if !ffmpeg_on_path() {
Expand Down
Loading