#730: Composition ▸ Copy Frame to Clipboard

Problem: After Effects 26.3 copies the current composition frame to the
system clipboard; EffectCraft could only write it to a file (Save Frame As).

Fix: new command comp.copyFrame {comp?, time?, scale?} renders the frame as
Save Frame As ▸ File does and emits Event::CopyImage (pixels not
serialized); the egui frontend puts it on the clipboard after checking the
buffer size. In the Composition menu (translated) and the viewer's context
menu; drivable by id over the control channel / MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 11:33:45 +02:00
parent 9a7d3e7699
commit ddcca91d50
9 changed files with 143 additions and 4 deletions

View File

@ -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<Value> {
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<Value> {
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<Value> {
let cid = comp_id(s, p)?;
@ -156,6 +176,7 @@ pub fn specs() -> Vec<CommandSpec> {
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),
]
}

View File

@ -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<u8>,
},
}
pub struct Session {

View File

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

View File

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

View File

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

View File

@ -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::*;

View File

@ -1113,6 +1113,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let context_layers: Vec<u64> =
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::<Vec<(u64, String)>>(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) {

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

View File

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