diff --git a/crates/engine/src/commands/create.rs b/crates/engine/src/commands/create.rs index 07e6af7d..850d38ad 100644 --- a/crates/engine/src/commands/create.rs +++ b/crates/engine/src/commands/create.rs @@ -36,14 +36,56 @@ fn create(s: &mut Session, p: &Value) -> Result { 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 { Ok(json!({"layers": created, "skipped": skipped})) } +/// An image of a vector file written as ` Image .png` next to it and imported: the +/// footage item. +fn image_footage(s: &mut Session, source: &str, n: usize, img: &crate::vector::VectorImage) -> Result { + 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( diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 23fc3f24..34edf5b7 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -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 { fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec { 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>, + /// 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, usize) { + fn walk(g: &effectcraft_svg::Group, m: Affine, opacity: f64, clipped: bool, out: &mut Vec, 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 { + 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) { + 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`]). diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index d8082573..44d2073f 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -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 { + 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 = (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:?}"); + } +}