diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index 2c4ca409..1e753c20 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -1,5 +1,5 @@ //! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File, -//! Responsive Design — Time. +//! Copy Frame to Clipboard, Responsive Design — Time. use effectcraft_project::Marker; use effectcraft_time::Tick; @@ -120,6 +120,26 @@ fn save_frame(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "width": img.width, "height": img.height, "time": t.seconds()})) } +/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): render the frame as Save Frame As +/// ▸ File does and hand it to the frontend, which puts it on the system clipboard. +fn copy_frame(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time()); + let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0); + let bg = comp.background; + let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); + let (width, height) = (img.width, img.height); + let rgba = img.to_rgba8_over(bg); + let expected = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4)); + if width == 0 || height == 0 || expected != Some(rgba.len()) { + return Err(EngineError::Other(format!("cannot copy an empty or malformed {width}×{height} frame"))); + } + s.events.push(crate::Event::CopyImage { width, height, rgba }); + s.toast("Copied frame to the clipboard"); + Ok(json!({"width": width, "height": height, "time": t.seconds()})) +} + /// Responsive Design — Time: protected comp markers for an intro, an outro or the work area. fn responsive_time(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; @@ -156,6 +176,7 @@ pub fn specs() -> Vec { has_comp, save_frame ), + cmd!("comp.copyFrame", "Copy Frame to Clipboard", ["Composition"], None, "{comp?, time?, scale?}", has_comp, copy_frame), cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9e9b606..ec860cb9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -350,6 +350,15 @@ pub enum Event { }, /// Drop cached frames / renders (Edit ▸ Purge). PurgeCaches, + /// Put an image on the system clipboard (Composition ▸ Copy Frame to Clipboard): `rgba` is + /// `width` × `height` straight-alpha RGBA8 rows. Headless sessions ignore it; the pixels are + /// not serialized. + CopyImage { + width: u32, + height: u32, + #[serde(skip)] + rgba: Vec, + }, } pub struct Session { diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index e98e29f6..cf84e2a5 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -693,6 +693,7 @@ Composition File... | comp.saveFrameAs Photoshop Layers... | comp.saveFrameAsPsd ProEXR... | comp.saveFrameAsExr + Copy Frame to Clipboard | comp.copyFrame Pre-render... | render.preRender Save Current Preview... | render.saveCurrentPreview --- diff --git a/crates/engine/src/tests_frame_export.rs b/crates/engine/src/tests_frame_export.rs index 3f319b83..87b0864a 100644 --- a/crates/engine/src/tests_frame_export.rs +++ b/crates/engine/src/tests_frame_export.rs @@ -1,4 +1,4 @@ -//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR…. +//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR…, and Copy Frame to Clipboard. use serde_json::json; @@ -121,3 +121,33 @@ fn proexr_values_at_or_below_one_are_unchanged() { assert!((got[0] - 1.0).abs() < 1e-4 && (got[1] - 0.1329).abs() < 1e-3 && got[2].abs() < 1e-6, "{got:?}"); } } + +/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): the frame, as Save Frame As +/// renders it, goes to the frontend as an image for the system clipboard. +#[test] +fn copy_frame_hands_the_rendered_frame_to_the_frontend() { + let mut s = frame(); + s.drain_events(); + let r = s.execute("comp.copyFrame", json!({})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(32), Some(32))); + let events = s.drain_events(); + let Some((width, height, rgba)) = events.iter().find_map(|e| match e { + crate::Event::CopyImage { width, height, rgba } => Some((*width, *height, rgba)), + _ => None, + }) else { + panic!("no CopyImage event in {events:?}") + }; + assert_eq!((width, height, rgba.len()), (32, 32, 32 * 32 * 4)); + // Red in the corner; red × (50% blue multiply) in the middle; opaque everywhere. + assert_eq!(&rgba[..4], &[255, 0, 0, 255]); + let mid = &rgba[(16 * 32 + 16) * 4..][..4]; + assert!(mid[0] > 100 && mid[0] < 160 && mid[2] < 13 && mid[3] == 255, "{mid:?}"); + assert!(rgba.chunks(4).all(|p| p[3] == 255)); + // The pixels are not serialized into the event stream. + let json = serde_json::to_value(events.iter().find(|e| matches!(e, crate::Event::CopyImage { .. })).unwrap()).unwrap(); + assert!(json["CopyImage"].get("rgba").is_none(), "{json}"); + // Scaled, and refused without a composition. + assert_eq!(s.execute("comp.copyFrame", json!({"scale": 0.5})).unwrap()["width"], 16); + assert!(Session::default().execute("comp.copyFrame", json!({})).is_err()); + assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition"))); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4bbac2f7..b3e365e4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -400,6 +400,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "文件..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop 图层..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "将帧复制到剪贴板"), ("render.preRender", "Pre-render...", "预渲染..."), ("render.saveCurrentPreview", "Save Current Preview...", "保存当前预览..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "在基本图形中打开"), @@ -1024,6 +1025,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "檔案..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop 圖層..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "將影格拷貝到剪貼簿"), ("render.preRender", "Pre-render...", "預先渲染..."), ("render.saveCurrentPreview", "Save Current Preview...", "儲存目前預覽..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "在基本圖形中開啟"), @@ -1645,6 +1647,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "ファイル..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshopレイヤー..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "フレームをクリップボードにコピー"), ("render.preRender", "Pre-render...", "プリレンダリング..."), ("render.saveCurrentPreview", "Save Current Preview...", "現在のプレビューを保存..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "エッセンシャルグラフィックスで開く"), @@ -2269,6 +2272,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "Файл..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Шари Photoshop..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Копіювати кадр у буфер обміну"), ("render.preRender", "Pre-render...", "Попередній рендеринг..."), ("render.saveCurrentPreview", "Save Current Preview...", "Зберегти поточний попередній перегляд..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Відкрити в основній графіці"), @@ -2894,6 +2898,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "Archivo..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Capas de Photoshop..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Copiar fotograma al portapapeles"), ("render.preRender", "Pre-render...", "Preprocesar..."), ("render.saveCurrentPreview", "Save Current Preview...", "Guardar la previsualización actual..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Abrir en Gráficos esenciales"), @@ -3517,6 +3522,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "Arquivo..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Camadas do Photoshop..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Copiar quadro para a área de transferência"), ("render.preRender", "Pre-render...", "Pré-renderização..."), ("render.saveCurrentPreview", "Save Current Preview...", "Salvar pré-visualização atual..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Abrir em Gráficos essenciais"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0e05e00..49576259 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1542,6 +1542,11 @@ impl EffectcraftApp { } } effectcraft_engine::Event::PurgeCaches => self.frames.clear(), + effectcraft_engine::Event::CopyImage { width, height, rgba } => { + if let Err(e) = copy_image(ctx, width, height, &rgba) { + self.ui.status = e; + } + } } } } @@ -1862,6 +1867,18 @@ pub(crate) fn tick_playback(app: &mut EffectcraftApp, ctx: &egui::Context, scale app.advance_playback(ctx, scale); } +/// Put `width` × `height` straight-alpha RGBA8 pixels on the system clipboard (Composition ▸ Copy +/// Frame to Clipboard). The pixel count is checked first: egui's image constructor panics on a +/// size that doesn't match its buffer. +fn copy_image(ctx: &egui::Context, width: u32, height: u32, rgba: &[u8]) -> Result<(), String> { + let (w, h) = (width as usize, height as usize); + if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) { + return Err(format!("cannot copy a malformed {width}×{height} image to the clipboard")); + } + ctx.copy_image(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba)); + Ok(()) +} + /// Maximize a first window that doesn't fit the screen, which fits it to the space beside the /// taskbar or dock. The 1680 × 1020 window on a smaller screen is only shrunk to the monitor's /// size, so with its title bar it ran under the taskbar and cut menus off at the bottom of the @@ -1895,6 +1912,55 @@ mod fit_window_tests { } } +#[cfg(test)] +mod copy_frame_tests { + use super::*; + + fn copied(output: &egui::FullOutput) -> Vec<&egui::ColorImage> { + output + .platform_output + .commands + .iter() + .filter_map(|c| match c { + egui::OutputCommand::CopyImage(image) => Some(image), + _ => None, + }) + .collect() + } + + /// Composition ▸ Copy Frame to Clipboard puts the rendered frame on the system clipboard. + #[test] + fn copy_frame_puts_the_frame_on_the_clipboard() { + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Copy", "width": 8, "height": 6, "duration": 1})).unwrap(); + session.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + let mut app = EffectcraftApp::new(session); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + crate::menus::invoke(&mut app, ui.ctx(), "comp.copyFrame", json!({})).unwrap(); + app.handle_events(ui.ctx()); + }); + output.textures_delta.clear(); + let images = copied(&output); + assert_eq!(images.len(), 1); + assert_eq!(images[0].size, [8, 6]); + assert!(images[0].pixels.iter().all(|p| *p == egui::Color32::from_rgb(255, 0, 0))); + } + + /// A malformed image never reaches egui's panicking constructor. + #[test] + fn a_malformed_image_is_an_error_not_a_crash() { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(copy_image(ui.ctx(), 4, 4, &[0; 10]).is_err()); + assert!(copy_image(ui.ctx(), 0, 4, &[]).is_err()); + assert!(copy_image(ui.ctx(), u32::MAX, u32::MAX, &[0; 4]).is_err()); + }); + output.textures_delta.clear(); + assert!(copied(&output).is_empty()); + } +} + #[cfg(test)] mod gpu_failure_tests { use super::*; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5fe7697c..871d7c86 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1113,6 +1113,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let context_layers: Vec = app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some_and(|l| !l.switches.locked)).map(|id| id.0).collect(); let mut context_actions = Vec::new(); + let copy_frame = crate::i18n::label(app, "comp.copyFrame", "Copy Frame to Clipboard"); resp.context_menu(|ui| { let hits = ui.data(|d| d.get_temp::>(menu_hits_id)).unwrap_or_default(); if !hits.is_empty() { @@ -1133,6 +1134,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { super::timeline::layer_menu(ui, &context_layers, &mut context_actions); } + ui.separator(); + if ui.button(copy_frame).clicked() { + context_actions.push(("comp.copyFrame".to_owned(), serde_json::json!({"comp": cid.0}))); + ui.close(); + } }); for (id, params) in context_actions { if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { diff --git a/docs/gaps.md b/docs/gaps.md index acb22a63..db583c5a 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), 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) | | 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) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fd807ebc..4751ae5a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -18,7 +18,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | | Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; dropping a panel on a stacked panel doesn't make a tab (#302); stacked-panel headers (#301); saved workspaces don't persist (#320); no panels on a second monitor (#486) | | Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; Graph Editor jitter and hidden buttons (#456, #457); rotation can't go negative by dragging (#569); bulk keyframing (#355); expand precomps in place (#601); Proportional Scrubbing (26.2) missing | -| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5); copy frame to clipboard (26.3) | +| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; pen and vertex tools on motion paths (#553) | | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; Linux multi-layer edits (#491) | | Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) |