effectcraft/crates/script/src/model.rs

705 lines
32 KiB
Rust

//! Read-only queries behind the object model (`__query(kind, json)`): project, items, layers,
//! property nodes, values and keyframes as JSON shaped for the prelude. Every edit goes through
//! engine commands instead (`__exec`), so it is undoable and journaled.
use effectcraft_engine::Session;
use effectcraft_engine::color::Label;
use effectcraft_keyframe::{Interp, TextDoc, Value as KV};
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, PropGroup};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
use serde_json::{Value as J, json};
use crate::matchnames;
type R = Result<J, String>;
fn u(a: &J, k: &str) -> Option<u64> {
a.get(k).and_then(J::as_u64)
}
fn f(a: &J, k: &str) -> Option<f64> {
a.get(k).and_then(J::as_f64)
}
fn comp_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp), String> {
let id = ItemId(u(a, "comp").ok_or("missing comp")?);
s.project.comp(id).map(|c| (id, c)).ok_or_else(|| "the composition no longer exists".to_string())
}
fn layer_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp, &'a Layer), String> {
let (cid, c) = comp_of(s, a)?;
let l = c.layer(LayerId(u(a, "layer").ok_or("missing layer")?)).ok_or("the layer no longer exists (it was deleted)")?;
Ok((cid, c, l))
}
pub fn query(s: &mut Session, kind: &str, a: &J) -> R {
match kind {
"project" => Ok(project(s)),
"item" => item(s, a),
"layer" => layer(s, a),
"node" => node(s, a),
"child" => child(s, a),
"value" => value(s, a),
"keys" => keys(s, a),
"textdoc" => textdoc(s, a),
"markers" => markers(s, a),
"sourceRect" => source_rect(s, a),
"resolveAdd" => resolve_add(s, a),
"menuCommandId" => Ok(menu_command_id(a.get("name").and_then(J::as_str).unwrap_or(""))),
"commandAt" => {
let i = u(a, "index").unwrap_or(0) as usize;
Ok(effectcraft_engine::command_specs().get(i.wrapping_sub(1)).map(|c| json!(c.id)).unwrap_or(J::Null))
}
"exprError" => {
let code = a.get("code").and_then(J::as_str).unwrap_or("");
Ok(match s.expr_check {
Some(check) => json!(check(code).err().unwrap_or_default()),
None => json!(""),
})
}
"labels" => Ok(json!(Label::ALL.iter().map(|l| l.name()).collect::<Vec<_>>())),
"effects" => Ok(json!(
effectcraft_engine::effects::all()
.into_iter()
.map(|e| json!({"id": e.id, "name": e.name, "matchName": matchnames::effect(e.id), "category": e.category}))
.collect::<Vec<_>>()
)),
_ => Err(format!("unknown query `{kind}`")),
}
}
fn project(s: &Session) -> J {
let p = &s.project;
let items: Vec<u64> = p.items.keys().map(|k| k.0).collect();
let active = s.active_comp_id().map(|c| c.0).or_else(|| (s.state.project_selection.len() == 1).then(|| s.state.project_selection[0].0));
json!({
"items": items,
"active": active,
"selection": s.state.project_selection.iter().map(|i| i.0).collect::<Vec<_>>(),
"path": s.path,
"dirty": s.is_dirty(),
"bitsPerChannel": p.settings.bit_depth.bits(),
"linearBlending": p.settings.blend_linear,
"linearizeWorkingSpace": p.settings.linearize,
"workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(),
"workingGamma": if p.settings.working_gamma_24() { 2.4 } else { 2.2 },
"timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames,
"renderQueue": p.render_queue.len(),
"revision": s.revision,
})
}
fn label_index(l: Label) -> usize {
Label::ALL.iter().position(|x| *x == l).unwrap_or(0)
}
/// A comp's selected properties as `[layer, uid]`: the selected ones, then those with selected
/// keyframes (selecting a keyframe selects its property in After Effects, so scripts find
/// selected keys through `selectedProperties`).
fn selected_props(s: &Session, c: &effectcraft_project::Comp) -> Vec<J> {
let mut v: Vec<(u64, u64)> = vec![];
let keyed = s.state.selected_keys.iter().map(|k| (k.layer, k.prop));
for (l, p) in s.state.selected_props.iter().copied().chain(keyed) {
if c.layer(l).is_some() && !v.contains(&(l.0, p)) {
v.push((l.0, p));
}
}
v.into_iter().map(|(l, p)| json!([l, p])).collect()
}
fn item(s: &Session, a: &J) -> R {
let id = ItemId(u(a, "id").ok_or("missing id")?);
let it = s.project.item(id).ok_or("the item no longer exists (it was deleted)")?;
let mut o = json!({
"id": id.0,
"name": it.name,
"typeName": it.type_name(),
"parent": it.parent.map(|p| p.0).unwrap_or(0),
"comment": it.comment,
"label": label_index(it.label),
"selected": s.state.project_selection.contains(&id),
});
let used_in: Vec<u64> = s.project.comps().filter(|(_, c)| c.layers.iter().any(|l| l.source.item() == Some(id))).map(|(cid, _)| cid.0).collect();
o["usedIn"] = json!(used_in);
match &it.kind {
ItemKind::Folder => {
o["kind"] = json!("folder");
o["children"] = json!(s.project.items.values().filter(|c| c.parent == Some(id)).map(|c| c.id.0).collect::<Vec<_>>());
}
ItemKind::Comp(c) => {
let cid = id;
o["kind"] = json!("comp");
let sel: Vec<u64> = s.state.selected_layers.iter().filter(|l| c.layer(**l).is_some()).map(|l| l.0).collect();
let fps = c.frame_rate.as_f64();
if let Some(m) = o.as_object_mut() {
m.extend(
json!({
"width": c.width, "height": c.height, "pixelAspect": c.pixel_aspect,
"duration": c.duration.seconds(), "frameRate": fps, "frameDuration": c.frame_duration().seconds(),
"layers": c.layers.iter().map(|l| l.id.0).collect::<Vec<_>>(),
"selectedLayers": sel,
"selectedProperties": selected_props(s, c),
"time": s.time_of(cid).seconds(),
"workAreaStart": c.work_area.0.seconds(), "workAreaDuration": (c.work_area.1 - c.work_area.0).seconds(),
"bgColor": c.background, "displayStartTime": c.display_start.seconds(),
"hideShyLayers": c.hide_shy, "motionBlur": c.enable_motion_blur, "frameBlending": c.enable_frame_blending,
"draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase,
"motionBlurSamplesPerFrame": c.motion_blur_samples,
"motionBlurAdaptiveSampleLimit": c.motion_blur_adaptive_limit,
"preserveNestedFrameRate": c.preserve_frame_rate, "preserveNestedResolution": c.preserve_resolution,
"renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" },
"open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid),
"hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })),
"posterTime": c.poster_time.seconds(),
})
.as_object()
.cloned()
.unwrap_or_default(),
)
}
}
ItemKind::Footage(fo) => {
o["kind"] = json!("footage");
o["file"] = json!(fo.path);
o["width"] = json!(fo.width);
o["height"] = json!(fo.height);
o["pixelAspect"] = json!(fo.pixel_aspect);
o["duration"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.duration.seconds() });
o["frameRate"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.frame_rate.as_f64() });
o["hasVideo"] = json!(fo.has_video);
o["hasAudio"] = json!(fo.has_audio);
o["footageMissing"] = json!(fo.missing);
o["isStill"] = json!(fo.kind == effectcraft_project::FootageKind::Still);
o["loop"] = json!(fo.loop_count);
}
ItemKind::Solid(so) => {
o["kind"] = json!("solid");
o["width"] = json!(so.width);
o["height"] = json!(so.height);
o["pixelAspect"] = json!(so.pixel_aspect);
o["color"] = json!(so.color);
o["duration"] = json!(0.0);
o["frameRate"] = json!(0.0);
o["hasVideo"] = json!(true);
o["hasAudio"] = json!(false);
o["isStill"] = json!(true);
}
}
Ok(o)
}
fn layer(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let kind = match &l.source {
LayerSource::Text => "text",
LayerSource::Shape => "shape",
LayerSource::Camera => "camera",
LayerSource::Light { .. } => "light",
_ => "av",
};
let (w, h) = effectcraft_render::source_size(&s.project, l);
let t = s.time_of(cid);
Ok(json!({
"id": l.id.0,
"comp": cid.0,
"kind": kind,
"name": l.name,
"index": c.index_of(l.id).unwrap_or(0),
"matchName": matchnames::layer(&l.source),
"root": l.props.uid,
"source": l.source.item().map(|i| i.0),
"nullLayer": matches!(l.source, LayerSource::Null),
"lightType": match &l.source { LayerSource::Light { kind } => kind.label(), _ => "" },
"enabled": l.switches.video,
"audioEnabled": l.switches.audio,
"solo": l.switches.solo,
"locked": l.switches.locked,
"shy": l.switches.shy,
"collapse": l.switches.collapse,
"quality": format!("{:?}", l.switches.quality),
"samplingQuality": format!("{:?}", l.switches.sampling),
"effectsActive": l.switches.effects,
"frameBlending": format!("{:?}", l.switches.frame_blend),
"motionBlur": l.switches.motion_blur,
"adjustmentLayer": l.switches.adjustment,
"threeDLayer": l.switches.three_d,
"guideLayer": l.switches.guide,
"preserveTransparency": l.preserve_transparency,
"inPoint": l.in_point.seconds(),
"outPoint": l.out_point.seconds(),
"startTime": l.start_time.seconds(),
"stretch": l.stretch,
"time": l.layer_time(t).seconds(),
"active": l.switches.video && l.is_active_at(t),
"parent": l.parent.map(|p| p.0),
"blendingMode": l.blend_mode.label(),
"trackMatte": l.track_matte.map(|m| json!({"layer": m.layer.0, "kind": format!("{:?}", m.kind)})),
"isTrackMatte": c.layers.iter().any(|x| x.track_matte.is_some_and(|m| m.layer == l.id)),
"label": label_index(l.label),
"comment": l.comment,
"selected": s.state.selected_layers.contains(&l.id),
"autoOrient": format!("{:?}", l.auto_orient),
"width": w, "height": h,
"hasVideo": l.source.is_av(),
"hasAudio": matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }) && l.props.sub("audio").is_some(),
"timeRemapEnabled": l.props.prop("timeRemap").is_some(),
}))
}
/// Node at the end of a uid chain below the layer root, and the chain itself.
fn chain(l: &Layer, uid: u64) -> Option<Vec<&Node>> {
l.props.node_chain(uid)
}
/// PropertyValueType of a property.
fn value_type(p: &effectcraft_project::Property) -> &'static str {
if matches!(p.ui, ParamUi::Mask) {
return "MASK_INDEX";
}
match &p.value {
KV::Scalar(_) | KV::Bool(_) | KV::Enum(_) => "OneD",
KV::Vec2(_) => {
if p.spatial {
"TwoD_SPATIAL"
} else {
"TwoD"
}
}
KV::Vec3(_) => {
if p.spatial {
"ThreeD_SPATIAL"
} else {
"ThreeD"
}
}
KV::Color(_) => "COLOR",
KV::Path(_) => "SHAPE",
KV::Text(_) => "TEXT_DOCUMENT",
KV::Layer(_) => "LAYER_INDEX",
KV::Gradient(_) | KV::Str(_) => "CUSTOM_VALUE",
}
}
/// Groups whose children are instances (effects, masks, shape items, animators).
fn indexed_parade(g: &PropGroup) -> bool {
matches!(g.match_id.as_str(), "effects" | "masks" | "contents" | "animators" | "selectors" | "layerStyles" | "motionTrackers")
}
fn node_json(c: &Comp, l: &Layer, uid: u64) -> R {
if uid == 0 || uid == l.props.uid {
let n = l.props.children.len();
return Ok(json!({
"uid": l.props.uid, "kind": "group", "name": l.name, "matchName": matchnames::layer(&l.source), "matchId": "layer",
"index": c.index_of(l.id).unwrap_or(0), "depth": 0, "parent": null, "isLayer": true, "numProperties": n,
"children": l.props.children.iter().map(Node::uid).collect::<Vec<_>>(), "propertyType": "NAMED_GROUP",
"enabled": l.switches.video, "canSetEnabled": true,
}));
}
let ch = chain(l, uid).ok_or("the property no longer exists (it was deleted)")?;
let node = *ch.last().ok_or("bad property")?;
let parent_group: &PropGroup = if ch.len() >= 2 { ch[ch.len() - 2].as_group().ok_or("bad tree")? } else { &l.props };
let index = parent_group.children.iter().position(|x| x.uid() == uid).map(|i| i + 1).unwrap_or(0);
let parent_uid = if ch.len() >= 2 { ch[ch.len() - 2].uid() } else { l.props.uid };
let match_name = matchnames::node(&ch);
let instance_parent = indexed_parade(parent_group) && ch.len() >= 2 || (ch.len() == 2 && ch[0].match_id() == "contents");
let mut o = json!({
"uid": uid, "name": node.name(), "matchName": match_name, "matchId": node.match_id(), "index": index,
"depth": ch.len(), "parent": parent_uid, "isLayer": false,
"isEffect": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Effect { .. })),
"isMask": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Mask { .. })),
});
match node {
Node::Group(g) => {
let instance = matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. });
o["kind"] = json!("group");
o["numProperties"] = json!(g.children.len());
o["children"] = json!(g.children.iter().map(Node::uid).collect::<Vec<_>>());
o["propertyType"] = json!(if indexed_parade(g) { "INDEXED_GROUP" } else { "NAMED_GROUP" });
o["enabled"] = json!(g.enabled);
o["canSetEnabled"] = json!(instance);
o["instance"] = json!(instance || instance_parent);
o["effectId"] = json!(match &g.kind {
GroupKind::Effect { effect } => Some(effect.clone()),
_ => None,
});
if let GroupKind::Mask { mode, inverted, color, locked, .. } = &g.kind {
o["maskMode"] = json!(mode.label());
o["inverted"] = json!(inverted);
o["color"] = json!([color[0] as f64 / 255.0, color[1] as f64 / 255.0, color[2] as f64 / 255.0]);
o["locked"] = json!(locked);
}
}
Node::Prop(p) => {
o["kind"] = json!("prop");
o["propertyType"] = json!("PROPERTY");
o["valueType"] = json!(value_type(p));
o["type"] = json!(p.value.kind_name());
o["dims"] = json!(p.value.dims());
o["spatial"] = json!(p.spatial);
o["interpolates"] = json!(p.value.interpolates());
o["numKeys"] = json!(p.keys.len());
o["expression"] = json!(p.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default());
o["expressionEnabled"] = json!(p.has_expression());
o["canVaryOverTime"] = json!(!p.static_only);
o["canSetExpression"] = json!(!p.static_only && !matches!(p.ui, ParamUi::Hidden));
o["hidden"] = json!(matches!(p.ui, ParamUi::Hidden));
o["enabled"] = json!(true);
o["canSetEnabled"] = json!(false);
o["isModified"] = json!(p.is_animated() || p.has_expression());
if let ParamUi::Slider { min, max, .. } = p.ui {
o["min"] = json!(min);
o["max"] = json!(max);
}
if let ParamUi::Popup { options } = &p.ui {
o["options"] = json!(options);
}
o["ui"] = json!(match &p.ui {
ParamUi::Percent => "%",
ParamUi::Angle => "degrees",
ParamUi::Pixels | ParamUi::Point | ParamUi::Point3 => "pixels",
_ => "",
});
// Position with Separate Dimensions.
o["separated"] = json!(node.match_id() == "position" && parent_group.match_id == "transform" && parent_group.get("positionX").is_some());
o["separationFollower"] = json!(matches!(node.match_id(), "positionX" | "positionY" | "positionZ"));
}
}
Ok(o)
}
fn node(s: &Session, a: &J) -> R {
let (_, c, l) = layer_of(s, a)?;
node_json(c, l, u(a, "uid").unwrap_or(0))
}
/// Normalized name for loose lookups: `Anchor Point`, `anchorPoint`, `anchor_point` → `anchorpoint`.
fn loose(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase()
}
/// AE-style aliases for our match ids (attribute names scripts use).
fn alias(k: &str) -> Option<&'static str> {
Some(match k {
"anchorpoint" => "anchor",
"pointofinterest" => "poi",
"xrotation" => "rotationX",
"yrotation" => "rotationY",
"zrotation" => "rotation",
"xposition" => "positionX",
"yposition" => "positionY",
"zposition" => "positionZ",
"effect" | "effects" => "effects",
"mask" | "masks" => "masks",
"content" | "contents" => "contents",
"maskshape" | "maskpath" => "path",
"maskfeather" => "feather",
"maskopacity" => "opacity",
"maskexpansion" | "maskoffset" => "expansion",
"sourcetext" => "sourceText",
"timeremap" => "timeRemap",
"audiolevels" => "levels",
_ => return None,
})
}
fn child(s: &Session, a: &J) -> R {
let (_, c, l) = layer_of(s, a)?;
let uid = u(a, "uid").unwrap_or(0);
let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? };
let key = a.get("key").cloned().unwrap_or(J::Null);
let found = match &key {
J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)),
J::String(k) => {
let chain_of = |n: &Node| -> String {
let mut ch = if uid == 0 || uid == l.props.uid { vec![] } else { chain(l, uid).unwrap_or_default() };
ch.push(n);
matchnames::node(&ch)
};
let lk = loose(k);
g.children
.iter()
.find(|n| chain_of(n) == *k)
.or_else(|| g.children.iter().find(|n| n.name() == k))
.or_else(|| g.children.iter().find(|n| n.match_id() == k))
.or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k)))
.or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk))
.or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m)))
}
_ => None,
};
match found {
Some(n) => node_json(c, l, n.uid()),
None => Ok(J::Null),
}
}
/// A value as the object model shows it: popups 1-based, checkboxes 0/1, layer references as
/// layer indices, paths as `{vertices, inTangents, outTangents, closed}`, text as the document.
fn js_value(c: &Comp, v: &KV) -> J {
match v {
KV::Bool(b) => json!(if *b { 1 } else { 0 }),
KV::Enum(i) => json!(*i + 1),
KV::Layer(l) => json!(l.and_then(|id| c.index_of(LayerId(id))).unwrap_or(0)),
KV::Path(p) => json!({"vertices": p.vertices, "inTangents": p.in_tangents, "outTangents": p.out_tangents, "closed": p.closed}),
KV::Text(d) => text_json(d),
v => v.to_json(),
}
}
/// A text document for `TextDocument`: the serialized document plus the first character's style.
fn text_json(d: &TextDoc) -> J {
let mut doc = serde_json::to_value(d).unwrap_or(J::Null);
let st = d.style_at(0);
if let Some(o) = doc.as_object_mut() {
o.insert("first".into(), effectcraft_keyframe::text_doc::char_style_json(&st));
o.insert("para".into(), effectcraft_keyframe::text_doc::para_style_json(&d.para(0)));
}
doc
}
fn value(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let uid = u(a, "uid").ok_or("missing uid")?;
let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?;
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let pre = a.get("pre").and_then(J::as_bool).unwrap_or(false);
let v = if pre || !p.has_expression() {
p.value_at(l.layer_time(t))
} else {
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
ctx.expr = s.expr.as_deref();
ctx.value(l, p)
};
Ok(js_value(c, &v))
}
fn interp(i: Interp) -> &'static str {
match i {
Interp::Linear => "LINEAR",
Interp::Bezier => "BEZIER",
Interp::Hold => "HOLD",
}
}
fn keys(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let uid = u(a, "uid").ok_or("missing uid")?;
let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?;
let sel: Vec<Tick> = s.state.selected_keys.iter().filter(|k| k.layer == l.id && k.prop == uid).map(|k| k.time).collect();
let _ = cid;
let dims = if p.spatial { 1 } else { p.value.dims().max(1) };
// Each side's ease as it plays (After Effects reports linear and auto-Bezier sides this way
// too), else as stored; influence is stored as a fraction, scripts see percent.
let ease = |i: usize, out: bool| -> J {
let Some(k) = p.keys.get(i) else { return json!([]) };
let stored = if out { &k.out_ease } else { &k.in_ease };
let v: Vec<J> = (0..dims)
.map(|d| {
let e =
effectcraft_keyframe::side_ease(&p.keys, i, d, p.spatial, out).or_else(|| stored.get(d).or(stored.first()).copied()).unwrap_or_default();
json!({"speed": e.speed, "influence": e.influence * 100.0})
})
.collect();
J::Array(v)
};
let out: Vec<J> = p
.keys
.iter()
.enumerate()
.map(|(i, k)| {
// Spatial tangents as the motion path plays (auto-Bezier and continuous keys too).
let (spatial_in, spatial_out) = if p.spatial { effectcraft_keyframe::spatial_tangents(&p.keys, i) } else { (k.spatial_in, k.spatial_out) };
json!({
"time": l.comp_time(k.time).seconds(),
"layerTime": k.time.seconds(),
"value": js_value(c, &k.value),
"inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp),
"inEase": ease(i, false), "outEase": ease(i, true),
"continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving,
"spatialIn": spatial_in, "spatialOut": spatial_out,
"spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto,
"selected": sel.contains(&k.time),
})
})
.collect();
Ok(J::Array(out))
}
/// Apply `layer.setText` attribute keys to the Source Text document at `time` and return the new
/// document (for `prop.set` / `prop.addKey`).
fn textdoc(s: &Session, a: &J) -> R {
let (cid, _, l) = layer_of(s, a)?;
let uid = u(a, "uid").ok_or("missing uid")?;
let p = l.props.find(uid).ok_or("the property no longer exists")?;
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let KV::Text(doc) = p.value_at(l.layer_time(t)) else { return Err("not a Source Text property".into()) };
let mut d = *doc;
if let Some(attrs) = a.get("attrs").and_then(J::as_object) {
if let Some(text) = attrs.get("text").and_then(J::as_str) {
d.set_text(text);
}
for (k, v) in attrs {
if k == "text" || v.is_null() {
continue;
}
match d.set_attr(k, v, None) {
Ok(true) => {}
Ok(false) => return Err(format!("TextDocument: unknown attribute `{k}`")),
Err(e) => return Err(format!("TextDocument.{k}: {e}")),
}
}
}
serde_json::to_value(&d).map_err(|e| e.to_string())
}
fn markers(s: &Session, a: &J) -> R {
let (_, c) = comp_of(s, a)?;
let (list, to_comp): (&[effectcraft_project::Marker], Option<&Layer>) = match u(a, "layer") {
Some(lid) => {
let l = c.layer(LayerId(lid)).ok_or("the layer no longer exists")?;
(&l.markers, Some(l))
}
None => (&c.markers, None),
};
let out: Vec<J> = list
.iter()
.map(|m| {
let t = to_comp.map(|l| l.comp_time(m.time)).unwrap_or(m.time);
json!({
"time": t.seconds(), "duration": m.duration.seconds(), "comment": m.comment, "chapter": m.chapter, "url": m.url,
"frameTarget": m.frame_target, "label": label_index(m.label), "protectedRegion": m.protected,
"cuePointName": m.cue_point.as_ref().map(|c| c.name.clone()).unwrap_or_default(),
"eventCuePoint": m.cue_point.as_ref().is_some_and(|c| !c.navigation),
"params": m.cue_point.as_ref().map(|c| c.params.clone()).unwrap_or_default(),
})
})
.collect();
Ok(J::Array(out))
}
fn source_rect(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
ctx.expr = s.expr.as_deref();
let r = match &l.source {
LayerSource::Text => {
let paths: Vec<_> = effectcraft_render::text::glyph_paths(&ctx, l).into_iter().map(|(p, _)| p).collect();
effectcraft_path::bounds(&paths).map(|b| [b.y0, b.x0, b.width(), b.height()]).unwrap_or([0.0; 4])
}
LayerSource::Shape => match l.props.sub("contents") {
Some(contents) => {
let buf = effectcraft_render::shapes::render(&ctx, l, contents, 1.0);
if buf.img.width <= 4 { [0.0; 4] } else { [2.0 - buf.offset[1], 2.0 - buf.offset[0], buf.img.width as f64 - 4.0, buf.img.height as f64 - 4.0] }
}
None => [0.0; 4],
},
_ => {
let (w, h) = effectcraft_render::source_size(&s.project, l);
[0.0, 0.0, w as f64, h as f64]
}
};
Ok(json!({"top": r[0], "left": r[1], "width": r[2], "height": r[3]}))
}
/// What `PropertyGroup.addProperty(name)` adds to the group `uid`.
fn resolve_add(s: &Session, a: &J) -> R {
let (_, _, l) = layer_of(s, a)?;
let uid = u(a, "uid").unwrap_or(0);
let name = a.get("name").and_then(J::as_str).unwrap_or("");
let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? };
let in_contents = g.match_id == "contents";
let in_group = l.props.sub("contents").is_some_and(|c| c.uid == g.uid || c.find_group(g.uid).is_some());
Ok(match g.match_id.as_str() {
"effects" => {
let id = matchnames::effect_from_match(name).map(str::to_string).or_else(|| effectcraft_engine::effects::lookup(name).map(|e| e.id.to_string()));
match id {
Some(id) => json!({"kind": "effect", "effect": id}),
None => json!({"error": format!("Can not add a property with name \"{name}\" to this PropertyGroup (unknown effect)")}),
}
}
"masks" => {
if name == "ADBE Mask Atom" || loose(name) == "mask" {
json!({"kind": "mask"})
} else {
json!({"error": format!("Can not add \"{name}\" to Masks (use \"ADBE Mask Atom\")")})
}
}
"animators" => json!({"kind": "animator"}),
_ if in_contents && in_group => match matchnames::shape_kind(name) {
Some(k) => {
json!({"kind": "shape", "shape": k, "group": if l.props.sub("contents").is_some_and(|c| c.uid == g.uid) { J::Null } else { json!(g.uid) }})
}
None => json!({"error": format!("Can not add \"{name}\" to Contents")}),
},
"properties" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => {
let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid));
match text_animator_property(name) {
Some(k) => json!({"kind": "animatorProperty", "property": k, "animator": animator}),
None => json!({"error": format!("Can not add \"{name}\" to an animator's Properties")}),
}
}
"selectors" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => {
let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid));
let kind = match name {
"ADBE Text Wiggly Selector" => "wiggly",
"ADBE Text Expressible Selector" => "expression",
n if loose(n).contains("wiggly") => "wiggly",
n if loose(n).contains("expression") => "expression",
_ => "range",
};
json!({"kind": "selector", "selector": kind, "animator": animator})
}
_ => json!({"error": format!("Can not add a property to \"{}\"", g.name)}),
})
}
/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match
/// name or a display name.
fn text_animator_property(name: &str) -> Option<&'static str> {
const AE: &[(&str, &str)] = &[
("ADBE Text Anchor Point 3D", "anchor"),
("ADBE Text Position 3D", "position"),
("ADBE Text Scale 3D", "scale"),
("ADBE Text Skew", "skew"),
("ADBE Text Rotation", "rotation"),
("ADBE Text Opacity", "opacity"),
("ADBE Text Fill Color", "fillColor"),
("ADBE Text Fill Hue", "fillHue"),
("ADBE Text Fill Saturation", "fillSaturation"),
("ADBE Text Fill Brightness", "fillBrightness"),
("ADBE Text Fill Opacity", "fillOpacity"),
("ADBE Text Stroke Color", "strokeColor"),
("ADBE Text Stroke Hue", "strokeHue"),
("ADBE Text Stroke Saturation", "strokeSaturation"),
("ADBE Text Stroke Brightness", "strokeBrightness"),
("ADBE Text Stroke Opacity", "strokeOpacity"),
("ADBE Text Stroke Width", "strokeWidth"),
("ADBE Text Tracking Amount", "tracking"),
("ADBE Text Line Anchor", "lineAnchor"),
("ADBE Text Line Spacing", "lineSpacing"),
("ADBE Text Character Offset", "characterOffset"),
("ADBE Text Character Replace", "characterValue"),
("ADBE Text Blur", "blur"),
];
if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) {
return Some(k);
}
let n = loose(name);
AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
}
/// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items
/// first), 0 when none does.
fn menu_command_id(name: &str) -> J {
let norm = |s: &str| s.trim().trim_end_matches("...").trim_end_matches('…').trim().to_ascii_lowercase();
let n = norm(name);
let specs = effectcraft_engine::command_specs();
let pick = specs
.iter()
.position(|c| !c.menu.is_empty() && norm(c.label) == n)
.or_else(|| specs.iter().position(|c| norm(c.label) == n))
.or_else(|| specs.iter().position(|c| c.id == name));
json!(pick.map(|i| i + 1).unwrap_or(0))
}