commit
8176c86043
|
|
@ -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).
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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), 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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue