238 lines
12 KiB
Rust
238 lines
12 KiB
Rust
//! 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());
|
|
}
|