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
commit 8176c86043
7 changed files with 179 additions and 7 deletions

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),

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");
}

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

View File

@ -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(&params) && 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).
@ -1563,6 +1572,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")
}
@ -1644,6 +1658,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,
@ -1759,9 +1776,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()));
}
}
}

View File

@ -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");
}

View File

@ -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 |

View File

@ -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), 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), 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) |