mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 02:41:57 -06:00
EXR export: no compand / tone mapping, and the output space's own curve
OpenEXR renders (Render Queue sequences and Save Frame As ProEXR) received the frame after Project Settings > HDR, so Compand and Tone Map (the default of every OCIO project) compressed highlights and changed values below 1.0 before the writer decoded them, always with the sRGB curve. - RenderOpts::scene_output: the top-level comp converts to the output space without the HDR step (CPU and GPU walks, via Renderer::output_pipe). - Pipe::output_linear: the writers decode with the output space's curve (the Working Gamma's when unmanaged) instead of a fixed sRGB curve. - Render Queue EXR jobs and ProEXR set the flag; PSD layers, 8-bit stills, movies and the viewer keep the HDR step. The flag is part of the viewer frame and disk cache keys (opts_hash). Golden hashes, taken before the change, pin PNG/TIFF/Targa bytes for 8/16/32 bpc x Clip/Compand/ToneMap, Clip-mode EXR samples with an sRGB output, and the layered PSD bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a646bdcc2e
commit
684e05859f
@@ -27,12 +27,15 @@ struct Isolated {
|
||||
image: Image,
|
||||
}
|
||||
|
||||
fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
|
||||
/// `scene_output`: a float output (ProEXR), rendered without the HDR step
|
||||
/// ([`RenderOpts::scene_output`]).
|
||||
fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64, scene_output: bool) -> Image {
|
||||
let opts = RenderOpts {
|
||||
scale,
|
||||
backend: Backend::Auto,
|
||||
nested_switches: s.prefs.general.switches_affect_nested_comps,
|
||||
draft_shadows: s.prefs.three_d.realtime_shadows,
|
||||
scene_output,
|
||||
..Default::default()
|
||||
};
|
||||
let mut r = Renderer::new(project, s.footage.as_ref(), opts);
|
||||
@@ -41,10 +44,11 @@ fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64
|
||||
r.comp_frame(comp, t)
|
||||
}
|
||||
|
||||
/// The comp frame and each visible layer rendered alone (top layer first).
|
||||
fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec<Isolated>)> {
|
||||
/// The comp frame and each visible layer rendered alone (top layer first). `scene_output`: as
|
||||
/// [`render_with`].
|
||||
fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64, scene_output: bool) -> Result<(Image, Vec<Isolated>)> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let full = render_with(s, &s.project, cid, t, scale);
|
||||
let full = render_with(s, &s.project, cid, t, scale, scene_output);
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.has_video());
|
||||
let ids: Vec<LayerId> = comp
|
||||
.layers
|
||||
@@ -72,7 +76,7 @@ fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec<
|
||||
}
|
||||
}
|
||||
let Some((name, blend, opacity)) = info else { continue };
|
||||
let image = render_with(s, &p, cid, t, scale);
|
||||
let image = render_with(s, &p, cid, t, scale, scene_output);
|
||||
out.push(Isolated { name, blend, opacity, image });
|
||||
}
|
||||
Ok((full, out))
|
||||
@@ -111,7 +115,7 @@ fn alpha_bounds(img: &Image) -> Option<(u32, u32, u32, u32)> {
|
||||
pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec<u8>, usize, u32, u32)> {
|
||||
use effectcraft_psd::Rect;
|
||||
use effectcraft_psd::write::{WDoc, WLayer, write};
|
||||
let (full, layers) = isolate(s, cid, t, scale)?;
|
||||
let (full, layers) = isolate(s, cid, t, scale, false)?;
|
||||
let mut doc = WDoc::new(full.width, full.height);
|
||||
let n = layers.len();
|
||||
// File order is bottom first.
|
||||
@@ -136,10 +140,6 @@ pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec
|
||||
Ok((write(&doc), n, full.width, full.height))
|
||||
}
|
||||
|
||||
fn srgb_to_linear(c: f32) -> f32 {
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
/// A unique EXR layer name (no dots: they separate the layer from the channel). Printable ASCII only:
|
||||
/// the `exr` crate panics on characters it cannot store in a channel name (#1702).
|
||||
fn exr_name(name: &str, used: &mut Vec<String>) -> String {
|
||||
@@ -158,15 +158,19 @@ fn exr_name(name: &str, used: &mut Vec<String>) -> String {
|
||||
/// The multi-layer EXR bytes of a frame and the channel names.
|
||||
pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec<u8>, Vec<String>, u32, u32)> {
|
||||
use exr::prelude::*;
|
||||
let (full, layers) = isolate(s, cid, t, scale)?;
|
||||
// The composite and every layer without the HDR step, decoded with the output space's
|
||||
// curve (linear light in the output space's primaries).
|
||||
let (full, layers) = isolate(s, cid, t, scale, true)?;
|
||||
let dec = effectcraft_render::color::Pipe::of(&s.project.settings).output_linear();
|
||||
let (w, h) = (full.width as usize, full.height as usize);
|
||||
let planes = |img: &effectcraft_raster::Image| -> [Vec<f32>; 4] {
|
||||
let mut out: [Vec<f32>; 4] = Default::default();
|
||||
for p in &img.data {
|
||||
let sp = straight(*p);
|
||||
let a = sp[3];
|
||||
let lin = dec.apply([sp[0].max(0.0), sp[1].max(0.0), sp[2].max(0.0)]);
|
||||
for c in 0..3 {
|
||||
out[c].push(srgb_to_linear(sp[c].max(0.0)) * a);
|
||||
out[c].push(lin[c] * a);
|
||||
}
|
||||
out[3].push(a);
|
||||
}
|
||||
|
||||
@@ -230,3 +230,111 @@ fn save_frame_as_queued_respects_frame_default_template() {
|
||||
s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "JPEG Sequence"})).unwrap();
|
||||
assert!(s.execute_checked("comp.saveFrameAs", json!({"queue": true})).is_ok());
|
||||
}
|
||||
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ *b as u64).wrapping_mul(0x0100_0000_01b3))
|
||||
}
|
||||
|
||||
/// A 32×32 comp in a managed project (sRGB working space, linearized): a full-frame over-range
|
||||
/// solid under a 16×16 in-range Multiply solid at 50%.
|
||||
fn hdr_frame(bit_depth: u64, hdr: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Frame", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
|
||||
s.execute("file.projectSettings", json!({"bitDepth": bit_depth, "workingSpace": "srgb", "linearize": true, "hdr": hdr})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Bright", "color": [4.0, 2.0, 0.5]})).unwrap();
|
||||
let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": [0.85, 0.9, 0.2], "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setBlendMode", json!({"layers": [box_], "mode": "Multiply"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": box_, "path": "transform/opacity", "value": 50})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Photoshop Layers keeps the HDR step (it is a display-referred output): its bytes for each bit
|
||||
/// depth × HDR mode.
|
||||
#[test]
|
||||
fn photoshop_layers_bytes_are_unchanged() {
|
||||
let dir = out_dir("frame-psd-golden");
|
||||
let mut got = vec![];
|
||||
for depth in [8, 16, 32] {
|
||||
for hdr in ["clip", "compand", "toneMap"] {
|
||||
let mut s = hdr_frame(depth, hdr);
|
||||
let path = dir.join(format!("{depth}-{hdr}.psd")).to_string_lossy().to_string();
|
||||
s.execute("comp.saveFrameAsPsd", json!({"path": path})).unwrap();
|
||||
got.push(fnv(&std::fs::read(&path).unwrap()));
|
||||
}
|
||||
}
|
||||
// The hashes before ProEXR skipped the HDR step.
|
||||
let want: [u64; 9] = [
|
||||
0x3b921d4d9fc3119f,
|
||||
0x1886d026edf36a7f,
|
||||
0x6b564aa7fb50139f,
|
||||
0xabd585992079fc9f,
|
||||
0x88ca38726eaa557f,
|
||||
0xa3636606d7a4927f,
|
||||
0x7385ed211e9815ff,
|
||||
0x7385ed211e9815ff,
|
||||
0xfb59fa3f3bdfac3f,
|
||||
];
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
/// Every sample of a ProEXR file, channels in name order.
|
||||
fn proexr_samples(path: &str) -> Vec<(String, Vec<f32>)> {
|
||||
use exr::prelude::*;
|
||||
let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_file(path).unwrap();
|
||||
let mut out: Vec<(String, Vec<f32>)> = img.layer_data[0]
|
||||
.channel_data
|
||||
.list
|
||||
.iter()
|
||||
.map(|c| match &c.sample_data {
|
||||
FlatSamples::F32(v) => (c.name.to_string(), v.clone()),
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
/// ProEXR samples in Clip mode (sRGB output) for each bit depth.
|
||||
#[test]
|
||||
fn proexr_clip_samples_are_unchanged() {
|
||||
let dir = out_dir("frame-exr-clip-golden");
|
||||
let mut got = vec![];
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = hdr_frame(depth, "clip");
|
||||
let path = dir.join(format!("{depth}.exr")).to_string_lossy().to_string();
|
||||
s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
|
||||
let bytes: Vec<u8> =
|
||||
proexr_samples(&path).iter().flat_map(|(n, v)| n.bytes().chain(v.iter().flat_map(|x| x.to_bits().to_le_bytes())).collect::<Vec<u8>>()).collect();
|
||||
got.push(fnv(&bytes));
|
||||
}
|
||||
// The hashes before ProEXR skipped the HDR step.
|
||||
let want: [u64; 3] = [0x4b9ecc87e6db7d03, 0xe06a35d302e71503, 0x694ca552f3efc903];
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
/// ProEXR ignores compand and tone mapping: the composite and every isolated layer hold the Clip
|
||||
/// mode's samples, and over-range values stay.
|
||||
#[test]
|
||||
fn proexr_layers_are_not_tone_mapped() {
|
||||
let dir = out_dir("frame-exr-hdr");
|
||||
let samples = |depth: u64, hdr: &str| {
|
||||
let mut s = hdr_frame(depth, hdr);
|
||||
let path = dir.join(format!("{depth}-{hdr}.exr")).to_string_lossy().to_string();
|
||||
s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
|
||||
proexr_samples(&path)
|
||||
};
|
||||
for depth in [8, 16, 32] {
|
||||
let clip = samples(depth, "clip");
|
||||
assert!(clip.iter().any(|(n, _)| n == "Bright.R") && clip.iter().any(|(n, _)| n == "Box.R"), "{:?}", clip.iter().map(|c| &c.0).collect::<Vec<_>>());
|
||||
for hdr in ["compand", "toneMap"] {
|
||||
assert!(samples(depth, hdr) == clip, "{depth} bpc {hdr}");
|
||||
}
|
||||
}
|
||||
let tone = samples(32, "toneMap");
|
||||
let at = |n: &str| tone.iter().find(|c| c.0 == n).unwrap().1[0];
|
||||
// The solid's colour is sRGB-encoded in the working space (4.0 is about 25.3 in linear light).
|
||||
let lin = effectcraft_color::srgb_to_linear;
|
||||
for (n, want) in [("Bright.R", lin(4.0)), ("Bright.G", lin(2.0)), ("Bright.B", lin(0.5)), ("R", lin(4.0))] {
|
||||
assert!((at(n) - want).abs() <= 1e-4 * want, "{n}: {} vs {want}", at(n));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user