Files
effectcraft/crates/script/src/tests/render_wait.rs
T
FredApps 86c08aa0ee Keep native control renders responsive (#3306)
* Keep native control renders responsive

* Refresh replies for immediately completed render jobs

* #3289: Bind waiting replies to their native render
2026-11-08 16:43:06 +01:00

244 lines
11 KiB
Rust

//! #3289: a native frontend may service the event loop while a script waits for rendering.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use effectcraft_engine::project::render_queue::OutputFormat;
use effectcraft_engine::{ExportJob, ExportResult, Exporter, Session};
use serde_json::{Value, json};
/// A slow exporter without expensive frames. A bounded wait makes the old blocking path
/// fail the assertions instead of hanging the test process.
struct Gate {
entered: AtomicUsize,
permits: AtomicUsize,
failed: bool,
}
impl Exporter for Gate {
fn formats(&self) -> Vec<OutputFormat> {
vec![OutputFormat::H264]
}
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
let call = self.entered.fetch_add(1, Ordering::SeqCst) + 1;
let end = Instant::now() + Duration::from_secs(2);
while self.permits.load(Ordering::SeqCst) < call {
if !progress(0, 1) {
return Err(effectcraft_engine::render_queue::CANCELLED.into());
}
if Instant::now() > end {
return Err("test exporter timed out waiting for the frontend".into());
}
std::thread::sleep(Duration::from_millis(1));
}
if self.failed { Err("test encoder failure".into()) } else { Ok(ExportResult { path: job.path.into(), frames: 1, ..Default::default() }) }
}
}
fn session(permits: usize, failed: bool) -> (Session, Arc<Gate>) {
let gate = Arc::new(Gate { entered: AtomicUsize::new(0), permits: AtomicUsize::new(permits), failed });
let mut s = super::session();
s.exporter = Some(gate.clone());
s.execute("comp.new", json!({"name": "Render", "width": 16, "height": 16, "duration": 1})).unwrap();
let path = std::env::temp_dir().join(format!("ec-script-render-{}.mp4", std::process::id()));
s.execute("renderQueue.add", json!({"output": path.to_string_lossy()})).unwrap();
s.prefs.auto_save.enabled = false;
(s, gate)
}
fn finish(s: &mut Session) -> Value {
let end = Instant::now() + Duration::from_secs(3);
loop {
if let Some(result) = crate::ui::poll_render_wait(s) {
return result;
}
assert!(Instant::now() < end, "script did not resume");
std::thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn render_yields_without_finishing_the_script_or_its_undo_group() {
let (mut s, gate) = session(0, false);
s.script_ui.yield_renders = true;
let before = s.history.undo.len();
let out = crate::run_code(
&mut s,
"app.beginUndoGroup('Render transaction'); app.project.activeItem.layers.addNull().name = 'Before'; $.writeln('before'); app.project.renderQueue.render(); app.project.activeItem.layers.addNull().name = 'After'; $.writeln('after'); app.endUndoGroup(); [42, app.project.renderQueue.rendering];",
"render.jsx",
);
assert!(out.error.is_none(), "{out:?}");
assert!(out.render_host.is_some(), "blocking render returned instead of yielding: {out:?}");
assert!(!out.waiting, "a render suspension is not a modal dialog");
assert!(out.output.is_empty(), "output belongs to the eventual reply");
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "the continuation has not run");
assert!(crate::ui::poll_render_wait(&mut s).is_none());
assert!(!s.undo(), "undo cannot discard the suspended script's live edits");
assert!(s.execute("engine.batch", json!({"steps": [{"command": "layer.newNull"}]})).is_err());
assert!(s.execute("jobs.list", json!({})).is_err(), "status polling must not apply unrelated jobs");
// The frontend resets the invocation-only opt-in after script.run returns.
s.script_ui.yield_renders = false;
gate.permits.store(1, Ordering::SeqCst);
let done = finish(&mut s);
assert_eq!(done["ok"], true, "{done}");
assert_eq!(done["result"], json!([42, false]));
assert_eq!(done["output"], "before\nafter");
assert_eq!(s.project.render_queue[0].status.label(), "Done");
assert_eq!(s.history.undo.len(), before + 1, "one undo group spans the render");
assert_eq!(s.history.undo.last().unwrap().0, "Render transaction");
assert!(s.script_ui.render_host.is_none());
s.undo();
assert!(s.active_comp().unwrap().layers.is_empty());
}
#[test]
fn a_second_render_keeps_the_same_script_pending() {
let (mut s, gate) = session(0, false);
s.script_ui.yield_renders = true;
let out = crate::run_code(
&mut s,
"app.project.renderQueue.render(); app.project.renderQueue.item(1).render = true; app.project.renderQueue.render(); 17;",
"twice.jsx",
);
let host = out.render_host.unwrap();
s.script_ui.yield_renders = false;
gate.permits.store(1, Ordering::SeqCst);
let end = Instant::now() + Duration::from_secs(3);
while gate.entered.load(Ordering::SeqCst) < 2 {
assert!(crate::ui::poll_render_wait(&mut s).is_none(), "no premature reply after the first render");
assert!(Instant::now() < end);
std::thread::sleep(Duration::from_millis(1));
}
assert_eq!(s.script_ui.render_host, Some(host));
gate.permits.store(2, Ordering::SeqCst);
assert_eq!(finish(&mut s)["result"], 17);
}
#[test]
fn cancellation_and_encoder_failure_resume_with_terminal_statuses() {
for failed in [false, true] {
let (mut s, gate) = session(0, failed);
s.script_ui.yield_renders = true;
let out = crate::run_code(&mut s, "app.project.renderQueue.render(); [9, app.project.renderQueue.rendering];", "stopped.jsx");
assert!(out.render_host.is_some());
if failed {
gate.permits.store(1, Ordering::SeqCst);
} else {
s.stop_render();
}
let done = finish(&mut s);
assert_eq!(done["ok"], true, "the synchronous command reports status, not a thrown script error: {done}");
assert_eq!(done["result"], json!([9, false]));
assert_ne!(s.project.render_queue[0].status.label(), "Done");
}
}
#[test]
fn headless_scripts_still_wait_and_start_errors_do_not_suspend() {
let (mut s, _) = session(1, false);
let out = crate::run_code(&mut s, "app.project.renderQueue.render(); [7, app.project.renderQueue.rendering];", "headless.jsx");
assert!(out.error.is_none(), "{out:?}");
assert_eq!(out.result, json!([7, false]));
assert!(out.render_host.is_none());
assert_eq!(s.project.render_queue[0].status.label(), "Done");
s.script_ui.yield_renders = true;
let out = crate::run_code(&mut s, "app.project.renderQueue.render();", "empty.jsx");
assert!(out.error.is_some());
assert!(out.render_host.is_none());
assert!(s.script_ui.render_host.is_none());
}
#[test]
fn retained_palette_callbacks_can_yield_after_the_original_control_invocation() {
let (mut s, gate) = session(0, false);
s.script_ui.yield_renders = true;
let out = crate::run_code(
&mut s,
"var w = new Window('palette', 'Render'); var b = w.add('button', undefined, 'Go', {name:'go'}); b.onClick = function () { app.project.renderQueue.render(); $.writeln('resumed'); }; w.show();",
"palette-render.jsx",
);
assert!(out.error.is_none(), "{out:?}");
s.script_ui.yield_renders = false;
let result = s.execute("scriptui.click", json!({"widget": "go"})).unwrap();
assert!(result["renderHost"].is_number(), "{result}");
gate.permits.store(1, Ordering::SeqCst);
assert_eq!(finish(&mut s)["output"], "resumed");
let window = s.script_ui.windows[0].id;
s.execute("scriptui.close", json!({"window": window})).unwrap();
}
#[test]
fn post_render_actions_complete_before_the_script_resumes() {
use effectcraft_engine::project::{Footage, FootageKind};
struct Probe;
impl effectcraft_engine::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() })
}
}
for action in ["import", "setProxy"] {
let (mut s, gate) = session(0, false);
s.importer = Some(Arc::new(Probe));
let id = s.project.render_queue[0].id;
s.execute("renderQueue.setOutputModule", json!({"item": id, "postRenderAction": action})).unwrap();
let before = s.project.items.len();
let comp = s.active_comp_id().unwrap();
s.script_ui.yield_renders = true;
let out = crate::run_code(&mut s, "app.project.renderQueue.render(); 29;", "post-render.jsx");
assert!(out.render_host.is_some(), "{out:?}");
gate.permits.store(1, Ordering::SeqCst);
// A query must not consume updates while the edit guard is still active.
let end = Instant::now() + Duration::from_secs(3);
while s.is_rendering() {
assert!(Instant::now() < end);
std::thread::sleep(Duration::from_millis(1));
}
s.execute("renderQueue.list", json!({})).unwrap();
assert_eq!(s.project.items.len(), before);
assert_eq!(finish(&mut s)["result"], 29);
if action == "import" {
assert_eq!(s.project.items.len(), before + 1);
} else {
assert!(s.project.item(comp).unwrap().proxy.is_some());
}
}
}
#[test]
fn deferred_errors_keep_their_included_source_location() {
let (mut s, gate) = session(0, false);
let dir = std::env::temp_dir().join(format!("ec-render-include-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("render.jsx"), "app.project.renderQueue.render();\nmissingAfterRender();").unwrap();
std::fs::write(dir.join("main.jsx"), "#include 'render.jsx'").unwrap();
s.script_ui.yield_renders = true;
let out = crate::run_code(&mut s, "#include 'render.jsx'", &dir.join("main.jsx").to_string_lossy());
assert!(out.render_host.is_some(), "{out:?}");
gate.permits.store(1, Ordering::SeqCst);
let done = finish(&mut s);
assert_eq!(done["ok"], false);
assert_eq!(done["error"]["line"], 2);
assert!(done["error"]["file"].as_str().unwrap().ends_with("render.jsx"), "{done}");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn included_error_mapping_survives_a_modal_yield_after_rendering() {
let (mut s, gate) = session(0, false);
let dir = std::env::temp_dir().join(format!("ec-render-modal-include-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("render.jsx"), "app.project.renderQueue.render();\nvar d = new Window('dialog', 'Continue'); d.add('button', undefined, 'OK', {name:'ok'}); d.show();\nmissingAfterModal();").unwrap();
std::fs::write(dir.join("main.jsx"), "#include 'render.jsx'").unwrap();
s.script_ui.yield_renders = true;
let out = crate::run_code(&mut s, "#include 'render.jsx'", &dir.join("main.jsx").to_string_lossy());
assert!(out.render_host.is_some(), "{out:?}");
gate.permits.store(1, Ordering::SeqCst);
assert_eq!(finish(&mut s)["waiting"], true);
let done = s.execute("scriptui.click", json!({"widget": "ok"})).unwrap();
assert_eq!(done["ok"], false);
assert_eq!(done["error"]["line"], 3);
assert!(done["error"]["file"].as_str().unwrap().ends_with("render.jsx"), "{done}");
std::fs::remove_dir_all(dir).unwrap();
}