mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 22:42:24 -06:00
refactor(engine): move psd_import, vector, xml_project to interchange/
This commit is contained in:
@@ -0,0 +1,795 @@
|
||||
//! 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![] };
|
||||
cx.warnings.extend(psd.warnings.iter().cloned());
|
||||
let (tree, flattened) = psd.tree_report();
|
||||
if flattened > 0 {
|
||||
cx.warnings.push(format!("{flattened} group(s) nested deeper than {} levels were flattened into their parent", effectcraft_psd::MAX_GROUP_DEPTH));
|
||||
}
|
||||
let comp =
|
||||
if psd.layers.is_empty() && !psd.warnings.is_empty() { flattened_comp(proj, &mut cx, name) } else { build_comp(proj, &mut cx, name, &tree, None) };
|
||||
PsdImport { comp, folder, items: cx.items, warnings: cx.warnings }
|
||||
}
|
||||
|
||||
/// The layers could not be read: the merged image as the composition's only layer.
|
||||
fn flattened_comp(proj: &mut Project, cx: &mut Ctx, name: &str) -> ItemId {
|
||||
let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(Arc::new(new_comp(cx))));
|
||||
cx.items.push(cid);
|
||||
let mut comp = new_comp(cx);
|
||||
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: cx.psd.width,
|
||||
height: cx.psd.height,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: cx.rate,
|
||||
has_video: true,
|
||||
alpha: if cx.psd.merged_alpha { AlphaMode::Straight } else { AlphaMode::Ignore },
|
||||
loop_count: 1,
|
||||
codec: "PSD".into(),
|
||||
layer: None,
|
||||
..Default::default()
|
||||
};
|
||||
let id = proj.add_item(&file, Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
|
||||
cx.items.push(id);
|
||||
let l = build::layer(proj, &comp, name, LayerSource::Footage { item: id }, (cx.psd.width, cx.psd.height), None);
|
||||
comp.layers.push(l);
|
||||
if let Some(item) = proj.item_mut(cid) {
|
||||
item.kind = ItemKind::Comp(Arc::new(comp));
|
||||
}
|
||||
cid
|
||||
}
|
||||
|
||||
fn new_comp(cx: &Ctx) -> Comp {
|
||||
// A PSB can be 300,000 px a side; a composition is at most 30,000 (#788).
|
||||
let size = |v: u32| effectcraft_project::limits::size(v.into());
|
||||
Comp::new(size(cx.psd.width), size(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);
|
||||
// At most 4× the document across and 2^24 pixels, however far the file moves a corner.
|
||||
let (k, (w, h)) = so.bake_scale(cw as f64, ch as f64, k, (cx.psd.width, cx.psd.height));
|
||||
(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]
|
||||
}
|
||||
|
||||
/// Sets a scalar property; a non-finite value (from corrupt transforms) keeps the default.
|
||||
fn set_scalar(l: &mut Layer, path: &str, v: f64) {
|
||||
if !v.is_finite() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = l.props.prop_mut(path) {
|
||||
p.value = Value::Scalar(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a 2D property; a non-finite component (from corrupt transforms) keeps the default.
|
||||
fn set_vec2(l: &mut Layer, path: &str, v: [f64; 2]) {
|
||||
if !v.iter().all(|c| c.is_finite()) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
// A transform too large to multiply (inf) falls back to 1×; products below stay finite.
|
||||
let scale = if scale.is_finite() { scale } else { 1.0 };
|
||||
if let Some(s) = t.style.size.map(|s| s * scale).filter(|s| s.is_finite() && *s > 0.0) {
|
||||
d.size = s;
|
||||
}
|
||||
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.filter(|v| v.is_finite()) {
|
||||
d.tracking = tr;
|
||||
}
|
||||
d.leading = t.style.leading.map(|l| l * scale).filter(|l| l.is_finite());
|
||||
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
|
||||
{
|
||||
let (size, pos) = ([(r - l) * scale, (b - top) * scale], [l * scale, top * scale]);
|
||||
if size.iter().chain(&pos).all(|v| v.is_finite()) {
|
||||
d.box_size = Some(size);
|
||||
d.box_pos = pos;
|
||||
}
|
||||
}
|
||||
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()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_text_values_never_reach_the_layer() {
|
||||
let style = effectcraft_psd::engine_data::TextStyle {
|
||||
size: Some(f64::NAN),
|
||||
tracking: Some(f64::INFINITY),
|
||||
leading: Some(f64::NEG_INFINITY),
|
||||
..Default::default()
|
||||
};
|
||||
let t = effectcraft_psd::TextInfo {
|
||||
text: "x".into(),
|
||||
transform: [f64::INFINITY, 0.0, 0.0, 1.0, f64::NAN, 1.0],
|
||||
style,
|
||||
bounds: Some([0.0, 0.0, f64::INFINITY, 1.0]),
|
||||
};
|
||||
let d = text_doc(&t);
|
||||
assert!(d.size.is_finite() && d.tracking.is_finite());
|
||||
assert!(d.leading.is_none_or(f64::is_finite));
|
||||
assert!(d.box_size.is_none_or(|b| b.iter().all(|v| v.is_finite())));
|
||||
let mut proj = Project::default();
|
||||
let comp = Comp::new(64, 64, FrameRate::FPS_24, Tick::ZERO);
|
||||
let l = text_layer(&mut proj, &comp, "t", &t);
|
||||
match l.props.prop("transform/position").map(|p| &p.value) {
|
||||
Some(Value::Vec2(v)) => assert!(v.iter().all(|c| c.is_finite())),
|
||||
Some(Value::Vec3(v)) => assert!(v.iter().all(|c| c.is_finite())),
|
||||
other => panic!("unexpected position {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG, PDF,
|
||||
//! Illustrator and EPS footage → shape layer), Create Shapes from Text and Create Masks from
|
||||
//! Text (glyph outlines), and vector files imported as compositions (one layer per file layer).
|
||||
|
||||
use effectcraft_keyframe::{Gradient, ShapePath, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemId, Layer, LayerSource, MaskMode, Project, PropGroup};
|
||||
use effectcraft_svg::{Affine, Doc, Geom, GradientKind, Node, Paint};
|
||||
use kurbo::{BezPath, Point};
|
||||
|
||||
fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec<PropGroup> {
|
||||
effectcraft_path::from_kurbo(path)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, sp)| {
|
||||
let mut g = build::shape_path(ids, sp);
|
||||
g.name = format!("Path {}", i + 1);
|
||||
g
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn det_scale(m: Affine) -> f64 {
|
||||
let c = m.as_coeffs();
|
||||
(c[0] * c[3] - c[1] * c[2]).abs().sqrt()
|
||||
}
|
||||
|
||||
/// Axis-aligned scale + translation only (basic shapes stay parametric).
|
||||
fn axis_aligned(m: Affine) -> Option<(f64, f64, f64, f64)> {
|
||||
let c = m.as_coeffs();
|
||||
(c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && c[0] > 0.0 && c[3] > 0.0).then_some((c[0], c[3], c[4], c[5]))
|
||||
}
|
||||
|
||||
fn gradient_value(g: &effectcraft_svg::Gradient) -> Gradient {
|
||||
Gradient {
|
||||
colors: g.stops.iter().map(|(o, c)| (*o, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0])).collect(),
|
||||
opacities: g.stops.iter().map(|(o, c)| (*o, c[3] as f32)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient start/end points in shape-layer space (`m`: shape user space → layer space).
|
||||
fn gradient_points(g: &effectcraft_svg::Gradient, m: Affine, bbox: kurbo::Rect) -> (bool, [f64; 2], [f64; 2]) {
|
||||
let unit = if g.bbox_units { Affine::new([bbox.width(), 0.0, 0.0, bbox.height(), bbox.x0, bbox.y0]) } else { Affine::IDENTITY };
|
||||
let full = m * unit * g.transform;
|
||||
let p = |x: f64, y: f64| {
|
||||
let q = full * Point::new(x, y);
|
||||
[q.x, q.y]
|
||||
};
|
||||
match g.kind {
|
||||
GradientKind::Linear { x1, y1, x2, y2 } => (false, p(x1, y1), p(x2, y2)),
|
||||
GradientKind::Radial { cx, cy, r, .. } => (true, p(cx, cy), p(cx + r, cy)),
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine) -> Vec<PropGroup> {
|
||||
use kurbo::Shape as _;
|
||||
let mut items = vec![];
|
||||
// Geometry: parametric when the transform keeps rectangles/ellipses axis-aligned.
|
||||
match (&s.geom, axis_aligned(m)) {
|
||||
(Geom::Rect { x, y, w, h, rx, ry }, Some((sx, sy, tx, ty))) if (rx - ry).abs() < 1e-9 && (*rx == 0.0 || (sx - sy).abs() < 1e-9) => {
|
||||
let (w2, h2) = (w * sx, h * sy);
|
||||
let pos = [tx + (x + w / 2.0) * sx, ty + (y + h / 2.0) * sy];
|
||||
let mut g = build::shape_rect(ids, [w2, h2], pos, rx * sx);
|
||||
g.name = "Rectangle Path 1".into();
|
||||
items.push(g);
|
||||
}
|
||||
(Geom::Ellipse { cx, cy, rx, ry }, Some((sx, sy, tx, ty))) => {
|
||||
items.push(build::shape_ellipse(ids, [2.0 * rx * sx, 2.0 * ry * sy], [tx + cx * sx, ty + cy * sy]));
|
||||
}
|
||||
(g, _) => items.extend(shape_paths(ids, &(m * g.to_path()))),
|
||||
}
|
||||
let bbox = s.geom.to_path().bounding_box();
|
||||
if let Some(st) = &s.stroke {
|
||||
let width = st.width * det_scale(m);
|
||||
let mut g = match &st.paint {
|
||||
Paint::Color(c) => build::shape_stroke(ids, [c[0], c[1], c[2], 1.0], width),
|
||||
Paint::Gradient(gr) => {
|
||||
let (radial, a, b) = gradient_points(gr, m, bbox);
|
||||
build::shape_gradient_stroke(ids, radial, a, b, gradient_value(gr), width)
|
||||
}
|
||||
};
|
||||
set(&mut g, "opacity", Value::Scalar(st.opacity * 100.0));
|
||||
set(&mut g, "cap", Value::Enum(st.cap as u32));
|
||||
set(&mut g, "join", Value::Enum(st.join as u32));
|
||||
set(&mut g, "miter", Value::Scalar(st.miter));
|
||||
if let Some((d, off)) = &st.dash
|
||||
&& let Some(dg) = g.sub_mut("dashes")
|
||||
{
|
||||
let k = det_scale(m);
|
||||
set(dg, "dash", Value::Scalar(d.first().copied().unwrap_or(0.0) * k));
|
||||
set(dg, "gap", Value::Scalar(d.get(1).or(d.first()).copied().unwrap_or(0.0) * k));
|
||||
set(dg, "offset", Value::Scalar(off * k));
|
||||
}
|
||||
items.push(g);
|
||||
}
|
||||
if let Some(f) = &s.fill {
|
||||
let mut g = match f {
|
||||
Paint::Color(c) => build::shape_fill(ids, [c[0], c[1], c[2], 1.0]),
|
||||
Paint::Gradient(gr) => {
|
||||
let (radial, a, b) = gradient_points(gr, m, bbox);
|
||||
build::shape_gradient_fill(ids, radial, a, b, gradient_value(gr))
|
||||
}
|
||||
};
|
||||
set(&mut g, "opacity", Value::Scalar(s.fill_opacity * 100.0));
|
||||
set(&mut g, "rule", Value::Enum(u32::from(s.fill_rule == effectcraft_svg::FillRule::EvenOdd)));
|
||||
items.push(g);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn set(g: &mut PropGroup, k: &str, v: Value) {
|
||||
if let Some(p) = g.get_mut(k) {
|
||||
p.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
fn group(ids: &mut Ids, name: &str, items: Vec<PropGroup>, opacity: f64) -> PropGroup {
|
||||
let mut g = build::shape_group(ids, name, items);
|
||||
if let Some(p) = g.prop_mut("transform/opacity") {
|
||||
p.value = Value::Scalar(opacity * 100.0);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// A Merge Paths item in `mode` (0 Merge, 3 Intersect).
|
||||
fn merge_item(ids: &mut Ids, mode: u32) -> Option<PropGroup> {
|
||||
let mut g = build::shape_simple_op(ids, "merge")?;
|
||||
set(&mut g, "mode", Value::Enum(mode));
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// A shape inside clipping groups: its geometry merged into one compound path (an inner group
|
||||
/// with Merge Paths), the clip paths, and Merge Paths ▸ Intersect before its paint, so the fill
|
||||
/// is exactly the clipped area. Open stroked paths stay unclipped (an intersection would close
|
||||
/// them).
|
||||
fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
|
||||
let items = shape_items(ids, s, m);
|
||||
let path = s.geom.to_path();
|
||||
let closed = path.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)) || s.fill.is_some();
|
||||
if clips.is_empty() || !closed {
|
||||
return items;
|
||||
}
|
||||
let (geom, paint): (Vec<PropGroup>, Vec<PropGroup>) =
|
||||
items.into_iter().partition(|g| !matches!(g.match_id.as_str(), "fill" | "stroke" | "gfill" | "gstroke"));
|
||||
let mut inner = geom;
|
||||
inner.extend(merge_item(ids, 0));
|
||||
let mut out = vec![build::shape_group(ids, "Clipped Path", inner)];
|
||||
for (i, c) in clips.iter().enumerate() {
|
||||
let mut parts = shape_paths(ids, c);
|
||||
parts.extend(merge_item(ids, 0));
|
||||
out.push(build::shape_group(ids, &format!("Clip Path {}", i + 1), parts));
|
||||
}
|
||||
out.extend(merge_item(ids, 3));
|
||||
out.extend(paint);
|
||||
out
|
||||
}
|
||||
|
||||
/// Shape layer contents for an SVG document, in document pixels (transforms are baked into the
|
||||
/// geometry; group and element opacity go to the group transforms, blend modes to the groups'
|
||||
/// Blend Mode). Top of the paint order first.
|
||||
///
|
||||
/// Clipping groups (PDF / Illustrator clipping masks): shapes inside them are intersected with
|
||||
/// the clip paths through Merge Paths ▸ Intersect (see `clipped_items`). Clips enclosing the
|
||||
/// whole document are layer masks instead ([`vector_clip_masks`], used by
|
||||
/// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see
|
||||
/// [`split_at_images`]); soft masks are not converted.
|
||||
pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec<PropGroup> {
|
||||
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
|
||||
let mut out = vec![];
|
||||
for n in nodes.iter().rev() {
|
||||
match n {
|
||||
Node::Group(g) => {
|
||||
let gm = m * g.transform;
|
||||
let mut inner_clips = clips.to_vec();
|
||||
inner_clips.extend(g.clip.iter().map(|(p, _)| gm * p.clone()));
|
||||
let items = walk(ids, &g.children, gm, &inner_clips);
|
||||
if !items.is_empty() {
|
||||
let mut grp = group(ids, &g.name, items, g.opacity);
|
||||
if g.blend != effectcraft_svg::BlendMode::Normal {
|
||||
set(&mut grp, "blend", Value::Enum(blend_index(g.blend)));
|
||||
}
|
||||
out.push(grp);
|
||||
}
|
||||
}
|
||||
Node::Shape(s) => {
|
||||
let sm = m * s.transform;
|
||||
let items = if clips.is_empty() { shape_items(ids, s, sm) } else { clipped_items(ids, s, sm, clips) };
|
||||
if !items.is_empty() {
|
||||
out.push(group(ids, &s.name, items, s.opacity));
|
||||
}
|
||||
}
|
||||
Node::Image(_) => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let root = &doc.root;
|
||||
let mut clips = vec![];
|
||||
clips.extend(root.clip.iter().map(|(p, _)| root.transform * p.clone()));
|
||||
let items = walk(ids, &root.children, root.transform, &clips);
|
||||
if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items }
|
||||
}
|
||||
|
||||
/// The shape-layer Blend Mode index of an SVG / PDF blend mode.
|
||||
fn blend_index(b: effectcraft_svg::BlendMode) -> u32 {
|
||||
use effectcraft_color::BlendMode as P;
|
||||
use effectcraft_svg::BlendMode as S;
|
||||
let p = match b {
|
||||
S::Normal => P::Normal,
|
||||
S::Multiply => P::Multiply,
|
||||
S::Screen => P::Screen,
|
||||
S::Overlay => P::Overlay,
|
||||
S::Darken => P::Darken,
|
||||
S::Lighten => P::Lighten,
|
||||
S::ColorDodge => P::ColorDodge,
|
||||
S::ColorBurn => P::ColorBurn,
|
||||
S::HardLight => P::HardLight,
|
||||
S::SoftLight => P::SoftLight,
|
||||
S::Difference => P::Difference,
|
||||
S::Exclusion => P::Exclusion,
|
||||
S::Hue => P::Hue,
|
||||
S::Saturation => P::Saturation,
|
||||
S::Color => P::Color,
|
||||
S::Luminosity => P::Luminosity,
|
||||
};
|
||||
P::ALL.iter().position(|x| *x == p).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
/// Clips that enclose the whole document (on a chain of single-child groups from the root, as
|
||||
/// an Illustrator artboard clip or a PDF page clip), in document pixels, and the document with
|
||||
/// those clips removed: Create Shapes from Vector Layer makes them layer masks.
|
||||
pub fn vector_clip_masks(doc: &Doc) -> (Vec<BezPath>, Doc) {
|
||||
let mut out = doc.clone();
|
||||
let mut masks = vec![];
|
||||
let mut m = Affine::IDENTITY;
|
||||
let mut g = &mut out.root;
|
||||
loop {
|
||||
m *= g.transform;
|
||||
for (p, _) in g.clip.drain(..) {
|
||||
masks.push(m * p);
|
||||
}
|
||||
if g.children.len() != 1 {
|
||||
break;
|
||||
}
|
||||
match &mut g.children[0] {
|
||||
Node::Group(sub) if sub.mask.is_none() && sub.blend == effectcraft_svg::BlendMode::Normal => g = sub,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
(masks, out)
|
||||
}
|
||||
|
||||
/// Whether text (the system clipboard's, say) is SVG markup: Edit ▸ Paste makes it a shape
|
||||
/// layer (`edit.pasteSvg`).
|
||||
pub fn is_svg_text(text: &str) -> bool {
|
||||
effectcraft_svg::looks_like_svg(text.as_bytes())
|
||||
}
|
||||
|
||||
/// Whether footage is a vector file (SVG / PDF / AI / EPS).
|
||||
pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
|
||||
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
|
||||
}
|
||||
|
||||
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page
|
||||
/// `page` of a PDF, restricted to one layer when `layer` names it.
|
||||
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32) -> Option<Result<Doc, String>> {
|
||||
if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) {
|
||||
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
|
||||
}
|
||||
effectcraft_pdf::sniff(bytes)?;
|
||||
Some(
|
||||
effectcraft_pdf::parse_page(bytes, page as usize)
|
||||
.map(|d| match layer {
|
||||
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
|
||||
None => d,
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the
|
||||
/// page: one footage item per file layer (optionally cropped to its artwork, continuously
|
||||
/// rasterisable), top layer first. Returns (comp, folder, items).
|
||||
pub fn import_vector_comp(
|
||||
proj: &mut Project,
|
||||
path: &str,
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
page: u32,
|
||||
retain: bool,
|
||||
timing: (effectcraft_time::FrameRate, effectcraft_time::Tick),
|
||||
) -> Result<(ItemId, ItemId, Vec<ItemId>), String> {
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer};
|
||||
let (rate, duration) = timing;
|
||||
let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?;
|
||||
let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF");
|
||||
let (w, h) = doc.pixel_size();
|
||||
let file = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder);
|
||||
let mut comp = Comp::new(w, h, rate, duration);
|
||||
let mut items = vec![];
|
||||
let names = effectcraft_pdf::layer_names(&doc);
|
||||
for (i, lname) in names.iter().enumerate() {
|
||||
let (size, offset) = if retain {
|
||||
let (layer, offset) = effectcraft_pdf::layer_doc_with_sizes(&doc, i, true);
|
||||
(layer.pixel_size(), offset)
|
||||
} else {
|
||||
((w, h), [0.0, 0.0])
|
||||
};
|
||||
let f = Footage {
|
||||
path: path.to_string(),
|
||||
kind: FootageKind::Still,
|
||||
width: size.0,
|
||||
height: size.1,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: rate,
|
||||
has_video: true,
|
||||
alpha: AlphaMode::Straight,
|
||||
loop_count: 1,
|
||||
codec: codec.into(),
|
||||
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: retain, ..Default::default() }),
|
||||
page,
|
||||
..Default::default()
|
||||
};
|
||||
let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f));
|
||||
items.push(id);
|
||||
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, size, None);
|
||||
if retain && let Some(position) = l.props.prop_mut("transform/position") {
|
||||
position.value = Value::Vec3([offset[0] + f64::from(size.0) / 2.0, offset[1] + f64::from(size.1) / 2.0, 0.0]);
|
||||
}
|
||||
l.name = comp.unique_layer_name(lname);
|
||||
comp.layers.insert(0, l);
|
||||
}
|
||||
let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(std::sync::Arc::new(comp)));
|
||||
Ok((cid, folder, items))
|
||||
}
|
||||
|
||||
/// A copy of `src`'s transform group with fresh uids.
|
||||
fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) {
|
||||
let Some(tr) = src.props.sub("transform") else { return };
|
||||
let mut tr = tr.clone();
|
||||
let mut n = proj.next_id;
|
||||
tr.reassign_uids(&mut n);
|
||||
proj.next_id = n + 1;
|
||||
if let Some(slot) = dst.props.children.iter_mut().find(|c| c.match_id() == "transform") {
|
||||
*slot = tr.into();
|
||||
}
|
||||
}
|
||||
|
||||
fn like_source(dst: &mut Layer, src: &Layer) {
|
||||
dst.start_time = src.start_time;
|
||||
dst.in_point = src.in_point;
|
||||
dst.out_point = src.out_point;
|
||||
dst.stretch = src.stretch;
|
||||
dst.parent = src.parent;
|
||||
dst.switches.three_d = src.switches.three_d;
|
||||
}
|
||||
|
||||
/// A raster image of a vector document (Create Shapes from Vector Layer makes it a footage
|
||||
/// layer: shape layers have no images).
|
||||
pub struct VectorImage {
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Straight RGBA8.
|
||||
pub rgba: std::sync::Arc<Vec<u8>>,
|
||||
/// Image pixels → document pixels.
|
||||
pub transform: Affine,
|
||||
pub opacity: f64,
|
||||
/// Inside a clipping group or under a soft mask (not carried over).
|
||||
pub clipped: bool,
|
||||
}
|
||||
|
||||
/// The document's images, bottom first, and how many soft-masked groups it has.
|
||||
pub fn vector_images(doc: &Doc) -> (Vec<VectorImage>, usize) {
|
||||
fn walk(g: &effectcraft_svg::Group, m: Affine, opacity: f64, clipped: bool, out: &mut Vec<VectorImage>, masks: &mut usize) {
|
||||
let m = m * g.transform;
|
||||
let opacity = opacity * g.opacity;
|
||||
let clipped = clipped || !g.clip.is_empty() || g.mask.is_some();
|
||||
if g.mask.is_some() {
|
||||
*masks += 1;
|
||||
}
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Group(sub) => walk(sub, m, opacity, clipped, out, masks),
|
||||
Node::Image(im) => out.push(VectorImage {
|
||||
name: im.name.clone(),
|
||||
width: im.width,
|
||||
height: im.height,
|
||||
rgba: im.rgba.clone(),
|
||||
transform: m * im.transform,
|
||||
opacity: opacity * im.opacity,
|
||||
clipped,
|
||||
}),
|
||||
Node::Shape(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, doc) = vector_clip_masks(doc);
|
||||
let mut out = vec![];
|
||||
let mut masks = 0;
|
||||
walk(&doc.root, Affine::IDENTITY, 1.0, false, &mut out, &mut masks);
|
||||
(out, masks)
|
||||
}
|
||||
|
||||
/// The document split at its images (paint order): segment `k` keeps the shapes drawn between
|
||||
/// image `k − 1` and image `k` (groups, clips and blend modes kept, images dropped), so shape
|
||||
/// layers and image footage layers can stack in the document's order. One more segment than
|
||||
/// images.
|
||||
pub fn split_at_images(doc: &Doc) -> Vec<Doc> {
|
||||
fn count(g: &effectcraft_svg::Group) -> usize {
|
||||
g.children
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
Node::Group(s) => count(s),
|
||||
Node::Image(_) => 1,
|
||||
Node::Shape(_) => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
/// Keep the shapes between images `seg − 1` and `seg`; `seen` counts images passed.
|
||||
fn prune(g: &effectcraft_svg::Group, seg: usize, seen: &mut usize) -> effectcraft_svg::Group {
|
||||
let mut out = g.clone();
|
||||
out.children = vec![];
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Image(_) => *seen += 1,
|
||||
Node::Shape(_) => {
|
||||
if *seen == seg {
|
||||
out.children.push(c.clone());
|
||||
}
|
||||
}
|
||||
Node::Group(s) => {
|
||||
let p = prune(s, seg, seen);
|
||||
if !p.children.is_empty() {
|
||||
out.children.push(Node::Group(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let n = count(&doc.root);
|
||||
(0..=n)
|
||||
.map(|seg| {
|
||||
let mut d = doc.clone();
|
||||
d.root = prune(&doc.root, seg, &mut 0);
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A footage layer for an image of a vector document, parented to `parent` (the shape layer,
|
||||
/// whose layer space is document pixels) and placed by the image's transform (anchor at its
|
||||
/// top-left corner; position, rotation and scale from the matrix). Returns a note when the
|
||||
/// placement has a skew, which layer transforms cannot express.
|
||||
pub fn image_layer(proj: &mut Project, comp: &Comp, item: ItemId, img: &VectorImage, parent: &Layer) -> (Layer, Option<String>) {
|
||||
let mut l = build::layer(proj, comp, &img.name, LayerSource::Footage { item }, (img.width, img.height), None);
|
||||
let c = img.transform.as_coeffs();
|
||||
let theta = c[1].atan2(c[0]);
|
||||
let (sn, cs) = theta.sin_cos();
|
||||
let sx = (c[0] * c[0] + c[1] * c[1]).sqrt();
|
||||
let sy = -c[2] * sn + c[3] * cs;
|
||||
let skew = c[2] * cs + c[3] * sn;
|
||||
let set = |l: &mut Layer, path: &str, v: Value| {
|
||||
if let Some(p) = l.props.prop_mut(path) {
|
||||
p.value = v;
|
||||
}
|
||||
};
|
||||
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
|
||||
set(&mut l, "transform/position", Value::Vec3([c[4], c[5], 0.0]));
|
||||
set(&mut l, "transform/scale", Value::Vec3([sx * 100.0, sy * 100.0, 100.0]));
|
||||
set(&mut l, "transform/rotation", Value::Scalar(theta.to_degrees()));
|
||||
set(&mut l, "transform/opacity", Value::Scalar((img.opacity * 100.0).clamp(0.0, 100.0)));
|
||||
l.start_time = parent.start_time;
|
||||
l.in_point = parent.in_point;
|
||||
l.out_point = parent.out_point;
|
||||
l.stretch = parent.stretch;
|
||||
l.parent = Some(parent.id);
|
||||
let note = (skew.abs() > 1e-3 * sy.abs().max(1e-9)).then(|| format!("{}: skewed image placed without its skew", img.name));
|
||||
(l, note)
|
||||
}
|
||||
|
||||
/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`. A clip
|
||||
/// enclosing the whole document becomes the layer's masks (Add, then Intersect); nested clips
|
||||
/// are kept with Merge Paths (see [`svg_contents`]).
|
||||
pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Doc) -> Layer {
|
||||
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
|
||||
add_doc_shapes(proj, &mut l, doc);
|
||||
copy_transform(proj, src, &mut l);
|
||||
like_source(&mut l, src);
|
||||
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
|
||||
l
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste of SVG markup (After Effects 26.3 pastes copied vector artwork as shape layers):
|
||||
/// a shape layer named `name` with the document's shapes, its Anchor Point at the centre of the
|
||||
/// SVG canvas and its Position at the comp centre, so the canvas lands centred in the comp.
|
||||
pub fn shapes_from_svg(proj: &mut Project, comp: &Comp, doc: &Doc, name: &str) -> Layer {
|
||||
let mut l = build::layer(proj, comp, name, LayerSource::Shape, (0, 0), None);
|
||||
add_doc_shapes(proj, &mut l, doc);
|
||||
let (w, h) = (doc.width, doc.height);
|
||||
if w.is_finite()
|
||||
&& h.is_finite()
|
||||
&& let Some(a) = l.props.sub_mut("transform").and_then(|t| t.get_mut("anchor"))
|
||||
{
|
||||
a.value = Value::Vec3([w / 2.0, h / 2.0, 0.0]);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// The shapes of `doc` into shape layer `l`'s Contents; clips enclosing the whole document
|
||||
/// become its masks (Add, then Intersect).
|
||||
fn add_doc_shapes(proj: &mut Project, l: &mut Layer, doc: &Doc) {
|
||||
let (clips, doc) = vector_clip_masks(doc);
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
let items = svg_contents(&mut ids, &doc);
|
||||
if let Some(c) = l.props.sub_mut("contents") {
|
||||
c.children.extend(items.into_iter().map(Into::into));
|
||||
}
|
||||
let mut n = 0;
|
||||
for (k, clip) in clips.iter().enumerate() {
|
||||
for sp in effectcraft_path::from_kurbo(clip) {
|
||||
n += 1;
|
||||
let mode = if k == 0 { MaskMode::Add } else { MaskMode::Intersect };
|
||||
let mk = build::mask(&mut ids, &format!("Mask {n}"), sp, mode, [255, 255, 0]);
|
||||
if let Some(masks) = l.props.sub_mut("masks") {
|
||||
masks.children.push(mk.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer ▸ Create ▸ Create Shapes from Text: one group per character with its outline, fill and
|
||||
/// stroke (as laid out at the comp time `ctx` was made for).
|
||||
pub fn shapes_from_text(proj: &mut Project, comp: &Comp, ctx_project: &Project, cid: ItemId, src: &Layer, time: effectcraft_time::Tick) -> Option<Layer> {
|
||||
let ctx = effectcraft_render::EvalCtx::new(ctx_project, cid, comp, time);
|
||||
let geom = effectcraft_render::text::text_geom(&ctx, src)?;
|
||||
let chars: Vec<char> = geom.doc.text.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
|
||||
let mut groups = vec![];
|
||||
{
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
for (i, g) in geom.glyphs.iter().enumerate() {
|
||||
let path = g.path();
|
||||
if path.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut items = shape_paths(&mut ids, &path);
|
||||
if g.stroke_width > 0.0 && g.stroke[3] > 0.0 {
|
||||
let c = g.stroke;
|
||||
items.push(build::shape_stroke(&mut ids, [c[0] as f64, c[1] as f64, c[2] as f64, 1.0], g.stroke_width));
|
||||
}
|
||||
if g.apply_fill {
|
||||
let c = g.fill;
|
||||
let mut f = build::shape_fill(&mut ids, [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
|
||||
set(&mut f, "opacity", Value::Scalar(c[3] as f64 * 100.0));
|
||||
items.push(f);
|
||||
}
|
||||
let name = chars.get(i).map(|c| c.to_string()).unwrap_or_else(|| format!("Glyph {}", i + 1));
|
||||
groups.push(group(&mut ids, &name, items, 1.0));
|
||||
}
|
||||
}
|
||||
// First character on top, as in After Effects.
|
||||
if let Some(c) = l.props.sub_mut("contents") {
|
||||
c.children.extend(groups.into_iter().map(Into::into));
|
||||
}
|
||||
copy_transform(proj, src, &mut l);
|
||||
like_source(&mut l, src);
|
||||
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
|
||||
Some(l)
|
||||
}
|
||||
|
||||
/// Glyph outlines of a text layer in comp space (for Create Masks from Text).
|
||||
pub fn text_outlines_in_comp(project: &Project, cid: ItemId, comp: &Comp, src: &Layer, time: effectcraft_time::Tick) -> Vec<ShapePath> {
|
||||
let ctx = effectcraft_render::EvalCtx::new(project, cid, comp, time);
|
||||
let Some(geom) = effectcraft_render::text::text_geom(&ctx, src) else { return vec![] };
|
||||
let (m, _) = ctx.layer_to_comp(src);
|
||||
let mut out = vec![];
|
||||
for g in &geom.glyphs {
|
||||
let p = g.path();
|
||||
let p = effectcraft_path::transform(std::slice::from_ref(&p), &m).remove(0);
|
||||
out.extend(effectcraft_path::from_kurbo(&p));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A comp-sized white solid with one mask per glyph contour (Difference, so counters stay open).
|
||||
pub fn masks_layer(proj: &mut Project, comp: &Comp, name: &str, outlines: Vec<ShapePath>, solid: ItemId) -> Layer {
|
||||
let mut l = build::layer(proj, comp, name, LayerSource::Solid { item: solid }, (comp.width, comp.height), None);
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
if let Some(masks) = l.props.sub_mut("masks") {
|
||||
for (i, sp) in outlines.into_iter().enumerate() {
|
||||
masks.children.push(build::mask(&mut ids, &format!("Mask {}", i + 1), sp, MaskMode::Difference, [255, 255, 0]).into());
|
||||
}
|
||||
}
|
||||
l
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! File ▸ Save a Copy As XML…: the project as an XML document (`.ecprojx`), the counterpart of
|
||||
//! After Effects' `.aepx`. It is a lossless, element-per-value encoding of the `.ecproj` JSON, so
|
||||
//! File ▸ Open reads it back (and saving to a `.ecprojx` path writes XML again):
|
||||
//!
|
||||
//! ```xml
|
||||
//! <?xml version="1.0" encoding="UTF-8"?>
|
||||
//! <EffectCraftProject encoding="json" version="1">
|
||||
//! <obj>
|
||||
//! <m n="version"><num>3</num></m>
|
||||
//! <m n="items"><arr>…</arr></m>
|
||||
//! </obj>
|
||||
//! </EffectCraftProject>
|
||||
//! ```
|
||||
//!
|
||||
//! `obj`/`m n="key"` (object members), `arr`, `str`, `num`, `bool`, `null`.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
const ROOT: &str = "EffectCraftProject";
|
||||
|
||||
/// How deep `arr`/`obj` elements may nest: the JSON reader's own limit (`serde_json` stops at
|
||||
/// 128), so every project the JSON form can hold reads from XML too. Without a limit a deeply
|
||||
/// nested file overflowed the stack and aborted the app instead of failing to open (#779).
|
||||
const MAX_DEPTH: usize = 128;
|
||||
|
||||
/// Whether `text` looks like an XML project.
|
||||
pub fn is_xml(text: &str) -> bool {
|
||||
let t = text.trim_start_matches('\u{feff}').trim_start();
|
||||
t.starts_with("<?xml") || t.starts_with(&format!("<{ROOT}"))
|
||||
}
|
||||
|
||||
/// Whether a path names an XML project copy.
|
||||
pub fn is_xml_path(path: &str) -> bool {
|
||||
let p = path.to_ascii_lowercase();
|
||||
p.ends_with(".ecprojx") || p.ends_with(".xml")
|
||||
}
|
||||
|
||||
fn esc(s: &str, out: &mut String) {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\r' => out.push_str(" "),
|
||||
c if (c as u32) < 0x20 && c != '\n' && c != '\t' => out.push_str(&format!("&#{};", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(v: &Value, depth: usize, out: &mut String) {
|
||||
let pad = " ".repeat(depth);
|
||||
out.push_str(&pad);
|
||||
match v {
|
||||
Value::Null => out.push_str("<null/>"),
|
||||
Value::Bool(b) => out.push_str(&format!("<bool>{b}</bool>")),
|
||||
Value::Number(n) => out.push_str(&format!("<num>{n}</num>")),
|
||||
Value::String(s) => {
|
||||
out.push_str("<str>");
|
||||
esc(s, out);
|
||||
out.push_str("</str>");
|
||||
}
|
||||
Value::Array(a) if a.is_empty() => out.push_str("<arr/>"),
|
||||
Value::Array(a) => {
|
||||
out.push_str("<arr>\n");
|
||||
for x in a {
|
||||
write(x, depth + 1, out);
|
||||
}
|
||||
out.push_str(&pad);
|
||||
out.push_str("</arr>");
|
||||
}
|
||||
Value::Object(m) if m.is_empty() => out.push_str("<obj/>"),
|
||||
Value::Object(m) => {
|
||||
out.push_str("<obj>\n");
|
||||
for (k, x) in m {
|
||||
out.push_str(&" ".repeat(depth + 1));
|
||||
out.push_str("<m n=\"");
|
||||
esc(k, out);
|
||||
out.push_str("\">\n");
|
||||
write(x, depth + 2, out);
|
||||
out.push_str(&" ".repeat(depth + 1));
|
||||
out.push_str("</m>\n");
|
||||
}
|
||||
out.push_str(&pad);
|
||||
out.push_str("</obj>");
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
/// The project JSON as an XML document.
|
||||
pub fn to_xml(json: &str) -> Result<String, String> {
|
||||
let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
|
||||
let mut out = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<{ROOT} encoding=\"json\" version=\"1\">\n");
|
||||
write(&v, 1, &mut out);
|
||||
out.push_str(&format!("</{ROOT}>\n"));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
struct P<'a> {
|
||||
s: &'a str,
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> P<'a> {
|
||||
fn err<T>(&self, m: &str) -> Result<T, String> {
|
||||
Err(format!("XML project: {m} at byte {}", self.i))
|
||||
}
|
||||
fn rest(&self) -> &'a str {
|
||||
&self.s[self.i..]
|
||||
}
|
||||
/// Skip whitespace, comments and processing instructions.
|
||||
fn skip(&mut self) {
|
||||
loop {
|
||||
let t = self.rest();
|
||||
let trimmed = t.trim_start();
|
||||
self.i += t.len() - trimmed.len();
|
||||
if trimmed.starts_with("<?") {
|
||||
self.i += trimmed.find("?>").map_or(trimmed.len(), |e| e + 2);
|
||||
} else if trimmed.starts_with("<!--") {
|
||||
self.i += trimmed.find("-->").map_or(trimmed.len(), |e| e + 3);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// An opening tag: (name, attribute `n`, self-closing).
|
||||
fn open(&mut self) -> Result<(String, Option<String>, bool), String> {
|
||||
self.skip();
|
||||
if !self.rest().starts_with('<') || self.rest().starts_with("</") {
|
||||
return self.err("expected an element");
|
||||
}
|
||||
let end = self.rest().find('>').ok_or("XML project: unterminated tag")?;
|
||||
let inner = &self.rest()[1..end];
|
||||
self.i += end + 1;
|
||||
let (inner, closed) = match inner.strip_suffix('/') {
|
||||
Some(x) => (x, true),
|
||||
None => (inner, false),
|
||||
};
|
||||
let mut parts = inner.splitn(2, char::is_whitespace);
|
||||
let name = parts.next().unwrap_or_default().to_string();
|
||||
let attrs = parts.next().unwrap_or("");
|
||||
let n = attrs.find("n=\"").map(|a| {
|
||||
let v = &attrs[a + 3..];
|
||||
unesc(&v[..v.find('"').unwrap_or(v.len())])
|
||||
});
|
||||
Ok((name, n, closed))
|
||||
}
|
||||
fn close(&mut self, name: &str) -> Result<(), String> {
|
||||
self.skip();
|
||||
let want = format!("</{name}>");
|
||||
if self.rest().starts_with(&want) {
|
||||
self.i += want.len();
|
||||
Ok(())
|
||||
} else {
|
||||
self.err(&format!("expected {want}"))
|
||||
}
|
||||
}
|
||||
fn text(&mut self) -> String {
|
||||
let end = self.rest().find('<').unwrap_or(self.rest().len());
|
||||
let t = unesc(&self.rest()[..end]);
|
||||
self.i += end;
|
||||
t
|
||||
}
|
||||
fn value(&mut self, depth: usize) -> Result<Value, String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return self.err(&format!("elements nested deeper than {MAX_DEPTH} levels"));
|
||||
}
|
||||
let (name, _, closed) = self.open()?;
|
||||
let v = match (name.as_str(), closed) {
|
||||
("null", _) => Value::Null,
|
||||
("arr", true) => Value::Array(vec![]),
|
||||
("obj", true) => Value::Object(Map::new()),
|
||||
("str", true) => Value::String(String::new()),
|
||||
("bool", false) => Value::Bool(self.text().trim() == "true"),
|
||||
("num", false) => serde_json::from_str(self.text().trim()).map_err(|e| format!("XML project: bad number: {e}"))?,
|
||||
("str", false) => Value::String(self.text()),
|
||||
("arr", false) => {
|
||||
let mut a = vec![];
|
||||
loop {
|
||||
self.skip();
|
||||
if self.rest().starts_with("</") {
|
||||
break;
|
||||
}
|
||||
a.push(self.value(depth + 1)?);
|
||||
}
|
||||
Value::Array(a)
|
||||
}
|
||||
("obj", false) => {
|
||||
let mut m = Map::new();
|
||||
loop {
|
||||
self.skip();
|
||||
if self.rest().starts_with("</") {
|
||||
break;
|
||||
}
|
||||
let (tag, key, _) = self.open()?;
|
||||
if tag != "m" {
|
||||
return self.err("expected <m n=\"…\">");
|
||||
}
|
||||
let v = self.value(depth + 1)?;
|
||||
self.close("m")?;
|
||||
m.insert(key.unwrap_or_default(), v);
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
(other, _) => return self.err(&format!("unexpected <{other}>")),
|
||||
};
|
||||
if !closed && name != "null" {
|
||||
self.close(&name)?;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
fn unesc(s: &str) -> String {
|
||||
if !s.contains('&') {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(a) = rest.find('&') {
|
||||
out.push_str(&rest[..a]);
|
||||
rest = &rest[a..];
|
||||
let Some(e) = rest.find(';') else { break };
|
||||
let ent = &rest[1..e];
|
||||
match ent {
|
||||
"amp" => out.push('&'),
|
||||
"lt" => out.push('<'),
|
||||
"gt" => out.push('>'),
|
||||
"quot" => out.push('"'),
|
||||
"apos" => out.push('\''),
|
||||
n if n.starts_with("#x") => out.extend(u32::from_str_radix(&n[2..], 16).ok().and_then(char::from_u32)),
|
||||
n if n.starts_with('#') => out.extend(n[1..].parse::<u32>().ok().and_then(char::from_u32)),
|
||||
other => {
|
||||
out.push('&');
|
||||
out.push_str(other);
|
||||
out.push(';');
|
||||
}
|
||||
}
|
||||
rest = &rest[e + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The project JSON of an XML project document.
|
||||
pub fn from_xml(text: &str) -> Result<String, String> {
|
||||
let mut p = P { s: text.trim_start_matches('\u{feff}'), i: 0 };
|
||||
let (root, _, _) = p.open()?;
|
||||
if root != ROOT {
|
||||
return Err(format!("not an EffectCraft XML project (root <{root}>)"));
|
||||
}
|
||||
let v = p.value(0)?;
|
||||
p.close(ROOT)?;
|
||||
serde_json::to_string(&v).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn json_round_trips_through_xml() {
|
||||
let j = serde_json::json!({"a": [1, 2.5, -3e-7, true, null, "x<y & \"z\"\n\tq"], "b": {}, "c": [], "d": "", "é": {"n": "ünï"}});
|
||||
let xml = to_xml(&j.to_string()).unwrap();
|
||||
assert!(is_xml(&xml));
|
||||
assert!(xml.contains("<m n=\"a\">"));
|
||||
let back: Value = serde_json::from_str(&from_xml(&xml).unwrap()).unwrap();
|
||||
assert_eq!(back, j);
|
||||
assert!(from_xml("<Other/>").is_err());
|
||||
}
|
||||
|
||||
/// #779 (found by the project-file fuzzer): a file of deeply nested elements overflowed the
|
||||
/// stack, which aborts the app; it is now refused. Nesting the JSON reader takes still reads.
|
||||
#[test]
|
||||
fn deep_nesting_is_refused_not_a_stack_overflow() {
|
||||
let nested = |n: usize| format!("<{ROOT}>{}<null/>{}</{ROOT}>", "<arr>".repeat(n), "</arr>".repeat(n));
|
||||
let e = from_xml(&nested(1_000_000)).unwrap_err();
|
||||
assert!(e.contains("nested deeper"), "{e}");
|
||||
let ok = from_xml(&nested(100)).unwrap();
|
||||
assert!(serde_json::from_str::<Value>(&ok).is_ok());
|
||||
let json = format!("{}null{}", "[".repeat(MAX_DEPTH - 1), "]".repeat(MAX_DEPTH - 1));
|
||||
let v: Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&from_xml(&to_xml(&json).unwrap()).unwrap()).unwrap(), v);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user