Merge remote-tracking branch 'origin/main' into enhance/keyframe-text-clipboard

This commit is contained in:
tylerjenningsw
2026-10-27 14:13:38 -04:00
570 changed files with 47083 additions and 44164 deletions
@@ -0,0 +1,348 @@
//! Copied keyframes as After Effects keyframe text, for the system clipboard (#1908). Other
//! applications read this tab-separated format (AE2Blend brings it into Blender). Like After
//! Effects it covers one layer: its copied properties, keyframed ones as one row per key (frame
//! number in comp time, then the value) and static ones as a single row with no frame. Easing
//! and tangents aren't part of the format; EffectCraft's own paste keeps them.
use effectcraft_project::{AutoOrient, Comp, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid};
use crate::{KeyRef, keyframe_text_effects};
/// Transform properties with the unit their columns are labelled with and, for vectors, the
/// third component a 2D layer shows. X / Y / Z Position (Separate Dimensions) have no unit.
const TRANSFORM: &[(&str, &str, Option<f64>)] = &[
("poi", "pixels", Some(0.0)),
("anchor", "pixels", Some(0.0)),
("position", "pixels", Some(0.0)),
("positionX", "", None),
("positionY", "", None),
("positionZ", "", None),
("scale", "percent", Some(100.0)),
("orientation", "degrees", None),
("rotationX", "degrees", None),
("rotationY", "degrees", None),
("rotation", "degrees", None),
("opacity", "percent", None),
];
/// How a copied property is written: its block's header line, column labels and values.
struct Channel {
header: String,
labels: Vec<String>,
values: Values,
}
/// How a property's value becomes the numbers in a row.
#[derive(Clone, Copy)]
enum Values {
Plain,
/// A 2D layer's Transform vector: the third component is the one the layer shows (it may
/// keep another from pasted 3D keys).
Flat(f64),
/// RGBA 0–1 as alpha, red, green, blue 0–255.
Argb,
/// A popup's 0-based choice as After Effects' 1-based one.
OneBased,
Scale(f64),
}
impl Values {
fn row(self, mut v: Vec<f64>) -> Vec<f64> {
match (self, v.as_slice()) {
(Values::Flat(z), _) => {
if let Some(c) = v.get_mut(2) {
*c = z;
}
v
}
(Values::Argb, [r, g, b, a]) => [a, r, g, b].map(|c| c * 255.0).to_vec(),
(Values::OneBased, _) => v.into_iter().map(|c| c + 1.0).collect(),
(Values::Scale(s), _) => v.into_iter().map(|c| c * s).collect(),
_ => v,
}
}
}
/// The text for copied keys (layer times) and unanimated properties. `None` unless they all come
/// from one layer and at least one property can be written.
pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(LayerId, Uid)]) -> Option<String> {
let lid = keys.first().map(|k| k.layer).or_else(|| statics.first().map(|s| s.0))?;
if keys.iter().any(|k| k.layer != lid) || statics.iter().any(|s| s.0 != lid) {
return None;
}
let layer = comp.layer(lid)?;
let fps = comp.frame_rate.as_f64();
let mut blocks = String::new();
layer.props.walk("", &mut |_, pr| {
let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect();
times.sort();
times.dedup();
let rows: Vec<(String, Vec<f64>)> = if !times.is_empty() {
let frame = |t| num(layer.comp_time(t).seconds() * fps);
times.into_iter().filter_map(|t| Some((frame(t), pr.keys.iter().find(|k| k.time == t)?.value.components()))).collect()
} else if statics.iter().any(|s| s.1 == pr.uid) {
vec![(String::new(), pr.value.components())]
} else {
return;
};
let Some(ch) = channel(layer, pr).filter(|ch| !ch.labels.is_empty()) else { return };
if rows.iter().any(|(_, v)| v.is_empty()) {
return;
}
blocks.push_str(&format!("{}\n\tFrame\t{}\t\n", ch.header, ch.labels.join("\t")));
for (frame, values) in rows {
let values: Vec<String> = ch.values.row(values).into_iter().map(num).collect();
blocks.push_str(&format!("\t{frame}\t{}\t\n", values.join("\t")));
}
blocks.push('\n');
});
if blocks.is_empty() {
return None;
}
// After Effects writes a text layer's source as 1 × 1. Other layers without a source of
// their own (shapes, cameras) take the comp's size, and all of them its pixel aspect.
let (w, h) = match (&layer.source, effectcraft_render::eval::source_size(project, layer)) {
(LayerSource::Text, _) => (1, 1),
(_, (0, 0)) => (comp.width, comp.height),
(_, size) => size,
};
let par = match layer.source {
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match project.item(item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => f.pixel_aspect,
Some(ItemKind::Solid(s)) => s.pixel_aspect,
Some(ItemKind::Comp(c)) => c.pixel_aspect,
_ => comp.pixel_aspect,
},
_ => comp.pixel_aspect,
};
// The layout of current After Effects: a `Layer` line before the layer's properties (parsers
// such as ae-keyframes reject properties without one), and a tab after every column.
Some(format!(
"Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}\nEnd of Keyframe Data\n",
num(fps),
num(par),
num(comp.pixel_aspect)
))
}
/// How the layer's property `pr` is written, by where it sits in the layer: `None` for
/// properties the text doesn't carry.
fn channel(layer: &Layer, pr: &Property) -> Option<Channel> {
let chain = layer.props.node_chain(pr.uid)?;
match chain.first().and_then(|n| n.as_group()).map(|g| g.match_id.as_str()) {
None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }),
Some("transform") => transform(layer, pr),
Some("effects") => effect(&chain, pr),
Some("masks" | "text" | "contents" | "cameraOptions" | "lightOptions" | "materialOptions") => nested(&chain, pr),
_ => None,
}
}
/// Indexed groups by match id, with the kind After Effects names them by: it writes them by kind
/// and place (`Mask #2`, `Group #1`), not by name.
const INSTANCES: &[(&str, &str)] = &[
("mask", "Mask"),
("animator", "Animator"),
("rangeSelector", "Range Selector"),
("wigglySelector", "Wiggly Selector"),
("expressionSelector", "Expression Selector"),
("group", "Group"),
("rect", "Rectangle Path"),
("ellipse", "Ellipse Path"),
("star", "Polystar Path"),
("path", "Path"),
("fill", "Fill"),
("stroke", "Stroke"),
("gfill", "Gradient Fill"),
("gstroke", "Gradient Stroke"),
("trim", "Trim Paths"),
("repeater", "Repeater"),
("round", "Round Corners"),
("offset", "Offset Paths"),
("pucker", "Pucker & Bloat"),
("twist", "Twist"),
("zigzag", "Zig Zag"),
("wiggle", "Wiggle Paths"),
("merge", "Merge Paths"),
];
/// A property under Masks, Text, Contents or Camera / Light / Material Options: the group's
/// name, then each group down to the property. Instances go by kind and place, and a text
/// selector's own properties carry their place too (`Start #1`).
fn nested(chain: &[&Node], pr: &Property) -> Option<Channel> {
let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect();
let parent = groups.last()?;
let (labels, values) = columns(&parent.match_id, &pr.match_id, pr, false)?;
let mut header = groups.first()?.name.clone();
for pair in groups.windows(2) {
let [outer, g] = pair else { continue };
match INSTANCES.iter().find(|(id, _)| *id == g.match_id).filter(|_| g.is_instance()) {
Some((_, kind)) => header.push_str(&format!("\t{kind} #{}", place(outer, g.uid)?)),
None => header.push_str(&format!("\t{}", g.name)),
}
}
if parent.match_id.ends_with("Selector") {
header.push_str(&format!("\t{} #{}", pr.name, place(parent, pr.uid)?));
} else {
header.push_str(&format!("\t{}", pr.name));
}
Some(Channel { header, labels, values })
}
/// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer,
/// then the property's name and After Effects index. Properties in twirl-down groups are
/// written without their group, as After Effects does.
fn effect(chain: &[&Node], pr: &Property) -> Option<Channel> {
let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None };
let GroupKind::Effect { effect } = &fx.kind else { return None };
let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name);
let path: String = chain.iter().skip(2).filter_map(|n| n.as_group()).map(|g| format!("{}/", g.match_id)).chain([pr.match_id.clone()]).collect();
let (labels, values) = columns(effect, &path, pr, true)?;
let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(effect, fx, pr.uid)?);
Some(Channel { header, labels, values })
}
/// After Effects' index of an effect property: the effect's hidden input layer is #1, and a
/// twirl-down group takes an index where it opens and another where it closes. The hidden
/// source choice EffectCraft keeps beside a layer parameter has none. Where After Effects
/// numbers a parameter otherwise ([`keyframe_text_effects::INDICES`]), counting goes on from it.
fn effect_index(effect: &str, fx: &PropGroup, uid: Uid) -> Option<usize> {
fn count(effect: &str, g: &PropGroup, prefix: &str, uid: Uid, n: &mut usize) -> bool {
for c in &g.children {
match c {
Node::Prop(p) if is_layer_source(g, p) => {}
Node::Prop(p) => {
let path = format!("{prefix}{}", p.match_id);
*n = keyframe_text_effects::INDICES.iter().find(|(e, q, _)| *e == effect && *q == path).map_or(*n + 1, |a| a.2);
if p.uid == uid {
return true;
}
}
Node::Group(sub) => {
*n += 1;
if count(effect, sub, &format!("{prefix}{}/", sub.match_id), uid, n) {
return true;
}
*n += 1;
}
}
}
false
}
let mut n = 1;
count(effect, fx, "", uid, &mut n).then_some(n)
}
fn is_layer_source(g: &PropGroup, p: &Property) -> bool {
p.ui == ParamUi::Hidden && g.props().any(|l| l.ui == ParamUi::Layer && effectcraft_effects::layer_source_id(&l.match_id) == p.match_id)
}
/// 1-based position of a child in its group.
fn place(group: &PropGroup, uid: Uid) -> Option<usize> {
group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1)
}
/// How After Effects writes a property differently from what its editor suggests ([`by_ui`]).
#[derive(Clone, Copy)]
enum Exception {
/// Not written.
Skip,
Unit(&'static str),
Values(Values),
}
/// Properties After Effects writes differently from what their editor suggests, by the effect
/// or group they're directly in and their match id.
const EXCEPTIONS: &[(&str, &str, Exception)] = &[
// Path Options' Path picks a mask, which After Effects doesn't write.
("pathOptions", "path", Exception::Skip),
("mask", "feather", Exception::Unit("pixels")),
("mask", "expansion", Exception::Unit("pixels")),
("cameraOptions", "zoom", Exception::Unit("pixels")),
("cameraOptions", "focusDistance", Exception::Unit("pixels")),
("cameraOptions", "aperture", Exception::Unit("pixels")),
("cameraOptions", "highlightGain", Exception::Unit("")),
("cameraOptions", "highlightSaturation", Exception::Unit("")),
// After Effects keeps the threshold as 0–1.
("cameraOptions", "highlightThreshold", Exception::Values(Values::Scale(1.0 / 255.0))),
("lightOptions", "shadowDiffusion", Exception::Unit("pixels")),
// Off, On, Only: written from 0.
("materialOptions", "castsShadows", Exception::Values(Values::Plain)),
("materialOptions", "acceptsShadows", Exception::Values(Values::Plain)),
];
/// Column labels and values for a property in `scope`, the effect (by id) or group (by match
/// id) it's directly in, at `path` there (an effect property's groups, then its match id): [`by_ui`]
/// unless [`EXCEPTIONS`] or, in effects, [`keyframe_text_effects::UNITS`] say otherwise.
fn columns(scope: &str, path: &str, pr: &Property, in_effect: bool) -> Option<(Vec<String>, Values)> {
let (by_editor, values) = by_ui(pr, in_effect)?;
let exception = EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == path).map(|e| e.2);
let effect_unit = || keyframe_text_effects::UNITS.iter().find(|(e, p, _)| in_effect && *e == scope && *p == path).map(|u| Exception::Unit(u.2));
Some(match exception.or_else(effect_unit) {
None => (by_editor, values),
Some(Exception::Skip) => return None,
Some(Exception::Unit(unit)) => (labels(unit, pr.value.dims()), values),
Some(Exception::Values(values)) => (by_editor, values),
})
}
/// Column labels and values for a property, by how it's edited. After Effects labels most 0–100
/// sliders (opacities) as percent, and ±100 ones too outside effects (text selector amounts),
/// and other sliders, checkboxes and popups with no unit. [`keyframe_text_effects::UNITS`] lists
/// the effect parameters After Effects labels otherwise.
fn by_ui(pr: &Property, in_effect: bool) -> Option<(Vec<String>, Values)> {
let dims = pr.value.dims();
Some(match &pr.ui {
ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb),
ParamUi::Popup { .. } => (labels("", dims), Values::OneBased),
ParamUi::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || (*min == -100.0 && !in_effect)) => (labels("percent", dims), Values::Plain),
ParamUi::Percent => (labels("percent", dims), Values::Plain),
ParamUi::Angle => (labels("degrees", dims), Values::Plain),
ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain),
ParamUi::Number | ParamUi::Slider { .. } | ParamUi::Pixels | ParamUi::Checkbox => (labels("", dims), Values::Plain),
_ => return None,
})
}
fn transform(layer: &Layer, pr: &Property) -> Option<Channel> {
let (id, unit, z) = TRANSFORM.iter().find(|(id, ..)| *id == pr.match_id)?;
if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) {
return None;
}
// After Effects labels only the first of Orientation's three columns, and names Z Rotation
// plain Rotation (as AE2Blend expects).
let labels = if *id == "orientation" { vec![format!("X {unit}")] } else { labels(unit, pr.value.dims()) };
let values = match z {
Some(z) if !layer.is_3d() => Values::Flat(*z),
_ => Values::Plain,
};
Some(Channel { header: format!("Transform\t{}", pr.name), labels, values })
}
/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`; `X ` with no unit, as After
/// Effects writes a shape's Size).
fn labels(unit: &str, dims: usize) -> Vec<String> {
match dims {
0 => vec![],
1 => vec![unit.to_string()],
_ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(),
}
}
/// Whether the layer uses this Transform property: one-node cameras (and lights not aimed at
/// it) have no Point of Interest, and Separate Dimensions replaces Position with X / Y / Z
/// Position.
fn active(layer: &Layer, id: &str) -> bool {
match id {
"poi" => !(layer.is_camera() || layer.is_light()) || layer.auto_orient == AutoOrient::TowardsPointOfInterest,
"position" => layer.transform().is_none_or(|tr| tr.get("positionX").is_none()),
_ => true,
}
}
/// A number with at most three decimals and no trailing zeros (`23.976`, `960`, `-0.5`).
fn num(v: f64) -> String {
let s = format!("{:.3}", if v.is_finite() { v } else { 0.0 });
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
@@ -0,0 +1,386 @@
//! After Effects' index and unit for effect parameters where they differ from what
//! [`crate::keyframe_text`]'s rules give, as After Effects 2019 writes them in its keyframe text.
//! After Effects keeps an effect's parameter indices for good (projects store them), so they
//! hold for later versions. Parameters After Effects lacks, or names differently and keeps
//! elsewhere, aren't listed.
/// Effect id, parameter path in the effect (its groups' match ids, then its own) and After
/// Effects' index for it; the parameters after it count on from there. After Effects numbers
/// legacy parameters it hides, so its indices skip ahead.
pub(crate) const INDICES: &[(&str, &str, usize)] = &[
("ec.3d.channelextract", "clampOutput", 6),
("ec.3d.channelextract", "antialias", 5),
("ec.3d.extractor", "blackPoint", 4),
("ec.audio.parametriceq", "band1Enable", 3),
("ec.blur.radial", "amount", 3),
("ec.color.cccolorneutralizer", "midtonesUnbalance", 8),
("ec.color.cccolorneutralizer", "highlightsUnbalance", 14),
("ec.color.cccolorneutralizer", "pinning", 20),
("ec.color.cckernel", "k1", 3),
("ec.color.cckernel", "k4", 8),
("ec.color.cckernel", "k7", 13),
("ec.color.cckernel", "scale", 17),
("ec.color.cckernel", "absolute", 18),
("ec.color.colorstabilizer", "stabilize", 2),
("ec.color.huesaturation", "hue", 4),
("ec.color.huesaturation", "colorize", 7),
("ec.color.levels", "inBlack", 4),
("ec.color.levelsic", "rgb/rgbInBlack", 5),
("ec.color.lumetri", "highDynamicRange", 3),
("ec.color.lumetri", "basicCorrection/inputLut", 8),
("ec.color.lumetri", "basicCorrection/whiteBalance/temperature", 12),
("ec.color.lumetri", "basicCorrection/tone/hdrSpecular", 22),
("ec.color.lumetri", "basicCorrection/saturation", 26),
("ec.color.lumetri", "creative/look", 32),
("ec.color.lumetri", "creative/lookIntensity", 33),
("ec.color.lumetri", "creative/adjustments/tintBalance", 40),
("ec.color.lumetri", "hslSecondary/key/showMask", 83),
("ec.color.lumetri", "hslSecondary/refine/denoise", 90),
("ec.color.lumetri", "hslSecondary/correction/temperature", 96),
("ec.color.lumetri", "vignette/vignetteAmount", 105),
("ec.color.selectivecolor", "details/reds/redsCyan", 10),
("ec.color.videolimiter", "compressionBeforeClipping", 3),
("ec.distort.ccflomotion", "knot1", 3),
("ec.distort.ccpowerpin", "expandTop", 9),
("ec.distort.upscale", "scale", 4),
("ec.distort.puppet", "refinement", 3),
("ec.control.stereo3d", "configuration", 3),
("ec.control.stereo3d", "convergenceOf", 8),
("ec.generate.advancedlightning", "turbulence", 17),
("ec.generate.advancedlightning", "alphaObstacle", 16),
("ec.generate.advancedlightning", "compositeOnOriginal", 21),
("ec.generate.ccgluegun", "style", 9),
("ec.generate.cclightburst", "setColor", 7),
("ec.generate.cclightrays", "colorFromSource", 8),
("ec.generate.cclightrays", "color", 10),
("ec.generate.circle", "feather/feather", 7),
("ec.generate.paintbucket", "color", 9),
("ec.vr.blur", "frameLayout", 3),
("ec.vr.blur", "blurriness", 6),
("ec.vr.chromaticaberrations", "frameLayout", 3),
("ec.vr.chromaticaberrations", "aberrationRed", 7),
("ec.vr.colorgradients", "frameLayout", 3),
("ec.vr.colorgradients", "point1", 10),
("ec.vr.colorgradients", "point2", 12),
("ec.vr.colorgradients", "point3", 14),
("ec.vr.colorgradients", "point4", 16),
("ec.vr.colorgradients", "point5", 18),
("ec.vr.colorgradients", "blend", 7),
("ec.vr.colorgradients", "opacity", 27),
("ec.vr.denoise", "frameLayout", 3),
("ec.vr.denoise", "noiseType", 6),
("ec.vr.digitalglitch", "frameLayout", 3),
("ec.vr.digitalglitch", "masterAmplitude", 9),
("ec.vr.digitalglitch", "distortionRate", 16),
("ec.vr.digitalglitch", "distortionComplexity", 15),
("ec.vr.digitalglitch", "distortionEvolution", 17),
("ec.vr.digitalglitch", "colorDistortion", 11),
("ec.vr.digitalglitch", "randomSeed", 39),
("ec.vr.fractalnoise", "frameLayout", 3),
("ec.vr.fractalnoise", "fractalType", 6),
("ec.vr.fractalnoise", "randomSeed", 25),
("ec.vr.glow", "frameLayout", 3),
("ec.vr.glow", "luminanceThreshold", 6),
("ec.vr.planetosphere", "frameLayout", 3),
("ec.vr.planetosphere", "scale", 6),
("ec.vr.planetosphere", "feather", 8),
("ec.vr.planetosphere", "tilt", 15),
("ec.vr.rotatesphere", "frameLayout", 3),
("ec.vr.rotatesphere", "invertRotation", 9),
("ec.vr.rotatesphere", "tilt", 6),
("ec.vr.sharpen", "frameLayout", 3),
("ec.vr.sharpen", "sharpenAmount", 6),
("ec.key.colordifference", "view", 3),
("ec.key.colorrange", "fuzziness", 3),
("ec.key.extract", "channel", 3),
("ec.key.linearcolor", "view", 3),
("ec.matte.refinehard", "feather", 2),
("ec.matte.refinehard", "reduceChatter", 5),
("ec.matte.refinehard", "decontaminationAmount", 14),
("ec.matte.refinesoft", "reduceChatter", 10),
("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", 3),
("ec.matte.rotobrush", "rotoBrushMatte/feather", 13),
("ec.matte.rotobrush", "refineEdgeMatte/smooth", 22),
("ec.matte.rotobrush", "refineEdgeMatte/reduceChatter", 27),
("ec.matte.rotobrush", "refineEdgeMatte/useMotionBlur", 29),
("ec.noise.noisealpha", "noiseOptions/cycleNoise", 8),
("ec.obsolete.basictext", "position", 2),
("ec.key.spill", "suppression", 3),
("ec.perspective.cameratracker", "shotType", 4),
("ec.perspective.cameratracker", "advanced/solveMethod", 12),
("ec.perspective.cameratracker", "advanced/detailedAnalysis", 17),
("ec.perspective.cameratracker", "advanced/autoDeletePoints", 18),
("ec.perspective.cccylinder", "rotationX", 9),
("ec.perspective.cccylinder", "render", 13),
("ec.perspective.cccylinder", "lightIntensity", 15),
("ec.perspective.cccylinder", "ambient", 21),
("ec.sim.ccdrizzle", "light/lightIntensity", 10),
("ec.sim.ccdrizzle", "light/lightHeight", 13),
("ec.sim.ccdrizzle", "light/lightDirection", 15),
("ec.sim.ccmrmercury", "blobBirthSize", 15),
("ec.sim.ccmrmercury", "light/lightIntensity", 19),
("ec.sim.ccmrmercury", "light/lightHeight", 22),
("ec.sim.ccmrmercury", "light/lightDirection", 24),
("ec.sim.ccparticlesystems2", "particle/maxOpacity", 24),
("ec.sim.ccparticlesystems2", "particle/birthColor", 27),
("ec.sim.ccrainfall", "transferMode", 16),
("ec.sim.ccrainfall", "extras/randomSeed", 23),
("ec.sim.ccsnowfall", "color", 19),
("ec.sim.ccsnowfall", "transferMode", 26),
("ec.sim.ccsnowfall", "extras/randomSeed", 32),
("ec.stylize.ccblockload", "startCleared", 4),
("ec.stylize.cchextile", "rotate", 6),
("ec.stylize.glow", "colorLooping", 9),
("ec.text.timecode", "timeUnits", 5),
("ec.text.timecode", "textPosition", 9),
("ec.utility.facemeasurements", "headOrientationX", 9),
("ec.utility.facemeasurements", "rightEyebrowRaise", 20),
("ec.utility.facemeasurements", "mouthOffsetX", 26),
("ec.utility.facetrackpoints", "leftEyebrowInner", 10),
("ec.utility.facetrackpoints", "leftEyebrowMiddle", 12),
("ec.utility.facetrackpoints", "leftEyebrowOuter", 14),
("ec.utility.facetrackpoints", "rightEyebrowInner", 24),
("ec.utility.facetrackpoints", "rightEyebrowMiddle", 26),
("ec.utility.facetrackpoints", "rightEyebrowOuter", 28),
("ec.utility.facetrackpoints", "leftEyeInner", 15),
("ec.utility.facetrackpoints", "leftEyeOuter", 21),
("ec.utility.facetrackpoints", "leftPupil", 20),
("ec.utility.facetrackpoints", "rightEyeInner", 29),
("ec.utility.facetrackpoints", "rightEyeOuter", 35),
("ec.utility.facetrackpoints", "rightPupil", 34),
("ec.utility.facetrackpoints", "noseTip", 41),
("ec.utility.facetrackpoints", "leftNostril", 44),
("ec.utility.facetrackpoints", "rightNostril", 46),
("ec.utility.facetrackpoints", "mouthLeft", 49),
("ec.utility.facetrackpoints", "mouthRight", 55),
("ec.utility.facetrackpoints", "mouthTop", 52),
("ec.utility.facetrackpoints", "mouthBottom", 58),
("ec.utility.facetrackpoints", "chin", 84),
];
/// Effect id, parameter path and the unit After Effects labels the parameter's columns with.
pub(crate) const UNITS: &[(&str, &str, &str)] = &[
("ec.3d.depthoffield", "focalBias", ""),
("ec.3d.idmatte", "feather", ""),
("ec.audio.flangechorus", "voiceSeparationTime", ""),
("ec.audio.flangechorus", "voicePhaseChange", ""),
("ec.audio.modulator", "modulationRate", ""),
("ec.audio.parametriceq", "band1BoostCut", "percent"),
("ec.audio.parametriceq", "band2BoostCut", "percent"),
("ec.audio.parametriceq", "band3BoostCut", "percent"),
("ec.audio.stereomixer", "leftLevel", "percent"),
("ec.audio.stereomixer", "rightLevel", "percent"),
("ec.audio.stereomixer", "leftPan", "percent"),
("ec.audio.stereomixer", "rightPan", "percent"),
("ec.blur.bilateral", "threshold", ""),
("ec.blur.ccradial", "amount", "pixels"),
("ec.blur.ccradialfast", "amount", ""),
("ec.blur.camerashakedeblur", "strength", "percent"),
("ec.blur.radial", "amount", ""),
("ec.blur.smart", "radius", ""),
("ec.blur.smart", "threshold", ""),
("ec.color.autocolor", "blackClip", "percent"),
("ec.color.autocolor", "whiteClip", "percent"),
("ec.color.autocontrast", "blackClip", "percent"),
("ec.color.autocontrast", "whiteClip", "percent"),
("ec.color.autolevels", "blackClip", "percent"),
("ec.color.autolevels", "whiteClip", "percent"),
("ec.color.brightnesscontrast", "contrast", "pixels"),
("ec.color.cccolorneutralizer", "pinning", ""),
("ec.color.cccolorneutralizer", "blendWOriginal", ""),
("ec.color.changecolor", "lightnessTransform", "pixels"),
("ec.color.changecolor", "saturationTransform", "pixels"),
("ec.color.colorbalance", "shadowRed", "pixels"),
("ec.color.colorbalance", "shadowGreen", "pixels"),
("ec.color.colorbalance", "shadowBlue", "pixels"),
("ec.color.colorbalance", "midRed", "pixels"),
("ec.color.colorbalance", "midGreen", "pixels"),
("ec.color.colorbalance", "midBlue", "pixels"),
("ec.color.colorbalance", "hiRed", "pixels"),
("ec.color.colorbalance", "hiGreen", "pixels"),
("ec.color.colorbalance", "hiBlue", "pixels"),
("ec.color.colorbalancehls", "lightness", "pixels"),
("ec.color.colorbalancehls", "saturation", "pixels"),
("ec.color.colorstabilizer", "sampleSize", "percent"),
("ec.color.gammapedestalgain", "blackStretch", "percent"),
("ec.color.huesaturation", "saturation", "pixels"),
("ec.color.huesaturation", "lightness", "pixels"),
("ec.color.huesaturation", "colorizeSaturation", ""),
("ec.color.huesaturation", "colorizeLightness", "pixels"),
("ec.color.lumetri", "creative/adjustments/fadedFilm", ""),
("ec.color.lumetri", "hslSecondary/refine/denoise", ""),
("ec.color.lumetri", "hslSecondary/refine/blur", ""),
("ec.color.lumetri", "vignette/vignetteMidpoint", ""),
("ec.color.lumetri", "vignette/vignetteFeather", ""),
("ec.color.psarbitrarymap", "phase", "degrees"),
("ec.color.selectivecolor", "details/reds/redsCyan", "percent"),
("ec.color.selectivecolor", "details/reds/redsMagenta", "percent"),
("ec.color.selectivecolor", "details/reds/redsYellow", "percent"),
("ec.color.selectivecolor", "details/reds/redsBlack", "percent"),
("ec.color.selectivecolor", "details/yellows/yellowsCyan", "percent"),
("ec.color.selectivecolor", "details/yellows/yellowsMagenta", "percent"),
("ec.color.selectivecolor", "details/yellows/yellowsYellow", "percent"),
("ec.color.selectivecolor", "details/yellows/yellowsBlack", "percent"),
("ec.color.selectivecolor", "details/greens/greensCyan", "percent"),
("ec.color.selectivecolor", "details/greens/greensMagenta", "percent"),
("ec.color.selectivecolor", "details/greens/greensYellow", "percent"),
("ec.color.selectivecolor", "details/greens/greensBlack", "percent"),
("ec.color.selectivecolor", "details/cyans/cyansCyan", "percent"),
("ec.color.selectivecolor", "details/cyans/cyansMagenta", "percent"),
("ec.color.selectivecolor", "details/cyans/cyansYellow", "percent"),
("ec.color.selectivecolor", "details/cyans/cyansBlack", "percent"),
("ec.color.selectivecolor", "details/blues/bluesCyan", "percent"),
("ec.color.selectivecolor", "details/blues/bluesMagenta", "percent"),
("ec.color.selectivecolor", "details/blues/bluesYellow", "percent"),
("ec.color.selectivecolor", "details/blues/bluesBlack", "percent"),
("ec.color.selectivecolor", "details/magentas/magentasCyan", "percent"),
("ec.color.selectivecolor", "details/magentas/magentasMagenta", "percent"),
("ec.color.selectivecolor", "details/magentas/magentasYellow", "percent"),
("ec.color.selectivecolor", "details/magentas/magentasBlack", "percent"),
("ec.color.selectivecolor", "details/whites/whitesCyan", "percent"),
("ec.color.selectivecolor", "details/whites/whitesMagenta", "percent"),
("ec.color.selectivecolor", "details/whites/whitesYellow", "percent"),
("ec.color.selectivecolor", "details/whites/whitesBlack", "percent"),
("ec.color.selectivecolor", "details/neutrals/neutralsCyan", "percent"),
("ec.color.selectivecolor", "details/neutrals/neutralsMagenta", "percent"),
("ec.color.selectivecolor", "details/neutrals/neutralsYellow", "percent"),
("ec.color.selectivecolor", "details/neutrals/neutralsBlack", "percent"),
("ec.color.selectivecolor", "details/blacks/blacksCyan", "percent"),
("ec.color.selectivecolor", "details/blacks/blacksMagenta", "percent"),
("ec.color.selectivecolor", "details/blacks/blacksYellow", "percent"),
("ec.color.selectivecolor", "details/blacks/blacksBlack", "percent"),
("ec.color.shadowhighlight", "shadowAmount", ""),
("ec.color.shadowhighlight", "highlightAmount", ""),
("ec.color.shadowhighlight", "moreOptions/shadowTonalWidth", ""),
("ec.color.shadowhighlight", "moreOptions/highlightTonalWidth", ""),
("ec.color.shadowhighlight", "moreOptions/blackClip", "percent"),
("ec.color.shadowhighlight", "moreOptions/whiteClip", "percent"),
("ec.distort.bulge", "height", "pixels"),
("ec.distort.bulge", "taperRadius", ""),
("ec.distort.ccbendit", "bend", "pixels"),
("ec.distort.ccbender", "amount", "pixels"),
("ec.distort.ccgriddler", "tileSize", "percent"),
("ec.distort.cctiler", "scale", "percent"),
("ec.distort.upscale", "scale", "percent"),
("ec.distort.magnify", "magnification", "pixels"),
("ec.distort.magnify", "size", "percent"),
("ec.distort.puppet", "refinement", ""),
("ec.distort.ripple", "radius", ""),
("ec.distort.ripple", "speed", "percent"),
("ec.distort.transform", "opacity", ""),
("ec.distort.turbulentdisplace", "complexity", "percent"),
("ec.distort.twirl", "radius", ""),
("ec.distort.wavewarp", "width", "percent"),
("ec.distort.wavewarp", "speed", "pixels"),
("ec.generate.fourcolor", "blend", "percent"),
("ec.generate.advancedlightning", "coreSettings/coreRadius", ""),
("ec.generate.advancedlightning", "glowSettings/glowRadius", "percent"),
("ec.generate.advancedlightning", "alphaObstacle", "pixels"),
("ec.generate.ccgluegun", "reflection", ""),
("ec.generate.cclightburst", "rayLength", ""),
("ec.generate.cclightsweep", "edgeThickness", ""),
("ec.generate.ccthreads", "coverage", ""),
("ec.generate.ccthreads", "shadowing", ""),
("ec.generate.ccthreads", "texture", ""),
("ec.generate.checkerboard", "width", "percent"),
("ec.generate.checkerboard", "height", "percent"),
("ec.generate.grid", "width", "percent"),
("ec.generate.grid", "height", "percent"),
("ec.generate.lensflare", "flareBrightness", "percent"),
("ec.vr.chromaticaberrations", "falloff", ""),
("ec.vr.denoise", "noiseLevel", ""),
("ec.vr.digitalglitch", "colorDistortion", ""),
("ec.vr.fractalnoise", "subSettings/subInfluence", ""),
("ec.vr.glow", "luminanceThreshold", ""),
("ec.vr.planetosphere", "feather", ""),
("ec.vr.sharpen", "sharpenAmount", ""),
("ec.key.advancedspill", "suppression", "percent"),
("ec.key.advancedspill", "ultraSettings/tolerance", ""),
("ec.key.advancedspill", "ultraSettings/desaturate", ""),
("ec.key.advancedspill", "ultraSettings/spillRange", ""),
("ec.key.advancedspill", "ultraSettings/spillColorCorrection", ""),
("ec.key.advancedspill", "ultraSettings/lumaCorrection", ""),
("ec.key.ccsimplewireremoval", "thickness", ""),
("ec.key.keycleaner", "additionalEdgeRadius", ""),
("ec.key.keycleaner", "alphaContrast", "percent"),
("ec.matte.refinehard", "feather", ""),
("ec.matte.refinehard", "increaseDecontaminationRadius", ""),
("ec.matte.refinesoft", "smooth", ""),
("ec.matte.refinesoft", "feather", ""),
("ec.matte.refinesoft", "shiftEdge", "percent"),
("ec.matte.refinesoft", "decontamination/increaseDecontaminationRadius", ""),
("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", ""),
("ec.matte.rotobrush", "rotoBrushMatte/feather", ""),
("ec.matte.rotobrush", "rotoBrushMatte/shiftEdge", "percent"),
("ec.matte.rotobrush", "refineEdgeMatte/smooth", ""),
("ec.matte.rotobrush", "refineEdgeMatte/feather", ""),
("ec.matte.rotobrush", "refineEdgeMatte/shiftEdge", "percent"),
("ec.matte.rotobrush", "refineEdgeMatte/decontamination/increaseDecontaminationRadius", ""),
("ec.matte.simplechoker", "chokeMatte", "pixels"),
("ec.noise.fractal", "transform/scale", "percent"),
("ec.noise.fractal", "transform/scaleWidth", "percent"),
("ec.noise.fractal", "transform/scaleHeight", "percent"),
("ec.noise.fractal", "complexity", "percent"),
("ec.noise.fractal", "subSettings/subInfluence", ""),
("ec.noise.turbulent", "transform/scale", "percent"),
("ec.noise.turbulent", "transform/scaleWidth", "percent"),
("ec.noise.turbulent", "transform/scaleHeight", "percent"),
("ec.noise.turbulent", "complexity", "percent"),
("ec.noise.turbulent", "subSettings/subInfluence", ""),
("ec.key.colorkey", "edgeFeather", ""),
("ec.obsolete.lightning", "branchAngle", ""),
("ec.key.luma", "edgeFeather", ""),
("ec.blur.reduceflicker", "softness", ""),
("ec.perspective.cameratracker", "trackPointSize", "percent"),
("ec.perspective.cameratracker", "targetSize", "percent"),
("ec.perspective.cccylinder", "lightHeight", "pixels"),
("ec.sim.ccbubbles", "bubbleSize", "percent"),
("ec.sim.ccdrizzle", "displacement", ""),
("ec.sim.ccdrizzle", "light/lightHeight", "pixels"),
("ec.sim.ccmrmercury", "longevity", ""),
("ec.sim.ccmrmercury", "blobInfluence", "percent"),
("ec.sim.ccmrmercury", "light/lightHeight", "pixels"),
("ec.sim.ccmrmercury", "shading/metal", ""),
("ec.sim.ccparticlesystems2", "birthRate", ""),
("ec.sim.ccparticlesystems2", "longevity", ""),
("ec.sim.ccparticlesystems2", "physics/gravity", "pixels"),
("ec.sim.ccrainfall", "windVariation", ""),
("ec.sim.ccrainfall", "opacity", ""),
("ec.sim.ccsnowfall", "sizeVariation", ""),
("ec.sim.ccsnowfall", "speedVariation", ""),
("ec.sim.ccsnowfall", "windVariation", ""),
("ec.sim.ccsnowfall", "wiggle/wiggleAmount", ""),
("ec.sim.ccsnowfall", "wiggle/wiggleAmountVariation", ""),
("ec.sim.ccsnowfall", "wiggle/wiggleFrequencyVariation", ""),
("ec.sim.ccsnowfall", "opacity", ""),
("ec.sim.ccstarburst", "speed", "percent"),
("ec.sim.ccstarburst", "size", "pixels"),
("ec.stylize.ccblockload", "completion", ""),
("ec.stylize.ccburnfilm", "burn", ""),
("ec.stylize.cchextile", "smearing", ""),
("ec.stylize.cckaleida", "size", "percent"),
("ec.stylize.ccvignette", "pinHighlights", ""),
("ec.stylize.cartoon", "detailThreshold", ""),
("ec.stylize.cartoon", "fill/shadingSmoothness", ""),
("ec.stylize.cartoon", "edge/edgeSoftness", ""),
("ec.stylize.cartoon", "advanced/edgeBlackLevel", ""),
("ec.stylize.glow", "colorLoops", "percent"),
("ec.stylize.roughenedges", "stretch", "pixels"),
("ec.time.pixelmotionblur", "vectorDetail", ""),
("ec.transition.blockdissolve", "blockWidth", "percent"),
("ec.transition.blockdissolve", "blockHeight", "percent"),
("ec.transition.ccgridwipe", "border", "pixels"),
("ec.transition.ccgridwipe", "tiles", "percent"),
("ec.transition.cclinesweep", "completion", ""),
("ec.transition.ccscalewipe", "stretch", "pixels"),
("ec.transition.venetian", "width", "percent"),
("ec.transition.venetian", "feather", ""),
("ec.utility.cineon", "highlightRolloff", ""),
("ec.utility.facemeasurements", "headOrientationX", "degrees"),
("ec.utility.facemeasurements", "headOrientationY", "degrees"),
("ec.utility.facemeasurements", "headOrientationZ", "degrees"),
("ec.utility.facemeasurements", "leftEyebrowRaise", "percent"),
("ec.utility.facemeasurements", "rightEyebrowRaise", "percent"),
("ec.utility.facemeasurements", "mouthOffsetX", "percent"),
("ec.utility.facemeasurements", "mouthOffsetY", "percent"),
];
+795
View File
@@ -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:?}"),
}
}
}
+599
View File
@@ -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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\r' => out.push_str("&#13;"),
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);
}
}