mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 13:37:37 -06:00
Merge remote-tracking branch 'origin/main' into enhance/keyframe-text-clipboard
This commit is contained in:
@@ -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"),
|
||||
];
|
||||
@@ -0,0 +1,795 @@
|
||||
//! Photoshop import as a composition (File ▸ Import ▸ File… with Import As: Composition or
|
||||
//! Composition – Retain Layer Sizes), and Layer ▸ Create ▸ Convert to Editable Text.
|
||||
//!
|
||||
//! Every pixel layer becomes a footage item that names its layer (`Footage::layer`), placed in
|
||||
//! a composition the size of the document. Groups become precomps (pass-through groups collapse
|
||||
//! so their blend modes act on the layers below, like Photoshop); type layers become editable
|
||||
//! text layers; layer masks are baked into the layer's alpha and vector masks become masks;
|
||||
//! layer effects map to Layer Styles; adjustment layers become adjustment layers with the
|
||||
//! matching effect (best effort); clipped layers preserve the transparency underneath. Smart
|
||||
//! objects whose file is embedded become footage of that file (an embedded Photoshop document's
|
||||
//! merged image, or the embedded image) placed with the smart object's transform.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::BlendMode;
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Gradient, Justify, ShapePath, TextDoc, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::styles::{self, STYLE_BLEND_MODES};
|
||||
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, Project, PropGroup, Solid, SourceLayer};
|
||||
use effectcraft_psd::{Adjustment, DValue, Descriptor, Node, Psd};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
/// Photoshop blend mode key → compositor mode (`pass` = pass through, handled by collapsing).
|
||||
pub fn blend_mode(key: &str) -> BlendMode {
|
||||
match key {
|
||||
"diss" => BlendMode::Dissolve,
|
||||
"dark" => BlendMode::Darken,
|
||||
"mul " => BlendMode::Multiply,
|
||||
"idiv" => BlendMode::ColorBurn,
|
||||
"lbrn" => BlendMode::LinearBurn,
|
||||
"dkCl" => BlendMode::DarkerColor,
|
||||
"lite" => BlendMode::Lighten,
|
||||
"scrn" => BlendMode::Screen,
|
||||
"div " => BlendMode::ColorDodge,
|
||||
"lddg" => BlendMode::LinearDodge,
|
||||
"lgCl" => BlendMode::LighterColor,
|
||||
"over" => BlendMode::Overlay,
|
||||
"sLit" => BlendMode::SoftLight,
|
||||
"hLit" => BlendMode::HardLight,
|
||||
"vLit" => BlendMode::VividLight,
|
||||
"lLit" => BlendMode::LinearLight,
|
||||
"pLit" => BlendMode::PinLight,
|
||||
"hMix" => BlendMode::HardMix,
|
||||
"diff" => BlendMode::Difference,
|
||||
"smud" => BlendMode::Exclusion,
|
||||
"fsub" => BlendMode::Subtract,
|
||||
"fdiv" => BlendMode::Divide,
|
||||
"hue " => BlendMode::Hue,
|
||||
"sat " => BlendMode::Saturation,
|
||||
"colr" => BlendMode::Color,
|
||||
"lum " => BlendMode::Luminosity,
|
||||
_ => BlendMode::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Descriptor blend mode enum (`BlnM`) → compositor mode.
|
||||
fn desc_blend(v: Option<&str>) -> Option<BlendMode> {
|
||||
Some(match v? {
|
||||
"Nrml" => BlendMode::Normal,
|
||||
"Dslv" => BlendMode::Dissolve,
|
||||
"Drkn" => BlendMode::Darken,
|
||||
"Mltp" => BlendMode::Multiply,
|
||||
"CBrn" => BlendMode::ColorBurn,
|
||||
"linearBurn" => BlendMode::LinearBurn,
|
||||
"darkerColor" => BlendMode::DarkerColor,
|
||||
"Lghn" => BlendMode::Lighten,
|
||||
"Scrn" => BlendMode::Screen,
|
||||
"CDdg" => BlendMode::ColorDodge,
|
||||
"linearDodge" => BlendMode::LinearDodge,
|
||||
"lighterColor" => BlendMode::LighterColor,
|
||||
"Ovrl" => BlendMode::Overlay,
|
||||
"SftL" => BlendMode::SoftLight,
|
||||
"HrdL" => BlendMode::HardLight,
|
||||
"vividLight" => BlendMode::VividLight,
|
||||
"linearLight" => BlendMode::LinearLight,
|
||||
"pinLight" => BlendMode::PinLight,
|
||||
"hardMix" => BlendMode::HardMix,
|
||||
"Dfrn" => BlendMode::Difference,
|
||||
"Xclu" => BlendMode::Exclusion,
|
||||
"blendSubtraction" => BlendMode::Subtract,
|
||||
"blendDivide" => BlendMode::Divide,
|
||||
"H " => BlendMode::Hue,
|
||||
"Strt" => BlendMode::Saturation,
|
||||
"Clr " => BlendMode::Color,
|
||||
"Lmns" => BlendMode::Luminosity,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// What the import created.
|
||||
pub struct PsdImport {
|
||||
pub comp: ItemId,
|
||||
pub folder: ItemId,
|
||||
pub items: Vec<ItemId>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
struct Ctx<'a> {
|
||||
psd: &'a Psd,
|
||||
path: &'a str,
|
||||
retain: bool,
|
||||
folder: ItemId,
|
||||
rate: FrameRate,
|
||||
duration: Tick,
|
||||
items: Vec<ItemId>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Import `psd` (read from `path`) as a composition named `name`.
|
||||
pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: bool, rate: FrameRate, duration: Tick) -> PsdImport {
|
||||
let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder);
|
||||
let mut cx = Ctx { psd, path, retain, folder, rate, duration, items: vec![], warnings: vec![] };
|
||||
cx.warnings.extend(psd.warnings.iter().cloned());
|
||||
let (tree, flattened) = psd.tree_report();
|
||||
if flattened > 0 {
|
||||
cx.warnings.push(format!("{flattened} group(s) nested deeper than {} levels were flattened into their parent", effectcraft_psd::MAX_GROUP_DEPTH));
|
||||
}
|
||||
let comp =
|
||||
if psd.layers.is_empty() && !psd.warnings.is_empty() { flattened_comp(proj, &mut cx, name) } else { build_comp(proj, &mut cx, name, &tree, None) };
|
||||
PsdImport { comp, folder, items: cx.items, warnings: cx.warnings }
|
||||
}
|
||||
|
||||
/// The layers could not be read: the merged image as the composition's only layer.
|
||||
fn flattened_comp(proj: &mut Project, cx: &mut Ctx, name: &str) -> ItemId {
|
||||
let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(Arc::new(new_comp(cx))));
|
||||
cx.items.push(cid);
|
||||
let mut comp = new_comp(cx);
|
||||
let file = std::path::Path::new(cx.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let f = Footage {
|
||||
path: cx.path.to_string(),
|
||||
kind: FootageKind::Still,
|
||||
width: cx.psd.width,
|
||||
height: cx.psd.height,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: cx.rate,
|
||||
has_video: true,
|
||||
alpha: if cx.psd.merged_alpha { AlphaMode::Straight } else { AlphaMode::Ignore },
|
||||
loop_count: 1,
|
||||
codec: "PSD".into(),
|
||||
layer: None,
|
||||
..Default::default()
|
||||
};
|
||||
let id = proj.add_item(&file, Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
|
||||
cx.items.push(id);
|
||||
let l = build::layer(proj, &comp, name, LayerSource::Footage { item: id }, (cx.psd.width, cx.psd.height), None);
|
||||
comp.layers.push(l);
|
||||
if let Some(item) = proj.item_mut(cid) {
|
||||
item.kind = ItemKind::Comp(Arc::new(comp));
|
||||
}
|
||||
cid
|
||||
}
|
||||
|
||||
fn new_comp(cx: &Ctx) -> Comp {
|
||||
// A PSB can be 300,000 px a side; a composition is at most 30,000 (#788).
|
||||
let size = |v: u32| effectcraft_project::limits::size(v.into());
|
||||
Comp::new(size(cx.psd.width), size(cx.psd.height), cx.rate, cx.duration)
|
||||
}
|
||||
|
||||
fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option<ItemId>) -> ItemId {
|
||||
let cid = proj.add_item(name, Label::Sandstone, parent, ItemKind::Comp(Arc::new(new_comp(cx))));
|
||||
cx.items.push(cid);
|
||||
let mut comp = new_comp(cx);
|
||||
for n in nodes {
|
||||
if let Some(l) = build_layer(proj, cx, &comp, n) {
|
||||
comp.layers.push(l);
|
||||
}
|
||||
}
|
||||
styles::sync_comp(&mut comp);
|
||||
if let Some(item) = proj.item_mut(cid) {
|
||||
item.kind = ItemKind::Comp(Arc::new(comp));
|
||||
}
|
||||
cid
|
||||
}
|
||||
|
||||
fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (ItemId, (u32, u32)) {
|
||||
let l = &cx.psd.layers[index];
|
||||
let retain = cx.retain;
|
||||
let (w, h) = if retain && !l.rect.is_empty() { (l.rect.width(), l.rect.height()) } else { (cx.psd.width, cx.psd.height) };
|
||||
let file = std::path::Path::new(cx.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let f = Footage {
|
||||
path: cx.path.to_string(),
|
||||
kind: FootageKind::Still,
|
||||
width: w,
|
||||
height: h,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: cx.rate,
|
||||
native_rate: None,
|
||||
duration: Tick::ZERO,
|
||||
has_video: true,
|
||||
has_audio: false,
|
||||
alpha: AlphaMode::Straight,
|
||||
premul_color: [0.0; 3],
|
||||
loop_count: 1,
|
||||
codec: "PSD".into(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None, placed: false }),
|
||||
..Default::default()
|
||||
};
|
||||
let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
|
||||
cx.items.push(id);
|
||||
(id, (w, h))
|
||||
}
|
||||
|
||||
/// Size of an embedded file's image: the smart object's recorded size, else the embedded
|
||||
/// document's header (Photoshop, PNG).
|
||||
fn embedded_size(so: &effectcraft_psd::SmartObject, data: &[u8]) -> Option<(u32, u32)> {
|
||||
if let Some([w, h]) = so.size.filter(|s| s[0] >= 1.0 && s[1] >= 1.0) {
|
||||
return Some((w.round() as u32, h.round() as u32));
|
||||
}
|
||||
if effectcraft_psd::is_psd(data) && data.len() >= 22 {
|
||||
let h = u32::from_be_bytes([data[14], data[15], data[16], data[17]]);
|
||||
let w = u32::from_be_bytes([data[18], data[19], data[20], data[21]]);
|
||||
return Some((w, h));
|
||||
}
|
||||
if data.starts_with(b"\x89PNG") && data.len() >= 24 {
|
||||
let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
|
||||
let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
|
||||
return Some((w, h));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A smart object layer as footage of its embedded file, placed by the smart object's
|
||||
/// transform. `None` when the file is not embedded (linked externally) or unreadable.
|
||||
fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usize, name: &str) -> Option<Layer> {
|
||||
let pl = &cx.psd.layers[index];
|
||||
let so = pl.smart_object.clone()?;
|
||||
let data = cx.psd.linked_data(&so.uuid)?;
|
||||
let (cw, ch) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?;
|
||||
let file = cx.psd.linked.iter().find(|f| f.uuid == so.uuid).map(|f| f.name.clone()).unwrap_or_default();
|
||||
// Perspective quads and warps are baked over the placed bounds, at up to 4× the document
|
||||
// resolution when the embedded content has that much detail.
|
||||
let bake = so.needs_bake();
|
||||
let (w, h, k, bounds) = if bake {
|
||||
let bb = so.placed_bounds(cw as f64, ch as f64);
|
||||
let area = ((bb[2] - bb[0]) * (bb[3] - bb[1])).max(1.0);
|
||||
let k = ((cw as f64 * ch as f64) / area).sqrt().clamp(1.0, 4.0);
|
||||
// At most 4× the document across and 2^24 pixels, however far the file moves a corner.
|
||||
let (k, (w, h)) = so.bake_scale(cw as f64, ch as f64, k, (cx.psd.width, cx.psd.height));
|
||||
(w, h, k, bb)
|
||||
} else {
|
||||
(cw, ch, 1.0, [0.0; 4])
|
||||
};
|
||||
let f = Footage {
|
||||
path: cx.path.to_string(),
|
||||
kind: FootageKind::Still,
|
||||
width: w,
|
||||
height: h,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: cx.rate,
|
||||
has_video: true,
|
||||
alpha: AlphaMode::Straight,
|
||||
loop_count: 1,
|
||||
codec: if effectcraft_psd::is_psd(data) { "PSD".into() } else { "PSD (embedded image)".into() },
|
||||
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()), placed: bake }),
|
||||
..Default::default()
|
||||
};
|
||||
let label = if file.is_empty() { name.to_string() } else { file };
|
||||
let id = proj.add_item(&format!("{label} (Smart Object)"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
|
||||
cx.items.push(id);
|
||||
let mut l = build::layer(proj, comp, name, LayerSource::Footage { item: id }, (w, h), None);
|
||||
if bake {
|
||||
// The baked pixels cover the placed bounds: centred there, scaled back to 1×.
|
||||
let c = [bounds[0] + w as f64 / k / 2.0, bounds[1] + h as f64 / k / 2.0];
|
||||
set_vec2(&mut l, "transform/position", c);
|
||||
set_vec2(&mut l, "transform/scale", [100.0 / k, 100.0 / k]);
|
||||
return Some(l);
|
||||
}
|
||||
let (c, sx, sy, rot) = so.placement(w as f64, h as f64);
|
||||
set_vec2(&mut l, "transform/position", c);
|
||||
set_vec2(&mut l, "transform/scale", [sx * 100.0, sy * 100.0]);
|
||||
if rot.abs() > 1e-9 {
|
||||
set_scalar(&mut l, "transform/rotation", rot);
|
||||
}
|
||||
Some(l)
|
||||
}
|
||||
|
||||
fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Option<Layer> {
|
||||
let (index, children) = match n {
|
||||
Node::Layer(i) => (*i, None),
|
||||
Node::Group { layer, children } => (*layer, Some(children)),
|
||||
};
|
||||
let pl = cx.psd.layers[index].clone();
|
||||
let name = if pl.name.is_empty() { format!("Layer {}", index + 1) } else { pl.name.clone() };
|
||||
let mut layer = if let Some(children) = children {
|
||||
let sub = build_comp(proj, cx, &name, children, Some(cx.folder));
|
||||
let mut l = build::layer(proj, comp, &name, LayerSource::Comp { item: sub }, (comp.width, comp.height), None);
|
||||
// Pass-through groups composite their layers straight into the parent.
|
||||
l.switches.collapse = pl.blend_key() == "pass";
|
||||
l
|
||||
} else if let Some(t) = &pl.text {
|
||||
text_layer(proj, comp, &name, t)
|
||||
} else if let Some(adj) = &pl.adjustment {
|
||||
let mut l = adjustment_layer(proj, comp, &name);
|
||||
add_adjustment_effect(proj, &mut l, adj, comp, &mut cx.warnings, &name);
|
||||
l
|
||||
} else if let Some(l) = pl.smart_object.as_ref().and_then(|_| smart_object_layer(proj, cx, comp, index, &name)) {
|
||||
l
|
||||
} else if pl.has_pixels() || pl.fill.is_some() {
|
||||
let (item, size) = footage_item(proj, cx, index, &name);
|
||||
let mut l = build::layer(proj, comp, &name, LayerSource::Footage { item }, size, None);
|
||||
if cx.retain && !pl.rect.is_empty() {
|
||||
set_vec2(&mut l, "transform/position", [pl.rect.left as f64 + size.0 as f64 / 2.0, pl.rect.top as f64 + size.1 as f64 / 2.0]);
|
||||
}
|
||||
l
|
||||
} else {
|
||||
// Empty layers have nothing to show.
|
||||
return None;
|
||||
};
|
||||
layer.name = comp.unique_layer_name(&name);
|
||||
layer.blend_mode = blend_mode(pl.blend_key());
|
||||
layer.switches.video = !pl.hidden;
|
||||
layer.preserve_transparency = pl.clipping;
|
||||
set_scalar(&mut layer, "transform/opacity", pl.opacity as f64 / 255.0 * 100.0);
|
||||
// Vector mask → a mask in layer space.
|
||||
let origin = layer_origin(&pl, cx.retain, &layer);
|
||||
if let Some(vm) = pl.vector_mask.as_ref().filter(|v| !v.disabled)
|
||||
&& let Some(masks) = layer.props.sub_mut("masks")
|
||||
{
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
for (k, sp) in vm.subpaths.iter().enumerate() {
|
||||
let n = sp.knots.len();
|
||||
let mut path = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: sp.closed, feather: Vec::new() };
|
||||
for kn in &sp.knots {
|
||||
let v = [kn[1][0] - origin[0], kn[1][1] - origin[1]];
|
||||
path.vertices.push(v);
|
||||
path.in_tangents.push([kn[0][0] - kn[1][0], kn[0][1] - kn[1][1]]);
|
||||
path.out_tangents.push([kn[2][0] - kn[1][0], kn[2][1] - kn[1][1]]);
|
||||
}
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let mode = if vm.inverted { MaskMode::Subtract } else { MaskMode::Add };
|
||||
masks.children.push(build::mask(&mut ids, &format!("Mask {}", k + 1), path, mode, [255, 255, 0]).into());
|
||||
}
|
||||
}
|
||||
// Layer effects → Layer Styles; Fill Opacity → Advanced Blending.
|
||||
let gl = comp.global_light.clone();
|
||||
if let Some(fx) = &pl.effects
|
||||
&& fx.bool("masterFXSwitch") != Some(false)
|
||||
&& layer.can_have_styles()
|
||||
{
|
||||
apply_effects(proj, &mut layer, fx, &gl);
|
||||
}
|
||||
if pl.fill_opacity < 255 && layer.can_have_styles() {
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
if layer.props.sub(styles::GROUP).is_none() {
|
||||
let g = styles::layer_styles(&mut ids, &gl);
|
||||
let at = layer.props.children.iter().position(|c| c.match_id() == "transform").map(|i| i + 1).unwrap_or(layer.props.children.len());
|
||||
layer.props.children.insert(at, g.into());
|
||||
}
|
||||
if let Some(p) = layer.props.prop_mut("layerStyles/blendingOptions/advancedBlending/fillOpacity") {
|
||||
p.value = Value::Scalar(pl.fill_opacity as f64 / 255.0 * 100.0);
|
||||
}
|
||||
}
|
||||
Some(layer)
|
||||
}
|
||||
|
||||
/// Where layer space's origin sits in document pixels.
|
||||
fn layer_origin(pl: &effectcraft_psd::Layer, retain: bool, layer: &Layer) -> [f64; 2] {
|
||||
if let Some(t) = &pl.text {
|
||||
return [t.transform[4], t.transform[5]];
|
||||
}
|
||||
if matches!(layer.source, LayerSource::Footage { .. }) && retain && !pl.rect.is_empty() {
|
||||
return [pl.rect.left as f64, pl.rect.top as f64];
|
||||
}
|
||||
[0.0, 0.0]
|
||||
}
|
||||
|
||||
/// Sets a scalar property; a non-finite value (from corrupt transforms) keeps the default.
|
||||
fn set_scalar(l: &mut Layer, path: &str, v: f64) {
|
||||
if !v.is_finite() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = l.props.prop_mut(path) {
|
||||
p.value = Value::Scalar(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a 2D property; a non-finite component (from corrupt transforms) keeps the default.
|
||||
fn set_vec2(l: &mut Layer, path: &str, v: [f64; 2]) {
|
||||
if !v.iter().all(|c| c.is_finite()) {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = l.props.prop_mut(path) {
|
||||
p.value = match p.value {
|
||||
Value::Vec3(o) => Value::Vec3([v[0], v[1], o[2]]),
|
||||
_ => Value::Vec2(v),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a PostScript font name into family and style (`Inter-Bold` → `Inter`, `Bold`).
|
||||
fn font_names(ps: &str) -> (String, String) {
|
||||
match ps.split_once('-') {
|
||||
Some((fam, style)) => (fam.to_string(), style.to_string()),
|
||||
None => (ps.to_string(), "Regular".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_doc(t: &effectcraft_psd::TextInfo) -> TextDoc {
|
||||
let mut d = TextDoc::default();
|
||||
d.set_text(&t.text);
|
||||
let [xx, xy, yx, yy, _, _] = t.transform;
|
||||
let scale = (xx * yy - xy * yx).abs().sqrt().max(1e-6);
|
||||
// A transform too large to multiply (inf) falls back to 1×; products below stay finite.
|
||||
let scale = if scale.is_finite() { scale } else { 1.0 };
|
||||
if let Some(s) = t.style.size.map(|s| s * scale).filter(|s| s.is_finite() && *s > 0.0) {
|
||||
d.size = s;
|
||||
}
|
||||
if let Some(f) = &t.style.font {
|
||||
let (fam, style) = font_names(f);
|
||||
d.font = fam;
|
||||
d.style = style;
|
||||
}
|
||||
if let Some(c) = t.style.color {
|
||||
d.fill = [c[0] as f32, c[1] as f32, c[2] as f32, 1.0];
|
||||
}
|
||||
d.justify = match t.style.justification {
|
||||
Some(1) => Justify::Right,
|
||||
Some(2) => Justify::Center,
|
||||
Some(3) => Justify::JustifyLastLeft,
|
||||
Some(4) => Justify::JustifyLastRight,
|
||||
Some(5) => Justify::JustifyLastCenter,
|
||||
Some(6) => Justify::JustifyAll,
|
||||
_ => Justify::Left,
|
||||
};
|
||||
if let Some(tr) = t.style.tracking.filter(|v| v.is_finite()) {
|
||||
d.tracking = tr;
|
||||
}
|
||||
d.leading = t.style.leading.map(|l| l * scale).filter(|l| l.is_finite());
|
||||
d.faux_bold = t.style.faux_bold;
|
||||
d.faux_italic = t.style.faux_italic;
|
||||
if t.style.box_text
|
||||
&& let Some([l, top, r, b]) = t.bounds
|
||||
{
|
||||
let (size, pos) = ([(r - l) * scale, (b - top) * scale], [l * scale, top * scale]);
|
||||
if size.iter().chain(&pos).all(|v| v.is_finite()) {
|
||||
d.box_size = Some(size);
|
||||
d.box_pos = pos;
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn text_layer(proj: &mut Project, comp: &Comp, name: &str, t: &effectcraft_psd::TextInfo) -> Layer {
|
||||
let mut l = build::layer(proj, comp, name, LayerSource::Text, (0, 0), None);
|
||||
if let Some(p) = l.props.prop_mut("text/sourceText") {
|
||||
p.value = Value::Text(Box::new(text_doc(t)));
|
||||
}
|
||||
set_vec2(&mut l, "transform/position", [t.transform[4], t.transform[5]]);
|
||||
let [xx, xy, _, _, _, _] = t.transform;
|
||||
let rot = xy.atan2(xx).to_degrees();
|
||||
if rot.abs() > 1e-6 {
|
||||
set_scalar(&mut l, "transform/rotation", rot);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
fn adjustment_layer(proj: &mut Project, comp: &Comp, name: &str) -> Layer {
|
||||
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
|
||||
let sid = proj.add_item(
|
||||
name,
|
||||
Label::Red,
|
||||
Some(folder),
|
||||
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),
|
||||
);
|
||||
let mut l = build::layer(proj, comp, name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None);
|
||||
l.switches.adjustment = true;
|
||||
l.label = Label::Purple;
|
||||
l
|
||||
}
|
||||
|
||||
fn add_effect(proj: &mut Project, l: &mut Layer, effect: &str, comp: &Comp, params: &[(&str, Value)]) {
|
||||
let Some(spec) = effectcraft_effects::lookup(effect) else { return };
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
let mut g = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [comp.width as f64, comp.height as f64]);
|
||||
for (k, v) in params {
|
||||
// Paths reach parameters in twirl-down groups (`master/exposure`).
|
||||
if let Some(p) = g.prop_mut(k) {
|
||||
p.value = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(fx) = l.props.sub_mut("effects") {
|
||||
fx.children.push(g.into());
|
||||
}
|
||||
}
|
||||
|
||||
fn add_adjustment_effect(proj: &mut Project, l: &mut Layer, adj: &Adjustment, comp: &Comp, warnings: &mut Vec<String>, name: &str) {
|
||||
match adj {
|
||||
Adjustment::Invert => add_effect(proj, l, "Invert", comp, &[]),
|
||||
Adjustment::BrightnessContrast { brightness, contrast, legacy } => add_effect(
|
||||
proj,
|
||||
l,
|
||||
"Brightness & Contrast",
|
||||
comp,
|
||||
&[("brightness", Value::Scalar(*brightness)), ("contrast", Value::Scalar(*contrast)), ("useLegacy", Value::Bool(*legacy))],
|
||||
),
|
||||
Adjustment::HueSaturation { hue, saturation, lightness, colorize } => {
|
||||
let mut p = vec![("hue", Value::Scalar(*hue)), ("saturation", Value::Scalar(*saturation)), ("lightness", Value::Scalar(*lightness))];
|
||||
if *colorize {
|
||||
p = vec![
|
||||
("colorize", Value::Bool(true)),
|
||||
("colorizeHue", Value::Scalar(*hue)),
|
||||
("colorizeSaturation", Value::Scalar(*saturation)),
|
||||
("colorizeLightness", Value::Scalar(*lightness)),
|
||||
];
|
||||
}
|
||||
add_effect(proj, l, "Hue/Saturation", comp, &p)
|
||||
}
|
||||
Adjustment::Levels { in_black, in_white, gamma, out_black, out_white } => add_effect(
|
||||
proj,
|
||||
l,
|
||||
"Levels",
|
||||
comp,
|
||||
&[
|
||||
("inBlack", Value::Scalar(in_black / 255.0)),
|
||||
("inWhite", Value::Scalar(in_white / 255.0)),
|
||||
("gamma", Value::Scalar(*gamma)),
|
||||
("outBlack", Value::Scalar(out_black / 255.0)),
|
||||
("outWhite", Value::Scalar(out_white / 255.0)),
|
||||
],
|
||||
),
|
||||
Adjustment::Exposure { exposure, offset, gamma } => add_effect(
|
||||
proj,
|
||||
l,
|
||||
"Exposure",
|
||||
comp,
|
||||
&[("master/exposure", Value::Scalar(*exposure)), ("master/offset", Value::Scalar(*offset)), ("master/gamma", Value::Scalar(*gamma))],
|
||||
),
|
||||
Adjustment::Other(k) => warnings.push(format!("{name}: adjustment `{k}` is not converted (layer kept as an empty adjustment layer)")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- layer effects → styles
|
||||
|
||||
fn style_mode(m: BlendMode) -> Value {
|
||||
Value::Enum(STYLE_BLEND_MODES.iter().position(|x| x.1 == m).unwrap_or(0) as u32)
|
||||
}
|
||||
|
||||
fn set(g: &mut PropGroup, k: &str, v: Value) {
|
||||
if let Some(p) = g.get_mut(k) {
|
||||
p.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
fn color4(c: [f64; 3]) -> Value {
|
||||
Value::Color([c[0], c[1], c[2], 1.0])
|
||||
}
|
||||
|
||||
/// Copy the common style settings (`Md `, `Clr `, `Opct`, angle, distance, size…).
|
||||
fn common(g: &mut PropGroup, d: &Descriptor) {
|
||||
if let Some(m) = desc_blend(d.enum_value("Md ")) {
|
||||
set(g, "blendMode", style_mode(m));
|
||||
}
|
||||
if let Some(c) = d.color("Clr ") {
|
||||
set(g, "color", color4(c));
|
||||
}
|
||||
for (k, prop) in [("Opct", "opacity"), ("Nose", "noise"), ("Dstn", "distance"), ("blur", "size"), ("lagl", "angle"), ("ShdN", "jitter"), ("Inpr", "range")]
|
||||
{
|
||||
if let Some(v) = d.num(k) {
|
||||
set(g, prop, Value::Scalar(v));
|
||||
}
|
||||
}
|
||||
if let Some(b) = d.bool("uglg") {
|
||||
set(g, "useGlobalLight", Value::Bool(b));
|
||||
}
|
||||
}
|
||||
|
||||
fn gradient_of(d: &Descriptor) -> Option<Gradient> {
|
||||
let g = d.obj("Grad")?;
|
||||
let list = |k: &str| match g.get(k) {
|
||||
Some(DValue::List(v)) => v.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
let colors: Vec<(f64, [f32; 4])> = list("Clrs")
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
DValue::Descriptor(s) => {
|
||||
let col = s.color("Clr ")?;
|
||||
Some((s.num("Lctn").unwrap_or(0.0) / 4096.0, [col[0] as f32, col[1] as f32, col[2] as f32, 1.0]))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let opacities: Vec<(f64, f32)> = list("Trns")
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
DValue::Descriptor(s) => Some((s.num("Lctn").unwrap_or(0.0) / 4096.0, (s.num("Opct").unwrap_or(100.0) / 100.0) as f32)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(!colors.is_empty()).then(|| Gradient { colors, opacities: if opacities.is_empty() { vec![(0.0, 1.0), (1.0, 1.0)] } else { opacities } })
|
||||
}
|
||||
|
||||
/// The effect descriptor for `key`, or the first enabled one of its `…Multi` list.
|
||||
fn effect<'a>(fx: &'a Descriptor, key: &str, multi: &str) -> Option<&'a Descriptor> {
|
||||
fx.obj(key).or_else(|| match fx.get(multi) {
|
||||
Some(DValue::List(v)) => v.iter().find_map(|x| match x {
|
||||
DValue::Descriptor(d) if d.bool("enab") != Some(false) => Some(d),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_effects(proj: &mut Project, layer: &mut Layer, fx: &Descriptor, gl: &styles::GlobalLight) {
|
||||
const MAP: &[(&str, &str, &str)] = &[
|
||||
("DrSh", "dropShadowMulti", "dropShadow"),
|
||||
("IrSh", "innerShadowMulti", "innerShadow"),
|
||||
("OrGl", "outerGlowMulti", "outerGlow"),
|
||||
("IrGl", "innerGlowMulti", "innerGlow"),
|
||||
("ebbl", "bevelEmbossMulti", "bevelEmboss"),
|
||||
("ChFX", "satinMulti", "satin"),
|
||||
("SoFi", "solidFillMulti", "colorOverlay"),
|
||||
("GrFl", "gradientFillMulti", "gradientOverlay"),
|
||||
("FrFX", "frameFXMulti", "stroke"),
|
||||
];
|
||||
for (key, multi, id) in MAP {
|
||||
let Some(d) = effect(fx, key, multi) else { continue };
|
||||
let enabled = d.bool("enab").unwrap_or(true);
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
let Some(uid) = styles::add_style(layer, &mut ids, id, gl, enabled) else { continue };
|
||||
let Some(g) = layer.props.find_group_mut(uid) else { continue };
|
||||
common(g, d);
|
||||
match *id {
|
||||
"dropShadow" => {
|
||||
if let Some(v) = d.num("Ckmt") {
|
||||
set(g, "spread", Value::Scalar(v));
|
||||
}
|
||||
if let Some(b) = d.bool("layerConceals") {
|
||||
set(g, "knocksOut", Value::Bool(b));
|
||||
}
|
||||
}
|
||||
"innerShadow" | "innerGlow" => {
|
||||
if let Some(v) = d.num("Ckmt") {
|
||||
set(g, "choke", Value::Scalar(v));
|
||||
}
|
||||
if d.enum_value("glwS") == Some("SrcC") {
|
||||
set(g, "source", Value::Enum(0));
|
||||
}
|
||||
}
|
||||
"outerGlow" => {
|
||||
if let Some(v) = d.num("Ckmt") {
|
||||
set(g, "spread", Value::Scalar(v));
|
||||
}
|
||||
}
|
||||
"bevelEmboss" => {
|
||||
let style = match d.enum_value("bvlS") {
|
||||
Some("OtrB") => 0,
|
||||
Some("Embs") => 2,
|
||||
Some("PlEb") => 3,
|
||||
Some("strokeEmboss") => 4,
|
||||
_ => 1,
|
||||
};
|
||||
set(g, "style", Value::Enum(style));
|
||||
let tech = match d.enum_value("bvlT") {
|
||||
Some("PrBL") => 1,
|
||||
Some("Slmt") => 2,
|
||||
_ => 0,
|
||||
};
|
||||
set(g, "technique", Value::Enum(tech));
|
||||
if let Some(v) = d.num("srgR") {
|
||||
set(g, "depth", Value::Scalar(v));
|
||||
}
|
||||
set(g, "direction", Value::Enum(u32::from(d.enum_value("bvlD") == Some("Out "))));
|
||||
if let Some(v) = d.num("Sftn") {
|
||||
set(g, "soften", Value::Scalar(v));
|
||||
}
|
||||
if let Some(v) = d.num("Lald") {
|
||||
set(g, "altitude", Value::Scalar(v));
|
||||
}
|
||||
for (k, p) in [("hglM", "highlightMode"), ("sdwM", "shadowMode")] {
|
||||
if let Some(m) = desc_blend(d.enum_value(k)) {
|
||||
set(g, p, style_mode(m));
|
||||
}
|
||||
}
|
||||
for (k, p) in [("hglC", "highlightColor"), ("sdwC", "shadowColor")] {
|
||||
if let Some(c) = d.color(k) {
|
||||
set(g, p, color4(c));
|
||||
}
|
||||
}
|
||||
for (k, p) in [("hglO", "highlightOpacity"), ("sdwO", "shadowOpacity")] {
|
||||
if let Some(v) = d.num(k) {
|
||||
set(g, p, Value::Scalar(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
"satin" => {
|
||||
if let Some(b) = d.bool("Invr") {
|
||||
set(g, "invert", Value::Bool(b));
|
||||
}
|
||||
}
|
||||
"gradientOverlay" => {
|
||||
if let Some(gr) = gradient_of(d) {
|
||||
set(g, "colors", Value::Gradient(gr));
|
||||
}
|
||||
if let Some(a) = d.num("Angl") {
|
||||
set(g, "angle", Value::Scalar(a));
|
||||
}
|
||||
let st = match d.enum_value("Type") {
|
||||
Some("Rdl ") => 1,
|
||||
Some("Angl") => 2,
|
||||
Some("Rflc") => 3,
|
||||
Some("Dmnd") => 4,
|
||||
_ => 0,
|
||||
};
|
||||
set(g, "style", Value::Enum(st));
|
||||
if let Some(b) = d.bool("Rvrs") {
|
||||
set(g, "reverse", Value::Bool(b));
|
||||
}
|
||||
if let Some(b) = d.bool("Algn") {
|
||||
set(g, "alignWithLayer", Value::Bool(b));
|
||||
}
|
||||
if let Some(v) = d.num("Scl ") {
|
||||
set(g, "scale", Value::Scalar(v));
|
||||
}
|
||||
}
|
||||
"stroke" => {
|
||||
if let Some(v) = d.num("Sz ") {
|
||||
set(g, "size", Value::Scalar(v));
|
||||
}
|
||||
let pos = match d.enum_value("Styl") {
|
||||
Some("InsF") => 1,
|
||||
Some("CtrF") => 2,
|
||||
_ => 0,
|
||||
};
|
||||
set(g, "position", Value::Enum(pos));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer ▸ Create ▸ Convert to Editable Text: a footage layer showing a Photoshop type layer
|
||||
/// becomes a text layer (`None` when the layer's source has no text data).
|
||||
pub fn editable_text(proj: &mut Project, comp: &Comp, layer: &Layer, psd: &Psd, index: usize) -> Option<Layer> {
|
||||
let t = psd.layers.get(index)?.text.as_ref()?;
|
||||
let mut l = text_layer(proj, comp, &layer.name, t);
|
||||
l.name = layer.name.clone();
|
||||
l.blend_mode = layer.blend_mode;
|
||||
l.switches.video = layer.switches.video;
|
||||
l.in_point = layer.in_point;
|
||||
l.out_point = layer.out_point;
|
||||
l.start_time = layer.start_time;
|
||||
if let (Some(o), Some(p)) = (layer.props.prop("transform/opacity"), l.props.prop_mut("transform/opacity")) {
|
||||
*p = effectcraft_project::Property { uid: p.uid, ..o.clone() };
|
||||
}
|
||||
Some(l)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blend_keys() {
|
||||
assert_eq!(blend_mode("mul "), BlendMode::Multiply);
|
||||
assert_eq!(blend_mode("smud"), BlendMode::Exclusion);
|
||||
assert_eq!(blend_mode("pass"), BlendMode::Normal);
|
||||
assert_eq!(desc_blend(Some("linearDodge")), Some(BlendMode::LinearDodge));
|
||||
assert_eq!(font_names("Inter-Bold"), ("Inter".into(), "Bold".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_text_values_never_reach_the_layer() {
|
||||
let style = effectcraft_psd::engine_data::TextStyle {
|
||||
size: Some(f64::NAN),
|
||||
tracking: Some(f64::INFINITY),
|
||||
leading: Some(f64::NEG_INFINITY),
|
||||
..Default::default()
|
||||
};
|
||||
let t = effectcraft_psd::TextInfo {
|
||||
text: "x".into(),
|
||||
transform: [f64::INFINITY, 0.0, 0.0, 1.0, f64::NAN, 1.0],
|
||||
style,
|
||||
bounds: Some([0.0, 0.0, f64::INFINITY, 1.0]),
|
||||
};
|
||||
let d = text_doc(&t);
|
||||
assert!(d.size.is_finite() && d.tracking.is_finite());
|
||||
assert!(d.leading.is_none_or(f64::is_finite));
|
||||
assert!(d.box_size.is_none_or(|b| b.iter().all(|v| v.is_finite())));
|
||||
let mut proj = Project::default();
|
||||
let comp = Comp::new(64, 64, FrameRate::FPS_24, Tick::ZERO);
|
||||
let l = text_layer(&mut proj, &comp, "t", &t);
|
||||
match l.props.prop("transform/position").map(|p| &p.value) {
|
||||
Some(Value::Vec2(v)) => assert!(v.iter().all(|c| c.is_finite())),
|
||||
Some(Value::Vec3(v)) => assert!(v.iter().all(|c| c.is_finite())),
|
||||
other => panic!("unexpected position {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG, PDF,
|
||||
//! Illustrator and EPS footage → shape layer), Create Shapes from Text and Create Masks from
|
||||
//! Text (glyph outlines), and vector files imported as compositions (one layer per file layer).
|
||||
|
||||
use effectcraft_keyframe::{Gradient, ShapePath, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemId, Layer, LayerSource, MaskMode, Project, PropGroup};
|
||||
use effectcraft_svg::{Affine, Doc, Geom, GradientKind, Node, Paint};
|
||||
use kurbo::{BezPath, Point};
|
||||
|
||||
fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec<PropGroup> {
|
||||
effectcraft_path::from_kurbo(path)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, sp)| {
|
||||
let mut g = build::shape_path(ids, sp);
|
||||
g.name = format!("Path {}", i + 1);
|
||||
g
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn det_scale(m: Affine) -> f64 {
|
||||
let c = m.as_coeffs();
|
||||
(c[0] * c[3] - c[1] * c[2]).abs().sqrt()
|
||||
}
|
||||
|
||||
/// Axis-aligned scale + translation only (basic shapes stay parametric).
|
||||
fn axis_aligned(m: Affine) -> Option<(f64, f64, f64, f64)> {
|
||||
let c = m.as_coeffs();
|
||||
(c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && c[0] > 0.0 && c[3] > 0.0).then_some((c[0], c[3], c[4], c[5]))
|
||||
}
|
||||
|
||||
fn gradient_value(g: &effectcraft_svg::Gradient) -> Gradient {
|
||||
Gradient {
|
||||
colors: g.stops.iter().map(|(o, c)| (*o, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0])).collect(),
|
||||
opacities: g.stops.iter().map(|(o, c)| (*o, c[3] as f32)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient start/end points in shape-layer space (`m`: shape user space → layer space).
|
||||
fn gradient_points(g: &effectcraft_svg::Gradient, m: Affine, bbox: kurbo::Rect) -> (bool, [f64; 2], [f64; 2]) {
|
||||
let unit = if g.bbox_units { Affine::new([bbox.width(), 0.0, 0.0, bbox.height(), bbox.x0, bbox.y0]) } else { Affine::IDENTITY };
|
||||
let full = m * unit * g.transform;
|
||||
let p = |x: f64, y: f64| {
|
||||
let q = full * Point::new(x, y);
|
||||
[q.x, q.y]
|
||||
};
|
||||
match g.kind {
|
||||
GradientKind::Linear { x1, y1, x2, y2 } => (false, p(x1, y1), p(x2, y2)),
|
||||
GradientKind::Radial { cx, cy, r, .. } => (true, p(cx, cy), p(cx + r, cy)),
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine) -> Vec<PropGroup> {
|
||||
use kurbo::Shape as _;
|
||||
let mut items = vec![];
|
||||
// Geometry: parametric when the transform keeps rectangles/ellipses axis-aligned.
|
||||
match (&s.geom, axis_aligned(m)) {
|
||||
(Geom::Rect { x, y, w, h, rx, ry }, Some((sx, sy, tx, ty))) if (rx - ry).abs() < 1e-9 && (*rx == 0.0 || (sx - sy).abs() < 1e-9) => {
|
||||
let (w2, h2) = (w * sx, h * sy);
|
||||
let pos = [tx + (x + w / 2.0) * sx, ty + (y + h / 2.0) * sy];
|
||||
let mut g = build::shape_rect(ids, [w2, h2], pos, rx * sx);
|
||||
g.name = "Rectangle Path 1".into();
|
||||
items.push(g);
|
||||
}
|
||||
(Geom::Ellipse { cx, cy, rx, ry }, Some((sx, sy, tx, ty))) => {
|
||||
items.push(build::shape_ellipse(ids, [2.0 * rx * sx, 2.0 * ry * sy], [tx + cx * sx, ty + cy * sy]));
|
||||
}
|
||||
(g, _) => items.extend(shape_paths(ids, &(m * g.to_path()))),
|
||||
}
|
||||
let bbox = s.geom.to_path().bounding_box();
|
||||
if let Some(st) = &s.stroke {
|
||||
let width = st.width * det_scale(m);
|
||||
let mut g = match &st.paint {
|
||||
Paint::Color(c) => build::shape_stroke(ids, [c[0], c[1], c[2], 1.0], width),
|
||||
Paint::Gradient(gr) => {
|
||||
let (radial, a, b) = gradient_points(gr, m, bbox);
|
||||
build::shape_gradient_stroke(ids, radial, a, b, gradient_value(gr), width)
|
||||
}
|
||||
};
|
||||
set(&mut g, "opacity", Value::Scalar(st.opacity * 100.0));
|
||||
set(&mut g, "cap", Value::Enum(st.cap as u32));
|
||||
set(&mut g, "join", Value::Enum(st.join as u32));
|
||||
set(&mut g, "miter", Value::Scalar(st.miter));
|
||||
if let Some((d, off)) = &st.dash
|
||||
&& let Some(dg) = g.sub_mut("dashes")
|
||||
{
|
||||
let k = det_scale(m);
|
||||
set(dg, "dash", Value::Scalar(d.first().copied().unwrap_or(0.0) * k));
|
||||
set(dg, "gap", Value::Scalar(d.get(1).or(d.first()).copied().unwrap_or(0.0) * k));
|
||||
set(dg, "offset", Value::Scalar(off * k));
|
||||
}
|
||||
items.push(g);
|
||||
}
|
||||
if let Some(f) = &s.fill {
|
||||
let mut g = match f {
|
||||
Paint::Color(c) => build::shape_fill(ids, [c[0], c[1], c[2], 1.0]),
|
||||
Paint::Gradient(gr) => {
|
||||
let (radial, a, b) = gradient_points(gr, m, bbox);
|
||||
build::shape_gradient_fill(ids, radial, a, b, gradient_value(gr))
|
||||
}
|
||||
};
|
||||
set(&mut g, "opacity", Value::Scalar(s.fill_opacity * 100.0));
|
||||
set(&mut g, "rule", Value::Enum(u32::from(s.fill_rule == effectcraft_svg::FillRule::EvenOdd)));
|
||||
items.push(g);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn set(g: &mut PropGroup, k: &str, v: Value) {
|
||||
if let Some(p) = g.get_mut(k) {
|
||||
p.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
fn group(ids: &mut Ids, name: &str, items: Vec<PropGroup>, opacity: f64) -> PropGroup {
|
||||
let mut g = build::shape_group(ids, name, items);
|
||||
if let Some(p) = g.prop_mut("transform/opacity") {
|
||||
p.value = Value::Scalar(opacity * 100.0);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// A Merge Paths item in `mode` (0 Merge, 3 Intersect).
|
||||
fn merge_item(ids: &mut Ids, mode: u32) -> Option<PropGroup> {
|
||||
let mut g = build::shape_simple_op(ids, "merge")?;
|
||||
set(&mut g, "mode", Value::Enum(mode));
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// A shape inside clipping groups: its geometry merged into one compound path (an inner group
|
||||
/// with Merge Paths), the clip paths, and Merge Paths ▸ Intersect before its paint, so the fill
|
||||
/// is exactly the clipped area. Open stroked paths stay unclipped (an intersection would close
|
||||
/// them).
|
||||
fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
|
||||
let items = shape_items(ids, s, m);
|
||||
let path = s.geom.to_path();
|
||||
let closed = path.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)) || s.fill.is_some();
|
||||
if clips.is_empty() || !closed {
|
||||
return items;
|
||||
}
|
||||
let (geom, paint): (Vec<PropGroup>, Vec<PropGroup>) =
|
||||
items.into_iter().partition(|g| !matches!(g.match_id.as_str(), "fill" | "stroke" | "gfill" | "gstroke"));
|
||||
let mut inner = geom;
|
||||
inner.extend(merge_item(ids, 0));
|
||||
let mut out = vec![build::shape_group(ids, "Clipped Path", inner)];
|
||||
for (i, c) in clips.iter().enumerate() {
|
||||
let mut parts = shape_paths(ids, c);
|
||||
parts.extend(merge_item(ids, 0));
|
||||
out.push(build::shape_group(ids, &format!("Clip Path {}", i + 1), parts));
|
||||
}
|
||||
out.extend(merge_item(ids, 3));
|
||||
out.extend(paint);
|
||||
out
|
||||
}
|
||||
|
||||
/// Shape layer contents for an SVG document, in document pixels (transforms are baked into the
|
||||
/// geometry; group and element opacity go to the group transforms, blend modes to the groups'
|
||||
/// Blend Mode). Top of the paint order first.
|
||||
///
|
||||
/// Clipping groups (PDF / Illustrator clipping masks): shapes inside them are intersected with
|
||||
/// the clip paths through Merge Paths ▸ Intersect (see `clipped_items`). Clips enclosing the
|
||||
/// whole document are layer masks instead ([`vector_clip_masks`], used by
|
||||
/// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see
|
||||
/// [`split_at_images`]); soft masks are not converted.
|
||||
pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec<PropGroup> {
|
||||
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
|
||||
let mut out = vec![];
|
||||
for n in nodes.iter().rev() {
|
||||
match n {
|
||||
Node::Group(g) => {
|
||||
let gm = m * g.transform;
|
||||
let mut inner_clips = clips.to_vec();
|
||||
inner_clips.extend(g.clip.iter().map(|(p, _)| gm * p.clone()));
|
||||
let items = walk(ids, &g.children, gm, &inner_clips);
|
||||
if !items.is_empty() {
|
||||
let mut grp = group(ids, &g.name, items, g.opacity);
|
||||
if g.blend != effectcraft_svg::BlendMode::Normal {
|
||||
set(&mut grp, "blend", Value::Enum(blend_index(g.blend)));
|
||||
}
|
||||
out.push(grp);
|
||||
}
|
||||
}
|
||||
Node::Shape(s) => {
|
||||
let sm = m * s.transform;
|
||||
let items = if clips.is_empty() { shape_items(ids, s, sm) } else { clipped_items(ids, s, sm, clips) };
|
||||
if !items.is_empty() {
|
||||
out.push(group(ids, &s.name, items, s.opacity));
|
||||
}
|
||||
}
|
||||
Node::Image(_) => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let root = &doc.root;
|
||||
let mut clips = vec![];
|
||||
clips.extend(root.clip.iter().map(|(p, _)| root.transform * p.clone()));
|
||||
let items = walk(ids, &root.children, root.transform, &clips);
|
||||
if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items }
|
||||
}
|
||||
|
||||
/// The shape-layer Blend Mode index of an SVG / PDF blend mode.
|
||||
fn blend_index(b: effectcraft_svg::BlendMode) -> u32 {
|
||||
use effectcraft_color::BlendMode as P;
|
||||
use effectcraft_svg::BlendMode as S;
|
||||
let p = match b {
|
||||
S::Normal => P::Normal,
|
||||
S::Multiply => P::Multiply,
|
||||
S::Screen => P::Screen,
|
||||
S::Overlay => P::Overlay,
|
||||
S::Darken => P::Darken,
|
||||
S::Lighten => P::Lighten,
|
||||
S::ColorDodge => P::ColorDodge,
|
||||
S::ColorBurn => P::ColorBurn,
|
||||
S::HardLight => P::HardLight,
|
||||
S::SoftLight => P::SoftLight,
|
||||
S::Difference => P::Difference,
|
||||
S::Exclusion => P::Exclusion,
|
||||
S::Hue => P::Hue,
|
||||
S::Saturation => P::Saturation,
|
||||
S::Color => P::Color,
|
||||
S::Luminosity => P::Luminosity,
|
||||
};
|
||||
P::ALL.iter().position(|x| *x == p).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
/// Clips that enclose the whole document (on a chain of single-child groups from the root, as
|
||||
/// an Illustrator artboard clip or a PDF page clip), in document pixels, and the document with
|
||||
/// those clips removed: Create Shapes from Vector Layer makes them layer masks.
|
||||
pub fn vector_clip_masks(doc: &Doc) -> (Vec<BezPath>, Doc) {
|
||||
let mut out = doc.clone();
|
||||
let mut masks = vec![];
|
||||
let mut m = Affine::IDENTITY;
|
||||
let mut g = &mut out.root;
|
||||
loop {
|
||||
m *= g.transform;
|
||||
for (p, _) in g.clip.drain(..) {
|
||||
masks.push(m * p);
|
||||
}
|
||||
if g.children.len() != 1 {
|
||||
break;
|
||||
}
|
||||
match &mut g.children[0] {
|
||||
Node::Group(sub) if sub.mask.is_none() && sub.blend == effectcraft_svg::BlendMode::Normal => g = sub,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
(masks, out)
|
||||
}
|
||||
|
||||
/// Whether text (the system clipboard's, say) is SVG markup: Edit ▸ Paste makes it a shape
|
||||
/// layer (`edit.pasteSvg`).
|
||||
pub fn is_svg_text(text: &str) -> bool {
|
||||
effectcraft_svg::looks_like_svg(text.as_bytes())
|
||||
}
|
||||
|
||||
/// Whether footage is a vector file (SVG / PDF / AI / EPS).
|
||||
pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
|
||||
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
|
||||
}
|
||||
|
||||
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page
|
||||
/// `page` of a PDF, restricted to one layer when `layer` names it.
|
||||
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32) -> Option<Result<Doc, String>> {
|
||||
if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) {
|
||||
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
|
||||
}
|
||||
effectcraft_pdf::sniff(bytes)?;
|
||||
Some(
|
||||
effectcraft_pdf::parse_page(bytes, page as usize)
|
||||
.map(|d| match layer {
|
||||
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
|
||||
None => d,
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the
|
||||
/// page: one footage item per file layer (optionally cropped to its artwork, continuously
|
||||
/// rasterisable), top layer first. Returns (comp, folder, items).
|
||||
pub fn import_vector_comp(
|
||||
proj: &mut Project,
|
||||
path: &str,
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
page: u32,
|
||||
retain: bool,
|
||||
timing: (effectcraft_time::FrameRate, effectcraft_time::Tick),
|
||||
) -> Result<(ItemId, ItemId, Vec<ItemId>), String> {
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer};
|
||||
let (rate, duration) = timing;
|
||||
let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?;
|
||||
let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF");
|
||||
let (w, h) = doc.pixel_size();
|
||||
let file = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder);
|
||||
let mut comp = Comp::new(w, h, rate, duration);
|
||||
let mut items = vec![];
|
||||
let names = effectcraft_pdf::layer_names(&doc);
|
||||
for (i, lname) in names.iter().enumerate() {
|
||||
let (size, offset) = if retain {
|
||||
let (layer, offset) = effectcraft_pdf::layer_doc_with_sizes(&doc, i, true);
|
||||
(layer.pixel_size(), offset)
|
||||
} else {
|
||||
((w, h), [0.0, 0.0])
|
||||
};
|
||||
let f = Footage {
|
||||
path: path.to_string(),
|
||||
kind: FootageKind::Still,
|
||||
width: size.0,
|
||||
height: size.1,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: rate,
|
||||
has_video: true,
|
||||
alpha: AlphaMode::Straight,
|
||||
loop_count: 1,
|
||||
codec: codec.into(),
|
||||
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: retain, ..Default::default() }),
|
||||
page,
|
||||
..Default::default()
|
||||
};
|
||||
let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f));
|
||||
items.push(id);
|
||||
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, size, None);
|
||||
if retain && let Some(position) = l.props.prop_mut("transform/position") {
|
||||
position.value = Value::Vec3([offset[0] + f64::from(size.0) / 2.0, offset[1] + f64::from(size.1) / 2.0, 0.0]);
|
||||
}
|
||||
l.name = comp.unique_layer_name(lname);
|
||||
comp.layers.insert(0, l);
|
||||
}
|
||||
let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(std::sync::Arc::new(comp)));
|
||||
Ok((cid, folder, items))
|
||||
}
|
||||
|
||||
/// A copy of `src`'s transform group with fresh uids.
|
||||
fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) {
|
||||
let Some(tr) = src.props.sub("transform") else { return };
|
||||
let mut tr = tr.clone();
|
||||
let mut n = proj.next_id;
|
||||
tr.reassign_uids(&mut n);
|
||||
proj.next_id = n + 1;
|
||||
if let Some(slot) = dst.props.children.iter_mut().find(|c| c.match_id() == "transform") {
|
||||
*slot = tr.into();
|
||||
}
|
||||
}
|
||||
|
||||
fn like_source(dst: &mut Layer, src: &Layer) {
|
||||
dst.start_time = src.start_time;
|
||||
dst.in_point = src.in_point;
|
||||
dst.out_point = src.out_point;
|
||||
dst.stretch = src.stretch;
|
||||
dst.parent = src.parent;
|
||||
dst.switches.three_d = src.switches.three_d;
|
||||
}
|
||||
|
||||
/// A raster image of a vector document (Create Shapes from Vector Layer makes it a footage
|
||||
/// layer: shape layers have no images).
|
||||
pub struct VectorImage {
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Straight RGBA8.
|
||||
pub rgba: std::sync::Arc<Vec<u8>>,
|
||||
/// Image pixels → document pixels.
|
||||
pub transform: Affine,
|
||||
pub opacity: f64,
|
||||
/// Inside a clipping group or under a soft mask (not carried over).
|
||||
pub clipped: bool,
|
||||
}
|
||||
|
||||
/// The document's images, bottom first, and how many soft-masked groups it has.
|
||||
pub fn vector_images(doc: &Doc) -> (Vec<VectorImage>, usize) {
|
||||
fn walk(g: &effectcraft_svg::Group, m: Affine, opacity: f64, clipped: bool, out: &mut Vec<VectorImage>, masks: &mut usize) {
|
||||
let m = m * g.transform;
|
||||
let opacity = opacity * g.opacity;
|
||||
let clipped = clipped || !g.clip.is_empty() || g.mask.is_some();
|
||||
if g.mask.is_some() {
|
||||
*masks += 1;
|
||||
}
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Group(sub) => walk(sub, m, opacity, clipped, out, masks),
|
||||
Node::Image(im) => out.push(VectorImage {
|
||||
name: im.name.clone(),
|
||||
width: im.width,
|
||||
height: im.height,
|
||||
rgba: im.rgba.clone(),
|
||||
transform: m * im.transform,
|
||||
opacity: opacity * im.opacity,
|
||||
clipped,
|
||||
}),
|
||||
Node::Shape(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, doc) = vector_clip_masks(doc);
|
||||
let mut out = vec![];
|
||||
let mut masks = 0;
|
||||
walk(&doc.root, Affine::IDENTITY, 1.0, false, &mut out, &mut masks);
|
||||
(out, masks)
|
||||
}
|
||||
|
||||
/// The document split at its images (paint order): segment `k` keeps the shapes drawn between
|
||||
/// image `k − 1` and image `k` (groups, clips and blend modes kept, images dropped), so shape
|
||||
/// layers and image footage layers can stack in the document's order. One more segment than
|
||||
/// images.
|
||||
pub fn split_at_images(doc: &Doc) -> Vec<Doc> {
|
||||
fn count(g: &effectcraft_svg::Group) -> usize {
|
||||
g.children
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
Node::Group(s) => count(s),
|
||||
Node::Image(_) => 1,
|
||||
Node::Shape(_) => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
/// Keep the shapes between images `seg − 1` and `seg`; `seen` counts images passed.
|
||||
fn prune(g: &effectcraft_svg::Group, seg: usize, seen: &mut usize) -> effectcraft_svg::Group {
|
||||
let mut out = g.clone();
|
||||
out.children = vec![];
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Image(_) => *seen += 1,
|
||||
Node::Shape(_) => {
|
||||
if *seen == seg {
|
||||
out.children.push(c.clone());
|
||||
}
|
||||
}
|
||||
Node::Group(s) => {
|
||||
let p = prune(s, seg, seen);
|
||||
if !p.children.is_empty() {
|
||||
out.children.push(Node::Group(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let n = count(&doc.root);
|
||||
(0..=n)
|
||||
.map(|seg| {
|
||||
let mut d = doc.clone();
|
||||
d.root = prune(&doc.root, seg, &mut 0);
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A footage layer for an image of a vector document, parented to `parent` (the shape layer,
|
||||
/// whose layer space is document pixels) and placed by the image's transform (anchor at its
|
||||
/// top-left corner; position, rotation and scale from the matrix). Returns a note when the
|
||||
/// placement has a skew, which layer transforms cannot express.
|
||||
pub fn image_layer(proj: &mut Project, comp: &Comp, item: ItemId, img: &VectorImage, parent: &Layer) -> (Layer, Option<String>) {
|
||||
let mut l = build::layer(proj, comp, &img.name, LayerSource::Footage { item }, (img.width, img.height), None);
|
||||
let c = img.transform.as_coeffs();
|
||||
let theta = c[1].atan2(c[0]);
|
||||
let (sn, cs) = theta.sin_cos();
|
||||
let sx = (c[0] * c[0] + c[1] * c[1]).sqrt();
|
||||
let sy = -c[2] * sn + c[3] * cs;
|
||||
let skew = c[2] * cs + c[3] * sn;
|
||||
let set = |l: &mut Layer, path: &str, v: Value| {
|
||||
if let Some(p) = l.props.prop_mut(path) {
|
||||
p.value = v;
|
||||
}
|
||||
};
|
||||
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
|
||||
set(&mut l, "transform/position", Value::Vec3([c[4], c[5], 0.0]));
|
||||
set(&mut l, "transform/scale", Value::Vec3([sx * 100.0, sy * 100.0, 100.0]));
|
||||
set(&mut l, "transform/rotation", Value::Scalar(theta.to_degrees()));
|
||||
set(&mut l, "transform/opacity", Value::Scalar((img.opacity * 100.0).clamp(0.0, 100.0)));
|
||||
l.start_time = parent.start_time;
|
||||
l.in_point = parent.in_point;
|
||||
l.out_point = parent.out_point;
|
||||
l.stretch = parent.stretch;
|
||||
l.parent = Some(parent.id);
|
||||
let note = (skew.abs() > 1e-3 * sy.abs().max(1e-9)).then(|| format!("{}: skewed image placed without its skew", img.name));
|
||||
(l, note)
|
||||
}
|
||||
|
||||
/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`. A clip
|
||||
/// enclosing the whole document becomes the layer's masks (Add, then Intersect); nested clips
|
||||
/// are kept with Merge Paths (see [`svg_contents`]).
|
||||
pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Doc) -> Layer {
|
||||
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
|
||||
add_doc_shapes(proj, &mut l, doc);
|
||||
copy_transform(proj, src, &mut l);
|
||||
like_source(&mut l, src);
|
||||
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
|
||||
l
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste of SVG markup (After Effects 26.3 pastes copied vector artwork as shape layers):
|
||||
/// a shape layer named `name` with the document's shapes, its Anchor Point at the centre of the
|
||||
/// SVG canvas and its Position at the comp centre, so the canvas lands centred in the comp.
|
||||
pub fn shapes_from_svg(proj: &mut Project, comp: &Comp, doc: &Doc, name: &str) -> Layer {
|
||||
let mut l = build::layer(proj, comp, name, LayerSource::Shape, (0, 0), None);
|
||||
add_doc_shapes(proj, &mut l, doc);
|
||||
let (w, h) = (doc.width, doc.height);
|
||||
if w.is_finite()
|
||||
&& h.is_finite()
|
||||
&& let Some(a) = l.props.sub_mut("transform").and_then(|t| t.get_mut("anchor"))
|
||||
{
|
||||
a.value = Value::Vec3([w / 2.0, h / 2.0, 0.0]);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// The shapes of `doc` into shape layer `l`'s Contents; clips enclosing the whole document
|
||||
/// become its masks (Add, then Intersect).
|
||||
fn add_doc_shapes(proj: &mut Project, l: &mut Layer, doc: &Doc) {
|
||||
let (clips, doc) = vector_clip_masks(doc);
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
let items = svg_contents(&mut ids, &doc);
|
||||
if let Some(c) = l.props.sub_mut("contents") {
|
||||
c.children.extend(items.into_iter().map(Into::into));
|
||||
}
|
||||
let mut n = 0;
|
||||
for (k, clip) in clips.iter().enumerate() {
|
||||
for sp in effectcraft_path::from_kurbo(clip) {
|
||||
n += 1;
|
||||
let mode = if k == 0 { MaskMode::Add } else { MaskMode::Intersect };
|
||||
let mk = build::mask(&mut ids, &format!("Mask {n}"), sp, mode, [255, 255, 0]);
|
||||
if let Some(masks) = l.props.sub_mut("masks") {
|
||||
masks.children.push(mk.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer ▸ Create ▸ Create Shapes from Text: one group per character with its outline, fill and
|
||||
/// stroke (as laid out at the comp time `ctx` was made for).
|
||||
pub fn shapes_from_text(proj: &mut Project, comp: &Comp, ctx_project: &Project, cid: ItemId, src: &Layer, time: effectcraft_time::Tick) -> Option<Layer> {
|
||||
let ctx = effectcraft_render::EvalCtx::new(ctx_project, cid, comp, time);
|
||||
let geom = effectcraft_render::text::text_geom(&ctx, src)?;
|
||||
let chars: Vec<char> = geom.doc.text.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
|
||||
let mut groups = vec![];
|
||||
{
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
for (i, g) in geom.glyphs.iter().enumerate() {
|
||||
let path = g.path();
|
||||
if path.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut items = shape_paths(&mut ids, &path);
|
||||
if g.stroke_width > 0.0 && g.stroke[3] > 0.0 {
|
||||
let c = g.stroke;
|
||||
items.push(build::shape_stroke(&mut ids, [c[0] as f64, c[1] as f64, c[2] as f64, 1.0], g.stroke_width));
|
||||
}
|
||||
if g.apply_fill {
|
||||
let c = g.fill;
|
||||
let mut f = build::shape_fill(&mut ids, [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
|
||||
set(&mut f, "opacity", Value::Scalar(c[3] as f64 * 100.0));
|
||||
items.push(f);
|
||||
}
|
||||
let name = chars.get(i).map(|c| c.to_string()).unwrap_or_else(|| format!("Glyph {}", i + 1));
|
||||
groups.push(group(&mut ids, &name, items, 1.0));
|
||||
}
|
||||
}
|
||||
// First character on top, as in After Effects.
|
||||
if let Some(c) = l.props.sub_mut("contents") {
|
||||
c.children.extend(groups.into_iter().map(Into::into));
|
||||
}
|
||||
copy_transform(proj, src, &mut l);
|
||||
like_source(&mut l, src);
|
||||
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
|
||||
Some(l)
|
||||
}
|
||||
|
||||
/// Glyph outlines of a text layer in comp space (for Create Masks from Text).
|
||||
pub fn text_outlines_in_comp(project: &Project, cid: ItemId, comp: &Comp, src: &Layer, time: effectcraft_time::Tick) -> Vec<ShapePath> {
|
||||
let ctx = effectcraft_render::EvalCtx::new(project, cid, comp, time);
|
||||
let Some(geom) = effectcraft_render::text::text_geom(&ctx, src) else { return vec![] };
|
||||
let (m, _) = ctx.layer_to_comp(src);
|
||||
let mut out = vec![];
|
||||
for g in &geom.glyphs {
|
||||
let p = g.path();
|
||||
let p = effectcraft_path::transform(std::slice::from_ref(&p), &m).remove(0);
|
||||
out.extend(effectcraft_path::from_kurbo(&p));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A comp-sized white solid with one mask per glyph contour (Difference, so counters stay open).
|
||||
pub fn masks_layer(proj: &mut Project, comp: &Comp, name: &str, outlines: Vec<ShapePath>, solid: ItemId) -> Layer {
|
||||
let mut l = build::layer(proj, comp, name, LayerSource::Solid { item: solid }, (comp.width, comp.height), None);
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
if let Some(masks) = l.props.sub_mut("masks") {
|
||||
for (i, sp) in outlines.into_iter().enumerate() {
|
||||
masks.children.push(build::mask(&mut ids, &format!("Mask {}", i + 1), sp, MaskMode::Difference, [255, 255, 0]).into());
|
||||
}
|
||||
}
|
||||
l
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! File ▸ Save a Copy As XML…: the project as an XML document (`.ecprojx`), the counterpart of
|
||||
//! After Effects' `.aepx`. It is a lossless, element-per-value encoding of the `.ecproj` JSON, so
|
||||
//! File ▸ Open reads it back (and saving to a `.ecprojx` path writes XML again):
|
||||
//!
|
||||
//! ```xml
|
||||
//! <?xml version="1.0" encoding="UTF-8"?>
|
||||
//! <EffectCraftProject encoding="json" version="1">
|
||||
//! <obj>
|
||||
//! <m n="version"><num>3</num></m>
|
||||
//! <m n="items"><arr>…</arr></m>
|
||||
//! </obj>
|
||||
//! </EffectCraftProject>
|
||||
//! ```
|
||||
//!
|
||||
//! `obj`/`m n="key"` (object members), `arr`, `str`, `num`, `bool`, `null`.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
const ROOT: &str = "EffectCraftProject";
|
||||
|
||||
/// How deep `arr`/`obj` elements may nest: the JSON reader's own limit (`serde_json` stops at
|
||||
/// 128), so every project the JSON form can hold reads from XML too. Without a limit a deeply
|
||||
/// nested file overflowed the stack and aborted the app instead of failing to open (#779).
|
||||
const MAX_DEPTH: usize = 128;
|
||||
|
||||
/// Whether `text` looks like an XML project.
|
||||
pub fn is_xml(text: &str) -> bool {
|
||||
let t = text.trim_start_matches('\u{feff}').trim_start();
|
||||
t.starts_with("<?xml") || t.starts_with(&format!("<{ROOT}"))
|
||||
}
|
||||
|
||||
/// Whether a path names an XML project copy.
|
||||
pub fn is_xml_path(path: &str) -> bool {
|
||||
let p = path.to_ascii_lowercase();
|
||||
p.ends_with(".ecprojx") || p.ends_with(".xml")
|
||||
}
|
||||
|
||||
fn esc(s: &str, out: &mut String) {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\r' => out.push_str(" "),
|
||||
c if (c as u32) < 0x20 && c != '\n' && c != '\t' => out.push_str(&format!("&#{};", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(v: &Value, depth: usize, out: &mut String) {
|
||||
let pad = " ".repeat(depth);
|
||||
out.push_str(&pad);
|
||||
match v {
|
||||
Value::Null => out.push_str("<null/>"),
|
||||
Value::Bool(b) => out.push_str(&format!("<bool>{b}</bool>")),
|
||||
Value::Number(n) => out.push_str(&format!("<num>{n}</num>")),
|
||||
Value::String(s) => {
|
||||
out.push_str("<str>");
|
||||
esc(s, out);
|
||||
out.push_str("</str>");
|
||||
}
|
||||
Value::Array(a) if a.is_empty() => out.push_str("<arr/>"),
|
||||
Value::Array(a) => {
|
||||
out.push_str("<arr>\n");
|
||||
for x in a {
|
||||
write(x, depth + 1, out);
|
||||
}
|
||||
out.push_str(&pad);
|
||||
out.push_str("</arr>");
|
||||
}
|
||||
Value::Object(m) if m.is_empty() => out.push_str("<obj/>"),
|
||||
Value::Object(m) => {
|
||||
out.push_str("<obj>\n");
|
||||
for (k, x) in m {
|
||||
out.push_str(&" ".repeat(depth + 1));
|
||||
out.push_str("<m n=\"");
|
||||
esc(k, out);
|
||||
out.push_str("\">\n");
|
||||
write(x, depth + 2, out);
|
||||
out.push_str(&" ".repeat(depth + 1));
|
||||
out.push_str("</m>\n");
|
||||
}
|
||||
out.push_str(&pad);
|
||||
out.push_str("</obj>");
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
/// The project JSON as an XML document.
|
||||
pub fn to_xml(json: &str) -> Result<String, String> {
|
||||
let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
|
||||
let mut out = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<{ROOT} encoding=\"json\" version=\"1\">\n");
|
||||
write(&v, 1, &mut out);
|
||||
out.push_str(&format!("</{ROOT}>\n"));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
struct P<'a> {
|
||||
s: &'a str,
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> P<'a> {
|
||||
fn err<T>(&self, m: &str) -> Result<T, String> {
|
||||
Err(format!("XML project: {m} at byte {}", self.i))
|
||||
}
|
||||
fn rest(&self) -> &'a str {
|
||||
&self.s[self.i..]
|
||||
}
|
||||
/// Skip whitespace, comments and processing instructions.
|
||||
fn skip(&mut self) {
|
||||
loop {
|
||||
let t = self.rest();
|
||||
let trimmed = t.trim_start();
|
||||
self.i += t.len() - trimmed.len();
|
||||
if trimmed.starts_with("<?") {
|
||||
self.i += trimmed.find("?>").map_or(trimmed.len(), |e| e + 2);
|
||||
} else if trimmed.starts_with("<!--") {
|
||||
self.i += trimmed.find("-->").map_or(trimmed.len(), |e| e + 3);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// An opening tag: (name, attribute `n`, self-closing).
|
||||
fn open(&mut self) -> Result<(String, Option<String>, bool), String> {
|
||||
self.skip();
|
||||
if !self.rest().starts_with('<') || self.rest().starts_with("</") {
|
||||
return self.err("expected an element");
|
||||
}
|
||||
let end = self.rest().find('>').ok_or("XML project: unterminated tag")?;
|
||||
let inner = &self.rest()[1..end];
|
||||
self.i += end + 1;
|
||||
let (inner, closed) = match inner.strip_suffix('/') {
|
||||
Some(x) => (x, true),
|
||||
None => (inner, false),
|
||||
};
|
||||
let mut parts = inner.splitn(2, char::is_whitespace);
|
||||
let name = parts.next().unwrap_or_default().to_string();
|
||||
let attrs = parts.next().unwrap_or("");
|
||||
let n = attrs.find("n=\"").map(|a| {
|
||||
let v = &attrs[a + 3..];
|
||||
unesc(&v[..v.find('"').unwrap_or(v.len())])
|
||||
});
|
||||
Ok((name, n, closed))
|
||||
}
|
||||
fn close(&mut self, name: &str) -> Result<(), String> {
|
||||
self.skip();
|
||||
let want = format!("</{name}>");
|
||||
if self.rest().starts_with(&want) {
|
||||
self.i += want.len();
|
||||
Ok(())
|
||||
} else {
|
||||
self.err(&format!("expected {want}"))
|
||||
}
|
||||
}
|
||||
fn text(&mut self) -> String {
|
||||
let end = self.rest().find('<').unwrap_or(self.rest().len());
|
||||
let t = unesc(&self.rest()[..end]);
|
||||
self.i += end;
|
||||
t
|
||||
}
|
||||
fn value(&mut self, depth: usize) -> Result<Value, String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return self.err(&format!("elements nested deeper than {MAX_DEPTH} levels"));
|
||||
}
|
||||
let (name, _, closed) = self.open()?;
|
||||
let v = match (name.as_str(), closed) {
|
||||
("null", _) => Value::Null,
|
||||
("arr", true) => Value::Array(vec![]),
|
||||
("obj", true) => Value::Object(Map::new()),
|
||||
("str", true) => Value::String(String::new()),
|
||||
("bool", false) => Value::Bool(self.text().trim() == "true"),
|
||||
("num", false) => serde_json::from_str(self.text().trim()).map_err(|e| format!("XML project: bad number: {e}"))?,
|
||||
("str", false) => Value::String(self.text()),
|
||||
("arr", false) => {
|
||||
let mut a = vec![];
|
||||
loop {
|
||||
self.skip();
|
||||
if self.rest().starts_with("</") {
|
||||
break;
|
||||
}
|
||||
a.push(self.value(depth + 1)?);
|
||||
}
|
||||
Value::Array(a)
|
||||
}
|
||||
("obj", false) => {
|
||||
let mut m = Map::new();
|
||||
loop {
|
||||
self.skip();
|
||||
if self.rest().starts_with("</") {
|
||||
break;
|
||||
}
|
||||
let (tag, key, _) = self.open()?;
|
||||
if tag != "m" {
|
||||
return self.err("expected <m n=\"…\">");
|
||||
}
|
||||
let v = self.value(depth + 1)?;
|
||||
self.close("m")?;
|
||||
m.insert(key.unwrap_or_default(), v);
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
(other, _) => return self.err(&format!("unexpected <{other}>")),
|
||||
};
|
||||
if !closed && name != "null" {
|
||||
self.close(&name)?;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
fn unesc(s: &str) -> String {
|
||||
if !s.contains('&') {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(a) = rest.find('&') {
|
||||
out.push_str(&rest[..a]);
|
||||
rest = &rest[a..];
|
||||
let Some(e) = rest.find(';') else { break };
|
||||
let ent = &rest[1..e];
|
||||
match ent {
|
||||
"amp" => out.push('&'),
|
||||
"lt" => out.push('<'),
|
||||
"gt" => out.push('>'),
|
||||
"quot" => out.push('"'),
|
||||
"apos" => out.push('\''),
|
||||
n if n.starts_with("#x") => out.extend(u32::from_str_radix(&n[2..], 16).ok().and_then(char::from_u32)),
|
||||
n if n.starts_with('#') => out.extend(n[1..].parse::<u32>().ok().and_then(char::from_u32)),
|
||||
other => {
|
||||
out.push('&');
|
||||
out.push_str(other);
|
||||
out.push(';');
|
||||
}
|
||||
}
|
||||
rest = &rest[e + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The project JSON of an XML project document.
|
||||
pub fn from_xml(text: &str) -> Result<String, String> {
|
||||
let mut p = P { s: text.trim_start_matches('\u{feff}'), i: 0 };
|
||||
let (root, _, _) = p.open()?;
|
||||
if root != ROOT {
|
||||
return Err(format!("not an EffectCraft XML project (root <{root}>)"));
|
||||
}
|
||||
let v = p.value(0)?;
|
||||
p.close(ROOT)?;
|
||||
serde_json::to_string(&v).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn json_round_trips_through_xml() {
|
||||
let j = serde_json::json!({"a": [1, 2.5, -3e-7, true, null, "x<y & \"z\"\n\tq"], "b": {}, "c": [], "d": "", "é": {"n": "ünï"}});
|
||||
let xml = to_xml(&j.to_string()).unwrap();
|
||||
assert!(is_xml(&xml));
|
||||
assert!(xml.contains("<m n=\"a\">"));
|
||||
let back: Value = serde_json::from_str(&from_xml(&xml).unwrap()).unwrap();
|
||||
assert_eq!(back, j);
|
||||
assert!(from_xml("<Other/>").is_err());
|
||||
}
|
||||
|
||||
/// #779 (found by the project-file fuzzer): a file of deeply nested elements overflowed the
|
||||
/// stack, which aborts the app; it is now refused. Nesting the JSON reader takes still reads.
|
||||
#[test]
|
||||
fn deep_nesting_is_refused_not_a_stack_overflow() {
|
||||
let nested = |n: usize| format!("<{ROOT}>{}<null/>{}</{ROOT}>", "<arr>".repeat(n), "</arr>".repeat(n));
|
||||
let e = from_xml(&nested(1_000_000)).unwrap_err();
|
||||
assert!(e.contains("nested deeper"), "{e}");
|
||||
let ok = from_xml(&nested(100)).unwrap();
|
||||
assert!(serde_json::from_str::<Value>(&ok).is_ok());
|
||||
let json = format!("{}null{}", "[".repeat(MAX_DEPTH - 1), "]".repeat(MAX_DEPTH - 1));
|
||||
let v: Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&from_xml(&to_xml(&json).unwrap()).unwrap()).unwrap(), v);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user