140 lines
5.5 KiB
Rust
140 lines
5.5 KiB
Rust
//! Headless UI snapshot: runs the real [`EffectcraftApp`] without a window (egui_kittest + wgpu)
|
|
//! and writes PNGs. Works regardless of window focus, Spaces or display sleep, so agents can
|
|
//! look at the UI at any time.
|
|
//!
|
|
//! ```text
|
|
//! cargo run -p effectcraft-ui-egui --example snapshot -- [--out ui.png] [--size 1680x1020]
|
|
//! [--scale 2] [--settle 1.5] [--empty] [--script steps.jsonl | --step '<json>']...
|
|
//! ```
|
|
//!
|
|
//! Each script step is a control-channel request (`{"method":"engine.execute","params":{...}}`,
|
|
//! see `docs/control-protocol.md`), run in order with the app settling in between. The extra
|
|
//! method `{"method":"snap","params":{"path":"x.png"}}` writes an intermediate snapshot.
|
|
//! Replies are printed to stdout as JSON lines.
|
|
|
|
use std::sync::mpsc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp};
|
|
use egui_kittest::Harness;
|
|
use serde_json::{Value, json};
|
|
|
|
struct Args {
|
|
out: String,
|
|
size: (f32, f32),
|
|
scale: f32,
|
|
settle: f64,
|
|
demo: bool,
|
|
steps: Vec<Value>,
|
|
}
|
|
|
|
fn parse_args() -> Result<Args, String> {
|
|
let mut a = Args { out: "ui.png".into(), size: (1680.0, 1020.0), scale: 2.0, settle: 1.5, demo: true, steps: Vec::new() };
|
|
let mut it = std::env::args().skip(1);
|
|
while let Some(arg) = it.next() {
|
|
let mut val = || it.next().ok_or_else(|| format!("{arg} needs a value"));
|
|
match arg.as_str() {
|
|
"--out" => a.out = val()?,
|
|
"--size" => {
|
|
let v = val()?;
|
|
let (w, h) = v.split_once('x').ok_or("--size WxH")?;
|
|
a.size = (w.parse().map_err(|_| "bad width")?, h.parse().map_err(|_| "bad height")?);
|
|
}
|
|
"--scale" => a.scale = val()?.parse().map_err(|_| "bad --scale")?,
|
|
"--settle" => a.settle = val()?.parse().map_err(|_| "bad --settle")?,
|
|
"--empty" => a.demo = false,
|
|
"--step" => a.steps.push(serde_json::from_str(&val()?).map_err(|e| format!("--step: {e}"))?),
|
|
"--script" => {
|
|
let path = val()?;
|
|
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
|
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with("//")) {
|
|
a.steps.push(serde_json::from_str(line).map_err(|e| format!("{path}: {e}: {line}"))?);
|
|
}
|
|
}
|
|
"-h" | "--help" => {
|
|
return Err("usage: snapshot [--out ui.png] [--size WxH] [--scale 2] [--settle secs] [--empty] [--script f.jsonl | --step json]...".into());
|
|
}
|
|
_ => return Err(format!("unknown argument {arg}")),
|
|
}
|
|
}
|
|
Ok(a)
|
|
}
|
|
|
|
/// One frame, first queueing the app's synthetic input (kittest doesn't call
|
|
/// `raw_input_hook`); the harness runs one frame per queued event.
|
|
fn step_frame(h: &mut Harness<'_, EffectcraftApp>) {
|
|
for e in h.state_mut().take_synthetic_input() {
|
|
h.event(e);
|
|
}
|
|
h.step();
|
|
}
|
|
|
|
/// Step the app for `secs` of wall time so background frame renders land in the viewer.
|
|
fn settle(h: &mut Harness<'_, EffectcraftApp>, secs: f64) {
|
|
let end = Instant::now() + Duration::from_secs_f64(secs);
|
|
while Instant::now() < end {
|
|
step_frame(h);
|
|
std::thread::sleep(Duration::from_millis(30));
|
|
}
|
|
step_frame(h);
|
|
}
|
|
|
|
fn snap(h: &mut Harness<'_, EffectcraftApp>, path: &str) -> Result<(), String> {
|
|
let img = h.render()?;
|
|
img.save(path).map_err(|e| format!("{path}: {e}"))?;
|
|
println!("{}", json!({"snapshot": path, "width": img.width(), "height": img.height()}));
|
|
Ok(())
|
|
}
|
|
|
|
fn main() {
|
|
let args = match parse_args() {
|
|
Ok(a) => a,
|
|
Err(e) => {
|
|
eprintln!("{e}");
|
|
std::process::exit(2);
|
|
}
|
|
};
|
|
let mut session = effectcraft_host::session();
|
|
if args.demo {
|
|
let _ = session.execute("file.openDemoProject", json!({}));
|
|
}
|
|
let (tx, rx) = mpsc::channel::<ControlRequest>();
|
|
let mut harness = Harness::builder()
|
|
.with_size(egui::vec2(args.size.0, args.size.1))
|
|
.with_pixels_per_point(args.scale)
|
|
.wgpu()
|
|
.build_eframe(move |_cc| EffectcraftApp::new(session).with_control(rx));
|
|
settle(&mut harness, args.settle);
|
|
for step in &args.steps {
|
|
let method = step["method"].as_str().unwrap_or_default().to_string();
|
|
let params = step.get("params").cloned().unwrap_or(json!({}));
|
|
if method == "snap" {
|
|
if let Err(e) = snap(&mut harness, params["path"].as_str().unwrap_or("snap.png")) {
|
|
eprintln!("{e}");
|
|
}
|
|
continue;
|
|
}
|
|
let (reply_tx, reply_rx) = mpsc::channel();
|
|
let _ = tx.send(ControlRequest { method: method.clone(), params, reply: reply_tx });
|
|
// Requests are answered on a later frame (some wait for input to be processed).
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
let reply = loop {
|
|
step_frame(&mut harness);
|
|
if let Ok(v) = reply_rx.try_recv() {
|
|
break v;
|
|
}
|
|
if Instant::now() > deadline {
|
|
break json!({"ok": false, "error": "no reply within 10 s"});
|
|
}
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
};
|
|
println!("{}", json!({"method": method, "reply": reply}));
|
|
settle(&mut harness, 0.3);
|
|
}
|
|
settle(&mut harness, args.settle.min(1.0));
|
|
if let Err(e) = snap(&mut harness, &args.out) {
|
|
eprintln!("{e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|