mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 19:18:14 -06:00
Merge each offloaded property-group result against the snapshot the job started from. Preserve disjoint live edits, group identity and child changes; refuse conflicting results without changing the document or undo/redo history. Record an apply rejection on its originating job so completion logs and waiting control commands report failure. Keep concurrent jobs' outcomes separate and retain the correct completion position when the bounded log rolls over. Add actual-worker regressions for conflicts, disjoint edits, concurrent analyses and undo/redo, plus control completion/error regressions. Fixes #1249 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
665 lines
30 KiB
Rust
665 lines
30 KiB
Rust
//! Programmatic control of the running app (agents, tests, MCP bridge).
|
|
//!
|
|
//! Transport-agnostic: a transport thread (TCP in the desktop app) sends [`ControlRequest`]s; the
|
|
//! UI thread handles them between frames and replies with `{"ok":…, "result"|"error":…}`.
|
|
//!
|
|
//! Methods:
|
|
//! - `engine.execute {command, params}` / `ui.menu.invoke {id, params}`: run any engine or UI command
|
|
//! - `engine.commands`: list engine commands; `ui.menu.list`: menu tree with enablement
|
|
//! - `ui.inspect`: UI state + summary; `ui.elements {prefix?}`: registered widgets (id, label, rect)
|
|
//! - `ui.set {tool?, workspace?, theme?, focused?, viewer?: {zoom, res, pan, grid, …}, timeline?: {pps, start, graphEditor, showModes}}`
|
|
//! - `ui.panel.show {panel}` / `ui.panel.close {panel}`
|
|
//! - `ui.click {id | x,y, button?, count?, modifiers?}` / `ui.move {x,y}` / `ui.scroll {x,y,dx,dy}`
|
|
//! - `ui.drag {from:{id|x,y}, to:{id|x,y}, steps?, modifiers?}`: synthetic press-move-release
|
|
//! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}`
|
|
//! - `ui.viewer.locate {x, y}`: screen point of a comp pixel; `ui.viewer.hit {x, y}`: comp pixel at a screen point
|
|
//! - `ui.timeline.locate {layer, time?}`: screen point of a layer bar (at a comp time)
|
|
//! - `ui.playback {action: play|stop|toggle}`
|
|
//! - `ui.screenshot {path?, panel?, id?}`: PNG of the window (or one panel / element)
|
|
//! - `render.frame {comp?, time?, max_side?, path?, base64?}`: PNG of a comp frame rendered by the session
|
|
//! - `ui.resize {width, height}` / `ui.focus` / `app.quit {force?}` (a modified project asks to save first unless `force`)
|
|
//!
|
|
//! The full reference with examples is `docs/control-protocol.md`.
|
|
|
|
use std::sync::mpsc::Sender;
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::EffectcraftApp;
|
|
use crate::dock::PanelKind;
|
|
use crate::state::{Resolution, Tool};
|
|
|
|
/// Prefix marking errors that may resolve after another frame.
|
|
pub const RETRY: &str = "\u{1}";
|
|
|
|
pub struct ControlRequest {
|
|
pub method: String,
|
|
pub params: Value,
|
|
pub reply: Sender<Value>,
|
|
}
|
|
|
|
impl ControlRequest {
|
|
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<Value>) {
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
(Self { method: method.into(), params, reply: tx }, rx)
|
|
}
|
|
}
|
|
|
|
pub enum Outcome {
|
|
Done(Value),
|
|
/// Try again on a later frame (e.g. the element is not on screen yet).
|
|
Retry(String),
|
|
/// Reply once queued synthetic input has been processed.
|
|
AfterInput,
|
|
Screenshot {
|
|
path: Option<String>,
|
|
crop: Option<[f32; 4]>,
|
|
},
|
|
/// Reply once background jobs end (a `wait: true` command whose job runs in a worker).
|
|
AwaitJobs(JobWaiter),
|
|
}
|
|
|
|
/// A `wait: true` engine command whose job was sent to the session's offload (the browser's
|
|
/// Web Workers) instead of blocking the UI thread: the reply goes out when the job ends.
|
|
pub struct JobWaiter {
|
|
pub command: String,
|
|
pub params: Value,
|
|
/// The command's own result (with `wait: false`).
|
|
pub result: Value,
|
|
/// Ids of the jobs it started (`jobs.list` ids: `render`, `track`, `roto`…).
|
|
pub jobs: Vec<String>,
|
|
/// Absolute completion-log position when it started (including evicted entries).
|
|
pub log_cursor: usize,
|
|
}
|
|
|
|
/// Commands that block with `wait` (and its default).
|
|
fn waits(command: &str, params: &Value) -> bool {
|
|
let default = command == "renderQueue.render";
|
|
params.get("wait").and_then(Value::as_bool).unwrap_or(default)
|
|
}
|
|
|
|
/// Run an engine command. A waiting command (`wait: true`) whose job the session offloads
|
|
/// runs with `wait: false` instead, and the reply waits for the job ([`Outcome::AwaitJobs`]),
|
|
/// so the browser's UI keeps drawing while it runs.
|
|
fn execute_engine(app: &mut EffectcraftApp, id: &str, params: Value) -> Result<Outcome, String> {
|
|
if !app.session.offloads() || !waits(id, ¶ms) {
|
|
return app.session.execute_checked(id, params).map(ok).map_err(|e| e.to_string());
|
|
}
|
|
let before: Vec<String> = app.session.jobs().into_iter().map(|j| j.id).collect();
|
|
let log_cursor = app.session.offload_log_start.saturating_add(app.session.offload_log.len());
|
|
let mut p = params.clone();
|
|
p["wait"] = json!(false);
|
|
let result = app.session.execute_checked(id, p).map_err(|e| e.to_string())?;
|
|
let jobs: Vec<String> = app.session.jobs().into_iter().map(|j| j.id).filter(|j| !before.contains(j)).collect();
|
|
if jobs.is_empty() {
|
|
return Ok(ok(result));
|
|
}
|
|
Ok(Outcome::AwaitJobs(JobWaiter { command: id.to_string(), params, result, jobs, log_cursor }))
|
|
}
|
|
|
|
impl JobWaiter {
|
|
/// Whether its jobs are still running.
|
|
pub fn pending(&self, app: &EffectcraftApp) -> bool {
|
|
app.session.jobs().iter().any(|j| self.jobs.contains(&j.id))
|
|
}
|
|
|
|
/// The reply: what the blocking command would have returned (`running: false`, the final
|
|
/// progress, render items and analysis status), or the job's error.
|
|
pub fn finish(self, app: &mut EffectcraftApp) -> Value {
|
|
let s = &mut app.session;
|
|
s.poll_render();
|
|
let error = s
|
|
.offload_log
|
|
.iter()
|
|
.skip(self.log_cursor.saturating_sub(s.offload_log_start))
|
|
.filter(|(kind, _)| self.jobs.iter().any(|id| id == kind.job_id()))
|
|
.find_map(|(_, e)| e.as_ref());
|
|
if let Some(e) = error {
|
|
return json!({"ok": false, "error": e});
|
|
}
|
|
let mut r = self.result;
|
|
if r.get("running").is_some() {
|
|
r["running"] = json!(false);
|
|
}
|
|
if self.command == "renderQueue.render" {
|
|
let ids: Vec<Value> = r["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default();
|
|
if let Ok(list) = s.execute("renderQueue.list", json!({})) {
|
|
r["items"] =
|
|
json!(list["items"].as_array().map(|a| a.iter().filter(|i| ids.contains(&i["id"])).cloned().collect::<Vec<_>>()).unwrap_or_default());
|
|
}
|
|
r["rendering"] = json!(s.is_rendering());
|
|
}
|
|
let prefix = self.command.split('.').next().unwrap_or("");
|
|
if matches!(prefix, "warp" | "camera" | "roto")
|
|
&& r.get("layer").is_some()
|
|
&& let Ok(st) = s.execute(&format!("{prefix}.status"), json!({"layer": r["layer"], "effect": r["effect"]}))
|
|
{
|
|
r["status"] = st;
|
|
}
|
|
if r.get("progress").is_some() {
|
|
r["progress"] = Value::Null;
|
|
}
|
|
json!({"ok": true, "result": r})
|
|
}
|
|
}
|
|
|
|
fn ok(v: Value) -> Outcome {
|
|
Outcome::Done(json!({"ok": true, "result": v}))
|
|
}
|
|
fn err(e: impl std::fmt::Display) -> Outcome {
|
|
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
|
|
}
|
|
|
|
fn modifiers(p: &Value) -> egui::Modifiers {
|
|
let m = p.get("modifiers").unwrap_or(p);
|
|
let b = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false);
|
|
egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: cfg!(target_os = "macos") && b("command"), command: b("command") }
|
|
}
|
|
|
|
/// Resolve a point from `{id}` (element centre) or `{x, y}`.
|
|
fn point(app: &EffectcraftApp, p: &Value) -> Result<egui::Pos2, String> {
|
|
if let Some(id) = p.get("id").and_then(Value::as_str) {
|
|
let e = app.auto.find(id).ok_or_else(|| format!("{RETRY}no element `{id}` (see ui.elements)"))?;
|
|
let fx = p.get("fx").and_then(Value::as_f64).unwrap_or(0.5) as f32;
|
|
let fy = p.get("fy").and_then(Value::as_f64).unwrap_or(0.5) as f32;
|
|
return Ok(egui::pos2(e.rect[0] + e.rect[2] * fx, e.rect[1] + e.rect[3] * fy));
|
|
}
|
|
let x = p.get("x").and_then(Value::as_f64).ok_or("need `id` or `x`,`y`")?;
|
|
let y = p.get("y").and_then(Value::as_f64).ok_or("need `y`")?;
|
|
Ok(egui::pos2(x as f32, y as f32))
|
|
}
|
|
|
|
pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
|
let p = &req.params;
|
|
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
|
match req.method.as_str() {
|
|
"engine.execute" | "ui.menu.invoke" => {
|
|
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
|
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
|
// `engine.execute` runs engine commands exactly as headless (never opens a dialog, e.g.
|
|
// `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`,
|
|
// `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click.
|
|
let r = if req.method == "engine.execute" && effectcraft_engine::find_command(id).is_some() {
|
|
execute_engine(app, id, params)
|
|
} else {
|
|
crate::menus::invoke(app, ctx, id, params).map(ok)
|
|
};
|
|
match r {
|
|
Ok(o) => o,
|
|
Err(e) => {
|
|
app.ui.status = e.clone();
|
|
err(e)
|
|
}
|
|
}
|
|
}
|
|
"engine.commands" => match app.session.execute("command.list", json!({"filter": p.get("filter"), "enabledOnly": p.get("enabledOnly")})) {
|
|
Ok(v) => ok(v),
|
|
Err(e) => err(e),
|
|
},
|
|
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_items(app)).unwrap_or_default()),
|
|
"ui.inspect" => ok(inspect(app, ctx)),
|
|
"ui.elements" => {
|
|
let prefix = s("prefix").unwrap_or("");
|
|
ok(serde_json::to_value(app.auto.query(prefix)).unwrap_or_default())
|
|
}
|
|
"ui.set" => {
|
|
if let Some(t) = s("tool") {
|
|
match Tool::from_name(t) {
|
|
Some(t) => app.ui.tool = t,
|
|
None => return err(format!("unknown tool `{t}`")),
|
|
}
|
|
}
|
|
if let Some(w) = s("workspace") {
|
|
match crate::dock::WORKSPACES.iter().find(|x| x.eq_ignore_ascii_case(w)) {
|
|
Some(w) => app.set_workspace(w),
|
|
None => return err(format!("unknown workspace `{w}`")),
|
|
}
|
|
}
|
|
if let Some(th) = s("theme") {
|
|
match crate::theme::ThemeKind::from_name(th) {
|
|
Some(k) => app.set_theme(ctx, k),
|
|
None => return err("unknown theme (dark, darker, light)"),
|
|
}
|
|
}
|
|
if let Some(f) = s("focused").and_then(PanelKind::from_name) {
|
|
app.ui.focused = f;
|
|
}
|
|
if let Some(v) = p.get("viewer") {
|
|
if let Some(z) = v.get("zoom") {
|
|
app.ui.viewer.zoom = z.as_f64().map(|z| z as f32);
|
|
}
|
|
// The active comp's Resolution (`view.res.*`), saved with the project.
|
|
if let Some(r) = v.get("res").and_then(Value::as_str) {
|
|
let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else {
|
|
return err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)"));
|
|
};
|
|
if let Some(cid) = app.session.active_comp_id()
|
|
&& let Err(e) = app.session.set_resolution(cid, res)
|
|
{
|
|
return err(e);
|
|
}
|
|
}
|
|
if let Some(Value::Array(a)) = v.get("pan") {
|
|
app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32];
|
|
}
|
|
for (k, slot) in [
|
|
("grid", &mut app.ui.viewer.grid),
|
|
("rulers", &mut app.ui.viewer.rulers),
|
|
("safeMargins", &mut app.ui.viewer.safe_margins),
|
|
("transparencyGrid", &mut app.ui.viewer.transparency_grid),
|
|
] {
|
|
if let Some(b) = v.get(k).and_then(Value::as_bool) {
|
|
*slot = b;
|
|
}
|
|
}
|
|
}
|
|
if let Some(tl) = p.get("timeline") {
|
|
if let Some(v) = tl.get("pps") {
|
|
app.ui.timeline.pps = v.as_f64();
|
|
}
|
|
if let Some(v) = tl.get("start").and_then(Value::as_f64) {
|
|
app.ui.timeline.start = v;
|
|
}
|
|
if let Some(v) = tl.get("graphEditor").and_then(Value::as_bool) {
|
|
app.ui.timeline.graph_editor = v;
|
|
}
|
|
if let Some(v) = tl.get("showModes").and_then(Value::as_bool) {
|
|
app.ui.timeline.show_modes = v;
|
|
}
|
|
if let Some(Value::Array(a)) = tl.get("openLayers") {
|
|
app.ui.timeline.open_layers = a.iter().filter_map(Value::as_u64).collect();
|
|
}
|
|
if let Some(Value::Array(a)) = tl.get("openGroups") {
|
|
app.ui.timeline.open_groups = a.iter().filter_map(Value::as_u64).collect();
|
|
}
|
|
}
|
|
if let Some(b) = p.get("menuBar").and_then(Value::as_bool) {
|
|
app.ui.show_menu_bar = b;
|
|
}
|
|
// Project panel optional columns, e.g. ["type", "duration", "path", "comment"].
|
|
if let Some(Value::Array(a)) = p.get("projectColumns") {
|
|
let mut cols = vec![];
|
|
for k in a.iter().filter_map(Value::as_str) {
|
|
crate::panels::project::set_column(&mut cols, k, true);
|
|
}
|
|
app.ui.project_columns = cols;
|
|
}
|
|
// Wiggler / Smoother / Motion Sketch settings (fields of `AnimToolsState`).
|
|
if let Some(Value::Object(m)) = p.get("animTools") {
|
|
let mut cur = serde_json::to_value(&app.ui.anim_tools).unwrap_or_default();
|
|
for (k, v) in m {
|
|
cur[k] = v.clone();
|
|
}
|
|
match serde_json::from_value(cur) {
|
|
Ok(v) => app.ui.anim_tools = v,
|
|
Err(e) => return err(format!("animTools: {e}")),
|
|
}
|
|
}
|
|
if let Some(b) = p.get("home").and_then(Value::as_bool) {
|
|
app.ui.start_screen = b;
|
|
}
|
|
// Home screen tab: "home", "templates" or "learn".
|
|
if let Some(tab) = p.get("homeTab").and_then(Value::as_str) {
|
|
app.ui.home_learn = tab == "learn";
|
|
app.ui.home_templates = tab == "templates";
|
|
}
|
|
ok(Value::Null)
|
|
}
|
|
"ui.panel.show" | "ui.panel.close" => {
|
|
let Some(panel) = s("panel").and_then(PanelKind::from_name) else { return err("unknown `panel`") };
|
|
if req.method == "ui.panel.show" {
|
|
app.show_panel(panel);
|
|
} else {
|
|
app.ui.dock.close(panel);
|
|
}
|
|
ok(Value::Null)
|
|
}
|
|
"ui.click" | "ui.move" => {
|
|
let pos = match point(app, p) {
|
|
Ok(p) => p,
|
|
Err(e) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()),
|
|
Err(e) => return err(e),
|
|
};
|
|
let m = modifiers(p);
|
|
// egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event)
|
|
// and the synthetic events arrive one per frame: hold them for the whole gesture.
|
|
app.synthetic.push(egui::Event::ModifiersChanged(m));
|
|
app.synthetic.push(egui::Event::PointerMoved(pos));
|
|
if req.method == "ui.click" {
|
|
let button = match s("button") {
|
|
Some("right") => egui::PointerButton::Secondary,
|
|
Some("middle") => egui::PointerButton::Middle,
|
|
_ => egui::PointerButton::Primary,
|
|
};
|
|
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
|
for _ in 0..count {
|
|
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m });
|
|
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
|
|
}
|
|
}
|
|
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
|
Outcome::AfterInput
|
|
}
|
|
"ui.drag" => {
|
|
let (Some(from), Some(to)) = (p.get("from"), p.get("to")) else { return err("need `from` and `to`") };
|
|
let (a, b) = match (point(app, from), point(app, to)) {
|
|
(Ok(a), Ok(b)) => (a, b),
|
|
(Err(e), _) | (_, Err(e)) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()),
|
|
(Err(e), _) | (_, Err(e)) => return err(e),
|
|
};
|
|
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2);
|
|
let m = modifiers(p);
|
|
app.synthetic.push(egui::Event::ModifiersChanged(m));
|
|
app.synthetic.push(egui::Event::PointerMoved(a));
|
|
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
|
for i in 1..=steps {
|
|
let f = i as f32 / steps as f32;
|
|
app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f));
|
|
}
|
|
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
|
|
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
|
Outcome::AfterInput
|
|
}
|
|
"ui.scroll" => {
|
|
let pos = match point(app, p) {
|
|
Ok(p) => p,
|
|
Err(e) => return err(e),
|
|
};
|
|
let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
|
let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
|
app.synthetic.push(egui::Event::PointerMoved(pos));
|
|
app.synthetic.push(egui::Event::MouseWheel {
|
|
unit: egui::MouseWheelUnit::Point,
|
|
delta: egui::vec2(dx, dy),
|
|
modifiers: modifiers(p),
|
|
phase: egui::TouchPhase::Move,
|
|
});
|
|
app.synthetic.push(egui::Event::PointerButton {
|
|
pos: egui::pos2(-100.0, -100.0),
|
|
button: egui::PointerButton::Extra1,
|
|
pressed: false,
|
|
modifiers: Default::default(),
|
|
});
|
|
Outcome::AfterInput
|
|
}
|
|
"ui.key" => {
|
|
let Some(name) = s("key") else { return err("missing `key`") };
|
|
let Some((mut m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) };
|
|
let extra = modifiers(p);
|
|
m |= extra;
|
|
let physical_key = name.starts_with("Physical+").then_some(key);
|
|
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers: m });
|
|
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: false, repeat: false, modifiers: m });
|
|
Outcome::AfterInput
|
|
}
|
|
"ui.type" => {
|
|
let Some(text) = s("text") else { return err("missing `text`") };
|
|
app.synthetic.push(egui::Event::Text(text.to_string()));
|
|
app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
|
|
Outcome::AfterInput
|
|
}
|
|
"ui.viewer.locate" => {
|
|
let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else {
|
|
return err("need `x`, `y` (comp pixels)");
|
|
};
|
|
match crate::panels::viewer::comp_to_screen(ctx, [x as f32, y as f32]) {
|
|
Some(pt) => ok(json!({"x": pt.x, "y": pt.y})),
|
|
None => Outcome::Retry("the composition viewer is not visible".into()),
|
|
}
|
|
}
|
|
"ui.viewer.hit" => {
|
|
let pos = match point(app, p) {
|
|
Ok(p) => p,
|
|
Err(e) => return err(e),
|
|
};
|
|
match crate::panels::viewer::screen_to_comp(ctx, pos) {
|
|
Some(c) => ok(json!({"x": c[0], "y": c[1]})),
|
|
None => err("the composition viewer is not visible"),
|
|
}
|
|
}
|
|
"ui.timeline.locate" => {
|
|
let Some(l) = p.get("layer").and_then(Value::as_u64) else { return err("need `layer` (id)") };
|
|
match crate::panels::timeline::locate(app, l, p.get("time").and_then(Value::as_f64)) {
|
|
Some((x, y)) => ok(json!({"x": x, "y": y})),
|
|
None => Outcome::Retry("layer not visible in the timeline".into()),
|
|
}
|
|
}
|
|
"ui.playback" => {
|
|
let now = ctx.input(|i| i.time);
|
|
match s("action").unwrap_or("toggle") {
|
|
"play" => app.play(now),
|
|
"stop" => app.stop(),
|
|
"status" => {}
|
|
_ => app.toggle_play(now),
|
|
}
|
|
ok(
|
|
json!({"playing": app.playback.playing, "time": app.session.time().seconds(), "audio": app.audio.is_some(), "levelsDb": app.meter.level_db, "peaksDb": app.meter.peak_db}),
|
|
)
|
|
}
|
|
"ui.screenshot" => {
|
|
let crop = s("panel").and_then(PanelKind::from_name).and_then(|pk| app.auto.find(&format!("panel.{}", pk.id())).map(|e| e.rect));
|
|
let crop = crop.or_else(|| s("id").and_then(|id| app.auto.find(id).map(|e| e.rect)));
|
|
// Bring the window on screen so a frame is presented, without taking keyboard focus.
|
|
app.raise_for_control(ctx);
|
|
Outcome::Screenshot { path: s("path").map(str::to_string), crop }
|
|
}
|
|
"render.frame" => render_frame(app, p),
|
|
"ui.resize" => {
|
|
let w = p.get("width").and_then(Value::as_f64).unwrap_or(1600.0) as f32;
|
|
let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0) as f32;
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
|
|
ok(Value::Null)
|
|
}
|
|
"ui.focus" => {
|
|
// Explicit request: activate the app and take keyboard focus.
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
|
ok(Value::Null)
|
|
}
|
|
"app.quit" => {
|
|
// A modified project asks to save first unless `force: true`.
|
|
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
|
|
app.dialog_state.unsaved.quitting = true;
|
|
}
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
ok(Value::Null)
|
|
}
|
|
m => err(format!("unknown method `{m}`")),
|
|
}
|
|
}
|
|
|
|
/// `render.frame {comp?, time?, max_side?, path?, base64?}`: render a comp frame through the session
|
|
/// (independent of the viewer zoom/resolution) and return it as PNG, written to `path` (default a
|
|
/// temp file) or inline as base64 when `base64: true`.
|
|
fn render_frame(app: &EffectcraftApp, p: &Value) -> Outcome {
|
|
let s = &app.session;
|
|
let cid = match s.resolve_comp(p.get("comp")) {
|
|
Ok(c) => c,
|
|
Err(e) => return err(e),
|
|
};
|
|
let t = p.get("time").or(p.get("seconds")).and_then(Value::as_f64).map(effectcraft_time::Tick::from_seconds_f64).unwrap_or_else(|| s.time());
|
|
let max_side = p.get("max_side").or(p.get("maxSide")).and_then(Value::as_u64).unwrap_or(0) as u32;
|
|
let transparent = p.get("transparent").and_then(Value::as_bool).unwrap_or(false);
|
|
let (w, h, rgba) = match s.render_rgba8_alpha(cid, t, max_side, transparent) {
|
|
Ok(r) => r,
|
|
Err(e) => return err(e),
|
|
};
|
|
let png = match encode_png(&rgba, w, h) {
|
|
Ok(p) => p,
|
|
Err(e) => return err(e),
|
|
};
|
|
let mut out = json!({"comp": cid.0, "time": t.seconds(), "width": w, "height": h});
|
|
// (the web has no file system: inline unless a path is given)
|
|
let inline = p.get("base64").and_then(Value::as_bool).unwrap_or(cfg!(target_arch = "wasm32") && p.get("path").is_none());
|
|
if inline {
|
|
out["png"] = json!(base64(&png));
|
|
return ok(out);
|
|
}
|
|
let path = p
|
|
.get("path")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-frame-{}.png", std::process::id())).to_string_lossy().to_string());
|
|
match std::fs::write(&path, png) {
|
|
Ok(()) => {
|
|
out["path"] = json!(path);
|
|
ok(out)
|
|
}
|
|
Err(e) => err(format!("cannot write {path}: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Standard base64 (RFC 4648, padded).
|
|
pub fn base64(data: &[u8]) -> String {
|
|
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut s = String::with_capacity(data.len().div_ceil(3) * 4);
|
|
for c in data.chunks(3) {
|
|
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
|
|
for i in 0..4 {
|
|
if i <= c.len() {
|
|
s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
|
|
} else {
|
|
s.push('=');
|
|
}
|
|
}
|
|
}
|
|
s
|
|
}
|
|
|
|
pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
|
|
let size = ctx.content_rect().size();
|
|
let keyboard_focus = ctx.memory(|m| m.focused()).map(|id| {
|
|
let response = ctx.read_response(id);
|
|
let rect = response.as_ref().map(|r| [r.rect.min.x, r.rect.min.y, r.rect.width(), r.rect.height()]);
|
|
// The registry names screen elements independently of egui's internal ids. Report
|
|
// exact rectangle matches rather than guessing which enclosing panel owns focus.
|
|
let candidates: Vec<&str> = app
|
|
.auto
|
|
.previous
|
|
.iter()
|
|
.filter(|e| rect.is_some_and(|r| e.rect.iter().zip(r).all(|(a, b)| (*a - b).abs() < 0.01)))
|
|
.map(|e| e.id.as_str())
|
|
.collect();
|
|
let automation_id = app.auto.elements.iter().chain(app.auto.previous.iter()).find(|e| e.response_id == Some(id)).map(|e| e.id.as_str());
|
|
json!({"id": format!("{:016x}", id.value()), "rect": rect, "automationId": automation_id, "registryCandidates": candidates})
|
|
});
|
|
let mut ui = json!(app.ui);
|
|
// The viewer's Resolution is the active comp's (kept in the project).
|
|
if let Some(v) = ui.get_mut("viewer").and_then(Value::as_object_mut) {
|
|
v.insert("res".into(), json!(app.viewer_res()));
|
|
}
|
|
json!({
|
|
"window": [size.x, size.y],
|
|
"pixelsPerPoint": ctx.pixels_per_point(),
|
|
"fps": app.fps,
|
|
"ui": ui,
|
|
"playing": app.playback.playing,
|
|
"time": app.session.time().seconds(),
|
|
"activeComp": app.session.state.active_comp.map(|i| i.0),
|
|
"selectedLayers": app.session.state.selected_layers.iter().map(|c| c.0).collect::<Vec<_>>(),
|
|
"selectedKeys": app.session.state.selected_keys.len(),
|
|
"elements": app.auto.previous.len(),
|
|
"keyboardFocus": keyboard_focus,
|
|
"windowFocused": ctx.input(|i| i.focused),
|
|
"workspaceStorageError": app.workspace_store.error,
|
|
"dialog": app.dialog.map(|d| format!("{d:?}")),
|
|
"renderMs": app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0),
|
|
})
|
|
}
|
|
|
|
/// Save a screenshot (optionally cropped to a rect in points) as PNG; returns the reply JSON.
|
|
pub fn save_screenshot(ctx: &egui::Context, image: &egui::ColorImage, path: Option<&str>, crop: Option<[f32; 4]>) -> Value {
|
|
let ppp = ctx.pixels_per_point();
|
|
let [w, h] = image.size;
|
|
let (x0, y0, cw, ch) = match crop {
|
|
Some([x, y, cw, ch]) => {
|
|
let x0 = ((x * ppp) as usize).min(w);
|
|
let y0 = ((y * ppp) as usize).min(h);
|
|
(x0, y0, ((cw * ppp) as usize).min(w - x0), ((ch * ppp) as usize).min(h - y0))
|
|
}
|
|
None => (0, 0, w, h),
|
|
};
|
|
let mut rgba = Vec::with_capacity(cw * ch * 4);
|
|
for y in y0..y0 + ch {
|
|
for x in x0..x0 + cw {
|
|
let c = image.pixels[y * w + x];
|
|
rgba.extend_from_slice(&[c.r(), c.g(), c.b(), 255]);
|
|
}
|
|
}
|
|
if path.is_none() && cfg!(target_arch = "wasm32") {
|
|
// no file system on the web: inline PNG
|
|
return match encode_png(&rgba, cw as u32, ch as u32) {
|
|
Ok(png) => json!({"ok": true, "result": {"png": base64(&png), "width": cw, "height": ch}}),
|
|
Err(e) => json!({"ok": false, "error": e}),
|
|
};
|
|
}
|
|
let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("effectcraft-screenshot.png").to_string_lossy().to_string());
|
|
match encode_png(&rgba, cw as u32, ch as u32) {
|
|
Ok(png) => match std::fs::write(&path, png) {
|
|
Ok(()) => json!({"ok": true, "result": {"path": path, "width": cw, "height": ch}}),
|
|
Err(e) => json!({"ok": false, "error": e.to_string()}),
|
|
},
|
|
Err(e) => json!({"ok": false, "error": e}),
|
|
}
|
|
}
|
|
|
|
/// Minimal PNG encoder (stored deflate blocks, no compression dependency).
|
|
pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result<Vec<u8>, String> {
|
|
fn chunk(out: &mut Vec<u8>, ty: &[u8], data: &[u8]) {
|
|
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
|
let mut c = Vec::with_capacity(4 + data.len());
|
|
c.extend_from_slice(ty);
|
|
c.extend_from_slice(data);
|
|
out.extend_from_slice(&c);
|
|
out.extend_from_slice(&crc(&c).to_be_bytes());
|
|
}
|
|
fn crc(d: &[u8]) -> u32 {
|
|
let mut c = 0xFFFF_FFFFu32;
|
|
for &b in d {
|
|
c ^= b as u32;
|
|
for _ in 0..8 {
|
|
c = if c & 1 != 0 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };
|
|
}
|
|
}
|
|
!c
|
|
}
|
|
let mut raw = Vec::with_capacity((w as usize * 4 + 1) * h as usize);
|
|
for y in 0..h as usize {
|
|
raw.push(0);
|
|
raw.extend_from_slice(&rgba[y * w as usize * 4..(y + 1) * w as usize * 4]);
|
|
}
|
|
// zlib stream with stored blocks
|
|
let mut z = vec![0x78, 0x01];
|
|
for (i, block) in raw.chunks(65535).enumerate() {
|
|
let last = (i + 1) * 65535 >= raw.len();
|
|
z.push(last as u8);
|
|
z.extend_from_slice(&(block.len() as u16).to_le_bytes());
|
|
z.extend_from_slice(&(!(block.len() as u16)).to_le_bytes());
|
|
z.extend_from_slice(block);
|
|
}
|
|
let (mut a, mut b) = (1u32, 0u32);
|
|
for &x in &raw {
|
|
a = (a + x as u32) % 65521;
|
|
b = (b + a) % 65521;
|
|
}
|
|
z.extend_from_slice(&((b << 16) | a).to_be_bytes());
|
|
let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
|
let mut ihdr = Vec::new();
|
|
ihdr.extend_from_slice(&w.to_be_bytes());
|
|
ihdr.extend_from_slice(&h.to_be_bytes());
|
|
ihdr.extend_from_slice(&[8, 6, 0, 0, 0]);
|
|
chunk(&mut out, b"IHDR", &ihdr);
|
|
chunk(&mut out, b"IDAT", &z);
|
|
chunk(&mut out, b"IEND", &[]);
|
|
Ok(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn base64_rfc4648_vectors() {
|
|
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] {
|
|
assert_eq!(super::base64(i.as_bytes()), o);
|
|
}
|
|
}
|
|
}
|