Merge pull request #739 from vangel76

# Conflicts:
#	docs/gaps.md
This commit is contained in:
Brandon Thomas
2026-10-12 03:03:10 -04:00
7 changed files with 179 additions and 7 deletions
+45
View File
@@ -531,6 +531,42 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
/// Largest SVG text `edit.pasteSvg` reads (a clipboard can hold anything).
const MAX_SVG_PASTE: usize = 64 << 20;
/// `edit.pasteSvg`: SVG markup (the system clipboard's, copied from a browser, Illustrator or a
/// design tool) as a new shape layer above the selected layer, its canvas centred in the comp:
/// After Effects 26.3 pastes copied vector artwork as shape layers. Edit ▸ Paste runs it when
/// the system clipboard holds SVG copied after anything copied in the app.
fn paste_svg(s: &mut Session, p: &Value) -> Result<Value> {
let c = "edit.pasteSvg";
let text = str_p(p, "svg").ok_or_else(|| super::bad(c, "missing `svg` (SVG markup)"))?;
if text.len() > MAX_SVG_PASTE {
return Err(super::bad(c, format!("the SVG is larger than {} MB", MAX_SVG_PASTE >> 20)));
}
if !crate::vector::is_svg_text(text) {
return Err(super::bad(c, "the text is not SVG markup"));
}
let doc = effectcraft_svg::parse(text.as_bytes()).map_err(|e| super::bad(c, format!("cannot read the SVG: {e}")))?;
if doc.flatten().is_empty() {
return Err(super::bad(c, "the SVG has no shapes"));
}
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = match str_p(p, "name") {
Some(n) => n.to_string(),
None => {
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
comp.unique_layer_name(&format!("Shape Layer {}", count.saturating_add(1)))
}
};
let id = s.edit("Paste", None, |proj, st| {
let l = crate::vector::shapes_from_svg(proj, &comp, &doc, &name);
super::layer::insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0, "skipped": doc.skipped}))
}
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let t = s.time();
@@ -818,6 +854,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_clip,
paste
),
cmd!(
"edit.pasteSvg",
"Paste SVG as Shapes",
[],
None,
"{svg: SVG markup, name?, comp?} → {layer, skipped} (Edit ▸ Paste runs it for SVG on the system clipboard)",
has_comp,
paste_svg
),
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
+48
View File
@@ -235,3 +235,51 @@ fn create_masks_from_text() {
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");
}
+32 -4
View File
@@ -250,6 +250,12 @@ pub fn vector_clip_masks(doc: &Doc) -> (Vec<BezPath>, Doc) {
(masks, out)
}
/// Whether text (the system clipboard's, say) is SVG markup: Edit ▸ Paste makes it a shape
/// layer (`edit.pasteSvg`).
pub fn is_svg_text(text: &str) -> bool {
effectcraft_svg::looks_like_svg(text.as_bytes())
}
/// Whether footage is a vector file (SVG / PDF / AI / EPS).
pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
@@ -471,6 +477,32 @@ pub fn image_layer(proj: &mut Project, comp: &Comp, item: ItemId, img: &VectorIm
/// are kept with Merge Paths (see [`svg_contents`]).
pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Doc) -> Layer {
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
add_doc_shapes(proj, &mut l, doc);
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
l
}
/// Edit ▸ Paste of SVG markup (After Effects 26.3 pastes copied vector artwork as shape layers):
/// a shape layer named `name` with the document's shapes, its Anchor Point at the centre of the
/// SVG canvas and its Position at the comp centre, so the canvas lands centred in the comp.
pub fn shapes_from_svg(proj: &mut Project, comp: &Comp, doc: &Doc, name: &str) -> Layer {
let mut l = build::layer(proj, comp, name, LayerSource::Shape, (0, 0), None);
add_doc_shapes(proj, &mut l, doc);
let (w, h) = (doc.width, doc.height);
if w.is_finite()
&& h.is_finite()
&& let Some(a) = l.props.sub_mut("transform").and_then(|t| t.get_mut("anchor"))
{
a.value = Value::Vec3([w / 2.0, h / 2.0, 0.0]);
}
l
}
/// The shapes of `doc` into shape layer `l`'s Contents; clips enclosing the whole document
/// become its masks (Add, then Intersect).
fn add_doc_shapes(proj: &mut Project, l: &mut Layer, doc: &Doc) {
let (clips, doc) = vector_clip_masks(doc);
let mut ids = Ids(&mut proj.next_id);
let items = svg_contents(&mut ids, &doc);
@@ -488,10 +520,6 @@ pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Do
}
}
}
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
l
}
/// Layer ▸ Create ▸ Create Shapes from Text: one group per character with its outline, fill and