#738: Edit ▸ Paste pastes SVG markup as a shape layer
Problem: After Effects 26.3 pastes copied vector artwork as shape layers. Edit ▸ Paste only
knew the app's own clipboard, so SVG markup on the system clipboard was ignored; turning SVG
into shapes needed an imported footage file and Create Shapes from Vector Layer.
Fix: a new `edit.pasteSvg {svg, name?, comp?}` command builds a shape layer from SVG text with
the same conversion as Create Shapes from Vector Layer (paths, fills, strokes, gradients, clip
masks), above the selected layer with the SVG canvas centred in the comp, as one undo step;
oversized, non-SVG or shapeless text is rejected. In the UI, Ctrl/Cmd+V (and the native Edit ▸
Paste) run it when the system clipboard holds SVG. Copying in the app already replaces the
system clipboard with a note, so SVG there is newer than the app's clipboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9a7d3e7699
commit
d58253cc5a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -291,6 +291,15 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
// Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After
|
||||
// Effects 26.3). Copying in the app replaces the system clipboard with a note naming what
|
||||
// was copied, so SVG there was copied after anything copied in the app.
|
||||
if id == "edit.paste" && no_params(¶ms) && app.session.state.text_edit.is_none() {
|
||||
let text = ctx.data_mut(|d| d.remove_temp::<String>(system_paste_id())).or_else(|| app.hooks.clipboard_text.as_ref().and_then(|f| f()));
|
||||
if let Some(svg) = text.filter(|t| effectcraft_engine::vector::is_svg_text(t)) {
|
||||
return run_engine(app, ctx, "edit.pasteSvg", json!({"svg": svg}));
|
||||
}
|
||||
}
|
||||
// Clear follows panel focus, including the Edit menu and keyboard shortcut.
|
||||
if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project {
|
||||
// Through `invoke`, so deleting items that compositions use still asks first (M3.16).
|
||||
|
|
@ -1561,6 +1570,11 @@ fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool {
|
|||
|
||||
/// Set (egui temp data) from a plain Spacebar press until its release while a tap would still
|
||||
/// run the Spacebar shortcut.
|
||||
/// The system clipboard's text of this frame's paste event, for the Edit ▸ Paste it runs.
|
||||
fn system_paste_id() -> egui::Id {
|
||||
egui::Id::new("menus-system-paste")
|
||||
}
|
||||
|
||||
fn space_tap_id() -> egui::Id {
|
||||
egui::Id::new("spacebar-tap")
|
||||
}
|
||||
|
|
@ -1641,6 +1655,9 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
|||
// Text editing in the viewer takes the clipboard events itself.
|
||||
let clipboard = app.session.state.text_edit.is_none();
|
||||
let project = app.ui.focused == PanelKind::Project;
|
||||
// The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer.
|
||||
let pasted =
|
||||
if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None };
|
||||
let mut events: Vec<(egui::Key, egui::Modifiers, bool)> = ctx.input(|i| {
|
||||
// The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of
|
||||
// key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut,
|
||||
|
|
@ -1756,9 +1773,15 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
|||
}
|
||||
if let Some((_, _, id, params)) = binds.iter().find(|(m, k, ..)| *k == key && mods_match(*m, mods)) {
|
||||
let (id, params) = (id.clone(), if params.is_null() { json!({}) } else { params.clone() });
|
||||
if id == "edit.paste"
|
||||
&& let Some(t) = &pasted
|
||||
{
|
||||
ctx.data_mut(|d| d.insert_temp(system_paste_id(), t.clone()));
|
||||
}
|
||||
if let Err(e) = invoke(app, ctx, &id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
ctx.data_mut(|d| d.remove_temp::<String>(system_paste_id()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic
|
||||
//! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the
|
||||
//! layers deselecting them (#205), Mask Feather's linked values (#203), and copying shape items
|
||||
//! between shape layers (#227).
|
||||
//! between shape layers (#227), and Ctrl+V of SVG markup making a shape layer (#738).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
|
|
@ -243,3 +243,31 @@ fn timeline_colour_eyedropper_samples_the_comp() {
|
|||
assert!((c[k] - want[k]).abs() < 0.02, "{c:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+V with SVG markup on the system clipboard pastes it as a shape layer (After Effects
|
||||
/// 26.3), even with layers on the app's clipboard: copying in the app replaces the system
|
||||
/// clipboard, so the SVG was copied later (#738).
|
||||
#[test]
|
||||
fn ctrl_v_pastes_svg_markup_as_a_shape_layer() {
|
||||
let (mut h, a, b, _) = two_shape_layers();
|
||||
click(&mut h, &format!("timeline.layer.{b}.row"));
|
||||
h.input_mut().events.push(Event::Copy);
|
||||
h.step();
|
||||
assert_eq!(h.state().session.state.clipboard.len(), 1, "a layer on the app's clipboard");
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><circle cx="10" cy="10" r="8" fill="#00ff00"/><rect x="20" width="20" height="20"/></svg>"##;
|
||||
h.input_mut().events.push(Event::Paste(svg.into()));
|
||||
h.run_steps(3);
|
||||
let c = h.state().session.active_comp().unwrap().clone();
|
||||
assert_eq!(c.layers.len(), 3, "{:?}", h.state().ui.status);
|
||||
let top = &c.layers[0];
|
||||
assert!(top.id != LayerId(a) && top.id != LayerId(b));
|
||||
assert_eq!(top.name, "Shape Layer 3");
|
||||
assert_eq!(top.props.sub("contents").unwrap().groups().count(), 2);
|
||||
assert_eq!(h.state().session.state.selected_layers, vec![top.id]);
|
||||
// The app's own clipboard note pastes the copied layer as before.
|
||||
h.input_mut().events.push(Event::Paste("EffectCraft: 1 layer".into()));
|
||||
h.run_steps(3);
|
||||
let c = h.state().session.active_comp().unwrap();
|
||||
assert_eq!(c.layers.len(), 4);
|
||||
assert_eq!(c.layers[0].name, "Oval 2");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
|
|||
| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); EXRs without an unnamed RGB layer decode twice (#474) |
|
||||
| Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec |
|
||||
| Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS |
|
||||
| SVG | R | yes | — | good; pasting SVG/AI as shape layers (26.3) missing |
|
||||
| SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't |
|
||||
| GIF | R/W | yes | yes (animated) | good |
|
||||
| BMP | R | yes | — | good |
|
||||
| WebP | — | yes | — | beyond After Effects |
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ are grouped by area; the issue tracker has the rest.
|
|||
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
|
||||
| 20 | **Multi-monitor and video output** | #486 multi-screen, floating panels on other screens, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
|
||||
| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), copy frame to clipboard and paste SVG/AI as shapes (26.3), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) |
|
||||
| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), copy frame to clipboard (26.3), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) |
|
||||
| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; #372 uncompressed single-file output; #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 Scale exposes X and Y; #611 FilmCraft rejects exported `.fcgt` | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) |
|
||||
| 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #355 bulk keyframes, #425 keyframe confusion, #428 more shortcuts | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue