From 4580257ee99495b06ed27e714052b9fdde28aef6 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 30 Sep 2026 17:38:21 +0200 Subject: [PATCH] fix(encode): tag HEVC as hvc1 so AVFoundation players open the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--codec h265` produced an mp4 that QuickTime, Safari and avconvert refuse. The bitstream was never the problem: ffmpeg's mp4/mov muxer writes HEVC under the `hev1` sample entry unless told otherwise, and Apple's stack reads only `hvc1`. A `-c copy` remux that changes nothing but the tag is enough to make the same file play, which is what the issue measured. The tag now comes from the codec and the output extension, so both the libx265 branch and the hardware one get it, and mkv — which has no such sample entry — gets nothing. `rustmotion concat` needed no change: `-c copy` carries the tag across, verified on two rendered segments. Closes #457 --- crates/rustmotion/CLAUDE.md | 1 + crates/rustmotion/src/encode/video/ffmpeg.rs | 131 +++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/crates/rustmotion/CLAUDE.md b/crates/rustmotion/CLAUDE.md index 5b6e222..ee66e61 100644 --- a/crates/rustmotion/CLAUDE.md +++ b/crates/rustmotion/CLAUDE.md @@ -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 diff --git a/crates/rustmotion/src/encode/video/ffmpeg.rs b/crates/rustmotion/src/encode/video/ffmpeg.rs index 1234181..e5bd629 100644 --- a/crates/rustmotion/src/encode/video/ffmpeg.rs +++ b/crates/rustmotion/src/encode/video/ffmpeg.rs @@ -113,6 +113,17 @@ fn probe_ffmpeg_encoders() -> HashSet { } } +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, @@ -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); } @@ -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!( @@ -1040,6 +1103,27 @@ Encoders: .unwrap_or(false) } + fn ffprobe_video_codec_tag(path: &str) -> Option { + 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 { let out = std::process::Command::new("ffprobe") .args([ @@ -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() {