//! 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; fn u(a: &J, k: &str) -> Option { a.get(k).and_then(J::as_u64) } fn f(a: &J, k: &str) -> Option { 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::>())), "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::>() )), _ => Err(format!("unknown query `{kind}`")), } } fn project(s: &Session) -> J { let p = &s.project; let items: Vec = 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::>(), "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, "displayStartFrame": p.settings.frame_start, "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 { 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 { 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 = 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::>()); } 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::>(), "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> { 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::>(), "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::>()); 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::().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 = 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 = (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 = 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> { let table = matchnames::TEXT_ANIMATOR_PROPERTIES; if let Some((k, _)) = table.iter().find(|(_, m)| m.eq_ignore_ascii_case(name)) { return Some(k); } let n = loose(name); table.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)) }