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.
This commit is contained in:
JonRC
2026-11-01 22:28:23 -03:00
parent 720c67117e
commit d0e7e09ecb
4 changed files with 155 additions and 6 deletions
+34 -5
View File
@@ -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<Vec<Packet>> {
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();
+1
View File
@@ -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;
+2 -1
View File
@@ -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");
}
+118
View File
@@ -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<ColorSpace>,
output: Option<ColorSpace>,
channels: Channels,
/// H.273 primaries, transfer and matrix.
codes: [u16; 3],
/// The same as ffprobe names them.
names: [&'static str; 3],
}
const SRGB: Option<ColorSpace> = Some(ColorSpace::Srgb);
const PQ: Option<ColorSpace> = 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<Vec<String>> {
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);
}
}
}