From d0e7e09ecb00b83cdf916bf2cc3b39a09265f66d Mon Sep 17 00:00:00 2001 From: JonRC Date: Sun, 1 Nov 2026 22:28:23 -0300 Subject: [PATCH] Export: write a colr atom in ProRes MOVs (#2801) ProRes renders had no colr atom, so the stream reported no primaries, transfer or matrix. Write an nclc atom, as FilmCraft does for MOV, with the codes of what the render path makes: the project's output space when it has a working space, BT.709 otherwise. sRGB output stays at transfer 1, which is still open on #340 and #464. The frames don't change, so the pinned ProRes bytes move by exactly the atom's 18 bytes. --- crates/export/src/encode.rs | 39 ++++++-- crates/export/tests/it/main.rs | 1 + crates/export/tests/it/movie_bytes.rs | 3 +- crates/export/tests/it/prores_color.rs | 118 +++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 crates/export/tests/it/prores_color.rs diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index 619d0a9d9..175c9063d 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -4,8 +4,8 @@ use std::io::Write; -use effectcraft_project::Comp; use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile}; +use effectcraft_project::{Comp, ProjectSettings}; use effectcraft_time::{FrameRate, Tick}; use filmcraft_isobmff::{Brand, ColorInfo, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use rayon::prelude::*; @@ -164,6 +164,28 @@ struct ProRes { w: u32, h: u32, alpha: bool, + colr: ColorInfo, +} + +/// The `colr` atom of a ProRes render: what its frames are, in H.273 code points. MOV uses +/// QuickTime's `nclc`, which has no range flag; ProRes frames are limited range. The matrix is the +/// one [`rgba_to_yuv_10`] always uses. Primaries and transfer follow the render path +/// (`Pipe::output`): only a working space converts to the output space, so unmanaged frames pass +/// through. sRGB output keeps transfer 1 rather than 13 (#340), and an alpha matte is not in the +/// output space. +fn prores_colr(settings: &ProjectSettings, channels: Channels) -> ColorInfo { + use effectcraft_project::ColorSpace as Space; + let output = settings.working_space.and(settings.output_space).filter(|_| channels != Channels::Alpha); + let (primaries, transfer) = match output { + None | Some(Space::Srgb | Space::Rec709) => (1, 1), + Some(Space::Rec2020) => (9, 1), + Some(Space::DisplayP3) => (12, 13), + Some(Space::Rec2100Pq) => (9, 16), + Some(Space::Rec2100Hlg) => (9, 18), + // H.273 has no code for the ACES primaries: unspecified, in linear light. + Some(Space::AcesCg | Space::Aces2065) => (2, 8), + }; + ColorInfo::Nclc { primaries, transfer, matrix: 1 } } fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile { @@ -179,14 +201,14 @@ fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile { } impl ProRes { - fn new(w: u32, h: u32, profile: ProResProfile, channels: Channels) -> ProRes { + fn new(w: u32, h: u32, profile: ProResProfile, channels: Channels, colr: ColorInfo) -> ProRes { // Alpha needs a 4444 flavour. let profile = if channels == Channels::Rgba && !profile.is_4444() { ProResProfile::P4444 } else { profile }; let profile = prores_profile(profile); let alpha = channels == Channels::Rgba; let mut cfg = filmcraft_prores::EncoderConfig::new(profile, w, h); cfg.encode_alpha = alpha; - ProRes { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, alpha } + ProRes { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, alpha, colr } } fn is_444(&self) -> bool { matches!(self.profile, filmcraft_prores::Profile::P4444 | filmcraft_prores::Profile::P4444Xq) @@ -195,7 +217,11 @@ impl ProRes { impl VideoEncoder for ProRes { fn sample_entry(&self) -> SampleEntry { - SampleEntry::prores(FourCc(self.profile.fourcc()), self.w as u16, self.h as u16) + let mut entry = SampleEntry::prores(FourCc(self.profile.fourcc()), self.w as u16, self.h as u16); + if let Some(v) = entry.video.as_mut() { + v.color = Some(self.colr.clone()); + } + entry } fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { let chroma = if self.is_444() { filmcraft_prores::ChromaFormat::Yuv444 } else { filmcraft_prores::ChromaFormat::Yuv422 }; @@ -301,7 +327,10 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St OutputFormat::Hevc => Box::new(crate::hevc_av1::Hevc::new(w, h, rate, job.output)?), OutputFormat::Av1 => Box::new(crate::hevc_av1::Av1::new(w, h, rate, job.output)?), OutputFormat::Uncompressed => Box::new(crate::uncompressed::Raw::new(w, h, channels == Channels::Rgba)?), - _ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels })), + _ => { + let colr = prores_colr(&job.project.settings, channels); + Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels }, colr)) + } }; let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { Brand::Mov } else { Brand::Mp4 }; let batch = batch_size(); diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs index 88398a0e8..a5b9fa99a 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -11,6 +11,7 @@ mod ms_rounded_footage; #[cfg(not(target_arch = "wasm32"))] mod native_audio_sample_positions; mod opus_roundtrip; +mod prores_color; mod render_settings; mod roundtrip; mod targa_sequence; diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs index 2171bfe57..c72ec5427 100644 --- a/crates/export/tests/it/movie_bytes.rs +++ b/crates/export/tests/it/movie_bytes.rs @@ -102,5 +102,6 @@ fn h264_movie_bytes_are_pinned() { #[test] fn prores_movie_bytes_are_pinned() { let data = export_bytes(OutputFormat::ProRes, "/out/pinned.mov"); - assert_eq!((data.len(), fnv1a(&data)), (270_094, 8_038_709_743_204_075_800), "ProRes MOV"); + // The `nclc` colr atom (#2801) adds exactly 18 bytes; the frames don't move. + assert_eq!((data.len(), fnv1a(&data)), (270_112, 2_693_123_490_992_200_481), "ProRes MOV"); } diff --git a/crates/export/tests/it/prores_color.rs b/crates/export/tests/it/prores_color.rs new file mode 100644 index 000000000..89ecea486 --- /dev/null +++ b/crates/export/tests/it/prores_color.rs @@ -0,0 +1,118 @@ +//! ProRes `colr` atom (#2801): it describes the frames the render path made, read back with +//! FilmCraft's demuxer and, when installed, ffprobe as an external oracle. + +use std::path::Path; +use std::process::Command; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::{Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::{Job, export}; +use effectcraft_project::{ColorSpace, Comp, ItemKind, LayerSource, Project, Solid, build}; +use effectcraft_render::NoFootage; +use effectcraft_time::{FrameRate, Tick}; +use filmcraft_isobmff::{ColorInfo, TrackKind}; + +struct Row { + name: &'static str, + working: Option, + output: Option, + channels: Channels, + /// H.273 primaries, transfer and matrix. + codes: [u16; 3], + /// The same as ffprobe names them. + names: [&'static str; 3], +} + +const SRGB: Option = Some(ColorSpace::Srgb); +const PQ: Option = Some(ColorSpace::Rec2100Pq); +const BT709: [&str; 3] = ["bt709", "bt709", "bt709"]; + +const ROWS: &[Row] = &[ + Row { name: "unmanaged", working: None, output: None, channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 }, + Row { name: "unmanaged_ignores_output", working: None, output: PQ, channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 }, + Row { name: "srgb_output_stays_1_1_1", working: SRGB, output: None, channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 }, + Row { name: "rec709", working: SRGB, output: Some(ColorSpace::Rec709), channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 }, + Row { name: "rec2020", working: SRGB, output: Some(ColorSpace::Rec2020), channels: Channels::Rgb, codes: [9, 1, 1], names: ["bt2020", "bt709", "bt709"] }, + Row { + name: "p3", + working: SRGB, + output: Some(ColorSpace::DisplayP3), + channels: Channels::Rgb, + codes: [12, 13, 1], + names: ["smpte432", "iec61966-2-1", "bt709"], + }, + Row { name: "pq", working: SRGB, output: PQ, channels: Channels::Rgb, codes: [9, 16, 1], names: ["bt2020", "smpte2084", "bt709"] }, + Row { + name: "hlg", + working: SRGB, + output: Some(ColorSpace::Rec2100Hlg), + channels: Channels::Rgb, + codes: [9, 18, 1], + names: ["bt2020", "arib-std-b67", "bt709"], + }, + Row { name: "aces", working: SRGB, output: Some(ColorSpace::AcesCg), channels: Channels::Rgb, codes: [2, 8, 1], names: ["unknown", "linear", "bt709"] }, + Row { name: "pq_with_alpha", working: SRGB, output: PQ, channels: Channels::Rgba, codes: [9, 16, 1], names: ["bt2020", "smpte2084", "bt709"] }, + Row { name: "alpha_matte", working: SRGB, output: PQ, channels: Channels::Alpha, codes: [1, 1, 1], names: BT709 }, +]; + +fn render(row: &Row, path: &Path) { + let mut p = Project::default(); + p.settings.working_space = row.working; + p.settings.output_space = row.output; + let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.2)); + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.75, 0.25, 0.5], width: 64, height: 48, pixel_aspect: 1.0 })); + comp.layers.push(build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (64, 48), None)); + let comp = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let output = OutputModule { channels: row.channels, ..OutputModule::for_format(OutputFormat::ProRes) }; + let out = path.to_string_lossy().to_string(); + let job = Job { + project: &p, + footage: &NoFootage, + expr: None, + accel: None, + comp, + settings: &settings, + output: &output, + path: &out, + sink: None, + options: Default::default(), + nested_switches: true, + }; + export(&job, &mut |_| true).expect("export"); +} + +/// ffprobe's primaries, transfer and matrix for the stream, or `None` without ffprobe. +fn ffprobe_tags(path: &Path) -> Option> { + let out = Command::new("ffprobe") + .args(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=color_primaries,color_transfer,color_space", "-of", "default=nw=1"]) + .arg(path) + .output() + .ok()?; + assert!(out.status.success(), "ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr)); + let text = String::from_utf8_lossy(&out.stdout).to_string(); + let tag = |key: &str| text.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix('=')).unwrap_or("").to_string(); + Some(vec![tag("color_primaries"), tag("color_transfer"), tag("color_space")]) +} + +#[test] +fn prores_colr_describes_the_frames() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/prores_color"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("mkdir"); + for row in ROWS { + let path = dir.join(format!("{}.mov", row.name)); + render(row, &path); + + let bytes = std::fs::read(&path).expect("read movie"); + let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux"); + let track = mov.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track"); + let [primaries, transfer, matrix] = row.codes; + let colr = track.entries[0].video.as_ref().and_then(|v| v.color.clone()); + assert_eq!(colr, Some(ColorInfo::Nclc { primaries, transfer, matrix }), "{}: colr", row.name); + + if let Some(stream) = ffprobe_tags(&path) { + assert_eq!(stream, row.names, "{}: ffprobe stream tags", row.name); + } + } +}