Files
effectcraft/crates/engine/src/tests_vector_import.rs
T
vangel76andClaude Opus 5.5 d58253cc5a #738: Edit ▸ Paste pastes SVG markup as a shape layer
Problem: After Effects 26.3 pastes copied vector artwork as shape layers. Edit ▸ Paste only
knew the app's own clipboard, so SVG markup on the system clipboard was ignored; turning SVG
into shapes needed an imported footage file and Create Shapes from Vector Layer.

Fix: a new `edit.pasteSvg {svg, name?, comp?}` command builds a shape layer from SVG text with
the same conversion as Create Shapes from Vector Layer (paths, fills, strokes, gradients, clip
masks), above the selected layer with the SVG canvas centred in the comp, as one undo step;
oversized, non-SVG or shapeless text is rejected. In the UI, Ctrl/Cmd+V (and the native Edit ▸
Paste) run it when the system clipboard holds SVG. Copying in the app already replaces the
system clipboard with a note, so SVG there is newer than the app's clipboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:18 +02:00

286 lines
14 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.
//! 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<u8> {
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<String> = 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());
}
/// Edit ▸ Paste of SVG markup (After Effects 26.3): a shape layer with the SVG's shapes, its
/// canvas centred in the comp, above the selected layer, one undo step.
#[test]
fn paste_svg_markup_as_a_shape_layer() {
let (mut s, lid) = text_session();
let cid = s.active_comp_id().unwrap();
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
let svg = r##"<?xml version="1.0"?>
<!-- copied from a design tool -->
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20">
<rect x="0" y="0" width="20" height="20" fill="#ff0000"/>
<rect x="20" y="0" width="20" height="20" fill="#0000ff"/>
</svg>"##;
let r = s.execute_checked("edit.pasteSvg", json!({"svg": svg})).unwrap();
let sid = r["layer"].as_u64().unwrap();
let c = s.active_comp().unwrap();
assert_eq!(c.layers.len(), 2);
assert_eq!(c.layers[0].id.0, sid, "above the selected layer");
assert!(matches!(c.layers[0].source, LayerSource::Shape));
assert_eq!(c.layers[0].name, "Shape Layer 1");
assert_eq!(s.state.selected_layers, [effectcraft_project::LayerId(sid)]);
assert_eq!(c.layers[0].props.sub("contents").unwrap().groups().count(), 2);
// Hide the text so only the pasted shapes draw: the 40×20 canvas is centred in the
// 320×120 comp (x 140–180, y 50–70), red on the left, blue on the right.
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "video", "value": false})).unwrap();
let img = s.render(cid, s.time(), RenderOpts::default());
let px = |x: usize, y: usize| img.data[y * img.width as usize + x];
let red = px(150, 60);
assert!(red[3] > 0.99 && red[0] > 0.99 && red[2] < 0.01, "{red:?}");
let blue = px(170, 60);
assert!(blue[3] > 0.99 && blue[2] > 0.99 && blue[0] < 0.01, "{blue:?}");
assert!(px(130, 60)[3] < 0.01 && px(160, 45)[3] < 0.01, "nothing outside the canvas");
// One undo step removes it.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
}
#[test]
fn paste_svg_rejects_text_that_is_not_usable_svg() {
let (mut s, _) = text_session();
for bad in ["hello", "<svg", "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"></svg>", "<html><svg></html>"] {
assert!(s.execute_checked("edit.pasteSvg", json!({"svg": bad})).is_err(), "{bad}");
}
assert!(s.execute_checked("edit.pasteSvg", json!({})).is_err());
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "nothing pasted");
}