diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index e5d2f912..55e6c828 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -531,6 +531,42 @@ fn paste(s: &mut Session, p: &Value) -> Result { Ok(json!(new.iter().map(|l| l.0).collect::>())) } +/// 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 { + 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 { let (cid, ids) = layers_p(s, p)?; let t = s.time(); @@ -818,6 +854,15 @@ pub fn specs() -> Vec { 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), diff --git a/crates/engine/src/tests_vector_import.rs b/crates/engine/src/tests_vector_import.rs index d205630c..e33a38b0 100644 --- a/crates/engine/src/tests_vector_import.rs +++ b/crates/engine/src/tests_vector_import.rs @@ -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##" + + + + +"##; + 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", "", ""] { + 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"); +} diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 34edf5b7..23400953 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -250,6 +250,12 @@ pub fn vector_clip_masks(doc: &Doc) -> (Vec, 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 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b497..a92ccee8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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::(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::(system_paste_id())); } } } diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index dd9232d7..411bb22a 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -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##""##; + 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"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 66f18b47..e9c649b0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 | diff --git a/docs/gaps.md b/docs/gaps.md index acb22a63..48d09a89 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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) |