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
This commit is contained in:
FredApps
2026-11-08 16:43:06 +01:00
committed by GitHub
parent 07be19d402
commit 86c08aa0ee
20 changed files with 950 additions and 29 deletions
+5 -1
View File
@@ -734,7 +734,11 @@ fn stop(s: &mut Session, _: &Value) -> Result<Value> {
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
s.poll_render();
// The suspended script's frontend applies post-render edits before resuming it. A
// control inspection must not apply those edits while the undo-group guard is active.
if s.script_ui.render_host.is_none() {
s.poll_render();
}
let items: Vec<Value> = s.project.render_queue.iter().enumerate().map(|(k, i)| item_json(s, i, k)).collect();
Ok(json!({"items": items, "rendering": s.is_rendering(), "progress": s.render_progress()}))
}
+62 -1
View File
@@ -14,6 +14,7 @@ use effectcraft_project::render_queue::{OutputFormat, OutputModule, PostRenderAc
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
use effectcraft_render::{ExprHost, FootageSource};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{Event, Session};
@@ -113,6 +114,14 @@ pub struct JobShared {
pub cancel: AtomicBool,
pub state: Mutex<JobState>,
pub updates: Mutex<Vec<ItemUpdate>>,
/// A waiting control request retains its own items across requeue/removal/project edits.
reply: Mutex<Option<RenderReply>>,
}
#[derive(Default)]
struct RenderReply {
items: Vec<Value>,
finished: bool,
}
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
@@ -120,6 +129,48 @@ pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
}
impl JobShared {
/// Retain the requesting command's items before the UI can edit or replace the queue.
pub fn retain_reply_items(&self, items: Vec<Value>) {
*lock(&self.reply) = Some(RenderReply { items, finished: false });
}
/// Whether completion and post-render actions have been applied to this run.
pub fn reply_finished(&self) -> bool {
lock(&self.reply).as_ref().is_some_and(|reply| reply.finished)
}
/// Final items for this run, independent of the current project and any later render.
pub fn completed_items(&self) -> Option<Vec<Value>> {
lock(&self.reply).as_ref().filter(|reply| reply.finished).map(|reply| reply.items.clone())
}
fn update_reply(&self, updates: &[ItemUpdate]) {
let mut reply = lock(&self.reply);
let Some(reply) = reply.as_mut() else { return };
for update in updates {
let id = match update {
ItemUpdate::Started { id, .. } | ItemUpdate::Finished { id, .. } => *id,
};
let Some(item) = reply.items.iter_mut().find(|item| item["id"].as_u64() == Some(id)).and_then(Value::as_object_mut) else { continue };
match update {
ItemUpdate::Started { unix, .. } => {
item.insert("status".into(), json!(RenderStatus::Rendering));
item.insert("statusLabel".into(), json!(RenderStatus::Rendering.label()));
item.insert("started".into(), json!(unix));
item.insert("render_time".into(), Value::Null);
}
ItemUpdate::Finished { status, seconds, output, .. } => {
item.insert("status".into(), json!(status));
item.insert("statusLabel".into(), json!(status.label()));
item.insert("render_time".into(), json!(seconds));
if let Some(output) = output {
item.insert("last_output".into(), json!(output));
}
}
}
}
}
pub fn snapshot(&self) -> JobState {
lock(&self.state).clone()
}
@@ -626,8 +677,11 @@ impl Session {
pub fn poll_render(&mut self) -> bool {
self.drain_remote_render();
let Some(job) = &self.render_job else { return false };
let updates = std::mem::take(&mut *lock(&job.shared.updates));
// Observe completion before draining updates so a worker finishing between the two
// reads cannot leave its final item statuses behind when this job is removed.
let finished = job.is_finished();
let updates = std::mem::take(&mut *lock(&job.shared.updates));
job.shared.update_reply(&updates);
let changed = !updates.is_empty();
let mut post: Vec<(u64, String)> = vec![];
if changed {
@@ -659,6 +713,7 @@ impl Session {
}
self.bump();
}
let mut completed = None;
if finished && let Some(mut job) = self.render_job.take() {
if let Some(t) = job.thread.take() {
let _ = t.join();
@@ -685,12 +740,18 @@ impl Session {
self.events.push(Event::Frontend { command: "renderQueue.notify".into(), params: serde_json::json!({"message": msg, "error": failed > 0}) });
}
self.events.push(Event::Toast { message: msg, error: failed > 0 });
completed = Some(job.shared);
}
for (id, path) in post {
if let Err(e) = self.post_render_action(id, &path) {
self.events.push(Event::Toast { message: format!("Post-render action: {e}"), error: true });
}
}
if let Some(shared) = completed
&& let Some(reply) = lock(&shared.reply).as_mut()
{
reply.finished = true;
}
changed || finished
}
+22
View File
@@ -388,6 +388,18 @@ pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Res
/// and updates [`Session::script_ui`]. Frontends call it every frame (see [`ScriptUi::run_tasks`]).
pub type ScriptTaskRunner = fn(&mut Session) -> Value;
/// Resume a suspended script once its Render Queue worker has finished. `None` means the
/// script is still rendering; `Some` is its eventual result (or a real modal-dialog yield).
pub type ScriptRenderPoll = fn(&mut Session) -> Option<Value>;
pub const RENDER_WAIT_MESSAGE: &str = "A script is rendering. Stop the render or wait for it to finish before editing.";
/// These inspections do not run callbacks or collect unrelated background edits. In
/// particular, `journal: false` is not a read-only guarantee (`engine.batch`, `jobs.wait`).
pub fn allowed_during_render(command: &str) -> bool {
matches!(command, "renderQueue.stop" | "renderQueue.list" | "editor.state" | "app.capabilities" | "command.list" | "scriptui.list" | "scriptui.get")
}
/// The open script windows.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
@@ -401,6 +413,16 @@ pub struct ScriptUi {
/// The scripting engine's scheduled-task runner (set with [`Session::script`]).
#[serde(skip)]
pub tasks: Option<ScriptTaskRunner>,
/// A frontend that services [`Self::poll_render`] may opt a script invocation into yielding
/// during a blocking Render Queue call. Headless and lifecycle scripts remain synchronous.
#[serde(skip)]
pub yield_renders: bool,
/// The script host suspended in a Render Queue call. While set, frontends must not allow
/// other edits or script callbacks to interleave with its open undo groups.
#[serde(skip)]
pub render_host: Option<u32>,
#[serde(skip)]
pub poll_render: Option<ScriptRenderPoll>,
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
/// tasks of scripts without an open window stop then, except those of Startup scripts.
#[serde(skip)]
+6
View File
@@ -7,6 +7,9 @@ impl Session {
/// Run a command by id.
pub fn execute(&mut self, id: &str, params: Value) -> Result<Value> {
if self.script_ui.render_host.is_some() && !crate::scriptui::allowed_during_render(id) {
return Err(EngineError::Other(crate::scriptui::RENDER_WAIT_MESSAGE.into()));
}
let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if let Err(why) = (spec.enabled)(self) {
// Explicit targets (agents, scripts) don't need a UI selection.
@@ -57,6 +60,9 @@ impl Session {
/// Whether `id` names a known command whose `enabled` predicate returns `Ok` for this session;
/// an unknown id or a predicate error reads as disabled.
pub fn is_enabled(&self, id: &str) -> bool {
if self.script_ui.render_host.is_some() && !crate::scriptui::allowed_during_render(id) {
return false;
}
commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())
}
+12
View File
@@ -30,6 +30,9 @@ impl Session {
/// undo step under `label`, unless it merges with the current merge key (then the redo branch
/// is dropped). An `Err` from `f` propagates with the session unchanged.
fn edit_inner<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
if self.script_ui.render_host.is_some() {
return Err(EngineError::Other(crate::scriptui::RENDER_WAIT_MESSAGE.into()));
}
let before = self.project.clone();
let mut p = (*self.project).clone();
let mut st = self.state.clone();
@@ -105,6 +108,9 @@ impl Session {
/// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay
/// valid ([`effectcraft_project::Comp::same_pixels`]).
pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> {
if self.script_ui.render_host.is_some() {
return Err(EngineError::Other(crate::scriptui::RENDER_WAIT_MESSAGE.into()));
}
if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r {
return Ok(());
}
@@ -134,6 +140,9 @@ impl Session {
/// on-canvas editor gets a new IME epoch only when its own document changed) and the session is
/// bumped. Returns `false`, changing nothing, when there is nothing to undo.
pub fn undo(&mut self) -> bool {
if self.script_ui.render_host.is_some() {
return false;
}
let Some((label, p)) = self.history.undo.pop() else { return false };
let edited = commands::text_edit::active_editor_snapshot(self);
let probe = commands::kept_crypto_probe(self);
@@ -152,6 +161,9 @@ impl Session {
/// text document starts a new IME epoch) and the session is bumped. Returns `false`, changing
/// nothing, when there is nothing to redo.
pub fn redo(&mut self) -> bool {
if self.script_ui.render_host.is_some() {
return false;
}
let Some((label, p)) = self.history.redo.pop() else { return false };
let edited = commands::text_edit::active_editor_snapshot(self);
let probe = commands::kept_crypto_probe(self);