Files
effectcraft/crates/engine/src/tests_frame_export.rs
T
MindlessRuff 1b2c5d1944 fix(export): keep values above 1.0 in EXR output (#339)
The EXR sequence writer and comp.saveFrameAsExr clamped colour to 0..1 before the sRGB decode, so a 32 bpc project lost every over-range value. The lower bound stays at 0 and alpha stays clamped. Tests cover Rgba and Rgb sequences and saveFrameAsExr.
2026-10-08 20:22:49 -07:00

124 lines
6.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR….
use serde_json::json;
use crate::Session;
fn out_dir(name: &str) -> std::path::PathBuf {
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A 32×32 comp: a red full-frame solid under a 16×16 blue Multiply solid at 50%.
fn frame() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Frame", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000"})).unwrap();
let blue = s.execute("layer.newSolid", json!({"name": "Blue.Box", "color": "#0000ff", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setBlendMode", json!({"layers": [blue], "mode": "Multiply"})).unwrap();
s.execute("prop.set", json!({"layer": blue, "path": "transform/opacity", "value": 50})).unwrap();
s
}
#[test]
fn photoshop_layers_writes_one_psd_layer_per_comp_layer() {
let mut s = frame();
let dir = out_dir("frame-psd");
let path = dir.join("frame.psd").to_string_lossy().to_string();
let r = s.execute("comp.saveFrameAsPsd", json!({"path": path})).unwrap();
assert_eq!(r["layers"], 2);
let psd = effectcraft_psd::Psd::parse(std::fs::read(&path).unwrap()).unwrap();
assert_eq!((psd.width, psd.height), (32, 32));
let names: Vec<&str> = psd.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Red", "Blue.Box"], "bottom first");
let blue = &psd.layers[1];
assert_eq!(blue.blend_key(), "mul ");
assert_eq!(blue.opacity, 128);
// Cropped to its pixels (16×16, centred) and rendered at full opacity.
assert_eq!((blue.rect.left, blue.rect.top, blue.rect.width(), blue.rect.height()), (8, 8, 16, 16));
assert_eq!(psd.layers[0].blend_key(), "norm");
assert_eq!(psd.layers[0].opacity, 255);
// The merged image is the comp frame: red × (50% blue multiply) in the middle.
let m = psd.composite().unwrap();
let mid = m.data[16 * 32 + 16];
assert!(mid[0] > 0.4 && mid[0] < 0.6 && mid[2] < 0.05, "{mid:?}");
let corner = m.data[0];
assert!(corner[0] > 0.99 && corner[2] < 0.01, "{corner:?}");
// The menu has it.
assert!(crate::commands::find("comp.saveFrameAsPsd").is_some());
}
#[test]
fn proexr_writes_layer_prefixed_channels_and_the_composite() {
use exr::prelude::*;
let mut s = frame();
let dir = out_dir("frame-exr");
let path = dir.join("frame.exr").to_string_lossy().to_string();
let r = s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
let names: Vec<&str> = r["channels"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
assert_eq!(names.len(), 12);
assert!(names.contains(&"R") && names.contains(&"A") && names.contains(&"Red.R") && names.contains(&"Blue_Box.B"), "{names:?}");
let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_file(&path).unwrap();
let layer = &img.layer_data[0];
assert_eq!((layer.size.0, layer.size.1), (32, 32));
let ch = |n: &str| layer.channel_data.list.iter().find(|c| c.name.to_string() == n).unwrap_or_else(|| panic!("no channel {n}"));
let at = |n: &str, x: usize, y: usize| match &ch(n).sample_data {
FlatSamples::F32(v) => v[y * 32 + x],
o => panic!("{o:?}"),
};
// The blue layer alone: blue inside its box, transparent outside.
assert!((at("Blue_Box.B", 16, 16) - 1.0).abs() < 1e-3);
assert_eq!(at("Blue_Box.A", 0, 0), 0.0);
assert!((at("Red.R", 0, 0) - 1.0).abs() < 1e-3);
// Composite: opaque everywhere.
assert!((at("A", 16, 16) - 1.0).abs() < 1e-3);
assert!(at("R", 16, 16) < 0.5, "multiplied by 50% blue (linear)");
}
/// Save a 32×32 comp holding one opaque solid of `color` (an sRGB-encoded triple) as ProEXR and
/// return the centre pixel of the composite and of the solid's own layer: `[R, G, B]` each.
fn proexr_centre(color: [f64; 3], bit_depth: u64) -> ([f32; 3], [f32; 3]) {
use exr::prelude::*;
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Bright", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
s.execute("file.projectSettings", json!({"bitDepth": bit_depth})).unwrap();
s.execute("layer.newSolid", json!({"name": "Bright", "color": color})).unwrap();
let dir = out_dir(&format!("frame-exr-over-{}", color[0]));
let path = dir.join(format!("frame{bit_depth}.exr")).to_string_lossy().to_string();
s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_file(&path).unwrap();
let layer = &img.layer_data[0];
let at = |n: &str| {
let c = layer.channel_data.list.iter().find(|c| c.name.to_string() == n).unwrap_or_else(|| panic!("no channel {n}"));
match &c.sample_data {
FlatSamples::F32(v) => v[16 * 32 + 16],
o => panic!("{o:?}"),
}
};
([at("R"), at("G"), at("B")], [at("Bright.R"), at("Bright.G"), at("Bright.B")])
}
#[test]
fn proexr_keeps_values_above_one_in_32_bpc() {
// Issue #339: over-range colour reaches the EXR in scene-linear light (sRGB decode, uncapped).
let src = [1.5, 1.25, 0.75];
let (comp, layer) = proexr_centre(src, 32);
for i in 0..3 {
let want = effectcraft_color::srgb_to_linear(src[i] as f32);
for got in [comp[i], layer[i]] {
assert!((got - want).abs() < 1e-3 * want.max(1.0), "channel {i}: got {got}, want {want} ({comp:?} / {layer:?})");
}
}
assert!(comp[0] > 1.5 && comp[1] > 1.25, "{comp:?}");
}
#[test]
fn proexr_values_at_or_below_one_are_unchanged() {
let (comp, layer) = proexr_centre([1.0, 0.4, 0.0], 32);
for got in [comp, layer] {
assert!((got[0] - 1.0).abs() < 1e-4 && (got[1] - 0.1329).abs() < 1e-3 && got[2].abs() < 1e-6, "{got:?}");
}
}