M13.12: Create Shapes from Vector Layer keeps images as parented footage layers in paint order

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 09:23:29 -04:00
1 parent 740faf1c1f
commit fdeb146fb2
3 files changed
+256 -8

No files matched your search

+68 -7
View File
@@ -36,14 +36,56 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)
.unwrap_or_else(|| Err(format!("{}: not a vector file", f.path)))
.map_err(EngineError::Other)?;
let id = s.edit("Create Shapes from Vector Layer", None, |proj, st| {
let l = crate::vector::shapes_from_vector(proj, &comp, &src, &doc);
let id = l.id;
insert_above(proj, cid, lid, l, true)?;
st.selected_layers = vec![id];
Ok(id)
// Images become footage layers (PNG files written next to the source and
// imported), parented to the shape layer below it.
let (images, masks) = crate::vector::vector_images(&doc);
if masks > 0 {
skipped.push(format!("{}: {masks} soft mask(s) not converted (shape layers have no soft masks)", src.name));
}
let mut image_items = vec![];
for (i, img) in images.iter().enumerate() {
match image_footage(s, &f.path, i + 1, img) {
Ok(item) => image_items.push((item, i)),
Err(e) => skipped.push(format!("{}: image {} not converted ({e})", src.name, i + 1)),
}
if img.clipped {
skipped.push(format!("{}: image {} is clipped or masked; its footage layer is not", src.name, i + 1));
}
}
let mut notes = vec![];
let ids = s.edit("Create Shapes from Vector Layer", None, |proj, st| {
// Without images: one shape layer. With images: the shapes between them
// in layers of their own, so the stack keeps the document's paint order.
let segments = if image_items.is_empty() { vec![doc.clone()] } else { crate::vector::split_at_images(&doc) };
let top = crate::vector::shapes_from_vector(proj, &comp, &src, segments.last().unwrap_or(&doc));
// Top to bottom: the last segment, image n, segment n − 1, … image 1, segment 0.
let mut stack = vec![];
for k in (0..segments.len().saturating_sub(1)).rev() {
if let Some((item, i)) = image_items.iter().find(|(_, i)| *i == k) {
let (il, note) = crate::vector::image_layer(proj, &comp, *item, &images[*i], &top);
notes.extend(note);
stack.push(il);
}
let seg = &segments[k];
if seg.root.children.is_empty() {
continue;
}
let mut sl = crate::vector::shapes_from_vector(proj, &comp, &src, seg);
sl.name = format!("{} {}", top.name, k + 1);
stack.push(sl);
}
let mut ids = vec![top.id];
ids.extend(stack.iter().filter(|l| matches!(l.source, LayerSource::Shape)).map(|l| l.id));
// Each directly above the source: the top layer first.
insert_above(proj, cid, lid, top, true)?;
for l in stack {
insert_above(proj, cid, lid, l, true)?;
}
st.selected_layers = vec![ids[0]];
Ok(ids)
})?;
created.push(id.0);
skipped.extend(notes);
created.extend(ids.iter().map(|i| i.0));
}
"shapesFromText" => {
if !matches!(src.source, LayerSource::Text) {
@@ -118,6 +160,25 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layers": created, "skipped": skipped}))
}
/// An image of a vector file written as `<file> Image <n>.png` next to it and imported: the
/// footage item.
fn image_footage(s: &mut Session, source: &str, n: usize, img: &crate::vector::VectorImage) -> Result<effectcraft_project::ItemId> {
let src = std::path::Path::new(source);
let stem = src.file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_else(|| "Vector".into());
let name = format!("{stem} Image {n}.png");
let path = match src.parent().filter(|p| !p.as_os_str().is_empty()) {
Some(dir) => dir.join(&name).to_string_lossy().into_owned(),
None => name,
};
let png = super::comp_more::encode_png(&img.rgba, img.width, img.height).map_err(EngineError::Other)?;
s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
let r = super::file::import(s, &json!({"paths": [path]}))?;
if let Some(e) = r["errors"].as_array().and_then(|e| e.first()) {
return Err(EngineError::Other(e.as_str().unwrap_or("import failed").to_string()));
}
r["items"].get(0).and_then(Value::as_u64).map(effectcraft_project::ItemId).ok_or_else(|| EngineError::Other(format!("{path} was not imported")))
}
/// Insert `l` directly above layer `below`; optionally switch the source's video off (as After
/// Effects does after converting).
fn insert_above(
+127 -1
View File
@@ -162,7 +162,8 @@ fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[
/// Clipping groups (PDF / Illustrator clipping masks): shapes inside them are intersected with
/// the clip paths through Merge Paths ▸ Intersect (see [`clipped_items`]). Clips enclosing the
/// whole document are layer masks instead ([`vector_clip_masks`], used by
/// [`shapes_from_vector`]). Images and soft masks are not converted.
/// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see
/// [`split_at_images`]); soft masks are not converted.
pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec<PropGroup> {
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
let mut out = vec![];
@@ -340,6 +341,131 @@ fn like_source(dst: &mut Layer, src: &Layer) {
dst.switches.three_d = src.switches.three_d;
}
/// A raster image of a vector document (Create Shapes from Vector Layer makes it a footage
/// layer: shape layers have no images).
pub struct VectorImage {
pub name: String,
pub width: u32,
pub height: u32,
/// Straight RGBA8.
pub rgba: std::sync::Arc<Vec<u8>>,
/// Image pixels → document pixels.
pub transform: Affine,
pub opacity: f64,
/// Inside a clipping group or under a soft mask (not carried over).
pub clipped: bool,
}
/// The document's images, bottom first, and how many soft-masked groups it has.
pub fn vector_images(doc: &Doc) -> (Vec<VectorImage>, usize) {
fn walk(g: &effectcraft_svg::Group, m: Affine, opacity: f64, clipped: bool, out: &mut Vec<VectorImage>, masks: &mut usize) {
let m = m * g.transform;
let opacity = opacity * g.opacity;
let clipped = clipped || !g.clip.is_empty() || g.mask.is_some();
if g.mask.is_some() {
*masks += 1;
}
for c in &g.children {
match c {
Node::Group(sub) => walk(sub, m, opacity, clipped, out, masks),
Node::Image(im) => out.push(VectorImage {
name: im.name.clone(),
width: im.width,
height: im.height,
rgba: im.rgba.clone(),
transform: m * im.transform,
opacity: opacity * im.opacity,
clipped,
}),
Node::Shape(_) => {}
}
}
}
let (_, doc) = vector_clip_masks(doc);
let mut out = vec![];
let mut masks = 0;
walk(&doc.root, Affine::IDENTITY, 1.0, false, &mut out, &mut masks);
(out, masks)
}
/// The document split at its images (paint order): segment `k` keeps the shapes drawn between
/// image `k − 1` and image `k` (groups, clips and blend modes kept, images dropped), so shape
/// layers and image footage layers can stack in the document's order. One more segment than
/// images.
pub fn split_at_images(doc: &Doc) -> Vec<Doc> {
fn count(g: &effectcraft_svg::Group) -> usize {
g.children
.iter()
.map(|c| match c {
Node::Group(s) => count(s),
Node::Image(_) => 1,
Node::Shape(_) => 0,
})
.sum()
}
/// Keep the shapes between images `seg − 1` and `seg`; `seen` counts images passed.
fn prune(g: &effectcraft_svg::Group, seg: usize, seen: &mut usize) -> effectcraft_svg::Group {
let mut out = g.clone();
out.children = vec![];
for c in &g.children {
match c {
Node::Image(_) => *seen += 1,
Node::Shape(_) => {
if *seen == seg {
out.children.push(c.clone());
}
}
Node::Group(s) => {
let p = prune(s, seg, seen);
if !p.children.is_empty() {
out.children.push(Node::Group(p));
}
}
}
}
out
}
let n = count(&doc.root);
(0..=n)
.map(|seg| {
let mut d = doc.clone();
d.root = prune(&doc.root, seg, &mut 0);
d
})
.collect()
}
/// A footage layer for an image of a vector document, parented to `parent` (the shape layer,
/// whose layer space is document pixels) and placed by the image's transform (anchor at its
/// top-left corner; position, rotation and scale from the matrix). Returns a note when the
/// placement has a skew, which layer transforms cannot express.
pub fn image_layer(proj: &mut Project, comp: &Comp, item: ItemId, img: &VectorImage, parent: &Layer) -> (Layer, Option<String>) {
let mut l = build::layer(proj, comp, &img.name, LayerSource::Footage { item }, (img.width, img.height), None);
let c = img.transform.as_coeffs();
let theta = c[1].atan2(c[0]);
let (sn, cs) = theta.sin_cos();
let sx = (c[0] * c[0] + c[1] * c[1]).sqrt();
let sy = -c[2] * sn + c[3] * cs;
let skew = c[2] * cs + c[3] * sn;
let set = |l: &mut Layer, path: &str, v: Value| {
if let Some(p) = l.props.prop_mut(path) {
p.value = v;
}
};
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
set(&mut l, "transform/position", Value::Vec3([c[4], c[5], 0.0]));
set(&mut l, "transform/scale", Value::Vec3([sx * 100.0, sy * 100.0, 100.0]));
set(&mut l, "transform/rotation", Value::Scalar(theta.to_degrees()));
set(&mut l, "transform/opacity", Value::Scalar((img.opacity * 100.0).clamp(0.0, 100.0)));
l.start_time = parent.start_time;
l.in_point = parent.in_point;
l.out_point = parent.out_point;
l.stretch = parent.stretch;
l.parent = Some(parent.id);
let note = (skew.abs() > 1e-3 * sy.abs().max(1e-9)).then(|| format!("{}: skewed image placed without its skew", img.name));
(l, note)
}
/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`. A clip
/// enclosing the whole document becomes the layer's masks (Add, then Intersect); nested clips
/// are kept with Merge Paths (see [`svg_contents`]).
+61
View File
@@ -461,3 +461,64 @@ fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() {
assert!(top[0] > 0.5 || top[1] > 0.5, "top centre covered {top:?}");
assert!(img.get(21, 11)[0] < 0.05 && img.get(21, 11)[1] < 0.05, "corner region empty {:?}", img.get(21, 11));
}
/// A PDF with two placed images (one turned a quarter) between a background and a shape.
fn image_pdf() -> Vec<u8> {
let content = "0.9 0.9 0.9 rg 0 0 120 80 re f\nq 60 0 0 30 10 40 cm /Im Do Q\nq 0 40 -20 0 110 10 cm /Im Do Q\n0 0 1 rg 20 5 30 20 re f\n";
// 8×8 pixels in quadrants: red, green / white, black.
let quad = |x: usize, y: usize| -> [u8; 3] { [[[255, 0, 0], [0, 255, 0]], [[255, 255, 255], [0, 0, 0]]][y / 4][x / 4] };
let px: Vec<u8> = (0..64).flat_map(|i| quad(i % 8, i / 8)).collect();
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, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>".into(), None),
(4, "<< >>".into(), Some(content.as_bytes().to_vec())),
(5, "<< /Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 >>".into(), Some(px)),
],
1,
)
}
#[test]
fn shapes_from_vector_layer_keeps_images_as_footage_layers() {
let bytes = image_pdf();
let doc = effectcraft_pdf::parse(&bytes).unwrap();
let (w, h) = doc.pixel_size();
let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0);
let path = tmp("images.pdf");
std::fs::write(&path, &bytes).unwrap();
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": "I", "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);
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap();
assert_eq!(r["skipped"], json!([]), "{r}");
let sid = r["layers"][0].as_u64().unwrap();
let c = s.active_comp().unwrap();
// In the document's paint order: the shape drawn last, the two images (the later one
// higher), the background drawn first, then the hidden source.
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["images.pdf Outlines", "Image", "Image", "images.pdf Outlines 1", "images.pdf"]);
assert_eq!(c.layers[0].id.0, sid);
assert_eq!(r["layers"].as_array().unwrap().len(), 2);
for l in &c.layers[1..3] {
assert_eq!(l.parent.map(|p| p.0), Some(sid), "{}", l.name);
assert!(matches!(l.source, effectcraft_project::LayerSource::Footage { .. }));
}
assert!(!c.layers[4].switches.video);
let shapes = s.render(cid, t, RenderOpts::default());
let d = mean_diff(&shapes, &direct);
assert!(d < 0.02, "converted vs page: mean diff {d}");
// Image pixels in place: quarters of the upright image and of the turned one, and the
// shape over the background.
for (x, y) in [(25, 17), (55, 17), (25, 32), (55, 32), (95, 60), (95, 40), (105, 40), (30, 65)] {
let (a, b) = (shapes.get(x, y), direct.get(x, y));
assert!((0..3).all(|k| (a[k] - b[k]).abs() < 0.05), "({x}, {y}): {a:?} vs {b:?}");
}
}