Files
effectcraft/crates/script/src/model.rs
T
Brandon ThomasandClaude Opus 5.5 3ac283eee4 Review fixes for the landed batch
- Cryptomatte (#1183): planes shorter than the image read as empty instead of panicking.
- layer.addMarker (#1180): a comp marker no longer replaces one at the same time.
- Desktop (#1005): ignore macOS's -psn_ argument instead of refusing to launch.
- Doc comments put back on their own items (#1153, #1037).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:47:52 -04:00

790 lines
36 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),
"pointConvert" => point_convert(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)
}
/// Layer ids selected in `comp`, including a comp that is not the active one (#1001).
fn selected_layer_ids(s: &Session, comp: effectcraft_project::ItemId) -> Vec<u64> {
let layers = if s.active_comp_id() == Some(comp) {
&s.state.selected_layers
} else {
match s.state.comp_selections.get(&comp) {
Some(sel) => &sel.layers,
None => return Vec::new(),
}
};
let Some(c) = s.project.comp(comp) else { return Vec::new() };
layers.iter().filter(|l| c.layer(**l).is_some()).map(|l| l.0).collect()
}
fn layer_selected(s: &Session, comp: effectcraft_project::ItemId, layer: effectcraft_project::LayerId) -> bool {
if s.active_comp_id() == Some(comp) {
return s.state.selected_layers.contains(&layer);
}
s.state.comp_selections.get(&comp).is_some_and(|sel| sel.layers.contains(&layer))
}
/// 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);
// The item's proxy (footage and comps): its file and whether Use Proxy is on (#813).
o["proxy"] = match &it.proxy {
Some(p) => json!({"file": p.footage.path, "isStill": p.footage.kind == effectcraft_project::FootageKind::Still, "enabled": p.enabled}),
None => J::Null,
};
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 = selected_layer_ids(s, cid);
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);
// Interpret Footage ▸ Conform to frame rate: 0 when the file's own rate is used (#813).
o["conformFrameRate"] = json!(if fo.native_rate.is_some() { fo.frame_rate.as_f64() } else { 0.0 });
o["nativeFrameRate"] = json!(fo.native_rate.unwrap_or(fo.frame_rate).as_f64());
}
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": layer_selected(s, cid, 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",
// A light's options name it "ADBE Casts Shadows" in After Effects (#1063).
"adbecastsshadows" => "castsShadows",
"maskopacity" => "opacity",
"maskexpansion" | "maskoffset" => "expansion",
"sourcetext" => "sourceText",
"timeremap" => "timeRemap",
"audiolevels" => "levels",
_ => return None,
})
}
/// How many groups below a property group a string key may search (#1063: Pro Levels2's Alpha
/// controls sit one group down, under Alpha).
const NESTED_CHILD_DEPTH: u8 = 3;
/// The child of `g` that a string key names. The direct children are searched first, then the
/// groups nested below them, up to `depth` levels.
fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option<&'a Node> {
let chain_of = |n: &Node| -> String {
let mut ch = if g.uid == l.props.uid { vec![] } else { chain(l, g.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)))
.or_else(|| {
if depth == 0 {
return None;
}
g.children.iter().find_map(|n| match n {
Node::Group(sub) => find_child(l, sub, k, depth - 1),
Node::Prop(_) => 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) => find_child(l, g, k, NESTED_CHILD_DEPTH),
_ => 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();
// Sample expressions (`sampleImage`) read footage through the session's source; without
// it footage layers sample as transparent (#1000).
ctx.footage = Some(s.footage.as_ref());
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 key = |i: usize, k: &effectcraft_keyframe::Keyframe| -> J {
// 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),
})
};
// `index` (zero-based): only that key, as `{count, key}` (`key` null when out of range), so
// looking keys up one at a time doesn't serialise the whole list each time (#814).
if let Some(i) = u(a, "index") {
let k = usize::try_from(i).ok().and_then(|i| p.keys.get(i).map(|k| key(i, k)));
return Ok(json!({"count": p.keys.len(), "key": k}));
}
Ok(J::Array(p.keys.iter().enumerate().map(|(i, k)| key(i, k)).collect()))
}
/// 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))
}
/// A layer's source point in comp space (`toComp`) or the reverse, through the layer's transform
/// and its parents' at comp time (#808: `sourcePointToComp`, `compPointToSource`).
fn point_convert(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 p = a.get("point").and_then(J::as_array).ok_or("missing point")?;
let x = p.first().and_then(J::as_f64).unwrap_or(0.0);
let y = p.get(1).and_then(J::as_f64).unwrap_or(0.0);
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
ctx.expr = s.expr.as_deref();
let (layer_space, _) = ctx.layer_to_comp(l);
let m = if a.get("toComp").and_then(J::as_bool).unwrap_or(true) {
layer_space
} else {
layer_space.inverse().ok_or("the layer's transform can't be inverted")?
};
let k = m.0;
let w = k[2][0] * x + k[2][1] * y + k[2][2];
let (ox, oy) = (k[0][0] * x + k[0][1] * y + k[0][2], k[1][0] * x + k[1][1] * y + k[1][2]);
let (ox, oy) = if w != 0.0 && w.is_finite() { (ox / w, oy / w) } else { (ox, oy) };
Ok(json!([ox, oy]))
}
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"}),
// A stroke always holds Dash 1, Gap 1 and Offset (Dash 0 draws no dashes); adding one of
// them returns it, Dash/Gap 2 and 3 are added as a pair (`shape.dashes.add`) (#815).
"dashes" if in_group => match matchnames::stroke_dash(name) {
Some(id) => match g.children.iter().find(|n| n.match_id() == id) {
Some(n) => json!({"kind": "existing", "uid": n.uid()}),
None => json!({"kind": "dash", "id": id, "stroke": l.props.parent_of(g.uid).map(|p| p.uid)}),
},
None => json!({"error": format!("Can not add \"{name}\" to Dashes")}),
},
_ 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))
}