mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-22 18:22:19 +00:00
What: the Composition panel's Resolution (Auto, Full, Half, Third, Quarter, Custom) is now a
property of the composition (`Comp::resolution`, moved with its `Resolution` type from the UI
into the project crate) instead of one global viewer setting. Switching to another comp shows
that comp's resolution, switching back shows the first one's again, and saving and reopening the
project keeps them. `view.res.full|half|third|quarter|custom` and the new engine command
`view.res.auto` set it (headless too, with an optional `comp`); `ui.set {viewer: {res}}` sets
the active comp's and `ui.inspect` still reports `ui.viewer.res`; `comp.info` reports
`resolution` and `resolutionFactor`; View ▸ Resolution's check marks come from the engine.
Why: After Effects saves each composition's Resolution with the project (CompItem.resolutionFactor
is a per-comp attribute). As there, changing it marks the project modified but adds no undo step:
undo, redo and History jumps carry the current values over to the restored state. It is not part
of the comp's pixels (`Comp::same_pixels` ignores it and `Comp::pixel_form` normalises it), so
changing it keeps the comp's RAM and disk cache keys. Auto is not written (serde default and
skip_serializing_if), so projects saved before open with Auto and older versions read new files.
The magnification is not saved per comp: Adobe's documentation doesn't say it is.
Tests: project `comp_resolution_is_saved_and_old_projects_load_with_auto`,
`resolution_factors_and_scales`; engine
`each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file` (switching comps,
no undo step, undo/redo keep it, save/open round trip, menu check marks, Custom dialog);
ui-egui `each_comp_keeps_its_viewer_resolution` (the viewer popup on comp A, comp B through
`ui.set`, back to A, unchanged frame cache identity, save/open). They don't build before the
change (no `Comp::resolution`, no `view.res.auto` engine command).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
212 lines
9.6 KiB
Rust
212 lines
9.6 KiB
Rust
//! Read-only queries for agents (not journaled).
|
|
|
|
use effectcraft_project::{GroupKind, ItemKind, Layer, Node, PropGroup};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, b_p, comp_id, layer_p, str_p};
|
|
use crate::{EngineError, Result, Session, query};
|
|
|
|
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
|
|
let only = b_p(p, "enabledOnly").unwrap_or(false);
|
|
let schemas = b_p(p, "schemas").unwrap_or(false);
|
|
let v: Vec<Value> = super::command_specs()
|
|
.iter()
|
|
.filter(|c| filter.as_ref().is_none_or(|f| c.id.to_ascii_lowercase().contains(f) || c.label.to_ascii_lowercase().contains(f)))
|
|
.filter_map(|c| {
|
|
let en = (c.enabled)(s);
|
|
if only && en.is_err() {
|
|
return None;
|
|
}
|
|
let mut o =
|
|
json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": en.is_ok(), "why": en.err()});
|
|
if schemas {
|
|
o["schema"] = super::params_schema(c);
|
|
}
|
|
Some(o)
|
|
})
|
|
.collect();
|
|
Ok(json!(v))
|
|
}
|
|
|
|
fn summary(s: &mut Session, _: &Value) -> Result<Value> {
|
|
let items: Vec<Value> = s
|
|
.project
|
|
.items
|
|
.values()
|
|
.map(|i| {
|
|
let mut o = json!({"id": i.id.0, "name": i.name, "type": i.type_name(), "label": i.label.name(), "parent": i.parent.map(|p| p.0)});
|
|
if let Some((w, h)) = i.dimensions() {
|
|
o["size"] = json!([w, h]);
|
|
}
|
|
if let Some(d) = i.duration() {
|
|
o["duration"] = json!(d.seconds());
|
|
}
|
|
if let ItemKind::Comp(c) = &i.kind {
|
|
o["layers"] = json!(c.layers.len());
|
|
o["frameRate"] = json!(c.frame_rate.as_f64());
|
|
}
|
|
o
|
|
})
|
|
.collect();
|
|
Ok(json!({
|
|
"path": s.path,
|
|
"dirty": s.is_dirty(),
|
|
"bitDepth": s.project.settings.bit_depth.label(),
|
|
"renderer": super::file::backend_status(s),
|
|
"items": items,
|
|
"activeComp": s.state.active_comp.map(|c| c.0),
|
|
"time": s.time().seconds(),
|
|
"selectedLayers": s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>(),
|
|
"undo": s.history.undo.iter().map(|u| u.0.clone()).collect::<Vec<_>>(),
|
|
}))
|
|
}
|
|
|
|
fn layer_json(l: &Layer, idx: usize) -> Value {
|
|
json!({
|
|
"id": l.id.0, "index": idx, "name": l.name, "type": l.source.type_name(), "label": l.label.name(),
|
|
"in": l.in_point.seconds(), "out": l.out_point.seconds(), "start": l.start_time.seconds(), "stretch": l.stretch,
|
|
"blendMode": l.blend_mode.label(), "parent": l.parent.map(|p| p.0),
|
|
"trackMatte": l.track_matte.map(|m| json!({"layer": m.layer.0, "kind": m.kind.label()})),
|
|
"switches": serde_json::to_value(&l.switches).unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cid = comp_id(s, p)?;
|
|
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
|
let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
|
Ok(json!({
|
|
"id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(),
|
|
"workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background,
|
|
"preserveFrameRate": c.preserve_frame_rate, "preserveResolution": c.preserve_resolution,
|
|
"resolution": c.resolution.label(), "resolutionFactor": c.resolution.factor(),
|
|
"layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::<Vec<_>>(),
|
|
}))
|
|
}
|
|
|
|
/// The path segment that addresses child `i` of `g` (see `effectcraft_project::parse_path`): the match
|
|
/// id (with `#n` when it is not the first sibling matching it), or `#index` when the match id is not
|
|
/// path-safe (effects and other ids containing `.`).
|
|
fn segment(g: &PropGroup, i: usize) -> String {
|
|
let c = &g.children[i];
|
|
let m = c.match_id();
|
|
if m.is_empty() || m.contains(['/', '.', '#', '@']) || m.parse::<u64>().is_ok() {
|
|
return format!("#{}", i + 1);
|
|
}
|
|
let n = g.children[..i].iter().filter(|x| x.match_id() == m || x.name().eq_ignore_ascii_case(m)).count() + 1;
|
|
if n == 1 { m.to_string() } else { format!("{m}#{n}") }
|
|
}
|
|
|
|
fn group_json(g: &PropGroup, path: &str, l: &Layer, t: effectcraft_time::Tick, depth: usize, max_depth: usize) -> Value {
|
|
let children: Vec<Value> = if depth >= max_depth {
|
|
vec![]
|
|
} else {
|
|
g.children
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, c)| {
|
|
let seg = segment(g, i);
|
|
let cpath = if path.is_empty() { seg } else { format!("{path}/{seg}") };
|
|
match c {
|
|
Node::Prop(p) => {
|
|
let mut o = json!({"path": cpath, "uid": p.uid, "match": p.match_id, "name": p.name, "type": p.value.kind_name(), "value": p.value_at(l.layer_time(t)).to_json()});
|
|
if !p.keys.is_empty() {
|
|
o["keys"] = json!(
|
|
p.keys
|
|
.iter()
|
|
.map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()}))
|
|
.collect::<Vec<_>>()
|
|
);
|
|
}
|
|
if let Some(e) = &p.expr {
|
|
o["expression"] = json!(e.text);
|
|
}
|
|
o
|
|
}
|
|
Node::Group(g) => group_json(g, &cpath, l, t, depth + 1, max_depth),
|
|
}
|
|
})
|
|
.collect()
|
|
};
|
|
let mut o = json!({"path": path, "uid": g.uid, "match": g.match_id, "name": g.name, "enabled": g.enabled, "children": children});
|
|
if depth >= max_depth && !g.children.is_empty() {
|
|
// Not expanded at this `depth`: ask again with a larger one (or for this group's layer).
|
|
o["truncated"] = json!(true);
|
|
}
|
|
match &g.kind {
|
|
GroupKind::Mask { mode, inverted, .. } => {
|
|
o["mask"] = json!({"mode": mode.label(), "inverted": inverted});
|
|
}
|
|
GroupKind::Effect { effect } => {
|
|
o["effect"] = json!(effect);
|
|
}
|
|
_ => {}
|
|
}
|
|
o
|
|
}
|
|
|
|
fn layer_tree(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (cid, lid) = layer_p(s, p, "layer.tree")?;
|
|
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
|
let idx = c.index_of(lid).unwrap_or(0);
|
|
let l = c.layer(lid).ok_or(EngineError::NoComp)?;
|
|
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(16) as usize;
|
|
let t = p.get("time").and_then(Value::as_f64).map(effectcraft_time::Tick::from_seconds_f64).unwrap_or_else(|| s.time());
|
|
let mut o = layer_json(l, idx);
|
|
o["time"] = json!(t.seconds());
|
|
o["properties"] = group_json(&l.props, "", l, t, 0, depth);
|
|
Ok(o)
|
|
}
|
|
|
|
/// One command in full, with the JSON Schema of its parameters.
|
|
fn describe(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = str_p(p, "command").or(str_p(p, "id")).ok_or_else(|| super::bad("command.describe", "missing `command`"))?;
|
|
let c = super::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
|
let en = (c.enabled)(s);
|
|
Ok(json!({
|
|
"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params,
|
|
"schema": super::params_schema(c), "undoable": c.journal, "enabled": en.is_ok(), "why": en.err(),
|
|
}))
|
|
}
|
|
|
|
/// What this build can do: counts agents use to orient themselves, and the parity summary.
|
|
fn capabilities(s: &mut Session, _: &Value) -> Result<Value> {
|
|
let fx = effectcraft_effects::registry();
|
|
let formats: Vec<Value> = s.exporter.as_ref().map(|e| e.formats().iter().map(|f| json!(format!("{f:?}"))).collect()).unwrap_or_default();
|
|
Ok(json!({
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"commands": super::command_specs().len(),
|
|
"effects": {"total": fx.len(), "gpu": fx.iter().filter(|e| e.gpu).count(), "float": fx.iter().filter(|e| e.float).count()},
|
|
"export": formats,
|
|
"import": s.importer.is_some(),
|
|
"expressions": s.expr.is_some(),
|
|
"scripting": s.script.is_some(),
|
|
"gpu": s.accel.is_some(),
|
|
"parity": {
|
|
"target": "Adobe After Effects 2026",
|
|
"summary": PARITY,
|
|
"doc": "docs/parity.md",
|
|
},
|
|
}))
|
|
}
|
|
|
|
/// The headline of `docs/parity.md` (kept in sync by hand at each audit).
|
|
const PARITY: &str = "≈94% weighted feature parity (80 done / 12 partial / 0 missing of 92 features); 298 of 298 After Effects 2026 effects present";
|
|
|
|
fn state(s: &mut Session, _: &Value) -> Result<Value> {
|
|
Ok(serde_json::to_value(&s.state).unwrap_or_default())
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
query!("command.list", "List Commands", "{filter?, enabledOnly?, schemas? (add each command's params JSON Schema)}", list),
|
|
query!("command.describe", "Describe Command", "{command} → id, label, menu, shortcut, params doc, JSON `schema`, enabled", describe),
|
|
query!("app.capabilities", "App Capabilities", "{} → version, command/effect counts, export formats, parity summary", capabilities),
|
|
query!("project.summary", "Project Summary", "{}", summary),
|
|
query!("comp.info", "Composition Info", "{comp?}", comp_info),
|
|
query!("layer.tree", "Layer Property Tree", "{layer?, comp?, depth?, time? (comp s)} → nodes with `path`", layer_tree),
|
|
query!("editor.state", "Editor State", "{}", state),
|
|
]
|
|
}
|