mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 16:33:01 -06:00
Merge pull request #737 from vangel76/gap-copy-frame-clipboard
#730: Composition ▸ Copy Frame to Clipboard
This commit is contained in:
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user