mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 05:58:19 -06:00
M13.12: Create Shapes from Vector Layer keeps images as parented footage layers in paint order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
740faf1c1f
commit
fdeb146fb2
@@ -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
@@ -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`]).
|
||||
|
||||
Reference in New Issue
Block a user