mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 05:58:19 -06:00
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user