//! Photoshop and SVG import through the fully wired session (media decoding included): pixels //! of imported compositions, SVG footage at any scale, and Create Shapes from Vector Layer. use effectcraft_engine::render::RenderOpts; use effectcraft_project::ItemId; use effectcraft_psd::Rect; use effectcraft_psd::write::*; use effectcraft_raster::Image; use serde_json::json; fn tmp(name: &str) -> String { let d = std::env::temp_dir().join(format!("effectcraft-host-vector-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); d.join(name).to_string_lossy().to_string() } fn mean_diff(a: &Image, b: &Image) -> f32 { assert_eq!((a.width, a.height), (b.width, b.height)); let mut s = 0.0; for (p, q) in a.data.iter().zip(&b.data) { for c in 0..4 { s += (p[c] - q[c]).abs(); } } s / (a.data.len() * 4) as f32 } fn psd_doc() -> WDoc { let mut d = WDoc::new(64, 48); let mut masked = WLayer::solid("Masked", Rect::new(30, 8, 24, 24), [0.1, 0.9, 0.3, 1.0]); masked.mask = Some(WMask { rect: Rect::new(30, 8, 24, 24), data: (0..576).map(|i| if (i / 24) < 12 { 1.0 } else { 0.25 }).collect(), default_color: 0, disabled: false, }); d.layers = vec![ WLayer::solid("Background", Rect::new(0, 0, 64, 48), [0.2, 0.25, 0.3, 1.0]), WLayer::solid("Half Red", Rect::new(4, 4, 30, 20), [1.0, 0.0, 0.0, 1.0]).with_opacity(128), masked, WLayer::group_end(), WLayer::solid("In Group", Rect::new(10, 30, 20, 12), [0.9, 0.9, 0.1, 1.0]), WLayer::group("Group", true, *b"norm"), ]; d } /// Straight-alpha over of the layers as Photoshop composites them (normal blending), as the /// merged image to compare the imported composition with. fn reference(bytes: &[u8]) -> Vec<[f32; 4]> { let p = effectcraft_psd::Psd::parse(bytes.to_vec()).unwrap(); let mut acc = vec![[0.0f32; 4]; (p.width * p.height) as usize]; for l in p.layers.iter().filter(|l| l.has_pixels() && !l.hidden) { let px = p.layer_pixels(l.index, true).unwrap(); let o = l.opacity as f32 / 255.0; for (d, s) in acc.iter_mut().zip(&px.data) { let a = s[3] * o; let oa = a + d[3] * (1.0 - a); for c in 0..3 { d[c] = if oa > 0.0 { (s[c] * a + d[c] * d[3] * (1.0 - a)) / oa } else { 0.0 }; } d[3] = oa; } } acc } #[test] fn psd_composition_renders_like_the_document() { let mut doc = psd_doc(); let bytes = write(&doc); let merged = reference(&bytes); doc.composite = Some(merged.clone()); let bytes = write(&doc); let path = tmp("layers.psd"); std::fs::write(&path, &bytes).unwrap(); let mut s = effectcraft_host::session(); // Footage (merged image). let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); let merged_item = r["items"][0].as_u64().unwrap(); // Composition and Composition – Retain Layer Sizes. let mut renders = vec![]; for kind in ["composition", "compositionLayerSizes"] { let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": kind})).unwrap(); let cid = ItemId(r["comps"][0].as_u64().unwrap()); renders.push(s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default())); } // The merged footage in a comp of the same size. s.execute("comp.new", json!({"name": "Merged", "width": 64, "height": 48, "frameRate": 30, "duration": 1})).unwrap(); s.execute("layer.addItem", json!({"item": merged_item})).unwrap(); let mcid = s.active_comp_id().unwrap(); let merged_img = s.render(mcid, effectcraft_time::Tick::ZERO, RenderOpts::default()); let expected = Image { width: 64, height: 48, data: merged.iter().map(|p| [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]).collect() }; assert!(mean_diff(&merged_img, &expected) < 2.0 / 255.0, "merged footage"); for (i, img) in renders.iter().enumerate() { let d = mean_diff(img, &expected); assert!(d < 2.0 / 255.0, "import kind {i}: mean diff {d}"); } // A single layer as footage (Choose Layer). let r = s.execute_checked("file.import", json!({"paths": [path], "layer": "Masked"})).unwrap(); let it = s.project.item(ItemId(r["items"][0].as_u64().unwrap())).unwrap(); assert_eq!(it.name, "Masked/layers.psd"); } #[test] fn psd_16bit_cmyk_composition_imports() { let mut doc = psd_doc(); doc.depth = 16; doc.mode = effectcraft_psd::ColorMode::Cmyk; doc.rle = false; let bytes = write(&doc); let merged = reference(&bytes); let path = tmp("layers16.psd"); std::fs::write(&path, &bytes).unwrap(); let mut s = effectcraft_host::session(); let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); let cid = ItemId(r["comps"][0].as_u64().unwrap()); let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); let expected = Image { width: 64, height: 48, data: merged.iter().map(|p| [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]).collect() }; let d = mean_diff(&img, &expected); assert!(d < 2.0 / 255.0, "mean diff {d}"); } const SVG: &[u8] = include_bytes!("../../svg/tests/fixtures/basic-shapes.svg"); const SVG2: &[u8] = include_bytes!("../../svg/tests/fixtures/paths-gradients.svg"); #[test] fn svg_footage_and_shapes_from_vector_layer() { for (name, bytes) in [("basic-shapes.svg", SVG), ("paths-gradients.svg", SVG2)] { let path = tmp(name); std::fs::write(&path, bytes).unwrap(); let mut s = effectcraft_host::session(); let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); assert_eq!(r["errors"], json!([]), "{r}"); let item = r["items"][0].as_u64().unwrap(); let doc = effectcraft_svg::parse(bytes).unwrap(); let (w, h) = doc.pixel_size(); s.execute("comp.new", json!({"name": "V", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap(); let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); let cid = s.active_comp_id().unwrap(); let t = effectcraft_time::Tick::ZERO; let foot = s.render(cid, t, RenderOpts::default()); let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0); assert!(mean_diff(&foot, &direct) < 1.0 / 255.0, "{name}: footage = rasterised SVG"); s.execute("layer.select", json!({"layers": [lid]})).unwrap(); let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap(); let sid = r["layers"][0].as_u64().unwrap(); let c = s.active_comp().unwrap(); assert_eq!(c.layers[0].id.0, sid); assert!(!c.layers[1].switches.video); let shapes = s.render(cid, t, RenderOpts::default()); let d = mean_diff(&shapes, &direct); assert!(d < 0.02, "{name}: shapes vs SVG mean diff {d}"); // Scaled up 3×, the shape layer stays sharp and matches the SVG rasterised at 3×. s.execute("prop.set", json!({"layer": sid, "path": "transform/scale", "value": [300, 300]})).unwrap(); s.execute("prop.set", json!({"layer": sid, "path": "transform/position", "value": [0, 0]})).unwrap(); s.execute("prop.set", json!({"layer": sid, "path": "transform/anchor", "value": [0, 0]})).unwrap(); let big = s.render(cid, t, RenderOpts::default()); let direct3 = effectcraft_svg::rasterize(&doc, w, h, 3.0); let d3 = mean_diff(&big, &direct3); assert!(d3 < 0.03, "{name}: ×3 mean diff {d3}"); } } #[test] fn svg_footage_continuously_rasterizes() { let path = tmp("cr.svg"); std::fs::write(&path, SVG).unwrap(); let doc = effectcraft_svg::parse(SVG).unwrap(); let (w, h) = doc.pixel_size(); let mut s = effectcraft_host::session(); let item = s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap(); s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap(); let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([300, 300]))] { s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap(); } let cid = s.active_comp_id().unwrap(); let t = effectcraft_time::Tick::ZERO; let want = effectcraft_svg::rasterize(&doc, w, h, 3.0); let soft = mean_diff(&s.render(cid, t, RenderOpts::default()), &want); s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap(); let sharp = mean_diff(&s.render(cid, t, RenderOpts::default()), &want); assert!(sharp < 0.004, "continuously rasterised diff {sharp}"); assert!(sharp < soft, "sharper than the upscaled pixels ({sharp} vs {soft})"); } /// A two-layer Illustrator-style PDF (optional-content layers "Sky" and "Art"): a gradient sky, /// a red curved shape with a dark outline, and a clipped green bar. fn layered_pdf() -> Vec { let content = "/OC /L0 BDC\n/Pattern cs /P0 scn 0 0 120 80 re f\nEMC\n\ /OC /L1 BDC\n1 0 0 rg 0.1 0.1 0.1 RG 3 w 20 20 m 20 60 l 60 70 100 60 v 100 20 l h B\n\ q 30 0 40 80 re W n 0 0.8 0 rg 0 35 120 10 re f Q\nEMC\n"; let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>"; let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 0 80] /Function << /FunctionType 2 /Domain [0 1] /C0 [0.9 0.9 1] /C1 [0.2 0.4 0.9] /N 1 >> /Extend [true true] >> >>"; effectcraft_pdf::write::pdf( &[ (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), (3, page.into(), None), (4, "<< >>".into(), Some(content.as_bytes().to_vec())), (5, "<< /Type /OCG /Name (Sky) >>".into(), None), (6, "<< /Type /OCG /Name (Art) >>".into(), None), (7, pattern.into(), None), ], 1, ) } #[test] fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() { let bytes = layered_pdf(); let doc = effectcraft_pdf::parse(&bytes).unwrap(); let (w, h) = doc.pixel_size(); assert_eq!((w, h), (120, 80)); let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0); for name in ["art.pdf", "art.ai"] { let path = tmp(name); std::fs::write(&path, &bytes).unwrap(); let mut s = effectcraft_host::session(); let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); assert_eq!(r["errors"], json!([]), "{r}"); let item = r["items"][0].as_u64().unwrap(); let effectcraft_project::ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!() }; assert_eq!(f.codec, if name.ends_with(".ai") { "AI" } else { "PDF" }); assert_eq!((f.width, f.height), (120, 80)); s.execute("comp.new", json!({"name": "V", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap(); let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); let cid = s.active_comp_id().unwrap(); let t = effectcraft_time::Tick::ZERO; let foot = s.render(cid, t, RenderOpts::default()); assert!(mean_diff(&foot, &direct) < 1.0 / 255.0, "{name}: footage = rasterised page"); // Continuously Rasterize at 300 %. for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([300, 300]))] { s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap(); } let want = effectcraft_svg::rasterize(&doc, w, h, 3.0); let soft = mean_diff(&s.render(cid, t, RenderOpts::default()), &want); s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap(); let sharp = mean_diff(&s.render(cid, t, RenderOpts::default()), &want); assert!(sharp < 0.004 && sharp < soft, "{name}: continuously rasterised {sharp} vs {soft}"); s.execute("prop.set", json!({"layer": lid, "path": "transform/scale", "value": [100, 100]})).unwrap(); // Create Shapes from Vector Layer. s.execute("layer.select", json!({"layers": [lid]})).unwrap(); let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap(); let sid = r["layers"][0].as_u64().unwrap(); let c = s.active_comp().unwrap(); assert_eq!(c.layers[0].id.0, sid); let shapes = s.render(cid, t, RenderOpts::default()); // Clipping groups are not shape-layer features: the clipped bar (rows 33–47) draws in // full; everything else matches. let rows = |img: &Image| Image { width: w, height: h - 15, data: img.data.iter().enumerate().filter(|(i, _)| !(33..48).contains(&(*i as u32 / w))).map(|(_, p)| *p).collect(), }; let d = mean_diff(&rows(&shapes), &rows(&direct)); assert!(d < 0.01, "{name}: shapes vs page mean diff {d}"); let fill = shapes.get(60, 30); assert!(fill[0] > 0.9 && fill[1] < 0.1, "red shape {fill:?}"); } // Import As: Composition — one layer per file layer, top first, together like the page. let path = tmp("layers.ai"); std::fs::write(&path, &bytes).unwrap(); let mut s = effectcraft_host::session(); let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); let cid = ItemId(r["comps"][0].as_u64().unwrap()); let comp = s.project.comp(cid).unwrap(); let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect(); assert_eq!(names, vec!["Art", "Sky"]); assert_eq!((comp.width, comp.height), (120, 80)); let art = comp.layers[0].id.0; let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); let d = mean_diff(&img, &direct); assert!(d < 1.0 / 255.0, "layered comp vs page {d}"); // Each layer's footage shows only its layer. s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap(); let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); assert_eq!(sky.get(5, 5), img.get(5, 5)); let p = sky.get(60, 30); assert!(p[2] > p[0], "only sky under the art {p:?}"); } #[test] fn eps_footage_imports() { let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n1 0 0 setrgbcolor 0 0 20 20 rectfill 0 0 1 setrgbcolor newpath 30 10 8 0 360 arc fill\n%%EOF\n"; let path = tmp("shape.eps"); std::fs::write(&path, eps).unwrap(); let mut s = effectcraft_host::session(); let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); assert_eq!(r["errors"], json!([]), "{r}"); let item = r["items"][0].as_u64().unwrap(); s.execute("comp.new", json!({"name": "E", "width": 40, "height": 20, "frameRate": 30, "duration": 1})).unwrap(); s.execute_checked("layer.addItem", json!({"item": item})).unwrap(); let cid = s.active_comp_id().unwrap(); let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); assert!(img.get(10, 10)[0] > 0.99 && img.get(30, 10)[2] > 0.99, "{:?} {:?}", img.get(10, 10), img.get(30, 10)); }