mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 02:57:34 -06:00
242 lines
10 KiB
Rust
242 lines
10 KiB
Rust
//! 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 `<layer name>.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<Isolated>)> {
|
|
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<LayerId> = 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<u8>, 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>) -> 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<u8>, Vec<String>, 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<f32>; 4] {
|
|
let mut out: [Vec<f32>; 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<Value> {
|
|
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<Value> {
|
|
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<CommandSpec> {
|
|
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 + `<layer>.R/G/B/A` per layer (linear, premultiplied)",
|
|
has_comp,
|
|
save_exr
|
|
),
|
|
]
|
|
}
|