effectcraft/crates/ui-egui/tests/ui_web_offload.rs

205 lines
9.2 KiB
Rust

//! Headless checks for M13.10 (web app depth), run natively with stand-ins for the browser's
//! workers:
//!
//! - viewer frames go to a [`RemoteFrames`] renderer (the browser's frame worker, here an
//! in-process [`FrameServer`] fed through JSON with project diffs) instead of rendering on the
//! UI thread, the viewer's frame first, and come back identical;
//! - a control-channel `wait: true` command whose job is offloaded replies only when the job
//! ends, while the UI keeps running.
use std::sync::mpsc::TryRecvError;
use std::sync::{Arc, Mutex};
use effectcraft_engine::Session;
use effectcraft_engine::offload::{Inbox, Offload, Post, WorkerRequest};
use effectcraft_engine::remote::{FrameMsg, FrameReply, FrameServer, Mirror};
use effectcraft_engine::render::RenderOpts;
use effectcraft_ui_egui::frames::{FrameImage, FrameKey, Frames, RemoteDone, RemoteFrame, RemoteFrames, RemoteJob, RenderSource};
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp};
use egui_kittest::Harness;
use serde_json::json;
/// A frame worker in the same process: messages go through JSON like `postMessage`; rendered
/// frames are held until [`FakeWorker::deliver`] (a busy worker) or dropped by
/// [`FakeWorker::lose`].
struct FakeWorker {
slots: usize,
state: Mutex<(Mirror, FrameServer, Vec<(RemoteDone, RemoteFrame)>, u64)>,
/// Patch / full-project syncs sent.
syncs: Mutex<Vec<&'static str>>,
}
impl FakeWorker {
fn new(s: &Session, slots: usize) -> FakeWorker {
FakeWorker { slots, state: Mutex::new((Mirror::default(), FrameServer::new(s.footage.clone(), s.expr.clone()), vec![], 0)), syncs: Mutex::default() }
}
fn pending(&self) -> usize {
self.state.lock().unwrap().2.len()
}
fn take(&self) -> Vec<(RemoteDone, RemoteFrame)> {
std::mem::take(&mut self.state.lock().unwrap().2)
}
/// Post every rendered frame back.
fn deliver(&self) {
for (done, f) in self.take() {
done(Ok(f));
}
}
/// The worker died: its frames never arrive.
fn lose(&self) {
for (done, _) in self.take() {
done(Err("worker lost".into()));
}
}
}
fn rt<T: serde::Serialize + for<'de> serde::Deserialize<'de>>(v: &T) -> T {
serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap()
}
impl RemoteFrames for FakeWorker {
fn slots(&self) -> usize {
self.slots
}
fn start(&self, job: RemoteJob, done: RemoteDone) {
let mut st = self.state.lock().unwrap();
let (mirror, server, pending, next) = &mut *st;
if let Some(m) = mirror.sync(job.revision, &job.project) {
self.syncs.lock().unwrap().push(if matches!(m, FrameMsg::Patch { .. }) { "patch" } else { "project" });
assert!(server.handle(rt(&m)).is_empty());
}
*next += 1;
let out = server.handle(rt(&FrameMsg::Render { id: *next, revision: job.revision, comp: job.comp, time: job.t, opts: Box::new(job.opts), disk: None }));
match out.into_iter().next() {
Some((FrameReply::Frame { width, height, ms, .. }, Some(rgba))) => pending.push((done, RemoteFrame { width, height, rgba, ms })),
other => done(Err(format!("{:?}", other.map(|o| o.0)))),
}
}
}
fn source(s: &Session) -> RenderSource {
RenderSource {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
layer_cache: s.layer_cache.clone(),
gpu: None,
gpu_display: false,
disk: None,
}
}
#[test]
fn viewer_frames_render_remotely_from_project_diffs() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
let c = s.project.comp(comp).unwrap().clone();
let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() };
let key = |rev: u64, f: i64| FrameKey { revision: rev, comp: comp.0, frame: f, scale: 250, view: 0 };
let worker = Arc::new(FakeWorker::new(&s, 2));
let mut frames = Frames::default();
frames.set_remote(Some(worker.clone()));
assert!(frames.remote_active());
// Three prefetch frames, then the viewer's frame.
for f in 1..=3 {
frames.request(&source(&s), key(s.revision, f), comp, c.frame_rate.tick_of(f), opts);
}
frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts);
assert_eq!(frames.inflight(), 4);
// Nothing renders until dispatched (no frame threads with a remote renderer); the viewer's
// frame goes first, then prefetch in request order.
assert_eq!(frames.dispatch_remote(), 2);
assert_eq!(frames.remote_busy(), 2);
assert_eq!(worker.pending(), 2);
assert_eq!(frames.dispatch_remote(), 0, "both slots are busy");
assert_eq!(*worker.syncs.lock().unwrap(), vec!["project"], "the first sync is the whole project");
// Frames arrive: the cache holds exactly what the page would have rendered.
worker.deliver();
assert_eq!(frames.remote_busy(), 0);
let img = match frames.get(&key(s.revision, 10)).expect("viewer frame cached") {
FrameImage::Cpu(c) => c,
FrameImage::Gpu(_) => unreachable!(),
};
let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts));
assert_eq!(img.size, local.size);
assert_eq!(img.pixels, local.pixels);
assert!(frames.is_cached(&key(s.revision, 1)));
assert!(!frames.is_cached(&key(s.revision, 2)));
// The rest; an edit later travels as a patch.
frames.dispatch_remote();
worker.deliver();
frames.dispatch_remote();
worker.deliver();
assert_eq!(frames.inflight(), 0);
s.execute("layer.newSolid", json!({"color": "#ff8800"})).unwrap();
frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts);
frames.dispatch_remote();
worker.deliver();
assert_eq!(worker.syncs.lock().unwrap().last(), Some(&"patch"));
let FrameImage::Cpu(img) = frames.get(&key(s.revision, 10)).unwrap() else { unreachable!() };
let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts));
assert_eq!(img.pixels, local.pixels);
// A lost frame is released (requested again later), not cached.
frames.request(&source(&s), key(s.revision, 20), comp, c.frame_rate.tick_of(20), opts);
frames.dispatch_remote();
worker.lose();
assert_eq!(frames.inflight(), 0);
assert!(!frames.is_cached(&key(s.revision, 20)));
// Without slots the remote renderer is unusable (frames render here again).
frames.set_remote(Some(Arc::new(FakeWorker::new(&s, 0))));
assert!(!frames.remote_active());
}
/// Holds offloaded jobs until the test runs them.
#[derive(Default)]
struct Manual(Mutex<Vec<(WorkerRequest, Arc<Inbox>)>>);
impl Offload for Manual {
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
self.0.lock().unwrap().push((req, inbox));
Ok(())
}
fn cancel(&self, _: u64) {}
}
fn exporter() -> Arc<effectcraft_host::FileExporter> {
let sink: Arc<dyn Fn(&str, Vec<u8>) + Send + Sync> = Arc::new(|_: &str, _: Vec<u8>| {});
Arc::new(effectcraft_host::FileExporter { sink: Some(sink) })
}
#[test]
fn waiting_commands_reply_when_their_offloaded_job_ends() {
let off = Arc::new(Manual::default());
let mut s = Session { offload: Some(off.clone()), exporter: Some(exporter()), ..Default::default() };
s.execute("comp.new", json!({"name": "W", "width": 64, "height": 48, "frameRate": 10, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"color": "#406080"})).unwrap();
s.execute("renderQueue.add", json!({"format": "gif", "output": "/w.gif"})).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_eframe(|_| EffectcraftApp::new(s).with_control(rx));
h.run_steps(2);
// `renderQueue.render` waits by default: the job goes to the worker, the reply waits.
let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "renderQueue.render", "params": {}}));
tx.send(req).unwrap();
h.run_steps(4);
assert_eq!(reply.try_recv().unwrap_err(), TryRecvError::Empty, "no reply while the job runs");
assert!(h.state().session.is_rendering());
let (job, inbox) = off.0.lock().unwrap().pop().expect("the render went to the worker");
// The worker runs it.
let mut w = Session { exporter: Some(exporter()), ..Default::default() };
let post: Post = std::rc::Rc::new(move |r| inbox.push(r));
effectcraft_engine::offload::run_request(&mut w, job, &post);
h.run_steps(4);
let v = reply.try_recv().expect("replied once the job ended");
assert_eq!(v["ok"], true, "{v}");
assert_eq!(v["result"]["rendering"], false, "{v}");
assert_eq!(v["result"]["items"][0]["status"], "Done", "{v}");
// `wait: false` replies at once.
h.state_mut().session.execute("renderQueue.add", json!({"format": "gif", "output": "/w2.gif"})).unwrap();
let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "renderQueue.render", "params": {"wait": false}}));
tx.send(req).unwrap();
h.run_steps(2);
let v = reply.try_recv().expect("immediate reply");
assert_eq!(v["result"]["rendering"], true, "{v}");
}