154 lines
7.7 KiB
Rust
154 lines
7.7 KiB
Rust
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR…, and Copy Frame to Clipboard.
|
||
|
||
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:?}");
|
||
}
|
||
}
|
||
|
||
/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): the frame, as Save Frame As
|
||
/// renders it, goes to the frontend as an image for the system clipboard.
|
||
#[test]
|
||
fn copy_frame_hands_the_rendered_frame_to_the_frontend() {
|
||
let mut s = frame();
|
||
s.drain_events();
|
||
let r = s.execute("comp.copyFrame", json!({})).unwrap();
|
||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(32), Some(32)));
|
||
let events = s.drain_events();
|
||
let Some((width, height, rgba)) = events.iter().find_map(|e| match e {
|
||
crate::Event::CopyImage { width, height, rgba } => Some((*width, *height, rgba)),
|
||
_ => None,
|
||
}) else {
|
||
panic!("no CopyImage event in {events:?}")
|
||
};
|
||
assert_eq!((width, height, rgba.len()), (32, 32, 32 * 32 * 4));
|
||
// Red in the corner; red × (50% blue multiply) in the middle; opaque everywhere.
|
||
assert_eq!(&rgba[..4], &[255, 0, 0, 255]);
|
||
let mid = &rgba[(16 * 32 + 16) * 4..][..4];
|
||
assert!(mid[0] > 100 && mid[0] < 160 && mid[2] < 13 && mid[3] == 255, "{mid:?}");
|
||
assert!(rgba.chunks(4).all(|p| p[3] == 255));
|
||
// The pixels are not serialized into the event stream.
|
||
let json = serde_json::to_value(events.iter().find(|e| matches!(e, crate::Event::CopyImage { .. })).unwrap()).unwrap();
|
||
assert!(json["CopyImage"].get("rgba").is_none(), "{json}");
|
||
// Scaled, and refused without a composition.
|
||
assert_eq!(s.execute("comp.copyFrame", json!({"scale": 0.5})).unwrap()["width"], 16);
|
||
assert!(Session::default().execute("comp.copyFrame", json!({})).is_err());
|
||
assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition")));
|
||
}
|