718 lines
29 KiB
Rust
718 lines
29 KiB
Rust
//! Photoshop import as a composition (File ▸ Import ▸ File… with Import As: Composition or
|
||
//! Composition – Retain Layer Sizes), and Layer ▸ Create ▸ Convert to Editable Text.
|
||
//!
|
||
//! Every pixel layer becomes a footage item that names its layer (`Footage::layer`), placed in
|
||
//! a composition the size of the document. Groups become precomps (pass-through groups collapse
|
||
//! so their blend modes act on the layers below, like Photoshop); type layers become editable
|
||
//! text layers; layer masks are baked into the layer's alpha and vector masks become masks;
|
||
//! layer effects map to Layer Styles; adjustment layers become adjustment layers with the
|
||
//! matching effect (best effort); clipped layers preserve the transparency underneath. Smart
|
||
//! objects whose file is embedded become footage of that file (an embedded Photoshop document's
|
||
//! merged image, or the embedded image) placed with the smart object's transform.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use effectcraft_color::BlendMode;
|
||
use effectcraft_color::Label;
|
||
use effectcraft_keyframe::{Gradient, Justify, ShapePath, TextDoc, Value};
|
||
use effectcraft_project::build::{self, Ids};
|
||
use effectcraft_project::styles::{self, STYLE_BLEND_MODES};
|
||
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, Project, PropGroup, Solid, SourceLayer};
|
||
use effectcraft_psd::{Adjustment, DValue, Descriptor, Node, Psd};
|
||
use effectcraft_time::{FrameRate, Tick};
|
||
|
||
/// Photoshop blend mode key → compositor mode (`pass` = pass through, handled by collapsing).
|
||
pub fn blend_mode(key: &str) -> BlendMode {
|
||
match key {
|
||
"diss" => BlendMode::Dissolve,
|
||
"dark" => BlendMode::Darken,
|
||
"mul " => BlendMode::Multiply,
|
||
"idiv" => BlendMode::ColorBurn,
|
||
"lbrn" => BlendMode::LinearBurn,
|
||
"dkCl" => BlendMode::DarkerColor,
|
||
"lite" => BlendMode::Lighten,
|
||
"scrn" => BlendMode::Screen,
|
||
"div " => BlendMode::ColorDodge,
|
||
"lddg" => BlendMode::LinearDodge,
|
||
"lgCl" => BlendMode::LighterColor,
|
||
"over" => BlendMode::Overlay,
|
||
"sLit" => BlendMode::SoftLight,
|
||
"hLit" => BlendMode::HardLight,
|
||
"vLit" => BlendMode::VividLight,
|
||
"lLit" => BlendMode::LinearLight,
|
||
"pLit" => BlendMode::PinLight,
|
||
"hMix" => BlendMode::HardMix,
|
||
"diff" => BlendMode::Difference,
|
||
"smud" => BlendMode::Exclusion,
|
||
"fsub" => BlendMode::Subtract,
|
||
"fdiv" => BlendMode::Divide,
|
||
"hue " => BlendMode::Hue,
|
||
"sat " => BlendMode::Saturation,
|
||
"colr" => BlendMode::Color,
|
||
"lum " => BlendMode::Luminosity,
|
||
_ => BlendMode::Normal,
|
||
}
|
||
}
|
||
|
||
/// Descriptor blend mode enum (`BlnM`) → compositor mode.
|
||
fn desc_blend(v: Option<&str>) -> Option<BlendMode> {
|
||
Some(match v? {
|
||
"Nrml" => BlendMode::Normal,
|
||
"Dslv" => BlendMode::Dissolve,
|
||
"Drkn" => BlendMode::Darken,
|
||
"Mltp" => BlendMode::Multiply,
|
||
"CBrn" => BlendMode::ColorBurn,
|
||
"linearBurn" => BlendMode::LinearBurn,
|
||
"darkerColor" => BlendMode::DarkerColor,
|
||
"Lghn" => BlendMode::Lighten,
|
||
"Scrn" => BlendMode::Screen,
|
||
"CDdg" => BlendMode::ColorDodge,
|
||
"linearDodge" => BlendMode::LinearDodge,
|
||
"lighterColor" => BlendMode::LighterColor,
|
||
"Ovrl" => BlendMode::Overlay,
|
||
"SftL" => BlendMode::SoftLight,
|
||
"HrdL" => BlendMode::HardLight,
|
||
"vividLight" => BlendMode::VividLight,
|
||
"linearLight" => BlendMode::LinearLight,
|
||
"pinLight" => BlendMode::PinLight,
|
||
"hardMix" => BlendMode::HardMix,
|
||
"Dfrn" => BlendMode::Difference,
|
||
"Xclu" => BlendMode::Exclusion,
|
||
"blendSubtraction" => BlendMode::Subtract,
|
||
"blendDivide" => BlendMode::Divide,
|
||
"H " => BlendMode::Hue,
|
||
"Strt" => BlendMode::Saturation,
|
||
"Clr " => BlendMode::Color,
|
||
"Lmns" => BlendMode::Luminosity,
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// What the import created.
|
||
pub struct PsdImport {
|
||
pub comp: ItemId,
|
||
pub folder: ItemId,
|
||
pub items: Vec<ItemId>,
|
||
pub warnings: Vec<String>,
|
||
}
|
||
|
||
struct Ctx<'a> {
|
||
psd: &'a Psd,
|
||
path: &'a str,
|
||
retain: bool,
|
||
folder: ItemId,
|
||
rate: FrameRate,
|
||
duration: Tick,
|
||
items: Vec<ItemId>,
|
||
warnings: Vec<String>,
|
||
}
|
||
|
||
/// Import `psd` (read from `path`) as a composition named `name`.
|
||
pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: bool, rate: FrameRate, duration: Tick) -> PsdImport {
|
||
let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder);
|
||
let mut cx = Ctx { psd, path, retain, folder, rate, duration, items: vec![], warnings: vec![] };
|
||
let comp = build_comp(proj, &mut cx, name, &psd.tree(), None);
|
||
PsdImport { comp, folder, items: cx.items, warnings: cx.warnings }
|
||
}
|
||
|
||
fn new_comp(cx: &Ctx) -> Comp {
|
||
Comp::new(cx.psd.width, cx.psd.height, cx.rate, cx.duration)
|
||
}
|
||
|
||
fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option<ItemId>) -> ItemId {
|
||
let cid = proj.add_item(name, Label::Sandstone, parent, ItemKind::Comp(Arc::new(new_comp(cx))));
|
||
cx.items.push(cid);
|
||
let mut comp = new_comp(cx);
|
||
for n in nodes {
|
||
if let Some(l) = build_layer(proj, cx, &comp, n) {
|
||
comp.layers.push(l);
|
||
}
|
||
}
|
||
styles::sync_comp(&mut comp);
|
||
if let Some(item) = proj.item_mut(cid) {
|
||
item.kind = ItemKind::Comp(Arc::new(comp));
|
||
}
|
||
cid
|
||
}
|
||
|
||
fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (ItemId, (u32, u32)) {
|
||
let l = &cx.psd.layers[index];
|
||
let retain = cx.retain;
|
||
let (w, h) = if retain && !l.rect.is_empty() { (l.rect.width(), l.rect.height()) } else { (cx.psd.width, cx.psd.height) };
|
||
let file = std::path::Path::new(cx.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||
let f = Footage {
|
||
path: cx.path.to_string(),
|
||
kind: FootageKind::Still,
|
||
width: w,
|
||
height: h,
|
||
pixel_aspect: 1.0,
|
||
frame_rate: cx.rate,
|
||
native_rate: None,
|
||
duration: Tick::ZERO,
|
||
has_video: true,
|
||
has_audio: false,
|
||
alpha: AlphaMode::Straight,
|
||
premul_color: [0.0; 3],
|
||
loop_count: 1,
|
||
codec: "PSD".into(),
|
||
missing: false,
|
||
sequence: vec![],
|
||
color_profile: None,
|
||
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None, placed: false }),
|
||
..Default::default()
|
||
};
|
||
let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
|
||
cx.items.push(id);
|
||
(id, (w, h))
|
||
}
|
||
|
||
/// Size of an embedded file's image: the smart object's recorded size, else the embedded
|
||
/// document's header (Photoshop, PNG).
|
||
fn embedded_size(so: &effectcraft_psd::SmartObject, data: &[u8]) -> Option<(u32, u32)> {
|
||
if let Some([w, h]) = so.size.filter(|s| s[0] >= 1.0 && s[1] >= 1.0) {
|
||
return Some((w.round() as u32, h.round() as u32));
|
||
}
|
||
if effectcraft_psd::is_psd(data) && data.len() >= 22 {
|
||
let h = u32::from_be_bytes([data[14], data[15], data[16], data[17]]);
|
||
let w = u32::from_be_bytes([data[18], data[19], data[20], data[21]]);
|
||
return Some((w, h));
|
||
}
|
||
if data.starts_with(b"\x89PNG") && data.len() >= 24 {
|
||
let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
|
||
let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
|
||
return Some((w, h));
|
||
}
|
||
None
|
||
}
|
||
|
||
/// A smart object layer as footage of its embedded file, placed by the smart object's
|
||
/// transform. `None` when the file is not embedded (linked externally) or unreadable.
|
||
fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usize, name: &str) -> Option<Layer> {
|
||
let pl = &cx.psd.layers[index];
|
||
let so = pl.smart_object.clone()?;
|
||
let data = cx.psd.linked_data(&so.uuid)?;
|
||
let (cw, ch) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?;
|
||
let file = cx.psd.linked.iter().find(|f| f.uuid == so.uuid).map(|f| f.name.clone()).unwrap_or_default();
|
||
// Perspective quads and warps are baked over the placed bounds, at up to 4× the document
|
||
// resolution when the embedded content has that much detail.
|
||
let bake = so.needs_bake();
|
||
let (w, h, k, bounds) = if bake {
|
||
let bb = so.placed_bounds(cw as f64, ch as f64);
|
||
let area = ((bb[2] - bb[0]) * (bb[3] - bb[1])).max(1.0);
|
||
let k = ((cw as f64 * ch as f64) / area).sqrt().clamp(1.0, 4.0);
|
||
let (w, h) = so.placed_size(cw as f64, ch as f64, k);
|
||
(w, h, k, bb)
|
||
} else {
|
||
(cw, ch, 1.0, [0.0; 4])
|
||
};
|
||
let f = Footage {
|
||
path: cx.path.to_string(),
|
||
kind: FootageKind::Still,
|
||
width: w,
|
||
height: h,
|
||
pixel_aspect: 1.0,
|
||
frame_rate: cx.rate,
|
||
has_video: true,
|
||
alpha: AlphaMode::Straight,
|
||
loop_count: 1,
|
||
codec: if effectcraft_psd::is_psd(data) { "PSD".into() } else { "PSD (embedded image)".into() },
|
||
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()), placed: bake }),
|
||
..Default::default()
|
||
};
|
||
let label = if file.is_empty() { name.to_string() } else { file };
|
||
let id = proj.add_item(&format!("{label} (Smart Object)"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
|
||
cx.items.push(id);
|
||
let mut l = build::layer(proj, comp, name, LayerSource::Footage { item: id }, (w, h), None);
|
||
if bake {
|
||
// The baked pixels cover the placed bounds: centred there, scaled back to 1×.
|
||
let c = [bounds[0] + w as f64 / k / 2.0, bounds[1] + h as f64 / k / 2.0];
|
||
set_vec2(&mut l, "transform/position", c);
|
||
set_vec2(&mut l, "transform/scale", [100.0 / k, 100.0 / k]);
|
||
return Some(l);
|
||
}
|
||
let (c, sx, sy, rot) = so.placement(w as f64, h as f64);
|
||
set_vec2(&mut l, "transform/position", c);
|
||
set_vec2(&mut l, "transform/scale", [sx * 100.0, sy * 100.0]);
|
||
if rot.abs() > 1e-9 {
|
||
set_scalar(&mut l, "transform/rotation", rot);
|
||
}
|
||
Some(l)
|
||
}
|
||
|
||
fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Option<Layer> {
|
||
let (index, children) = match n {
|
||
Node::Layer(i) => (*i, None),
|
||
Node::Group { layer, children } => (*layer, Some(children)),
|
||
};
|
||
let pl = cx.psd.layers[index].clone();
|
||
let name = if pl.name.is_empty() { format!("Layer {}", index + 1) } else { pl.name.clone() };
|
||
let mut layer = if let Some(children) = children {
|
||
let sub = build_comp(proj, cx, &name, children, Some(cx.folder));
|
||
let mut l = build::layer(proj, comp, &name, LayerSource::Comp { item: sub }, (comp.width, comp.height), None);
|
||
// Pass-through groups composite their layers straight into the parent.
|
||
l.switches.collapse = pl.blend_key() == "pass";
|
||
l
|
||
} else if let Some(t) = &pl.text {
|
||
text_layer(proj, comp, &name, t)
|
||
} else if let Some(adj) = &pl.adjustment {
|
||
let mut l = adjustment_layer(proj, comp, &name);
|
||
add_adjustment_effect(proj, &mut l, adj, comp, &mut cx.warnings, &name);
|
||
l
|
||
} else if let Some(l) = pl.smart_object.as_ref().and_then(|_| smart_object_layer(proj, cx, comp, index, &name)) {
|
||
l
|
||
} else if pl.has_pixels() || pl.fill.is_some() {
|
||
let (item, size) = footage_item(proj, cx, index, &name);
|
||
let mut l = build::layer(proj, comp, &name, LayerSource::Footage { item }, size, None);
|
||
if cx.retain && !pl.rect.is_empty() {
|
||
set_vec2(&mut l, "transform/position", [pl.rect.left as f64 + size.0 as f64 / 2.0, pl.rect.top as f64 + size.1 as f64 / 2.0]);
|
||
}
|
||
l
|
||
} else {
|
||
// Empty layers have nothing to show.
|
||
return None;
|
||
};
|
||
layer.name = comp.unique_layer_name(&name);
|
||
layer.blend_mode = blend_mode(pl.blend_key());
|
||
layer.switches.video = !pl.hidden;
|
||
layer.preserve_transparency = pl.clipping;
|
||
set_scalar(&mut layer, "transform/opacity", pl.opacity as f64 / 255.0 * 100.0);
|
||
// Vector mask → a mask in layer space.
|
||
let origin = layer_origin(&pl, cx.retain, &layer);
|
||
if let Some(vm) = pl.vector_mask.as_ref().filter(|v| !v.disabled)
|
||
&& let Some(masks) = layer.props.sub_mut("masks")
|
||
{
|
||
let mut ids = Ids(&mut proj.next_id);
|
||
for (k, sp) in vm.subpaths.iter().enumerate() {
|
||
let n = sp.knots.len();
|
||
let mut path = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: sp.closed, feather: Vec::new() };
|
||
for kn in &sp.knots {
|
||
let v = [kn[1][0] - origin[0], kn[1][1] - origin[1]];
|
||
path.vertices.push(v);
|
||
path.in_tangents.push([kn[0][0] - kn[1][0], kn[0][1] - kn[1][1]]);
|
||
path.out_tangents.push([kn[2][0] - kn[1][0], kn[2][1] - kn[1][1]]);
|
||
}
|
||
if n == 0 {
|
||
continue;
|
||
}
|
||
let mode = if vm.inverted { MaskMode::Subtract } else { MaskMode::Add };
|
||
masks.children.push(build::mask(&mut ids, &format!("Mask {}", k + 1), path, mode, [255, 255, 0]).into());
|
||
}
|
||
}
|
||
// Layer effects → Layer Styles; Fill Opacity → Advanced Blending.
|
||
let gl = comp.global_light.clone();
|
||
if let Some(fx) = &pl.effects
|
||
&& fx.bool("masterFXSwitch") != Some(false)
|
||
&& layer.can_have_styles()
|
||
{
|
||
apply_effects(proj, &mut layer, fx, &gl);
|
||
}
|
||
if pl.fill_opacity < 255 && layer.can_have_styles() {
|
||
let mut ids = Ids(&mut proj.next_id);
|
||
if layer.props.sub(styles::GROUP).is_none() {
|
||
let g = styles::layer_styles(&mut ids, &gl);
|
||
let at = layer.props.children.iter().position(|c| c.match_id() == "transform").map(|i| i + 1).unwrap_or(layer.props.children.len());
|
||
layer.props.children.insert(at, g.into());
|
||
}
|
||
if let Some(p) = layer.props.prop_mut("layerStyles/blendingOptions/advancedBlending/fillOpacity") {
|
||
p.value = Value::Scalar(pl.fill_opacity as f64 / 255.0 * 100.0);
|
||
}
|
||
}
|
||
Some(layer)
|
||
}
|
||
|
||
/// Where layer space's origin sits in document pixels.
|
||
fn layer_origin(pl: &effectcraft_psd::Layer, retain: bool, layer: &Layer) -> [f64; 2] {
|
||
if let Some(t) = &pl.text {
|
||
return [t.transform[4], t.transform[5]];
|
||
}
|
||
if matches!(layer.source, LayerSource::Footage { .. }) && retain && !pl.rect.is_empty() {
|
||
return [pl.rect.left as f64, pl.rect.top as f64];
|
||
}
|
||
[0.0, 0.0]
|
||
}
|
||
|
||
fn set_scalar(l: &mut Layer, path: &str, v: f64) {
|
||
if let Some(p) = l.props.prop_mut(path) {
|
||
p.value = Value::Scalar(v);
|
||
}
|
||
}
|
||
|
||
fn set_vec2(l: &mut Layer, path: &str, v: [f64; 2]) {
|
||
if let Some(p) = l.props.prop_mut(path) {
|
||
p.value = match p.value {
|
||
Value::Vec3(o) => Value::Vec3([v[0], v[1], o[2]]),
|
||
_ => Value::Vec2(v),
|
||
};
|
||
}
|
||
}
|
||
|
||
/// Split a PostScript font name into family and style (`Inter-Bold` → `Inter`, `Bold`).
|
||
fn font_names(ps: &str) -> (String, String) {
|
||
match ps.split_once('-') {
|
||
Some((fam, style)) => (fam.to_string(), style.to_string()),
|
||
None => (ps.to_string(), "Regular".to_string()),
|
||
}
|
||
}
|
||
|
||
fn text_doc(t: &effectcraft_psd::TextInfo) -> TextDoc {
|
||
let mut d = TextDoc::default();
|
||
d.set_text(&t.text);
|
||
let [xx, xy, yx, yy, _, _] = t.transform;
|
||
let scale = (xx * yy - xy * yx).abs().sqrt().max(1e-6);
|
||
if let Some(s) = t.style.size {
|
||
d.size = s * scale;
|
||
}
|
||
if let Some(f) = &t.style.font {
|
||
let (fam, style) = font_names(f);
|
||
d.font = fam;
|
||
d.style = style;
|
||
}
|
||
if let Some(c) = t.style.color {
|
||
d.fill = [c[0] as f32, c[1] as f32, c[2] as f32, 1.0];
|
||
}
|
||
d.justify = match t.style.justification {
|
||
Some(1) => Justify::Right,
|
||
Some(2) => Justify::Center,
|
||
Some(3) => Justify::JustifyLastLeft,
|
||
Some(4) => Justify::JustifyLastRight,
|
||
Some(5) => Justify::JustifyLastCenter,
|
||
Some(6) => Justify::JustifyAll,
|
||
_ => Justify::Left,
|
||
};
|
||
if let Some(tr) = t.style.tracking {
|
||
d.tracking = tr;
|
||
}
|
||
if let Some(l) = t.style.leading {
|
||
d.leading = Some(l * scale);
|
||
}
|
||
d.faux_bold = t.style.faux_bold;
|
||
d.faux_italic = t.style.faux_italic;
|
||
if t.style.box_text
|
||
&& let Some([l, top, r, b]) = t.bounds
|
||
{
|
||
d.box_size = Some([(r - l) * scale, (b - top) * scale]);
|
||
d.box_pos = [l * scale, top * scale];
|
||
}
|
||
d
|
||
}
|
||
|
||
fn text_layer(proj: &mut Project, comp: &Comp, name: &str, t: &effectcraft_psd::TextInfo) -> Layer {
|
||
let mut l = build::layer(proj, comp, name, LayerSource::Text, (0, 0), None);
|
||
if let Some(p) = l.props.prop_mut("text/sourceText") {
|
||
p.value = Value::Text(Box::new(text_doc(t)));
|
||
}
|
||
set_vec2(&mut l, "transform/position", [t.transform[4], t.transform[5]]);
|
||
let [xx, xy, _, _, _, _] = t.transform;
|
||
let rot = xy.atan2(xx).to_degrees();
|
||
if rot.abs() > 1e-6 {
|
||
set_scalar(&mut l, "transform/rotation", rot);
|
||
}
|
||
l
|
||
}
|
||
|
||
fn adjustment_layer(proj: &mut Project, comp: &Comp, name: &str) -> Layer {
|
||
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
|
||
let sid = proj.add_item(
|
||
name,
|
||
Label::Red,
|
||
Some(folder),
|
||
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),
|
||
);
|
||
let mut l = build::layer(proj, comp, name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None);
|
||
l.switches.adjustment = true;
|
||
l.label = Label::Purple;
|
||
l
|
||
}
|
||
|
||
fn add_effect(proj: &mut Project, l: &mut Layer, effect: &str, comp: &Comp, params: &[(&str, Value)]) {
|
||
let Some(spec) = effectcraft_effects::lookup(effect) else { return };
|
||
let mut ids = Ids(&mut proj.next_id);
|
||
let mut g = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [comp.width as f64, comp.height as f64]);
|
||
for (k, v) in params {
|
||
// Paths reach parameters in twirl-down groups (`master/exposure`).
|
||
if let Some(p) = g.prop_mut(k) {
|
||
p.value = v.clone();
|
||
}
|
||
}
|
||
if let Some(fx) = l.props.sub_mut("effects") {
|
||
fx.children.push(g.into());
|
||
}
|
||
}
|
||
|
||
fn add_adjustment_effect(proj: &mut Project, l: &mut Layer, adj: &Adjustment, comp: &Comp, warnings: &mut Vec<String>, name: &str) {
|
||
match adj {
|
||
Adjustment::Invert => add_effect(proj, l, "Invert", comp, &[]),
|
||
Adjustment::BrightnessContrast { brightness, contrast, legacy } => add_effect(
|
||
proj,
|
||
l,
|
||
"Brightness & Contrast",
|
||
comp,
|
||
&[("brightness", Value::Scalar(*brightness)), ("contrast", Value::Scalar(*contrast)), ("useLegacy", Value::Bool(*legacy))],
|
||
),
|
||
Adjustment::HueSaturation { hue, saturation, lightness, colorize } => {
|
||
let mut p = vec![("hue", Value::Scalar(*hue)), ("saturation", Value::Scalar(*saturation)), ("lightness", Value::Scalar(*lightness))];
|
||
if *colorize {
|
||
p = vec![
|
||
("colorize", Value::Bool(true)),
|
||
("colorizeHue", Value::Scalar(*hue)),
|
||
("colorizeSaturation", Value::Scalar(*saturation)),
|
||
("colorizeLightness", Value::Scalar(*lightness)),
|
||
];
|
||
}
|
||
add_effect(proj, l, "Hue/Saturation", comp, &p)
|
||
}
|
||
Adjustment::Levels { in_black, in_white, gamma, out_black, out_white } => add_effect(
|
||
proj,
|
||
l,
|
||
"Levels",
|
||
comp,
|
||
&[
|
||
("inBlack", Value::Scalar(in_black / 255.0)),
|
||
("inWhite", Value::Scalar(in_white / 255.0)),
|
||
("gamma", Value::Scalar(*gamma)),
|
||
("outBlack", Value::Scalar(out_black / 255.0)),
|
||
("outWhite", Value::Scalar(out_white / 255.0)),
|
||
],
|
||
),
|
||
Adjustment::Exposure { exposure, offset, gamma } => add_effect(
|
||
proj,
|
||
l,
|
||
"Exposure",
|
||
comp,
|
||
&[("master/exposure", Value::Scalar(*exposure)), ("master/offset", Value::Scalar(*offset)), ("master/gamma", Value::Scalar(*gamma))],
|
||
),
|
||
Adjustment::Other(k) => warnings.push(format!("{name}: adjustment `{k}` is not converted (layer kept as an empty adjustment layer)")),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- layer effects → styles
|
||
|
||
fn style_mode(m: BlendMode) -> Value {
|
||
Value::Enum(STYLE_BLEND_MODES.iter().position(|x| x.1 == m).unwrap_or(0) as u32)
|
||
}
|
||
|
||
fn set(g: &mut PropGroup, k: &str, v: Value) {
|
||
if let Some(p) = g.get_mut(k) {
|
||
p.value = v;
|
||
}
|
||
}
|
||
|
||
fn color4(c: [f64; 3]) -> Value {
|
||
Value::Color([c[0], c[1], c[2], 1.0])
|
||
}
|
||
|
||
/// Copy the common style settings (`Md `, `Clr `, `Opct`, angle, distance, size…).
|
||
fn common(g: &mut PropGroup, d: &Descriptor) {
|
||
if let Some(m) = desc_blend(d.enum_value("Md ")) {
|
||
set(g, "blendMode", style_mode(m));
|
||
}
|
||
if let Some(c) = d.color("Clr ") {
|
||
set(g, "color", color4(c));
|
||
}
|
||
for (k, prop) in [("Opct", "opacity"), ("Nose", "noise"), ("Dstn", "distance"), ("blur", "size"), ("lagl", "angle"), ("ShdN", "jitter"), ("Inpr", "range")]
|
||
{
|
||
if let Some(v) = d.num(k) {
|
||
set(g, prop, Value::Scalar(v));
|
||
}
|
||
}
|
||
if let Some(b) = d.bool("uglg") {
|
||
set(g, "useGlobalLight", Value::Bool(b));
|
||
}
|
||
}
|
||
|
||
fn gradient_of(d: &Descriptor) -> Option<Gradient> {
|
||
let g = d.obj("Grad")?;
|
||
let list = |k: &str| match g.get(k) {
|
||
Some(DValue::List(v)) => v.clone(),
|
||
_ => vec![],
|
||
};
|
||
let colors: Vec<(f64, [f32; 4])> = list("Clrs")
|
||
.iter()
|
||
.filter_map(|c| match c {
|
||
DValue::Descriptor(s) => {
|
||
let col = s.color("Clr ")?;
|
||
Some((s.num("Lctn").unwrap_or(0.0) / 4096.0, [col[0] as f32, col[1] as f32, col[2] as f32, 1.0]))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let opacities: Vec<(f64, f32)> = list("Trns")
|
||
.iter()
|
||
.filter_map(|c| match c {
|
||
DValue::Descriptor(s) => Some((s.num("Lctn").unwrap_or(0.0) / 4096.0, (s.num("Opct").unwrap_or(100.0) / 100.0) as f32)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
(!colors.is_empty()).then(|| Gradient { colors, opacities: if opacities.is_empty() { vec![(0.0, 1.0), (1.0, 1.0)] } else { opacities } })
|
||
}
|
||
|
||
/// The effect descriptor for `key`, or the first enabled one of its `…Multi` list.
|
||
fn effect<'a>(fx: &'a Descriptor, key: &str, multi: &str) -> Option<&'a Descriptor> {
|
||
fx.obj(key).or_else(|| match fx.get(multi) {
|
||
Some(DValue::List(v)) => v.iter().find_map(|x| match x {
|
||
DValue::Descriptor(d) if d.bool("enab") != Some(false) => Some(d),
|
||
_ => None,
|
||
}),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
fn apply_effects(proj: &mut Project, layer: &mut Layer, fx: &Descriptor, gl: &styles::GlobalLight) {
|
||
const MAP: &[(&str, &str, &str)] = &[
|
||
("DrSh", "dropShadowMulti", "dropShadow"),
|
||
("IrSh", "innerShadowMulti", "innerShadow"),
|
||
("OrGl", "outerGlowMulti", "outerGlow"),
|
||
("IrGl", "innerGlowMulti", "innerGlow"),
|
||
("ebbl", "bevelEmbossMulti", "bevelEmboss"),
|
||
("ChFX", "satinMulti", "satin"),
|
||
("SoFi", "solidFillMulti", "colorOverlay"),
|
||
("GrFl", "gradientFillMulti", "gradientOverlay"),
|
||
("FrFX", "frameFXMulti", "stroke"),
|
||
];
|
||
for (key, multi, id) in MAP {
|
||
let Some(d) = effect(fx, key, multi) else { continue };
|
||
let enabled = d.bool("enab").unwrap_or(true);
|
||
let mut ids = Ids(&mut proj.next_id);
|
||
let Some(uid) = styles::add_style(layer, &mut ids, id, gl, enabled) else { continue };
|
||
let Some(g) = layer.props.find_group_mut(uid) else { continue };
|
||
common(g, d);
|
||
match *id {
|
||
"dropShadow" => {
|
||
if let Some(v) = d.num("Ckmt") {
|
||
set(g, "spread", Value::Scalar(v));
|
||
}
|
||
if let Some(b) = d.bool("layerConceals") {
|
||
set(g, "knocksOut", Value::Bool(b));
|
||
}
|
||
}
|
||
"innerShadow" | "innerGlow" => {
|
||
if let Some(v) = d.num("Ckmt") {
|
||
set(g, "choke", Value::Scalar(v));
|
||
}
|
||
if d.enum_value("glwS") == Some("SrcC") {
|
||
set(g, "source", Value::Enum(0));
|
||
}
|
||
}
|
||
"outerGlow" => {
|
||
if let Some(v) = d.num("Ckmt") {
|
||
set(g, "spread", Value::Scalar(v));
|
||
}
|
||
}
|
||
"bevelEmboss" => {
|
||
let style = match d.enum_value("bvlS") {
|
||
Some("OtrB") => 0,
|
||
Some("Embs") => 2,
|
||
Some("PlEb") => 3,
|
||
Some("strokeEmboss") => 4,
|
||
_ => 1,
|
||
};
|
||
set(g, "style", Value::Enum(style));
|
||
let tech = match d.enum_value("bvlT") {
|
||
Some("PrBL") => 1,
|
||
Some("Slmt") => 2,
|
||
_ => 0,
|
||
};
|
||
set(g, "technique", Value::Enum(tech));
|
||
if let Some(v) = d.num("srgR") {
|
||
set(g, "depth", Value::Scalar(v));
|
||
}
|
||
set(g, "direction", Value::Enum(u32::from(d.enum_value("bvlD") == Some("Out "))));
|
||
if let Some(v) = d.num("Sftn") {
|
||
set(g, "soften", Value::Scalar(v));
|
||
}
|
||
if let Some(v) = d.num("Lald") {
|
||
set(g, "altitude", Value::Scalar(v));
|
||
}
|
||
for (k, p) in [("hglM", "highlightMode"), ("sdwM", "shadowMode")] {
|
||
if let Some(m) = desc_blend(d.enum_value(k)) {
|
||
set(g, p, style_mode(m));
|
||
}
|
||
}
|
||
for (k, p) in [("hglC", "highlightColor"), ("sdwC", "shadowColor")] {
|
||
if let Some(c) = d.color(k) {
|
||
set(g, p, color4(c));
|
||
}
|
||
}
|
||
for (k, p) in [("hglO", "highlightOpacity"), ("sdwO", "shadowOpacity")] {
|
||
if let Some(v) = d.num(k) {
|
||
set(g, p, Value::Scalar(v));
|
||
}
|
||
}
|
||
}
|
||
"satin" => {
|
||
if let Some(b) = d.bool("Invr") {
|
||
set(g, "invert", Value::Bool(b));
|
||
}
|
||
}
|
||
"gradientOverlay" => {
|
||
if let Some(gr) = gradient_of(d) {
|
||
set(g, "colors", Value::Gradient(gr));
|
||
}
|
||
if let Some(a) = d.num("Angl") {
|
||
set(g, "angle", Value::Scalar(a));
|
||
}
|
||
let st = match d.enum_value("Type") {
|
||
Some("Rdl ") => 1,
|
||
Some("Angl") => 2,
|
||
Some("Rflc") => 3,
|
||
Some("Dmnd") => 4,
|
||
_ => 0,
|
||
};
|
||
set(g, "style", Value::Enum(st));
|
||
if let Some(b) = d.bool("Rvrs") {
|
||
set(g, "reverse", Value::Bool(b));
|
||
}
|
||
if let Some(b) = d.bool("Algn") {
|
||
set(g, "alignWithLayer", Value::Bool(b));
|
||
}
|
||
if let Some(v) = d.num("Scl ") {
|
||
set(g, "scale", Value::Scalar(v));
|
||
}
|
||
}
|
||
"stroke" => {
|
||
if let Some(v) = d.num("Sz ") {
|
||
set(g, "size", Value::Scalar(v));
|
||
}
|
||
let pos = match d.enum_value("Styl") {
|
||
Some("InsF") => 1,
|
||
Some("CtrF") => 2,
|
||
_ => 0,
|
||
};
|
||
set(g, "position", Value::Enum(pos));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Layer ▸ Create ▸ Convert to Editable Text: a footage layer showing a Photoshop type layer
|
||
/// becomes a text layer (`None` when the layer's source has no text data).
|
||
pub fn editable_text(proj: &mut Project, comp: &Comp, layer: &Layer, psd: &Psd, index: usize) -> Option<Layer> {
|
||
let t = psd.layers.get(index)?.text.as_ref()?;
|
||
let mut l = text_layer(proj, comp, &layer.name, t);
|
||
l.name = layer.name.clone();
|
||
l.blend_mode = layer.blend_mode;
|
||
l.switches.video = layer.switches.video;
|
||
l.in_point = layer.in_point;
|
||
l.out_point = layer.out_point;
|
||
l.start_time = layer.start_time;
|
||
if let (Some(o), Some(p)) = (layer.props.prop("transform/opacity"), l.props.prop_mut("transform/opacity")) {
|
||
*p = effectcraft_project::Property { uid: p.uid, ..o.clone() };
|
||
}
|
||
Some(l)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn blend_keys() {
|
||
assert_eq!(blend_mode("mul "), BlendMode::Multiply);
|
||
assert_eq!(blend_mode("smud"), BlendMode::Exclusion);
|
||
assert_eq!(blend_mode("pass"), BlendMode::Normal);
|
||
assert_eq!(desc_blend(Some("linearDodge")), Some(BlendMode::LinearDodge));
|
||
assert_eq!(font_names("Inter-Bold"), ("Inter".into(), "Bold".into()));
|
||
}
|
||
}
|