//! 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)"); } #[test] fn proexr_names_a_non_ascii_layer_with_underscores_instead_of_panicking() { let mut s = Session::default(); s.execute("comp.new", json!({"name": "Frame", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap(); s.execute("layer.newSolid", json!({"name": "你好", "color": "#ff0000"})).unwrap(); let dir = out_dir("frame-exr-non-ascii"); 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!(names.contains(&"__.R") && names.contains(&"__.A"), "{names:?}"); assert!(std::path::Path::new(&path).exists()); } /// 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"))); }