//! Composition ▸ Save Frame As ▸ Photoshop Layers… and ProEXR…: the current frame with every //! visible layer kept apart. //! //! - **Photoshop Layers** writes a layered PSD (crates/psd's writer): one pixel layer per comp //! layer, each rendered in isolation (solo, Normal mode, 100% opacity) and cropped to its //! pixels, carrying the layer's blend mode and opacity, plus the flattened frame as the merged //! image. Photoshop (and EffectCraft's PSD import) rebuild the frame from the layers. //! - **ProEXR** writes one multi-layer OpenEXR (half-float-free, 32-bit float, linear light, //! premultiplied): the composite as `R`, `G`, `B`, `A` plus one `.R/G/B/A` channel //! set per layer (the layer-prefixed channel convention multi-layer EXR readers use). use effectcraft_color::BlendMode; use effectcraft_project::{ItemId, LayerId, Project}; use effectcraft_raster::Image; use effectcraft_render::{Backend, RenderOpts, Renderer}; use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, bad, comp_id, f_p, has_comp, str_p}; use crate::{EngineError, Result, Session, cmd}; /// One isolated layer of a frame: name, blend mode, opacity (0..1), premultiplied pixels. struct Isolated { name: String, blend: BlendMode, opacity: f64, image: Image, } fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { let opts = RenderOpts { scale, backend: Backend::Auto, nested_switches: s.prefs.general.switches_affect_nested_comps, draft_shadows: s.prefs.three_d.realtime_shadows, ..Default::default() }; let mut r = Renderer::new(project, s.footage.as_ref(), opts); r.expr = s.expr.as_deref(); r.accel = s.accel.as_deref(); r.comp_frame(comp, t) } /// The comp frame and each visible layer rendered alone (top layer first). fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec)> { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let full = render_with(s, &s.project, cid, t, scale); let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.has_video()); let ids: Vec = comp .layers .iter() .filter(|l| l.has_video() && l.is_active_at(t) && !l.switches.adjustment && !l.switches.guide && !l.environment && (!any_solo || l.switches.solo)) .map(|l| l.id) .collect(); let mut out = vec![]; for id in ids { let mut p: Project = (*s.project).clone(); let Some(c) = p.comp_mut(cid) else { continue }; let mut info = None; for l in c.layers.iter_mut() { l.switches.solo = l.id == id; if l.id == id { let lt = l.layer_time(t); let opacity = l.props.prop("transform/opacity").map(|o| o.value_at(lt).as_f64() / 100.0).unwrap_or(1.0); info = Some((l.name.clone(), l.blend_mode, opacity.clamp(0.0, 1.0))); l.blend_mode = BlendMode::Normal; if let Some(o) = l.props.prop_mut("transform/opacity") { o.keys.clear(); o.expr = None; o.value = effectcraft_keyframe::Value::Scalar(100.0); } } } let Some((name, blend, opacity)) = info else { continue }; let image = render_with(s, &p, cid, t, scale); out.push(Isolated { name, blend, opacity, image }); } Ok((full, out)) } fn straight(p: [f32; 4]) -> [f32; 4] { if p[3] <= 0.0 { [0.0; 4] } else { [p[0] / p[3], p[1] / p[3], p[2] / p[3], p[3].min(1.0)] } } /// The Photoshop key of a blend mode (Normal for modes Photoshop lacks). pub fn psd_blend_key(m: BlendMode) -> [u8; 4] { const KEYS: [&[u8; 4]; 26] = [ b"diss", b"dark", b"mul ", b"idiv", b"lbrn", b"dkCl", b"lite", b"scrn", b"div ", b"lddg", b"lgCl", b"over", b"sLit", b"hLit", b"vLit", b"lLit", b"pLit", b"hMix", b"diff", b"smud", b"fsub", b"fdiv", b"hue ", b"sat ", b"colr", b"lum ", ]; KEYS.iter().find(|k| crate::psd_import::blend_mode(std::str::from_utf8(&k[..]).unwrap_or("")) == m).map(|k| **k).unwrap_or(*b"norm") } /// The bounding box of non-transparent pixels (`None` when empty). fn alpha_bounds(img: &Image) -> Option<(u32, u32, u32, u32)> { let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0); for y in 0..img.height { for x in 0..img.width { if img.data[(y * img.width + x) as usize][3] > 0.5 / 255.0 { x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x + 1); y1 = y1.max(y + 1); } } } (x1 > x0).then_some((x0, y0, x1, y1)) } /// The layered PSD bytes of a frame. pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec, usize, u32, u32)> { use effectcraft_psd::Rect; use effectcraft_psd::write::{WDoc, WLayer, write}; let (full, layers) = isolate(s, cid, t, scale)?; let mut doc = WDoc::new(full.width, full.height); let n = layers.len(); // File order is bottom first. for l in layers.into_iter().rev() { let mut w = match alpha_bounds(&l.image) { Some((x0, y0, x1, y1)) => { let mut px = Vec::with_capacity(((x1 - x0) * (y1 - y0)) as usize); for y in y0..y1 { for x in x0..x1 { px.push(straight(l.image.data[(y * l.image.width + x) as usize])); } } WLayer::pixels(&l.name, Rect::new(x0 as i32, y0 as i32, (x1 - x0) as i32, (y1 - y0) as i32), px) } None => WLayer::pixels(&l.name, Rect::default(), vec![]), }; w.blend = psd_blend_key(l.blend); w.opacity = (l.opacity * 255.0).round() as u8; doc.layers.push(w); } doc.composite = Some(full.data.iter().map(|p| straight(*p)).collect()); Ok((write(&doc), n, full.width, full.height)) } fn srgb_to_linear(c: f32) -> f32 { if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } } /// A unique EXR layer name (no dots: they separate the layer from the channel). Printable ASCII only: /// the `exr` crate panics on characters it cannot store in a channel name (#1702). fn exr_name(name: &str, used: &mut Vec) -> String { let base: String = name.chars().map(|c| if c == '.' || !(c == ' ' || c.is_ascii_graphic()) { '_' } else { c }).collect(); let base = if base.trim().is_empty() { "Layer".to_string() } else { base }; let mut n = base.clone(); let mut k = 2; while used.contains(&n) { n = format!("{base} {k}"); k += 1; } used.push(n.clone()); n } /// The multi-layer EXR bytes of a frame and the channel names. pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec, Vec, u32, u32)> { use exr::prelude::*; let (full, layers) = isolate(s, cid, t, scale)?; let (w, h) = (full.width as usize, full.height as usize); let planes = |img: &effectcraft_raster::Image| -> [Vec; 4] { let mut out: [Vec; 4] = Default::default(); for p in &img.data { let sp = straight(*p); let a = sp[3]; for c in 0..3 { out[c].push(srgb_to_linear(sp[c].max(0.0)) * a); } out[3].push(a); } out }; let mut channels = vec![]; let mut names = vec![]; let mut push = |prefix: &str, img: &effectcraft_raster::Image| { for (c, data) in ["R", "G", "B", "A"].iter().zip(planes(img)) { let n = if prefix.is_empty() { c.to_string() } else { format!("{prefix}.{c}") }; names.push(n.clone()); channels.push(AnyChannel::new(n.as_str(), FlatSamples::F32(data))); } }; push("", &full); let mut used = vec![]; for l in &layers { let n = exr_name(&l.name, &mut used); push(&n, &l.image); } let layer = Layer::new((w, h), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, AnyChannels::sort(channels.into())); let mut buf = std::io::Cursor::new(Vec::new()); Image::from_layer(layer).write().to_buffered(&mut buf).map_err(|e| EngineError::Other(format!("EXR encoding failed: {e}")))?; Ok((buf.into_inner(), names, full.width, full.height)) } fn frame_args(s: &Session, p: &Value, cmd: &str, ext: &str) -> Result<(String, ItemId, Tick, f64)> { let path = str_p(p, "path").ok_or_else(|| bad(cmd, format!("missing `path` (.{ext})")))?.to_string(); let cid = comp_id(s, p)?; 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); Ok((path, cid, t, scale)) } fn save_psd(s: &mut Session, p: &Value) -> Result { let (path, cid, t, scale) = frame_args(s, p, "comp.saveFrameAsPsd", "psd")?; let (bytes, n, w, h) = layered_psd(s, cid, t, scale)?; s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved {n} layers to {path}")); Ok(json!({"path": path, "layers": n, "width": w, "height": h, "time": t.seconds()})) } fn save_exr(s: &mut Session, p: &Value) -> Result { let (path, cid, t, scale) = frame_args(s, p, "comp.saveFrameAsExr", "exr")?; let (bytes, channels, w, h) = layered_exr(s, cid, t, scale)?; s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved a multi-layer EXR to {path}")); Ok(json!({"path": path, "channels": channels, "width": w, "height": h, "time": t.seconds()})) } pub fn specs() -> Vec { vec![ cmd!( "comp.saveFrameAsPsd", "Photoshop Layers...", ["Composition", "Save Frame As"], None, "{path (.psd), comp?, time?, scale?} → a layered PSD: one layer per visible comp layer (blend mode, opacity) + the merged frame", has_comp, save_psd ), cmd!( "comp.saveFrameAsExr", "ProEXR...", ["Composition", "Save Frame As"], None, "{path (.exr), comp?, time?, scale?} → a multi-layer OpenEXR: composite R,G,B,A + `.R/G/B/A` per layer (linear, premultiplied)", has_comp, save_exr ), ] }