//! Photoshop import as a composition (IO-2) and Layer ▸ Create text conversions (SHP-5). use effectcraft_color::BlendMode; use effectcraft_keyframe::Value as KV; use effectcraft_project::{ItemKind, LayerSource, MaskMode}; use effectcraft_psd::write::*; use effectcraft_psd::{DValue, Descriptor, Rect}; use serde_json::json; use crate::Session; use crate::render::RenderOpts; fn tmp(name: &str) -> String { let d = std::env::temp_dir().join(format!("effectcraft-psd-tests-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); d.join(name).to_string_lossy().to_string() } /// A layered document exercising groups, masks, blend modes, text, styles and adjustments. pub(crate) fn sample_psd() -> Vec { let mut d = WDoc::new(120, 80); let shadow = Descriptor::new("null").with("masterFXSwitch", DValue::Bool(true)).with( "DrSh", DValue::Descriptor( Descriptor::new("DrSh") .with("enab", DValue::Bool(true)) .with("Md ", DValue::Enum("BlnM".into(), "Mltp".into())) .with("Clr ", DValue::Descriptor(Descriptor::rgb([0.0, 0.0, 0.5]))) .with("Opct", DValue::UnitFloat("#Prc".into(), 60.0)) .with("uglg", DValue::Bool(false)) .with("lagl", DValue::UnitFloat("#Ang".into(), 45.0)) .with("Dstn", DValue::UnitFloat("#Pxl".into(), 9.0)) .with("blur", DValue::UnitFloat("#Pxl".into(), 4.0)), ), ); let stroke = Descriptor::new("null").with( "FrFX", DValue::Descriptor( Descriptor::new("FrFX") .with("enab", DValue::Bool(true)) .with("Styl", DValue::Enum("FStl".into(), "InsF".into())) .with("Md ", DValue::Enum("BlnM".into(), "Nrml".into())) .with("Opct", DValue::UnitFloat("#Prc".into(), 100.0)) .with("Sz ", DValue::UnitFloat("#Pxl".into(), 2.0)) .with("Clr ", DValue::Descriptor(Descriptor::rgb([1.0, 1.0, 0.0]))), ), ); let mut masked = WLayer::solid("Masked Square", Rect::new(10, 10, 40, 40), [0.0, 0.8, 0.2, 1.0]).with_blend(b"mul "); masked.mask = Some(WMask { rect: Rect::new(10, 10, 40, 40), data: (0..1600).map(|i| if i % 40 < 20 { 1.0 } else { 0.0 }).collect(), default_color: 0, disabled: false, }); let mut hidden = WLayer::solid("Hidden", Rect::new(0, 0, 10, 10), [1.0, 0.0, 1.0, 1.0]); hidden.hidden = true; let mut clipped = WLayer::solid("Clipped", Rect::new(60, 30, 20, 20), [1.0, 1.0, 1.0, 1.0]).with_blend(b"scrn").with_opacity(128); clipped.clipping = true; let tri = vec![[[70.0, 10.0]; 3], [[110.0, 10.0]; 3], [[90.0, 40.0]; 3]]; d.layers = vec![ WLayer::solid("Background", Rect::new(0, 0, 120, 80), [0.2, 0.3, 0.4, 1.0]), WLayer::group_end(), masked, hidden, WLayer::group("Group A", true, *b"pass"), WLayer::solid("Base", Rect::new(55, 25, 30, 30), [0.9, 0.1, 0.1, 1.0]).with_block(effects_block(&shadow)), clipped, WLayer::solid("Vector Masked", Rect::new(60, 0, 60, 50), [0.1, 0.1, 0.9, 1.0]) .with_block(vector_mask_block(&[tri], 120, 80, false)) .with_block(effects_block(&stroke)), WLayer::empty("Hue Shift", vec![hue_sat_block(40, 10, -5)]), WLayer::empty("Title", vec![text_block("Hello PSD", [8.0, 70.0], "Inter-Bold", 14.0, [1.0, 1.0, 1.0], 0)]), ]; write(&d) } #[test] fn psd_imports_as_composition() { let path = tmp("sample.psd"); std::fs::write(&path, sample_psd()).unwrap(); let mut s = Session::default(); let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); assert_eq!(r["errors"], json!([]), "{r}"); let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); assert_eq!(s.state.active_comp, Some(cid)); assert_eq!(s.project.item(cid).unwrap().name, "sample"); let c = s.project.comp(cid).unwrap().clone(); assert_eq!((c.width, c.height), (120, 80)); let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]); // Text layer: editable text, positioned at the type origin. let title = &c.layers[0]; assert!(matches!(title.source, LayerSource::Text)); let Some(KV::Text(doc)) = title.props.prop("text/sourceText").map(|p| p.value.clone()) else { panic!("text") }; assert_eq!(doc.text, "Hello PSD"); assert_eq!(doc.size, 14.0); assert_eq!(doc.font, "Inter"); assert_eq!(doc.style, "Bold"); assert_eq!(title.props.prop("transform/position").unwrap().value.as_vec2(), [8.0, 70.0]); // Adjustment layer with Hue/Saturation. let hue = &c.layers[1]; assert!(hue.switches.adjustment); let fx = hue.effects().unwrap().groups().next().unwrap(); assert_eq!(fx.get("hue").unwrap().value.as_f64(), 40.0); assert_eq!(fx.get("saturation").unwrap().value.as_f64(), 10.0); // Vector mask → mask; Stroke style. let vm = &c.layers[2]; let m = vm.masks().unwrap().groups().next().unwrap(); assert!(matches!(m.kind, effectcraft_project::GroupKind::Mask { mode: MaskMode::Add, .. })); let Some(KV::Path(p)) = m.get("path").map(|p| p.value.clone()) else { panic!("mask path") }; assert_eq!(p.vertices.len(), 3); assert!((p.vertices[1][0] - 110.0).abs() < 1e-3 && (p.vertices[2][1] - 40.0).abs() < 1e-3); let st = vm.props.group("layerStyles/stroke").unwrap(); assert_eq!(st.get("size").unwrap().value.as_f64(), 2.0); assert_eq!(st.get("position").unwrap().value, KV::Enum(1)); // Clipped → preserve transparency; opacity and blend mode. let clipped = &c.layers[3]; assert!(clipped.preserve_transparency); assert_eq!(clipped.blend_mode, BlendMode::Screen); assert!((clipped.props.prop("transform/opacity").unwrap().value.as_f64() - 128.0 / 255.0 * 100.0).abs() < 1e-9); // Drop shadow. let ds = c.layers[4].props.group("layerStyles/dropShadow").unwrap(); assert_eq!(ds.get("distance").unwrap().value.as_f64(), 9.0); assert_eq!(ds.get("angle").unwrap().value.as_f64(), 45.0); assert_eq!(ds.get("useGlobalLight").unwrap().value, KV::Bool(false)); assert_eq!(ds.get("opacity").unwrap().value.as_f64(), 60.0); // Group → collapsed precomp (pass through) with its layers. let g = &c.layers[5]; assert!(g.switches.collapse); let LayerSource::Comp { item } = g.source else { panic!("precomp") }; let inner = s.project.comp(item).unwrap(); let inner_names: Vec<&str> = inner.layers.iter().map(|l| l.name.as_str()).collect(); assert_eq!(inner_names, ["Hidden", "Masked Square"]); assert!(!inner.layers[0].switches.video); assert_eq!(inner.layers[1].blend_mode, BlendMode::Multiply); // Footage items name their Photoshop layer; document-sized footage. let LayerSource::Footage { item } = inner.layers[1].source else { panic!("footage") }; let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; let sl = f.layer.as_ref().unwrap(); assert_eq!((sl.name.as_str(), sl.layer_size), ("Masked Square", false)); assert_eq!((f.width, f.height), (120, 80)); assert_eq!(s.project.item(item).unwrap().name, "Masked Square/sample.psd"); // One undo step removes everything. s.execute("edit.undo", json!({})).unwrap(); assert!(s.project.comp(cid).is_none()); } #[test] fn psd_retain_layer_sizes() { let path = tmp("retain.psd"); std::fs::write(&path, sample_psd()).unwrap(); let mut s = Session::default(); let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap(); let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); let c = s.project.comp(cid).unwrap(); let base = c.layer_by_name("Base").unwrap(); let LayerSource::Footage { item } = base.source else { panic!() }; let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; assert_eq!((f.width, f.height), (30, 30)); assert!(f.layer.as_ref().unwrap().layer_size); assert_eq!(base.props.prop("transform/position").unwrap().value.as_vec2(), [70.0, 40.0]); assert_eq!(base.props.prop("transform/anchor").unwrap().value.as_vec2(), [15.0, 15.0]); // Vector mask vertices are in the layer's own space. let vm = c.layer_by_name("Vector Masked").unwrap(); let Some(KV::Path(p)) = vm.masks().unwrap().groups().next().unwrap().get("path").map(|p| p.value.clone()) else { panic!() }; assert!((p.vertices[0][0] - 10.0).abs() < 1e-3, "{:?}", p.vertices); } #[test] fn import_rejects_unknown_kind() { let mut s = Session::default(); assert!(s.execute_checked("file.import", json!({"paths": ["x.psd"], "importAs": "banana"})).is_err()); } fn text_session() -> (Session, u64) { let mut s = Session::default(); s.execute("comp.new", json!({"name": "T", "width": 320, "height": 120, "frameRate": 30, "duration": 2})).unwrap(); let r = s.execute_checked("layer.newText", json!({"text": "Ab8", "size": 64, "position": [160, 80], "fill": "#ffcc00"})).unwrap(); (s, r["layer"].as_u64().unwrap()) } fn alpha_diff(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image) -> (f32, f32) { let mut sum = 0.0; let mut mass = 0.0; for (p, q) in a.data.iter().zip(&b.data) { sum += (p[3] - q[3]).abs(); mass += p[3]; } (sum / a.data.len() as f32, mass) } #[test] fn create_shapes_from_text_matches_text_render() { let (mut s, lid) = text_session(); let cid = s.active_comp_id().unwrap(); let opts = RenderOpts::default(); let text_img = s.render(cid, s.time(), opts); s.execute("layer.select", json!({"layers": [lid]})).unwrap(); let r = s.execute_checked("layer.create", json!({"op": "shapesFromText"})).unwrap(); let sid = r["layers"][0].as_u64().unwrap(); let c = s.active_comp().unwrap(); assert_eq!(c.layers[0].id.0, sid); assert!(matches!(c.layers[0].source, LayerSource::Shape)); assert_eq!(c.layers[0].name, "Ab8 Outlines"); assert!(!c.layer(effectcraft_project::LayerId(lid)).unwrap().switches.video, "source hidden"); let groups: Vec = c.layers[0].props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect(); assert_eq!(groups, ["A", "b", "8"]); let shape_img = s.render(cid, s.time(), opts); let (d, mass) = alpha_diff(&text_img, &shape_img); assert!(mass > 500.0, "text drew something"); assert!(d < 0.01, "mean alpha difference {d}"); // Fill colour carried over. let i = shape_img.data.iter().position(|p| p[3] > 0.99).unwrap(); let p = shape_img.data[i]; assert!((p[0] - 1.0).abs() < 0.02 && (p[1] - 0.8).abs() < 0.02 && p[2] < 0.02, "{p:?}"); } #[test] fn create_masks_from_text() { let (mut s, lid) = text_session(); let cid = s.active_comp_id().unwrap(); let text_img = s.render(cid, s.time(), RenderOpts::default()); s.execute("layer.select", json!({"layers": [lid]})).unwrap(); let r = s.execute_checked("layer.create", json!({"op": "masksFromText"})).unwrap(); let mid = effectcraft_project::LayerId(r["layers"][0].as_u64().unwrap()); let c = s.active_comp().unwrap(); let l = c.layer(mid).unwrap(); assert!(matches!(l.source, LayerSource::Solid { .. })); // "A", "b" and "8" have 2, 2 and 3 contours. assert_eq!(l.masks().unwrap().groups().count(), 7); let img = s.render(cid, s.time(), RenderOpts::default()); let (d, _) = alpha_diff(&text_img, &img); assert!(d < 0.01, "mean alpha difference {d}"); // Other ops need the right layer type. assert!(s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).is_err()); assert!(s.execute_checked("layer.create", json!({"op": "nullTracing", "layers": [lid]})).is_err()); }