refactor(ui-egui): move EffectcraftApp methods to app/*.rs

This commit is contained in:
aiongg
2026-10-26 21:49:11 +00:00
parent 0eea2d67ab
commit 03f5ba581d
12 changed files with 2302 additions and 2251 deletions
+13 -2251
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
#[cfg(test)]
use super::*;
/// Put `width` × `height` straight-alpha RGBA8 pixels on the system clipboard (Composition ▸ Copy
/// Frame to Clipboard). The pixel count is checked first: egui's image constructor panics on a
/// size that doesn't match its buffer.
pub(super) fn copy_image(ctx: &egui::Context, width: u32, height: u32, rgba: &[u8]) -> Result<(), String> {
let (w, h) = (width as usize, height as usize);
if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) {
return Err(format!("cannot copy a malformed {width}×{height} image to the clipboard"));
}
ctx.copy_image(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba));
Ok(())
}
#[cfg(test)]
mod copy_frame_tests {
use super::*;
fn copied(output: &egui::FullOutput) -> Vec<&egui::ColorImage> {
output
.platform_output
.commands
.iter()
.filter_map(|c| match c {
egui::OutputCommand::CopyImage(image) => Some(image),
_ => None,
})
.collect()
}
/// Composition ▸ Copy Frame to Clipboard puts the rendered frame on the system clipboard.
#[test]
fn copy_frame_puts_the_frame_on_the_clipboard() {
let mut session = Session::default();
session.execute("comp.new", json!({"name": "Copy", "width": 8, "height": 6, "duration": 1})).unwrap();
session.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
let mut app = EffectcraftApp::new(session);
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
crate::menus::invoke(&mut app, ui.ctx(), "comp.copyFrame", json!({})).unwrap();
app.handle_events(ui.ctx());
});
output.textures_delta.clear();
let images = copied(&output);
assert_eq!(images.len(), 1);
assert_eq!(images[0].size, [8, 6]);
assert!(images[0].pixels.iter().all(|p| *p == egui::Color32::from_rgb(255, 0, 0)));
}
/// A malformed image never reaches egui's panicking constructor.
#[test]
fn a_malformed_image_is_an_error_not_a_crash() {
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
assert!(copy_image(ui.ctx(), 4, 4, &[0; 10]).is_err());
assert!(copy_image(ui.ctx(), 0, 4, &[]).is_err());
assert!(copy_image(ui.ctx(), u32::MAX, u32::MAX, &[0; 4]).is_err());
});
output.textures_delta.clear();
assert!(copied(&output).is_empty());
}
}
+11
View File
@@ -0,0 +1,11 @@
use super::*;
impl EffectcraftApp {
/// Show the crash-recovery dialog for a previous run that didn't exit cleanly.
pub fn offer_recovery(&mut self, r: effectcraft_engine::autosave::Recovery) {
if r.autosave.is_some() {
self.recovery = Some(r);
self.dialog = Some(Dialog::Recovery);
}
}
}
+551
View File
@@ -0,0 +1,551 @@
use super::clipboard::copy_image;
use super::window::fit_window;
use super::*;
impl EffectcraftApp {
/// Completed, non-discarded UI passes since launch.
pub fn ui_passes(&self) -> u64 {
self.ui_pass_seq
}
// ---------------------------------------------------------------- control channel
pub(crate) fn raise_for_control(&mut self, ctx: &egui::Context) {
ctx.request_repaint();
}
/// Handle the control channel for this tick: drain queued requests plus previously deferred ones, run each
/// through `control::handle`, answer `Done` immediately, retry `Retry` until its three-second deadline, hold
/// input/job/screenshot replies until their event happens and finish job waiters. Retry timeouts and refused
/// screenshots are answered as `{"ok": false, "error": ...}`; the receiver is kept for the next tick.
fn drain_control(&mut self, ctx: &egui::Context, now: f64) {
let Some(rx) = self.control_rx.take() else { return };
let mut reqs: Vec<(ControlRequest, f64)> = std::mem::take(&mut self.deferred);
while let Ok(req) = rx.try_recv() {
reqs.push((req, now + 3.0));
}
for (req, deadline) in reqs {
let reply = req.reply.clone();
match control::handle(self, ctx, &req, now) {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Retry(msg) => {
if now < deadline {
self.deferred.push((req, deadline));
ctx.request_repaint();
} else {
let _ = reply.send(json!({"ok": false, "error": msg}));
}
}
control::Outcome::AfterInput => {
self.input_waiters.push(reply);
// The reply waits for a real UI pass: a hidden window must run one.
self.want_paint = true;
}
control::Outcome::AwaitJobs(w) => {
self.job_waiters.push((w, reply));
ctx.request_repaint();
}
control::Outcome::Screenshot { path, target } => {
// A covered or minimized window presents no frames (macOS skips its redraws), so
// a screenshot would never arrive and nothing would tick its timeout. The
// hidden-window capture path (a pending screenshot forces a pass in 0.37) needs
// a native platform run before this gate is removed; the web build never paints
// a hidden tab.
if ctx.input(|i| i.viewport().occluded == Some(true) || i.viewport().minimized == Some(true)) {
let _ = reply.send(json!({"ok": false, "error": "window is covered or minimized: call ui.focus first (render.frame renders comp pixels without the window)"}));
continue;
}
self.admit_screenshot(ctx, path, target, reply);
}
}
}
self.control_rx = Some(rx);
self.finish_job_waiters(ctx);
}
/// Reply to the control requests whose offloaded jobs ended.
fn finish_job_waiters(&mut self, ctx: &egui::Context) {
if self.job_waiters.is_empty() {
return;
}
self.session.poll_render();
self.session.poll_offload();
let waiters = std::mem::take(&mut self.job_waiters);
for (w, reply) in waiters {
if w.pending(self) {
self.job_waiters.push((w, reply));
} else {
let v = w.finish(self);
let _ = reply.send(v);
}
}
if !self.job_waiters.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
}
// ---------------------------------------------------------------- frame
/// Apply the engine events drained this tick: open a comp, show a toast, open a URL, run a frontend command,
/// purge frame caches or copy an image; command and clipboard failures go to `app.ui.status`.
pub(super) fn handle_events(&mut self, ctx: &egui::Context) {
for ev in self.session.drain_events() {
match ev {
effectcraft_engine::Event::OpenComp(c) => {
panels::viewers::on_open_comp(self, c);
self.ui.timeline.pps = None;
}
effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, self.now)),
effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
// Frames are keyed by content: an edit keeps those of comps it doesn't touch.
effectcraft_engine::Event::ProjectChanged { .. } => {}
effectcraft_engine::Event::Frontend { command, params } => {
if let Err(e) = crate::menus::frontend(self, ctx, &command, params) {
self.ui.status = e;
}
}
effectcraft_engine::Event::PurgeCaches => self.frames.clear(),
effectcraft_engine::Event::CopyImage { width, height, rgba } => {
if let Err(e) = copy_image(ctx, width, height, &rgba) {
self.ui.status = e;
}
}
}
}
}
/// The app's only non-paint entry: one tick of background work.
///
/// This runs for every eframe logic callback, including the logic-only ticks of a hidden
/// window (which never reach [`Self::draw`]). It must therefore do no input handling, no
/// layout and no presentation: engine/job queues, autosave, playback, audio, the control
/// channel, screenshots and the close guard. Everything here is idempotent for a pass that
/// `Context::request_discard` replays.
pub fn tick(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
self.now = self.clock.now();
// The compositor exists before the first paint and while hidden (device-loss handling
// must run there too).
self.init_gpu(frame);
self.apply_gpu_failure(ctx);
if self.gpu_failure_applied == Some(true) {
self.close_after_device_loss(ctx);
return;
}
// Focus comes from the fresh viewport copy; a full pass's `InputState::focused` is only a
// fallback (a logic-only tick leaves it at the previous pass's value).
let focused = ctx.input(|i| i.viewport().focused.or(Some(i.focused)));
self.focused = focused.unwrap_or(self.focused);
// The interface language's fallback fonts are installed in `draw` (fonts need a pass), but
// commands handled here use the right catalog already.
let language = crate::i18n::language(self);
crate::i18n::set_current(language);
if self.styled_language != language {
self.want_paint = true;
}
// Frames and worker completions wake the app even while it is hidden.
self.frames.set_context(ctx);
updates_ui::tick(self, ctx);
if self.session.render_job.is_some() {
self.session.poll_render();
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
if self.session.track_job.is_some() {
self.session.poll_track();
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
if self.session.mask_job.is_some() {
self.session.poll_mask_track();
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
// Background tasks (Content-Aware Fill, Scene Edit Detection, imports).
if !self.session.tasks.is_empty() {
self.session.poll_jobs();
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// Analyses running in a worker (the web app).
if !self.session.offloaded.is_empty() {
self.session.poll_offload();
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// Warp Stabilizer: finish analyses and start queued (re-)analyses in the background.
if self.session.warp_job.is_some() || !self.session.warp_pending.is_empty() {
self.session.poll_warp(true);
self.session.poll_roto(true);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// Roto Brush propagation and Object Matte tracking / Freeze: finish jobs (a Freeze writes
// its mattes) and start queued ones in the background.
if self.session.roto_job.is_some() || !self.session.roto_pending.is_empty() {
self.session.poll_roto(true);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// 3D Camera Tracker: likewise (tracking, then solving).
if self.session.camera_job.is_some() || !self.session.camera_pending.is_empty() {
self.session.poll_camera(true);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.run_script_tasks(ctx);
self.run_dialog_answers();
self.handle_events(ctx);
if let Some(rx) = self.command_inbox.take() {
while let Ok(id) = rx.try_recv() {
if let Err(e) = menus::invoke(self, ctx, &id, json!({})) {
self.ui.status = e;
}
}
self.command_inbox = Some(rx);
}
// A close request (the window's button, Quit, `app.quit`) must be answered while hidden.
panels::unsaved::on_close_requested(self, ctx);
let title = panels::unsaved::window_title(self);
if title != self.window_title {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.window_title = title;
}
self.tick_autosave(ctx);
self.tick_workspace_autosave(ctx);
let now = self.now;
prefs_live::tick(self, ctx, now);
// Playback advances without a paint (its frames and the audio clock are background work).
// Idle prefetch keeps its old condition: it only runs once a viewer has drawn.
if (self.playback.playing || self.last_viewer_scale.is_some())
&& let Some(cid) = self.session.active_comp_id()
{
let (scale, active) = (self.last_viewer_scale.unwrap_or(1.0), self.input_active);
self.advance_playback(ctx, scale, now, active, cid);
}
self.drain_control(ctx, now);
// Screenshot completion, deadlines and issuance are app-side work: the callback only
// stores pixels and wakes this viewport.
self.tick_screenshots(ctx);
// Time-based FPS readout over UI passes (a hidden logic-only tick is not a frame).
let dt = (now - self.last_time) as f32;
if dt > 0.0 && dt < 2.0 {
self.fps = self.fps * 0.9 + (1.0 / dt).min(480.0) * 0.1;
}
self.last_time = now;
// A hidden window paints when work needs a UI pass: queued input and waits, a settled
// screenshot, or playback into a child viewport.
let focused = self.focused;
let child_viewports = !self.ui.dock.layout.floating.is_empty() || prefs_live::video_preview_active(self, focused);
let paint = self.want_paint
|| !self.synthetic.is_empty()
|| !self.input_waiters.is_empty()
|| self.pending_screenshots.iter().any(|s| s.slot.is_none())
|| (self.playback.playing && child_viewports);
if paint {
self.request_hidden_paint(ctx);
}
}
/// Ask the host for a UI pass even when the window is minimized or occluded.
///
/// Native eframe honours `RequestPaintWhileHidden`; the shared web backend does not carry it
/// yet (a browser does not paint a hidden tab), so the web build only requests a normal
/// repaint and hidden web screenshots/input remain unqualified.
fn request_hidden_paint(&mut self, ctx: &egui::Context) {
self.want_paint = false;
#[cfg(not(target_arch = "wasm32"))]
ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden);
ctx.request_repaint();
}
/// Install the theme and fonts for the current language. Fonts need a real pass: the pass
/// that installs them draws nothing, and the next pass draws with them (the two-pass warm-up
/// the app has always had). Never call this from a logic-only tick.
fn ui_setup(&mut self, ctx: &egui::Context) {
panels::viewer_text::install_hooks(ctx);
let language = crate::i18n::language(self);
crate::i18n::set_current(language);
if (!self.styled || self.styled_language != language) && self.synthetic_in_flight {
// The install needs a pass that draws nothing, but this pass has already applied a
// synthetic batch: defer the theme/font install by one pass instead of dropping the
// input (feeds are only put into a pass that can draw).
ctx.request_repaint();
return;
}
if !self.styled || self.styled_language != language {
theme::install(ctx, &self.tokens, language);
self.styled_language = language;
if !self.styled {
fit_window(ctx);
self.styled = true;
}
self.fonts_ready = false;
ctx.request_repaint();
} else if !self.fonts_ready {
// The pass after the install draws: egui has the new font definitions by now.
self.fonts_ready = true;
ctx.request_repaint();
}
}
/// One UI pass: setup, input-driven shortcuts, layout and painting. Called from
/// `eframe::App::ui` and by tests with the root `Ui`.
pub fn draw(&mut self, ui: &mut egui::Ui, frame: &eframe::Frame) {
panels::viewer_text::begin_ui(ui.ctx());
self.draw_inner(ui, frame);
// All exits, including fonts/device loss, observe this pass's real custom availability.
panels::viewer_text::finish_ime(self, ui);
}
/// Context creators install these hooks before the first full pass.
pub fn install_ime_reset_hooks(ctx: &egui::Context) {
panels::viewer_text::install_hooks(ctx);
}
/// Host wrappers call this after their last logic mutation, without replaying UI events.
pub fn finish_ime_logic(&self, ctx: &egui::Context) {
panels::viewer_text::finish_logic(self, ctx);
}
/// One UI pass after the IME begin hook: close on an applied GPU failure, run `ui_setup`, skip drawing until
/// fonts are ready, draw the frame, finish pointer wrapping, pump queued wasm frames, record input activity,
/// publish the completed pass's geometry and pass sequence, acknowledge applied synthetic input and answer
/// `AfterInput` replies only after a non-discarded pass. Requests repaints while frames are in flight.
fn draw_inner(&mut self, ui: &mut egui::Ui, frame: &eframe::Frame) {
let ctx = ui.ctx().clone();
if self.gpu_failure_applied == Some(true) {
self.close_after_device_loss(&ctx);
return;
}
self.ui_setup(&ctx);
if !self.fonts_ready {
// A pass that installs fonts draws nothing. No synthetic batch is in flight here (the
// hook feeds input only to a ready app), and events egui already applied are never
// fed again: that would double the pointer/key state of one click.
ctx.request_repaint();
return;
}
self.frame(ui);
#[cfg(not(target_arch = "wasm32"))]
pointer_wrap::finish_native(&ctx, frame);
// wasm32: no frame threads; render queued frames now, between UI frames.
if cfg!(target_arch = "wasm32") && self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })) {
ctx.request_repaint();
}
// Input belongs to this known UI phase: record it for the next tick.
let active = ctx.input(|i| !i.events.is_empty() || i.pointer.any_down() || i.pointer.is_moving());
self.input_active = active;
self.last_pointer_down = ctx.input(|i| i.pointer.any_down());
// Geometry of a completed pass (a discarded pass is laid out again; only the final one
// describes the surface the next capture will see). Window position is irrelevant.
if !ctx.will_discard() {
let rect = ctx.content_rect();
let ppp = ctx.pixels_per_point();
let changed = self.draw_geom.is_none_or(|g| g.rect.size() != rect.size() || g.ppp != ppp);
if changed {
self.geometry_epoch = self.geometry_epoch.wrapping_add(1);
}
self.draw_geom = Some(DrawGeometry { rect, ppp, epoch: self.geometry_epoch });
self.ui_pass_seq = self.ui_pass_seq.saturating_add(1);
}
// A batch is acknowledged once a UI pass applied it, even if layout was later discarded.
self.mark_synthetic_applied();
// `AfterInput` replies wait for the final (non-discarded) pass that applied the last
// synthetic event: a hidden request is answered only after its forced pass, and a pass
// egui throws away cannot produce an early success reply.
if !ctx.will_discard() && self.synthetic.is_empty() {
for w in self.input_waiters.drain(..) {
let _ = w.send(json!({"ok": true, "result": null}));
}
} else if !self.input_waiters.is_empty() {
self.want_paint = true;
ctx.request_repaint();
}
if self.frames.inflight() > 0 {
ctx.request_repaint_after(std::time::Duration::from_millis(30));
}
}
/// The panels, menus, dialogs and overlays of one pass.
fn frame(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
// Only a viewer that draws in *this* pass records a scale: a closed viewer, the Home
// screen or another comp must stop idle prefetch at a stale scale.
self.last_viewer_scale = None;
self.apply_prefs(&ctx);
// Video Preview is a second viewport: only a real pass can update it.
prefs_live::frame(self, &ctx);
self.auto.begin_frame();
let tab_had_widget_focus = ctx.egui_wants_keyboard_input();
menus::handle_shortcuts(self, &ctx);
if !ctx.input(|i| i.pointer.primary_down()) {
self.ui.viewer.property_interacting = false;
}
let t = self.tokens;
let full = ui.max_rect();
if self.dialog.is_some() {
ui.disable();
}
ui.painter().rect_filled(full, 0.0, t.app_bg);
let mut top = full.min.y;
if self.ui.show_menu_bar {
let mb = egui::Rect::from_min_size(full.min, egui::vec2(full.width(), 24.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(mb));
child.painter().rect_filled(mb, 0.0, t.header_bg);
menus::menu_bar(self, &mut child);
top = mb.max.y;
}
let header_h = 40.0;
let header = egui::Rect::from_min_size(egui::pos2(full.min.x, top), egui::vec2(full.width(), header_h));
header::show(self, ui, header);
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0));
self.dock_area(ui, body);
#[cfg(not(target_arch = "wasm32"))]
prefs_live::dropped_files(self, ui, full);
if !tab_had_widget_focus {
menus::handle_tab_shortcut(self, &ctx);
}
panels::precomp::mini_flowchart(self, &ctx);
panels::home::capture_thumbnail(self);
panels::dialogs::show(self, &ctx);
updates_ui::show(self, &ctx);
panels::scriptui_view::sync_panels(self);
panels::scriptui_view::show_windows(self, &ctx);
panels::learn::coach(self, &ctx);
let lift = panels::media_panels::import_card(self, ui, full);
self.draw_toast(ui, full, lift);
}
/// Workspace-layout auto-save: a background write that also runs while hidden, once the
/// pointer has lifted.
fn tick_workspace_autosave(&mut self, ctx: &egui::Context) {
if self.session.config.is_none() || !self.workspace_store.has_changes(&self.ui) || self.last_pointer_down {
return;
}
if self.workspace_store.due(self.now) {
self.save_workspace_state();
}
if self.workspace_store.has_changes(&self.ui) {
ctx.request_repaint_after(std::time::Duration::from_secs(1));
}
}
pub(super) fn save_workspace_state(&mut self) {
if let Err(error) = self.workspace_store.save(self.session.config.as_deref(), &self.ui) {
if self.workspace_store.error.as_ref() != Some(&error) {
log::warn!("{error}");
self.ui.status = error.clone();
}
self.workspace_store.error = Some(error);
}
}
/// The status message or the latest toast, bottom left (`lift` points higher while the
/// Importing card is there).
fn draw_toast(&mut self, ui: &mut egui::Ui, full: egui::Rect, lift: f32) {
let now = self.now;
let msg = if !self.ui.status.is_empty() {
Some(self.ui.status.clone())
} else {
self.toast.as_ref().filter(|(_, at)| now - at < 4.0).map(|(m, _)| m.clone())
};
if let Some(m) = msg {
let t = &self.tokens;
let galley = ui.painter().layout_no_wrap(m.clone(), Tokens::ui(12.0), t.text);
let r = egui::Rect::from_min_size(egui::pos2(full.min.x + 16.0, full.max.y - 44.0 - lift), galley.size() + egui::vec2(24.0, 14.0));
ui.painter().rect_filled(r, 6.0, t.panel_bg);
ui.painter().rect_stroke(r, 6.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
ui.painter().galley(r.min + egui::vec2(12.0, 7.0), galley, t.text);
self.auto.add("toast", r, &m);
let resp = ui.interact(r, egui::Id::unique("toast"), egui::Sense::click());
if resp.clicked() {
self.ui.status.clear();
self.toast = None;
}
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
if !self.ui.status.is_empty() {
let since: f64 = ui.data(|d| d.get_temp(egui::Id::unique("status-since")).unwrap_or(now));
ui.data_mut(|d| d.insert_temp(egui::Id::unique("status-since"), since));
if now - since > 5.0 {
self.ui.status.clear();
ui.data_mut(|d| d.remove::<f64>(egui::Id::unique("status-since")));
}
}
}
}
}
impl EffectcraftApp {
/// Scripts' `app.scheduleTask` tasks that are due (repeating ones keep a ScriptUI panel
/// fresh): errors as a toast, the last line printed in the status bar, and a repaint when
/// the next one is due.
fn run_script_tasks(&mut self, ctx: &egui::Context) {
if self.session.script_ui.task_hosts.is_empty() {
return;
}
let previous_comp = self.session.active_comp_id();
let Some(r) = effectcraft_engine::scriptui::ScriptUi::run_tasks(&mut self.session) else { return };
if self.session.active_comp_id().is_some() && self.session.active_comp_id() != previous_comp {
self.ui.start_screen = false;
}
if let Some(msg) = r["errors"].as_array().and_then(|e| e.first()).and_then(|e| e["message"].as_str()) {
self.toast = Some((format!("Scheduled script task: {msg}"), self.now));
} else if let Some(line) = r["output"].as_array().and_then(|o| o.last()).and_then(|l| l.as_str()) {
self.ui.status = line.to_string();
}
if let Some(next) = r["next"].as_u64() {
ctx.request_repaint_after(std::time::Duration::from_millis(next));
}
}
/// Auto-save a dirty project when the interval has passed (Settings ▸ Project ▸ Auto-Save).
fn tick_autosave(&mut self, ctx: &egui::Context) {
// Monotonic app time: a wall-clock jump must not skip or repeat an auto-save.
let now = self.now;
if let Some(Err(e)) = self.session.autosave_tick(now) {
self.ui.status = e.to_string();
}
if self.session.prefs.auto_save.enabled {
ctx.request_repaint_after(std::time::Duration::from_secs(30));
}
// File ▸ Watch Folder: look for new projects every 10 seconds.
if self.session.state.watch_folder.is_some() {
let key = egui::Id::unique("watchFolder.lastPoll");
let last: f64 = ctx.data(|d| d.get_temp(key)).unwrap_or(0.0);
if now - last >= 10.0 {
ctx.data_mut(|d| d.insert_temp(key, now));
match self.session.execute("file.watchFolder.poll", serde_json::json!({})) {
Ok(r) => {
if let Some(n) = r["rendered"].as_array().map(Vec::len).filter(|n| *n > 0) {
self.ui.status = format!("Watch Folder: rendered {n} project(s)");
}
}
Err(e) => self.ui.status = e.to_string(),
}
}
ctx.request_repaint_after(std::time::Duration::from_secs(10));
}
self.tick_footage_reload(ctx, now);
}
/// Settings ▸ Import ▸ Reload Footage Changed on Disk: every two seconds, and as soon as the
/// window comes back to the front (after saving in another app), stamp the footage files on a
/// worker thread and reload the ones that changed.
fn tick_footage_reload(&mut self, ctx: &egui::Context, now: f64) {
use effectcraft_engine::footage_reload;
if let Some(Err(e)) = footage_reload::poll_scan(&mut self.session) {
self.ui.status = e.to_string();
}
let (last_key, focus_key) = (egui::Id::unique("footageReload.lastScan"), egui::Id::unique("footageReload.focused"));
let focused = self.focused;
let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused);
ctx.data_mut(|d| d.insert_temp(focus_key, focused));
if self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty() {
return;
}
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0);
if now - last >= 2.0 || (focused && !was_focused) {
ctx.data_mut(|d| d.insert_temp(last_key, now));
footage_reload::start_scan(&mut self.session);
}
if self.session.footage_scan.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
} else {
ctx.request_repaint_after(std::time::Duration::from_secs(2));
}
}
}
+221
View File
@@ -0,0 +1,221 @@
use super::*;
impl EffectcraftApp {
pub fn render_source(&self) -> RenderSource {
RenderSource {
project: self.session.project.clone(),
footage: self.session.footage.clone(),
expr: self.session.expr.clone(),
layer_cache: self.session.layer_cache.clone(),
gpu: self.gpu.clone(),
gpu_display: self.gpu_display(),
disk: self.session.disk_cache.clone(),
}
}
/// Keep GPU-composited viewer frames on the GPU (drawn from their texture). The browser can't
/// read frames back (its thread can't wait for the GPU), so there the viewer shows GPU frames
/// directly whenever it draws them plainly (RGB, no exposure, no region of interest); the
/// desktop reads them back (see [`frames::RenderSource::gpu_display`]).
fn gpu_display(&self) -> bool {
let v = &self.session.state.viewer;
cfg!(target_arch = "wasm32")
&& v.channel == effectcraft_engine::viewer::Channel::Rgb
&& v.exposure == 0.0
&& self.session.state.region_of_interest.is_none()
&& self.ui.viewer.extended.is_none()
}
/// The UI draws its panels and handles input such as dropped files. Until the fonts are
/// installed (the first frames) it draws nothing, and that input goes unhandled.
pub fn is_ready(&self) -> bool {
self.fonts_ready
}
/// Provide the executable/embedding host's device notifications. The host installs the
/// handlers once; this frontend never replaces handlers on a borrowed device.
pub fn set_gpu_failure_bridge(&mut self, bridge: gpu_failure::GpuFailureBridge) {
if let Some(gpu) = &self.gpu {
bridge.forward_to(gpu.context().device_failure_notifier());
}
self.gpu_failures = Some(bridge);
}
pub(super) fn apply_gpu_failure(&mut self, ctx: &egui::Context) {
let Some(bridge) = &self.gpu_failures else { return };
// Readback deadlines can retire the compositor without a host device notification.
if let Some(gpu) = &self.gpu
&& let Err(reason) = gpu.context().check_health()
{
bridge.report(&reason, false);
}
let Some(failure) = bridge.failure() else { return };
if self.gpu_failure_applied == Some(failure.presentation_lost) {
return;
}
self.gpu_failure_applied = Some(failure.presentation_lost);
self.gpu_checked = true;
self.frames.retire_gpu();
self.session.accel = None;
self.session.accel_note = Some(format!("GPU preview retired: {}", failure.reason));
self.session.layer_cache.clear();
self.gpu = None;
if let Some(rs) = &self.wgpu {
let mut renderer = rs.renderer.write();
if let Some((id, _, _)) = &self.viewer_native {
renderer.free_texture(id);
}
for (_, texture) in self.passive_tex.values() {
if let panels::viewers::PassiveTexture::Gpu(id, _) = texture {
renderer.free_texture(id);
}
}
}
self.viewer_native = None;
self.passive_tex.retain(|_, (_, texture)| matches!(texture, panels::viewers::PassiveTexture::Cpu(_)));
self.viewer_shown = self.viewer_tex.as_ref().map(|(texture, key, _)| (texture.id(), *key));
self.viewer_image = None;
// Old completion closures retain only their detached slot, not the replacement.
self.viewer_readback = Default::default();
self.wgpu = None;
self.ui.status = if failure.presentation_lost {
"Graphics device lost. The project is still open; presentation requires restarting the application.".into()
} else {
"Shared GPU preview disabled; CPU compositing remains available.".into()
};
log::warn!("GPU preview retired: {}", failure.reason);
ctx.request_repaint();
}
/// After the graphics device is lost, eframe's renderer cannot draw again: any mesh makes
/// egui-wgpu panic on the main thread when it cannot stage its index buffer (#591). Draw
/// nothing from then on, auto-save a project with unsaved changes (keeping the crash
/// recovery marker, so the next launch offers it) and close the window.
pub(super) fn close_after_device_loss(&mut self, ctx: &egui::Context) {
if self.device_lost_close.is_some() {
return;
}
let keep_recovery = self.session.is_dirty();
let saved = keep_recovery.then(|| match self.session.autosave_now().and_then(|path| self.session.autosave_wait().map(|()| path)) {
Ok(path) => {
log::warn!("graphics device lost: auto-saved {path}; the next launch offers to recover it");
Ok(path)
}
Err(e) => {
log::error!("graphics device lost: {e}");
Err(e.to_string())
}
});
self.device_lost_close = Some(keep_recovery);
log::warn!("graphics device lost: closing the window");
if let Some(tell) = &self.hooks.device_lost {
tell(saved);
}
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
/// Run the commands whose non-blocking file dialogs have been answered.
pub(crate) fn run_dialog_answers(&mut self) {
while let Ok(answer) = self.dialog_answers.1.try_recv() {
self.file_dialogs.answered();
menus::dialog_answered(self, answer);
}
}
/// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs
/// compute shaders (WebGL2, software GL) everything stays on the CPU.
pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) {
if self.gpu_checked {
return;
}
self.gpu_checked = true;
if self.gpu_failures.as_ref().is_some_and(|bridge| bridge.failure().is_some()) {
return;
}
let Some(rs) = frame.wgpu_render_state() else { return };
// Help ▸ System Compatibility Report names the adapter and backend the window got.
let info = rs.adapter.get_info();
self.session.window_adapter = Some(format!(
"{} ({:?}, {:?}{})",
info.name,
info.backend,
info.device_type,
if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) }
));
// Backend validation can panic on shader compilation even when device creation
// succeeded. Keep the document and the egui renderer, and use CPU compositing.
let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone())));
match gpu {
Ok(Ok(g)) => {
if let Some(bridge) = &self.gpu_failures {
bridge.forward_to(g.context().device_failure_notifier());
if bridge.failure().is_some() {
return;
}
}
log::info!("GPU compositor: {}", effectcraft_engine::render::Accelerator::name(&g));
self.session.accel = Some(Arc::new(g.clone()));
self.gpu = Some(g);
self.wgpu = Some(rs.clone());
}
Ok(Err(e)) => {
log::info!("GPU compositor unavailable: {e}");
self.session.accel_note = Some(format!("GPU compositor unavailable: {e}"));
}
Err(e) => {
let reason = e.downcast_ref::<String>().map(String::as_str).or_else(|| e.downcast_ref::<&str>().copied()).unwrap_or("backend panicked");
log::warn!("GPU compositor setup failed; using CPU compositing: {reason}");
self.session.accel_note = Some(format!("GPU compositor setup failed: {reason}"));
}
}
}
/// The viewer shows a frame composited on the GPU (drawn from its wgpu texture).
pub fn viewer_on_gpu(&self) -> bool {
matches!((&self.viewer_shown, &self.viewer_native), (Some(s), Some(n)) if s.1 == n.1)
}
/// The GPU compositor's adapter, when the viewer has one.
pub fn gpu_adapter(&self) -> Option<String> {
self.gpu.as_ref().map(effectcraft_engine::render::Accelerator::name)
}
}
#[cfg(test)]
mod gpu_failure_tests {
use super::*;
#[test]
fn host_failure_preserves_document_undo_and_cpu_pixels_and_detaches_late_readbacks() {
let mut session = Session::default();
let comp = ItemId(session.execute("comp.new", json!({"name": "Recovery", "width": 4, "height": 4, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
session.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap();
let before = session.render(comp, Tick::ZERO, RenderOpts::default());
let project = session.project.clone();
let revision = session.revision;
let prefs = serde_json::to_value(&session.prefs).unwrap();
let mut app = EffectcraftApp::new(session);
let ctx = egui::Context::default();
let bridge = gpu_failure::GpuFailureBridge::new(&ctx);
app.set_gpu_failure_bridge(bridge.clone());
let late_slot = app.viewer_readback.1.clone();
let key = FrameKey { revision, content: 1, comp: comp.0, frame: 0, scale: 1000, view: 0, opts: 0 };
app.viewer_readback.0 = Some(key);
bridge.report("synthetic host device failure", true);
app.apply_gpu_failure(&ctx);
assert!(Arc::ptr_eq(&project, &app.session.project));
assert_eq!(app.session.revision, revision);
assert_eq!(serde_json::to_value(&app.session.prefs).unwrap(), prefs);
assert!(app.gpu_checked, "failed device cannot be initialized again next frame");
assert!(app.session.accel.is_none());
assert!(app.ui.status.contains("presentation requires restarting"));
assert_eq!(app.session.render(comp, Tick::ZERO, RenderOpts::default()).data, before.data);
*late_slot.lock().unwrap() = Some((key, Arc::new(egui::ColorImage::new([1, 1], vec![egui::Color32::RED]))));
assert!(app.viewer_readback.0.is_none());
assert!(app.viewer_readback.1.lock().unwrap().is_none(), "a late completion cannot refill the replacement slot");
app.session.execute("edit.undo", json!({})).unwrap();
assert!(app.session.project.comp(comp).unwrap().layers.is_empty());
app.session.execute("edit.redo", json!({})).unwrap();
assert_eq!(app.session.render(comp, Tick::ZERO, RenderOpts::default()).data, before.data);
}
}
+60
View File
@@ -0,0 +1,60 @@
use super::*;
impl EffectcraftApp {
/// Feed this frame's share of the queued synthetic input (control channel `ui.click`,
/// `ui.drag`…) into `raw_input`. Pointer events go one per frame so egui sees press → moves
/// → release as a real gesture.
///
/// Only one batch is ever moved out at a time, even when the host prepares several hidden
/// logic-only iterations before the forced UI pass: eframe keeps the last prepared raw input
/// and would otherwise apply a whole drag or click as one burst. A batch is released by the
/// first UI pass that applies it ([`Self::mark_synthetic_applied`]). Callers must not feed a
/// pass that cannot draw (fonts still installing): egui applies what it receives, so putting
/// an applied batch back would apply its pointer/key state twice.
pub fn feed_synthetic(&mut self, raw_input: &mut egui::RawInput) {
if self.synthetic_in_flight || self.synthetic.is_empty() {
return;
}
// Pointer events go one per frame so egui sees press → moves → release as a real drag.
let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. });
let n = if pointer(&self.synthetic[0]) {
1
} else {
self.synthetic
.iter()
.position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. }))
.map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 })
};
let batch: Vec<egui::Event> = self.synthetic.drain(..n).collect();
raw_input.events.extend(batch.iter().cloned());
self.synthetic_in_flight_events.extend(batch);
self.synthetic_in_flight = true;
}
/// Clear the single-pending-batch guard: a UI pass has applied the injected batch.
///
/// [`EffectcraftApp::draw`] calls this itself; harnesses that feed synthetic input
/// themselves (the headless kittest tests) call it after each pass they ran.
pub fn mark_synthetic_applied(&mut self) {
self.synthetic_in_flight_events.clear();
self.synthetic_in_flight = false;
}
/// The events of the injected batch that no UI pass has applied yet.
pub fn pending_synthetic_events(&self) -> &[egui::Event] {
&self.synthetic_in_flight_events
}
/// Whether a synthetic batch is waiting for its UI pass.
pub fn synthetic_in_flight(&self) -> bool {
self.synthetic_in_flight
}
}
impl EffectcraftApp {
/// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call
/// [`eframe::App::raw_input_hook`] (the headless test harness) feed these in themselves.
pub fn take_synthetic_input(&mut self) -> Vec<egui::Event> {
std::mem::take(&mut self.synthetic)
}
}
+186
View File
@@ -0,0 +1,186 @@
use super::*;
impl EffectcraftApp {
// ---------------------------------------------------------------- playback
pub fn play(&mut self, now: f64) {
let sc = self.session.prefs.preview.current;
self.play_with(now, sc);
}
/// Start the preview of a Preview panel shortcut (its range, play-from, rate, skip,
/// resolution, loop, caching, full screen and include options).
pub fn play_with(&mut self, now: f64, sc: effectcraft_engine::preview::PreviewShortcut) {
let Some(c) = self.session.active_comp_arc() else { return };
let preset = self.session.prefs.preview.get(sc).clone();
let plan = effectcraft_engine::preview::plan(&preset, &c, self.session.time());
let fr = c.frame_rate;
if plan.video {
self.session.set_time(fr.tick_of(plan.first));
}
let restore_max = preset.full_screen.then_some(self.ui.maximized);
let viewer = panels::viewers::active_panel(self);
if preset.full_screen && self.ui.dock.is_docked(viewer) {
self.ui.maximized = Some(viewer);
}
self.playback = Playback {
playing: true,
start_wall: now,
start_time: fr.tick_of(plan.first),
start_frame: plan.first,
shown: 0,
waiting: false,
fps: plan.fps,
shortcut: sc,
plan: Some(plan),
caching: plan.cache_first && plan.video,
res: preset.resolution.factor(preset.custom_resolution),
overlays: preset.include_overlays,
layer_controls: preset.include_layer_controls,
restore_max,
audio_frame: (!plan.video).then_some(plan.first),
audio_held: false,
shown_at: Default::default(),
};
if !self.playback.caching {
if plan.video {
self.playback.audio_held = self.audio_plays();
} else {
self.start_audio(fr.tick_of(plan.first));
}
}
}
/// The preview includes audio at the comp's frame rate and the comp has something audible.
fn audio_plays(&self) -> bool {
let (Some(pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false };
let Some(c) = self.session.project.comp(cid) else { return false };
// A preview at another frame rate plays slower or faster than real time: silent.
pl.audio && (pl.fps - c.frame_rate.as_f64()).abs() <= 0.01 && effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid)
}
/// Start audio preview from comp time `t` when [`Self::audio_plays`] and an output device
/// opens.
pub(super) fn start_audio(&mut self, t: Tick) {
self.audio = None;
self.meter.clipped = [false; 2];
let (Some(pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return };
let Some(c) = self.session.project.comp_arc(cid).filter(|_| self.audio_plays()) else { return };
let out = audio::AudioOutput::from_prefs(&self.session.prefs);
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
let mix = self.session.prefs.audio.preview_sample_rate;
let fr = c.frame_rate;
match audio::AudioPlayback::start(dev, self.render_source(), cid, t, fr.tick_of(pl.start), fr.tick_of(pl.end + 1), pl.looping, mix) {
Ok(a) => self.audio = Some(a),
Err(e) => log::warn!("audio preview: {e}"),
}
}
/// Audio scrubbing: play the frame of `comp` at `t` (see [`audio::AudioScrub`]), opening the
/// output on first use. Silent comps and missing devices do nothing.
pub fn scrub_audio(&mut self, comp: ItemId, t: Tick, now: f64) {
if self.playback.playing || !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, comp) {
return;
}
if self.scrub.is_none() {
let out = audio::AudioOutput::from_prefs(&self.session.prefs);
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
match audio::AudioScrub::open(dev, now) {
Ok(s) => self.scrub = Some(s),
Err(e) => {
log::warn!("audio scrubbing: {e}");
return;
}
}
}
let src = self.render_source();
let mix = self.session.prefs.audio.preview_sample_rate;
if let Some(s) = &mut self.scrub {
s.play(&src, comp, t, mix, now);
}
}
pub fn stop(&mut self) {
let was = std::mem::replace(&mut self.playback.playing, false);
self.audio = None;
self.playback.caching = false;
if !was {
return;
}
if let Some(m) = self.playback.restore_max.take() {
self.ui.maximized = m;
}
let Some(pl) = self.playback.plan else { return };
let move_time = self.session.prefs.preview.get(self.playback.shortcut).move_time_to_preview_time;
if !move_time {
self.session.set_time(pl.origin);
} else if let Some(f) = self.playback.audio_frame
&& let Some(c) = self.session.active_comp()
{
let t = c.frame_rate.tick_of(f);
self.session.set_time(t);
}
}
pub fn toggle_play(&mut self, now: f64) {
let sc = self.session.prefs.preview.current;
self.toggle_play_with(now, sc);
}
/// A preview shortcut pressed: start its preview, or stop the running one. While Cache
/// Before Playback is still rendering, "If caching, play cached frames" plays what is
/// cached so far instead of stopping.
pub fn toggle_play_with(&mut self, now: f64, sc: effectcraft_engine::preview::PreviewShortcut) {
if !self.playback.playing {
self.play_with(now, sc);
return;
}
if self.playback.caching && self.session.prefs.preview.get(self.playback.shortcut).play_cached_frames && self.play_cached(now) {
return;
}
self.stop();
}
/// Cut the caching preview down to the frames cached from its first frame on and play them.
fn play_cached(&mut self, now: f64) -> bool {
let (Some(mut pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false };
let scale = self.viewer_shown.as_ref().map(|(_, k)| k.scale as f64 / 1000.0).unwrap_or(1.0);
let series = self.frame_series(cid, scale);
let mut last = None;
let mut f = pl.first;
while f <= pl.end && self.frames.is_cached(&FrameKey { frame: f, ..series }) {
last = Some(f);
f += pl.step;
}
let Some(last) = last else { return false };
pl.end = last;
pl.start = pl.first;
self.playback.plan = Some(pl);
self.playback.caching = false;
self.playback.start_wall = now;
self.playback.start_frame = pl.first;
let t = self.session.active_comp().map(|c| c.frame_rate.tick_of(pl.first)).unwrap_or_default();
self.start_audio(t);
true
}
/// A preview plays with Video off in the Preview panel (audio only): the current time stays
/// put, so the viewer and Timeline say so instead of looking stuck (#1118). The frame under
/// the audible sample, when one is known.
pub fn video_preview_off(&self) -> Option<Option<i64>> {
let p = self.playback.plan.filter(|_| self.playback.playing)?;
(!p.video).then_some(self.playback.audio_frame)
}
/// The viewer shows grids, guides and safe margins (hidden while a preview without
/// Include Overlays plays).
pub fn overlays_visible(&self) -> bool {
!self.playback.playing || self.playback.overlays
}
/// The viewer shows layer handles and paths (View ▸ Show Layer Controls, and Include Layer
/// Controls while a preview plays).
pub fn layer_controls_visible(&self) -> bool {
self.ui.viewer.show_layer_controls && (!self.playback.playing || self.playback.layer_controls)
}
}
+381
View File
@@ -0,0 +1,381 @@
use super::*;
/// Seconds without a project change before paused prefetch resumes (see
/// [`EffectcraftApp::editing`]).
const EDIT_QUIET: f64 = 0.3;
impl EffectcraftApp {
/// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold
/// and keep rendering ahead (the green cache bar fills, then playback runs in real time).
pub(super) fn advance_playback(&mut self, ctx: &egui::Context, scale: f64, now: f64, input_active: bool, cid: ItemId) {
let Some(c) = self.session.project.comp_arc(cid) else { return };
let fr = c.frame_rate;
let plan = self.playback.plan.filter(|_| self.playback.playing);
let (wa, wb) = match plan {
Some(p) => (p.start, p.end),
None => (fr.frame_at(c.work_area.0), fr.frame_at(c.work_area.1 - c.frame_duration())),
};
let looping = plan.is_none_or(|p| p.looping);
let video = plan.is_none_or(|p| p.video);
// Prefetch ahead.
let cur = fr.frame_at(self.session.time());
let ahead = self.prefetch_ahead(ctx, now);
let queued = self.frames.inflight();
let series = self.frame_series(cid, scale);
// The preview's position orders prefetch and eviction (#595).
let playhead = plan.filter(|p| p.video).map(|p| frames::Playhead { series, cur, start: p.start, end: p.end, step: p.step, looping: p.looping });
self.frames.set_playhead(playhead);
let pass = PlaybackPass {
ctx,
comp: &c,
cid,
scale,
now,
frame_rate: fr,
series,
plan,
window: FrameWindow { first: wa, last: wb },
cur,
video,
looping,
ahead,
queued,
};
if self.cache_preview(&pass) {
return;
}
let step = plan.map_or(1, |p| p.step);
let fit_ahead = self.frames_that_fit(&c, scale) - 1;
self.prefetch_playback(&pass, fit_ahead);
self.pump_meters(ctx, now);
if !self.playback.playing {
self.cache_when_idle(ctx, &c, &series, now, input_active, (wa, wb));
return;
}
let fit = self.frames_that_fit(&c, scale);
self.start_held_audio(&pass, fit);
if self.advance_audio_clock(&pass, step) {
return;
}
self.advance_frame_clock(&pass, step);
}
/// While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few
/// frames, only once it is done, and not while edits keep coming (a layer dragged in the
/// viewer): those frames would be stale at the next step and would hold the CPU and GPU
/// the next viewer frame needs.
fn prefetch_ahead(&mut self, ctx: &egui::Context, now: f64) -> i64 {
if self.playback.playing {
(self.frames_parallelism() * 2).max(4) as i64
} else if self.frames.urgent_pending() {
0
} else if let Some(wait) = self.editing(now) {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
0
} else {
// Keep workers available for a newly edited viewer frame. Running obsolete
// background renders cannot be preempted by the priority queue.
self.frames_parallelism().saturating_sub(1).min(2) as i64
}
}
/// Cache Before Playback: the range in play order, then start the clock. A range larger than
/// the RAM preview budget is cut to the frames that fit (caching more would evict the first
/// ones and never finish). Returns true when this pass handled the preview.
fn cache_preview(&mut self, pass: &PlaybackPass<'_>) -> bool {
let PlaybackPass { ctx, comp, cid, scale, now, frame_rate: fr, series, plan, ahead, mut queued, .. } = *pass;
if !self.playback.caching {
return false;
}
let Some(p) = plan else { return false };
let mut all = true;
let n = (p.end - p.start) / p.step + 1;
let fit = self.frames_that_fit(comp, scale);
if n > fit {
let mut cut = p;
(cut.start, cut.end) = (p.first, (p.first + (fit - 1) * p.step).min(p.end));
self.playback.plan = Some(cut);
self.toast = Some((format!("Preview cut to {fit} of {n} frames: no more fit in the RAM preview (Settings ▸ Memory & CPU)"), now));
ctx.request_repaint();
return true;
}
let order: Vec<i64> = (0..n).filter_map(|k| p.frame_after(p.first, k)).collect();
for f in order {
if self.frames.is_cached(&FrameKey { frame: f, ..series }) {
continue;
}
all = false;
if queued < ahead as usize {
self.request_frame(cid, f, scale);
queued += 1;
}
}
self.playback.waiting = !all;
if all {
self.playback.caching = false;
self.playback.start_wall = now;
self.playback.start_frame = p.first;
self.session.set_time(fr.tick_of(p.first));
self.start_audio(fr.tick_of(p.first));
}
ctx.request_repaint();
true
}
/// Never further ahead than the RAM preview holds: those frames would evict the ones the
/// preview shows first, or be evicted before it gets there (#595). The window starts at the
/// first uncached frame ahead: after a loop or a restart the frames just ahead are cached,
/// and a window from the playhead left the workers idle until it came near the end of the
/// cached run. It stays ahead × 3 frames wide: workers spread over the whole preview render
/// frames with different layers at once (on the GPU, an atlas each: #1812).
fn prefetch_playback(&self, pass: &PlaybackPass<'_>, fit_ahead: i64) {
let PlaybackPass { cid, scale, plan, cur, ahead, series, mut queued, video, .. } = *pass;
let FrameWindow { first: wa, last: wb } = pass.window;
let frame = |k: i64| match plan {
Some(p) => p.frame_after(cur, k),
None => {
let f = cur + k;
Some(if f > wb { wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1)) } else { f })
}
};
let cached = self.frames.cached_frames(&series);
let uncached = |f: i64| cached.binary_search(&f).is_err();
let first = match plan.filter(|_| self.playback.playing) {
Some(p) => (0..fit_ahead.min((p.end - p.start) / p.step.max(1) + 1))
.map_while(|k| frame(k).map(|f| (k, f)))
.find(|&(_, f)| uncached(f))
.map_or(0, |(k, _)| k),
None => 0,
};
for k in first..(first + ahead * 3).min(fit_ahead) {
if queued >= ahead as usize || !video {
break;
}
let Some(f) = frame(k) else { break };
if uncached(f) {
self.request_frame(cid, f, scale);
queued += 1;
}
}
}
/// Advance the audio or scrub output and feed its latest peaks to the level meter; an idle scrub is closed after
/// one second. With no source but an active meter, drive the meter to silence and request a repaint.
fn pump_meters(&mut self, ctx: &egui::Context, now: f64) {
if let Some(a) = &mut self.audio {
a.pump();
self.meter.update(a.feed.take_peaks(), now);
} else if let Some(s) = &mut self.scrub {
// Scrubbing: the meters follow the snippets; an idle second closes the output.
s.pump();
self.meter.update(s.feed.take_peaks(), now);
if now - s.last_used > 1.0 {
self.scrub = None;
}
ctx.request_repaint_after(std::time::Duration::from_millis(50));
} else if self.meter.active() {
self.meter.update([0.0; 2], now);
ctx.request_repaint();
}
}
/// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the
/// prefetch window when the silent preview keeps real time, else the rest of the preview.
fn start_held_audio(&mut self, pass: &PlaybackPass<'_>, fit: i64) {
let PlaybackPass { now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass;
if !self.playback.audio_held {
return;
}
let Some(p) = plan else { return };
if (self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, ahead.min(fit))) || self.cached_ahead(&p, &series, cur, fit) {
self.playback.audio_held = false;
self.playback.start_wall = now;
self.playback.start_frame = cur;
self.start_audio(fr.tick_of(cur));
}
}
/// Audio clock drives playback: show the frame under the audible sample. Returns true when
/// the audio path handled this pass (including stopping at the end).
fn advance_audio_clock(&mut self, pass: &PlaybackPass<'_>, step: i64) -> bool {
let PlaybackPass { ctx, now, series, video, frame_rate: fr, cur, .. } = *pass;
let FrameWindow { first: wa, last: wb } = pass.window;
let Some(a) = &self.audio else { return false };
if a.finished() {
if video {
self.session.set_time(fr.tick_of(wb));
} else {
self.playback.audio_frame = Some(wb);
}
self.stop();
return true;
}
let snap = |f: i64| wa + (f - wa).div_euclid(step) * step;
let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb));
if !video {
self.playback.audio_frame = Some(target);
} else if self.frames.is_cached(&FrameKey { frame: target, ..series }) {
self.playback.waiting = false;
if target != cur {
self.session.set_time(fr.tick_of(target));
self.playback.frame_shown(now);
}
} else {
// Rendering can't keep up: rather than skip frames, the sound stops and every
// frame shows as it renders until the frames ahead are cached again.
self.audio = None;
self.playback.audio_held = true;
self.playback.waiting = true;
// The rate measured before the stall no longer says it keeps up.
self.playback.shown_at.clear();
self.playback.start_wall = now;
self.playback.start_frame = cur;
}
ctx.request_repaint();
true
}
/// The wall clock advances the preview when no audio device drives it, and shows the target
/// frame (or holds when it is not cached yet).
fn advance_frame_clock(&mut self, pass: &PlaybackPass<'_>, step: i64) {
let PlaybackPass { ctx, now, plan, video, looping, frame_rate: fr, cur, series, .. } = *pass;
let FrameWindow { first: wa, last: wb } = pass.window;
let elapsed = now - self.playback.start_wall;
// The plan carries the rate the preview was started with (and `play_with` copies it to
// `Playback::fps`). Deriving the frame from the plan keeps a logic-only tick correct even
// when a host/test supplies only the plan; a stale duplicate cannot hold playback open.
let fps = plan.map_or(self.playback.fps, |p| p.fps);
let n = ((elapsed * fps) / step as f64).floor() as i64;
let target = match plan {
Some(p) => p.frame_after(self.playback.start_frame, n),
None => {
let t = self.playback.start_frame + n;
if t <= wb {
Some(t)
} else if looping {
Some(wa + (t - wa).rem_euclid((wb - wa + 1).max(1)))
} else {
None
}
}
};
let Some(target) = target else {
if video {
self.session.set_time(fr.tick_of(wb));
} else {
self.playback.audio_frame = Some(wb);
}
self.stop();
return;
};
if !video {
// Audio-only preview without an audio device: the clock runs, the time stays.
self.playback.audio_frame = Some(target);
ctx.request_repaint();
return;
}
if self.frames.is_cached(&FrameKey { frame: target, ..series }) {
self.playback.waiting = false;
if target != cur {
self.session.set_time(fr.tick_of(target));
self.playback.frame_shown(now);
}
} else {
// Not cached yet: hold the clock at the current frame (cache first, then play).
self.playback.waiting = true;
let next = plan.and_then(|p| p.frame_after(cur, 1)).unwrap_or(if cur + 1 > wb { wa } else { cur + 1 });
if self.frames.is_cached(&FrameKey { frame: next, ..series }) {
self.session.set_time(fr.tick_of(next));
}
self.playback.start_wall = now;
self.playback.start_frame = fr.frame_at(self.session.time());
}
ctx.request_repaint();
}
/// While the project keeps changing (an edit within [`EDIT_QUIET`] seconds), how long until
/// it counts as settled.
fn editing(&mut self, now: f64) -> Option<f64> {
if self.last_edit.1 != self.session.revision {
self.last_edit = (now, self.session.revision);
}
let left = EDIT_QUIET - (now - self.last_edit.0);
(left > 0.0).then_some(left)
}
/// Composition ▸ Preview ▸ Cache Frames When Idle: after a second without input or edits,
/// render the work area's frames (from the current time on, then from its start) in the
/// background, a few at a time, until it is cached or the RAM preview budget is full.
fn cache_when_idle(
&mut self,
ctx: &egui::Context,
comp: &effectcraft_engine::project::Comp,
series: &FrameKey,
now: f64,
input_active: bool,
(wa, wb): (i64, i64),
) {
let active = input_active;
if active || self.last_activity.1 != self.session.revision {
self.last_activity = (now, self.session.revision);
}
if !self.ui.cache_when_idle || self.frames.urgent_pending() {
return;
}
let quiet = now - self.last_activity.0;
if quiet < 1.0 {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(1.0 - quiet));
return;
}
// An empty work area has nothing to cache.
let n = wb - wa + 1;
if n < 1 {
return;
}
let cur = comp.frame_rate.frame_at(self.session.time()).clamp(wa, wb);
let fit = self.frames_that_fit(comp, series.scale as f64 / 1000.0).min(n);
let slots = (self.frames_parallelism() / 2).max(1);
let mut queued = self.frames.inflight();
let scale = series.scale as f64 / 1000.0;
let mut pending = false;
for k in 0..fit {
let f = wa + (cur - wa + k).rem_euclid(n);
if self.frames.is_cached(&FrameKey { frame: f, ..*series }) {
continue;
}
pending = true;
if queued >= slots {
break;
}
self.request_frame(ItemId(series.comp), f, scale);
queued += 1;
}
if pending {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
/// How many viewer frames of `comp` at `scale` the RAM preview budget holds (90 % of it,
/// at least one).
/// The frames of `plan` from `cur` to its end, or as many as fit in the RAM preview, are
/// cached: sound can play from `cur` in real time.
fn cached_ahead(&self, plan: &effectcraft_engine::preview::PreviewPlan, series: &FrameKey, cur: i64, fit: i64) -> bool {
let cached = self.frames.cached_frames(series);
(0..fit).map_while(|k| plan.frame_after(cur, k)).take_while(|f| *f >= cur).all(|f| cached.binary_search(&f).is_ok())
}
fn frames_that_fit(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> i64 {
let side = |n: u32| (n as f64 * scale).ceil().max(1.0);
let frame = side(comp.width) * side(comp.height) * 4.0;
((self.frames.budget() as f64 * 0.9 / frame) as i64).max(1)
}
fn frames_parallelism(&self) -> usize {
if cfg!(target_arch = "wasm32") {
// frame workers render one frame each; without them frames render one at a time
// on the UI thread (`Frames::pump`)
return self.frames.remote_slots().max(1);
}
self.frames.parallelism()
}
}
+165
View File
@@ -0,0 +1,165 @@
use super::*;
impl EffectcraftApp {
/// Apply changed settings: theme and brightness, label colours, tool tips, preview caches.
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
// Sync with System re-applies when the system appearance changes (the host wakes the UI;
// nothing polls).
let system = if self.session.prefs.appearance.appearance_mode == "auto" { self.system_light(ctx) } else { None };
if self.applied_prefs == Some((self.session.prefs_revision, system)) {
return;
}
self.applied_prefs = Some((self.session.prefs_revision, system));
let p = &self.session.prefs;
self.ui.theme = theme::ThemeKind::from_name(p.appearance.resolved_theme(system)).unwrap_or_default();
self.tokens = Tokens::from_prefs(p, self.ui.theme);
theme::apply_visuals(ctx, &self.tokens);
ctx.set_theme(theme::theme_preference(&p.appearance.appearance_mode, self.ui.theme.is_light()));
let tips = p.general.show_tool_tips;
ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY });
// Settings ▸ Appearance ▸ UI Scale (#284). egui's own Ctrl+= / Ctrl+- / Ctrl+0 zoom stays
// off: Ctrl+0 is Window ▸ Project, and the scale is a setting.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
ctx.set_zoom_factor(p.appearance.ui_scale as f32 / 100.0);
self.ui.cache_when_idle = p.previews.cache_frames_when_idle;
self.ui.viewer.fast_preview = p.previews.fast_previews;
self.frames.set_budget(p.cache_budgets(self.session.sys_memory).preview);
}
/// Change settings from the UI (applied next frame and saved).
pub fn set_pref(&mut self, key: &str, value: serde_json::Value) -> Result<(), String> {
self.session.prefs.set(key, value)?;
self.session.prefs_changed();
self.session.save_prefs();
Ok(())
}
/// Show theme `k`: the appearance mode becomes its family's (Dark or Light), and `k` that
/// family's theme.
pub fn set_theme(&mut self, ctx: &egui::Context, k: theme::ThemeKind) {
if let Err(e) = self.set_pref("appearance.theme", json!(k.name())) {
self.ui.status = e;
}
self.apply_prefs(ctx);
}
/// The system appearance: light (`Some(true)`), dark, or unknown. The host's answer (the Linux
/// desktop portal) wins over egui's, which Wayland compositors often leave empty.
pub fn system_light(&self, ctx: &egui::Context) -> Option<bool> {
self.hooks.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()).map(|t| t == egui::Theme::Light)
}
/// The Tools bar's appearance button (`view.theme.toggle`): Sync with System, then Light, then
/// Dark, then Sync with System again. The light and dark theme choices are kept.
pub fn cycle_appearance(&mut self, ctx: &egui::Context) {
let next = match self.session.prefs.appearance.appearance_mode.as_str() {
"auto" => "light",
"light" => "dark",
_ => "auto",
};
if let Err(e) = self.set_pref("appearance.appearanceMode", json!(next)) {
self.ui.status = e;
}
self.apply_prefs(ctx);
}
}
#[cfg(test)]
mod appearance_tests {
use super::*;
use crate::theme::ThemeKind;
fn app() -> EffectcraftApp {
EffectcraftApp::new(effectcraft_engine::Session::default())
}
/// The Tools bar's appearance button: Sync with System, Light, Dark, then Sync with System
/// again, showing the saved theme of each family without losing either choice.
#[test]
fn appearance_button_cycles_modes_without_losing_theme_choices() {
let ctx = egui::Context::default();
let mut app = app();
app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark)));
app.set_pref("appearance.darkTheme", json!("darker")).unwrap();
app.apply_prefs(&ctx);
assert_eq!(app.ui.theme, ThemeKind::Darker);
for (mode, shown) in [("auto", ThemeKind::Darker), ("light", ThemeKind::Light), ("dark", ThemeKind::Darker), ("auto", ThemeKind::Darker)] {
menus::invoke(&mut app, &ctx, "view.theme.toggle", json!({})).unwrap();
assert_eq!(app.session.prefs.appearance.appearance_mode, mode);
assert_eq!(app.ui.theme, shown, "{mode}");
assert_eq!(app.tokens.kind, shown);
assert_eq!(app.session.prefs.appearance.dark_theme, "darker");
assert_eq!(app.session.prefs.appearance.light_theme, "light");
}
}
/// Sync with System follows the host's answer (the Linux portal) when it changes, and falls
/// back to Dark when nothing reports a system appearance.
#[test]
fn auto_follows_a_stubbed_system_theme() {
use std::sync::{Arc, Mutex};
let ctx = egui::Context::default();
let mut app = app();
let system = Arc::new(Mutex::new(Some(egui::Theme::Light)));
let read = Arc::clone(&system);
app.hooks.system_theme = Some(Box::new(move |_| *read.lock().unwrap_or_else(std::sync::PoisonError::into_inner)));
app.set_pref("appearance.appearanceMode", json!("auto")).unwrap();
app.apply_prefs(&ctx);
assert_eq!(app.ui.theme, ThemeKind::Light);
*system.lock().unwrap() = Some(egui::Theme::Dark);
app.apply_prefs(&ctx);
assert_eq!(app.ui.theme, ThemeKind::Dark, "a system change applies without a settings change");
*system.lock().unwrap() = None;
app.apply_prefs(&ctx);
assert_eq!(app.ui.theme, ThemeKind::Dark);
// A fixed mode ignores the system.
*system.lock().unwrap() = Some(egui::Theme::Light);
app.set_pref("appearance.appearanceMode", json!("dark")).unwrap();
app.apply_prefs(&ctx);
assert_eq!(app.ui.theme, ThemeKind::Dark);
}
/// Sync with System leaves egui's theme preference at System, so egui never gives the native
/// window an appearance of its own (on macOS that stops system appearance changes reaching
/// the app, #642); egui's light and dark styles both carry the resolved theme. Fixed modes
/// pin the window to their family.
#[test]
fn auto_mode_leaves_the_native_window_following_the_system() {
let ctx = egui::Context::default();
let mut app = app();
app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Light)));
app.set_pref("appearance.appearanceMode", json!("auto")).unwrap();
app.apply_prefs(&ctx);
assert_eq!(app.ui.theme, ThemeKind::Light);
assert_eq!(ctx.options(|o| o.theme_preference), egui::ThemePreference::System);
for theme in [egui::Theme::Light, egui::Theme::Dark] {
assert_eq!(ctx.style_of(theme).visuals.panel_fill, app.tokens.panel_bg, "{theme:?}");
}
// egui asks the window to follow the system, never to take Light or Dark.
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
let cmds = out.viewport_output.values().flat_map(|v| v.commands.iter()).collect::<Vec<_>>();
assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::SetTheme(t) if t != &egui::SystemTheme::SystemDefault)), "{cmds:?}");
for (mode, pref) in [("light", egui::ThemePreference::Light), ("dark", egui::ThemePreference::Dark)] {
app.set_pref("appearance.appearanceMode", json!(mode)).unwrap();
app.apply_prefs(&ctx);
assert_eq!(ctx.options(|o| o.theme_preference), pref, "{mode}");
assert_eq!(ctx.global_style().visuals.panel_fill, app.tokens.panel_bg, "{mode}");
}
}
/// Picking a theme (`view.theme.*`, `ui.set {theme}`) fixes the mode to its family.
#[test]
fn picking_a_theme_fixes_its_mode() {
let ctx = egui::Context::default();
let mut app = app();
app.set_pref("appearance.appearanceMode", json!("auto")).unwrap();
menus::invoke(&mut app, &ctx, "view.theme.light", json!({})).unwrap();
assert_eq!(app.session.prefs.appearance.appearance_mode, "light");
assert_eq!(app.ui.theme, ThemeKind::Light);
menus::invoke(&mut app, &ctx, "view.theme.darker", json!({})).unwrap();
assert_eq!(app.session.prefs.appearance.appearance_mode, "dark");
assert_eq!(app.session.prefs.appearance.dark_theme, "darker");
assert_eq!(app.ui.theme, ThemeKind::Darker);
}
}
+330
View File
@@ -0,0 +1,330 @@
use super::*;
/// How long a screenshot request waits for a frame, from admission (app clock seconds).
pub(crate) const SCREENSHOT_TIMEOUT_S: f64 = 8.0;
/// Seconds between a screenshot's admission and the earliest capture: the UI settles first.
pub(crate) const SCREENSHOT_SETTLE_S: f64 = 0.35;
/// Final (non-discarded) UI passes a screenshot waits for after admission before it is issued.
pub(crate) const SCREENSHOT_SETTLE_PASSES: u64 = 3;
/// Geometry changes a queued screenshot may retry before it reports an error.
const SCREENSHOT_MAX_RETRIES: u32 = 4;
/// Outstanding screenshot requests (queued or waiting for their pixels).
pub(crate) const MAX_PENDING_SHOTS: usize = 4;
/// One capture's pixels: 128 MiB.
const MAX_SHOT_BYTES: usize = 128 * 1024 * 1024;
/// Every pending capture's reserved pixels together: 256 MiB.
const MAX_SHOT_TOTAL_BYTES: usize = 256 * 1024 * 1024;
/// Whether a captured image's dimensions match `rect` at `ppp`.
///
/// The surface may be smaller than requested when the adapter-limit fit (fork E2) applies, but a
/// different aspect ratio means the pixels came from another layout than the one issued.
fn shot_image_matches(image: &egui::ColorImage, rect: egui::Rect, ppp: f32) -> bool {
let [w, h] = image.size;
if w == 0 || h == 0 || !ppp.is_finite() || ppp <= 0.0 {
return false;
}
let ew = (rect.width() as f64 * ppp as f64).max(1.0);
let eh = (rect.height() as f64 * ppp as f64).max(1.0);
let want = ew / eh;
let got = w as f64 / h as f64;
(got - want).abs() <= 0.02 * want.max(1.0)
}
impl EffectcraftApp {
/// The geometry of the last completed UI pass, if one ran.
pub fn draw_geometry(&self) -> Option<DrawGeometry> {
self.draw_geom
}
// ---------------------------------------------------------------- screenshots
/// The root surface geometry a screenshot is admitted against.
///
/// The last completed pass's geometry is authoritative. Before any pass, only the viewport's
/// `inner_rect` (the client surface a capture returns) is accepted: `outer_rect` also covers
/// the title bar and window frame, so a capture issued against it would be cropped and
/// validated against a different rectangle than the callback delivers. A request that arrives
/// before the geometry is known is refused with an actionable error, and the pass that would
/// establish it is requested.
fn current_geometry(&self, ctx: &egui::Context) -> Option<DrawGeometry> {
if let Some(g) = self.draw_geom {
return Some(g);
}
let rect = ctx.input(|i| i.viewport().inner_rect)?;
(rect.width() > 0.0 && rect.height() > 0.0).then_some(DrawGeometry { rect, ppp: ctx.pixels_per_point(), epoch: self.geometry_epoch })
}
/// The point rectangle a request captures: its crop, clamped to the window, or all of it.
fn shot_target_rect(geom: &DrawGeometry, crop: Option<[f32; 4]>) -> Option<egui::Rect> {
let r = match crop {
Some([x, y, w, h]) if [x, y, w, h].iter().all(|v| v.is_finite()) => {
egui::Rect::from_min_size(geom.rect.min + egui::vec2(x, y), egui::vec2(w.max(0.0), h.max(0.0))).intersect(geom.rect)
}
Some(_) => return None,
None => geom.rect,
};
(r.width() > 0.0 && r.height() > 0.0).then_some(r)
}
/// The screen-point crop `target` names right now (a panel's or element's current rectangle),
/// or an actionable error when it is not on screen. Resolved at issuance, never stored from
/// admission: the UI may move while it settles.
fn shot_crop(&self, target: &ShotTarget, geom: &DrawGeometry) -> Result<Option<egui::Rect>, String> {
let crop = match target {
ShotTarget::Window => None,
ShotTarget::Rect(r) => Some(*r),
ShotTarget::Panel(p) => {
let id = format!("panel.{}", p.id());
let found = self.auto.find(&id).ok_or_else(|| format!("the {} panel is not on screen", p.title()))?;
Some(found.rect)
}
ShotTarget::Id(id) => Some(self.auto.find(id).ok_or_else(|| format!("no element `{id}` is on screen"))?.rect),
};
Self::shot_target_rect(geom, crop).map(Some).ok_or_else(|| "the screenshot area is empty or not a finite rectangle".to_string())
}
/// `target` as a crop relative to `geom`'s origin: what the image's own pixels are cut with.
fn shot_relative_crop(&self, target: &ShotTarget, geom: &DrawGeometry) -> Result<Option<[f32; 4]>, String> {
let Some(r) = self.shot_crop(target, geom)? else { return Ok(None) };
Ok(Some([r.min.x - geom.rect.min.x, r.min.y - geom.rect.min.y, r.width(), r.height()]))
}
/// Pixel bytes `rect` takes at `ppp`, checked (`width * height * 4`).
fn shot_pixel_bytes(rect: egui::Rect, ppp: f32) -> Option<usize> {
if !ppp.is_finite() || ppp <= 0.0 {
return None;
}
let w = (rect.width() as f64 * ppp as f64).ceil().max(1.0) as usize;
let h = (rect.height() as f64 * ppp as f64).ceil().max(1.0) as usize;
w.checked_mul(h)?.checked_mul(4)
}
/// Reserve and queue one `ui.screenshot`, or reply with an actionable error.
pub(crate) fn admit_screenshot(&mut self, ctx: &egui::Context, path: Option<String>, target: ShotTarget, reply: Sender<serde_json::Value>) {
let Some(geom) = self.current_geometry(ctx) else {
let _ = reply.send(
json!({"ok": false, "error": "the window geometry is not known yet (it is recorded by a completed UI pass; retry after the next frame)"}),
);
// Ask for the pass that records it.
self.want_paint = true;
ctx.request_repaint();
return;
};
if self.pending_screenshots.len() >= MAX_PENDING_SHOTS {
let _ = reply.send(json!({"ok": false, "error": format!("too many screenshots are pending (at most {MAX_PENDING_SHOTS})")}));
return;
}
if let Err(e) = self.shot_crop(&target, &geom) {
let _ = reply.send(json!({"ok": false, "error": e}));
return;
}
// The capture is always the whole surface (the crop is applied when the PNG is written),
// so the reservation and the per-image bound follow the surface's actual dimensions.
let Some(bytes) = Self::shot_pixel_bytes(geom.rect, geom.ppp) else {
let _ = reply.send(json!({"ok": false, "error": "the screenshot area is too large to reserve"}));
return;
};
if bytes > MAX_SHOT_BYTES {
let _ = reply
.send(json!({"ok": false, "error": format!("the screenshot would need about {bytes} bytes, over the {MAX_SHOT_BYTES}-byte per-image limit")}));
return;
}
if self.shot_bytes.checked_add(bytes).is_none_or(|n| n > MAX_SHOT_TOTAL_BYTES) {
let _ = reply.send(json!({"ok": false, "error": format!("the screenshot would exceed the {MAX_SHOT_TOTAL_BYTES}-byte pending-image budget")}));
return;
}
self.shot_bytes += bytes;
self.pending_screenshots.push(PendingShot {
path,
target,
crop: None,
reply,
deadline: self.now + SCREENSHOT_TIMEOUT_S,
settle_at: self.now + SCREENSHOT_SETTLE_S,
admitted: geom,
admitted_pass: self.ui_pass_seq,
issued: None,
slot: None,
reserved_bytes: bytes,
attempts: 0,
});
// A queued capture needs a settled UI pass before it can be issued.
self.want_paint = true;
}
/// Re-reserve request `i` against `geom` after a surface change; false when it no longer fits.
fn refresh_shot_reservation(&mut self, i: usize, geom: &DrawGeometry) -> bool {
let Some(bytes) = Self::shot_pixel_bytes(geom.rect, geom.ppp) else { return false };
if bytes > MAX_SHOT_BYTES {
return false;
}
let released = self.shot_bytes.saturating_sub(self.pending_screenshots[i].reserved_bytes);
if released.checked_add(bytes).is_none_or(|n| n > MAX_SHOT_TOTAL_BYTES) {
return false;
}
self.shot_bytes = released + bytes;
self.pending_screenshots[i].reserved_bytes = bytes;
true
}
/// Reply with an error to request `i` and release its reservation.
fn fail_shot(&mut self, i: usize, message: impl Into<String>) {
let shot = self.pending_screenshots.remove(i);
self.shot_bytes = self.shot_bytes.saturating_sub(shot.reserved_bytes);
let _ = shot.reply.send(json!({"ok": false, "error": message.into()}));
}
/// Collect completed captures, enforce timeouts and issue every settled request.
///
/// Runs from [`Self::tick`] only: the callback stores pixels in its slot and wakes the
/// viewport, and everything that can write a file or reply happens here, on the app thread.
pub(super) fn tick_screenshots(&mut self, ctx: &egui::Context) {
let Some(geom) = self.current_geometry(ctx) else { return };
self.drain_completed_screenshots(geom);
self.timeout_screenshots();
self.issue_screenshots(ctx, geom);
// 4. Wake up for the earliest deadline even when no further pass is otherwise needed: an
// issued capture whose callback never arrives (hidden window, display asleep, renderer
// failure) would otherwise never reach its timeout. This is a timer, not a paint
// request: the issued screenshot command already forces the frame it needs.
if let Some(next) = self.pending_screenshots.iter().map(|s| s.deadline).reduce(f64::min) {
let wait = (next - self.now).max(0.001);
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
}
}
/// 1. Drain completed slots. A completion whose surface epoch changed since issuance is
/// discarded even if the geometry changed back: it was cropped with another frame's
/// pixels-per-point.
fn drain_completed_screenshots(&mut self, geom: DrawGeometry) {
let mut i = 0;
while i < self.pending_screenshots.len() {
let taken = self.pending_screenshots.get(i).and_then(|s| s.slot.as_ref()).and_then(craft_ui::capture::CaptureSlot::take);
let Some(result) = taken else {
i += 1;
continue;
};
let issued = self.pending_screenshots[i].issued;
match result {
Ok(image) => {
if issued.is_none_or(|g| g.epoch != geom.epoch) {
self.pending_screenshots[i].slot = None;
self.pending_screenshots[i].issued = None;
if self.pending_screenshots[i].attempts >= SCREENSHOT_MAX_RETRIES {
self.fail_shot(i, "the window resized while the screenshot was being taken");
} else if !self.refresh_shot_reservation(i, &geom) {
self.fail_shot(i, "the resized window is too large to capture within the screenshot budget");
} else {
let settle_at = self.now + SCREENSHOT_SETTLE_S;
let passes = self.ui_pass_seq;
let s = &mut self.pending_screenshots[i];
s.attempts += 1;
s.admitted = geom;
s.admitted_pass = passes;
s.settle_at = settle_at;
i += 1;
}
continue;
}
let Some(issued) = issued else {
i += 1;
continue;
};
if !shot_image_matches(&image, issued.rect, issued.ppp) {
self.fail_shot(i, "the screenshot's pixels do not match the window it was taken from");
continue;
}
// The returned image can be smaller than requested (the E2 adapter-limit fit),
// so the crop uses the image's own scale, never the issued ppp.
let [iw, ih] = image.size;
let (sx, sy) = (iw as f64 / issued.rect.width() as f64, ih as f64 / issued.rect.height() as f64);
if !(sx.is_finite() && sx > 0.0 && sy.is_finite() && sy > 0.0) {
self.fail_shot(i, "the screenshot's size does not describe the window it was taken from");
continue;
}
let shot = self.pending_screenshots.remove(i);
self.shot_bytes = self.shot_bytes.saturating_sub(shot.reserved_bytes);
let r = control::save_screenshot(&image, shot.path.as_deref(), shot.crop, [sx, sy]);
let _ = shot.reply.send(r);
}
Err(e) => {
self.fail_shot(i, e.to_string());
}
}
}
}
/// 2. Timeouts: a late callback finds its slot cancelled and writes nothing.
fn timeout_screenshots(&mut self) {
let mut i = 0;
while i < self.pending_screenshots.len() {
if self.now > self.pending_screenshots[i].deadline {
self.fail_shot(i, "no frame was presented (window hidden or display asleep)");
} else {
i += 1;
}
}
}
/// 3. Issue settled requests. The command itself requests the paint; a request still
/// waiting for its passes needs `want_paint` so a hidden window keeps painting.
fn issue_screenshots(&mut self, ctx: &egui::Context, geom: DrawGeometry) {
let mut i = 0;
while i < self.pending_screenshots.len() {
if self.pending_screenshots[i].slot.is_some() {
i += 1;
continue;
}
if geom.epoch != self.pending_screenshots[i].admitted.epoch {
if self.pending_screenshots[i].attempts >= SCREENSHOT_MAX_RETRIES {
self.fail_shot(i, "the window kept resizing before the screenshot could be taken");
continue;
}
if !self.refresh_shot_reservation(i, &geom) {
self.fail_shot(i, "the resized window is too large to capture within the screenshot budget");
continue;
}
let (now, passes) = (self.now, self.ui_pass_seq);
let s = &mut self.pending_screenshots[i];
s.attempts += 1;
s.admitted = geom;
s.admitted_pass = passes;
s.settle_at = now + SCREENSHOT_SETTLE_S;
self.want_paint = true;
i += 1;
continue;
}
let settled = self.ui_pass_seq >= self.pending_screenshots[i].admitted_pass.saturating_add(SCREENSHOT_SETTLE_PASSES)
&& self.now >= self.pending_screenshots[i].settle_at;
if !settled {
self.want_paint = true;
i += 1;
continue;
}
// Resolve the crop now, against the settled UI: an element that moved since admission
// is captured where it is, and its rectangle is relative to this issued surface.
let target = self.pending_screenshots[i].target.clone();
let crop = match self.shot_relative_crop(&target, &geom) {
Ok(c) => c,
Err(e) => {
self.fail_shot(i, e);
continue;
}
};
let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_SHOT_BYTES);
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback));
let s = &mut self.pending_screenshots[i];
s.slot = Some(slot);
s.issued = Some(geom);
s.crop = crop;
i += 1;
}
}
}
+166
View File
@@ -0,0 +1,166 @@
use super::*;
impl EffectcraftApp {
/// The size of the viewer frame's texture (CPU frames; fitted to the GPU's texture limit).
pub fn viewer_texture_size(&self) -> Option<[usize; 2]> {
self.viewer_tex.as_ref().map(|(t, _, _)| t.size())
}
/// The viewer's current pixels as 8-bit premultiplied RGBA, reading a GPU frame back the
/// first time something asks for it.
///
/// The browser can't wait for the GPU: there the first call starts an asynchronous readback
/// and returns `None`; a later call (the next frame) has the pixels.
pub fn viewer_pixels(&mut self) -> Option<Arc<egui::ColorImage>> {
let to_image = |bytes: Vec<u8>, w: u32, h: u32| {
let px = bytes.as_chunks::<4>().0.iter().map(|c| egui::Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3])).collect();
Arc::new(egui::ColorImage::new([w as usize, h as usize], px))
};
if self.viewer_image.is_none()
&& let (Some((_, key, f)), Some(g)) = (&self.viewer_native, &self.gpu)
&& self.viewer_shown.as_ref().map(|s| s.1) == Some(*key)
{
if cfg!(target_arch = "wasm32") {
let ready = self.viewer_readback.1.lock().ok().and_then(|mut r| r.take_if(|r| r.0 == *key));
if let Some((_, img)) = ready {
self.viewer_image = Some(img);
} else if self.viewer_readback.0 != Some(*key) {
self.viewer_readback.0 = Some(*key);
let (slot, k, (w, h)) = (self.viewer_readback.1.clone(), *key, (f.width, f.height));
let ctx = self.frames.context();
g.read_display_async(f, move |bytes| {
if let (Some(b), Ok(mut s)) = (bytes, slot.lock()) {
*s = Some((k, to_image(b, w, h)));
}
if let Some(c) = ctx {
c.request_repaint();
}
});
}
} else if let Some(bytes) = g.read_display(f) {
self.viewer_image = Some(to_image(bytes, f.width, f.height));
}
}
self.viewer_image.clone()
}
/// The viewer's Resolution: the active composition's (saved with the project).
pub fn viewer_res(&self) -> state::Resolution {
self.session.active_comp().map(|c| c.resolution).unwrap_or_default()
}
/// The render scale used by the viewer right now.
pub fn viewer_scale(&self, zoom: f32, ppp: f32) -> f64 {
// The preview's Resolution (when not Auto) while it plays.
if self.playback.playing
&& let Some(f) = self.playback.res
{
return state::Resolution::from_factor(f.into()).scale(zoom, ppp);
}
// Auto follows the zoom alike while playing and paused, so frames cached while scrubbing
// play back, and frames cached by a preview stay on screen when it stops.
let res = self.viewer_res();
let full = res.scale(zoom, ppp);
// Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging.
let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting);
if k < 1.0 && res.is_auto() {
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
}
full
}
/// The cache key of viewer frame `frame` of `comp` at `scale` (see [`Self::frame_series`]).
pub fn frame_key(&self, comp: ItemId, frame: i64, scale: f64) -> FrameKey {
FrameKey { frame, ..self.frame_series(comp, scale) }
}
/// The key of frame 0 of `comp`'s viewer frames at `scale` (the content, view and render
/// options are the same for every frame: loops compute this once).
pub fn frame_series(&self, comp: ItemId, scale: f64) -> FrameKey {
FrameKey {
revision: self.session.revision,
content: self.frames.content_of(&self.session.project, self.session.revision, comp),
comp: comp.0,
frame: 0,
scale: (scale * 1000.0).round() as u32,
view: self.view_hash(comp),
opts: frames::opts_hash(&self.frame_opts(comp, scale)),
}
}
/// The frame series the viewer of `comp` shows now (its scale, view and options, for the
/// comp's current content): what the cache bars count.
pub fn shown_series(&self, comp: ItemId) -> FrameKey {
let scale = self.viewer_shown.as_ref().filter(|(_, k)| k.comp == comp.0).map_or(1000, |(_, k)| k.scale);
self.frame_series(comp, scale as f64 / 1000.0)
}
/// Hash of the comp viewer's 3D view camera (0 for the active camera view).
pub fn view_hash(&self, comp: ItemId) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
// The region of interest changes what is rendered too.
let roi = self.viewer_region(comp);
if let Some(r) = roi {
r.map(f64::to_bits).hash(&mut h);
}
let Some(cam) = self.session.view_camera(comp) else { return if roi.is_some() { h.finish() | 1 } else { 0 } };
for row in cam.view.0 {
for v in row {
v.to_bits().hash(&mut h);
}
}
cam.zoom.to_bits().hash(&mut h);
cam.ortho.hash(&mut h);
h.finish() | 1
}
/// The comp-space region viewer frames of `comp` cover: the region of interest, else the
/// Extended Viewer's area (frame plus pasteboard), else `None` (the whole frame).
pub fn viewer_region(&self, comp: ItemId) -> Option<[f64; 4]> {
if self.session.active_comp_id() != Some(comp) {
return None;
}
self.session.state.region_of_interest.or(self.ui.viewer.extended)
}
/// Request a prefetch frame render (no-op if cached/in flight).
pub fn request_frame(&self, comp: ItemId, frame: i64, scale: f64) {
self.request_frame_with(comp, frame, scale, false);
}
/// Request the frame on screen: rendered before any queued prefetch.
pub fn request_frame_urgent(&self, comp: ItemId, frame: i64, scale: f64) {
self.request_frame_with(comp, frame, scale, true);
}
/// Render options of viewer frames of `comp` at `scale`.
pub fn frame_opts(&self, comp: ItemId, scale: f64) -> RenderOpts {
let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting);
let roi = self.viewer_region(comp);
RenderOpts {
scale,
motion_blur: true,
guides: true,
draft: self.ui.viewer.fast_preview || draft,
view: self.session.view_camera(comp),
roi,
backend: effectcraft_engine::render::Backend::Auto,
nested_switches: self.session.prefs.general.switches_affect_nested_comps,
draft_shadows: self.session.prefs.three_d.realtime_shadows,
proxy: Default::default(),
}
}
fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) {
let Some(c) = self.session.project.comp(comp) else { return };
let key = self.frame_key(comp, frame, scale);
let t = c.frame_rate.tick_of(frame);
let opts = self.frame_opts(comp, scale);
if urgent {
self.frames.request_urgent(&self.render_source(), key, comp, t, opts);
} else {
self.frames.request(&self.render_source(), key, comp, t, opts);
}
}
}
+155
View File
@@ -0,0 +1,155 @@
use super::*;
impl EffectcraftApp {
/// Keep the saved workspaces for the next session (see [`EffectcraftApp::new`]): After
/// Effects' saved workspaces last until they are deleted (#320). `Err` when they weren't
/// written (#696): the change applies for this session and a later save retries it, but until
/// then a restart brings back what is on disk.
pub(crate) fn store_saved_workspaces(&mut self) -> Result<(), String> {
let Err(error) = self.workspace_store.save_now(self.session.config.as_deref(), &self.ui) else { return Ok(()) };
let message = format!("Workspace changes couldn't be saved and may be lost after a restart: {error}");
log::warn!("{message}");
self.ui.status = message.clone();
// The raw error, so the background retry doesn't report the same failure again.
self.workspace_store.error = Some(error);
Err(message)
}
pub fn set_workspace(&mut self, name: &str) {
// Learn: the Home screen's Learn tab (tutorials) in the Composition panel. Leaving it for
// another workspace closes the Home screen again: it stayed over every workspace until a
// project was created (#272).
if name == "Learn" {
self.ui.start_screen = true;
self.ui.home_learn = true;
self.ui.home_templates = false;
} else if self.ui.workspace == "Learn" {
self.ui.start_screen = false;
self.ui.home_learn = false;
}
self.ui.workspace = name.to_string();
self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::Workspace::new(dock::workspace(name)));
self.ui.maximized = None;
}
/// Built-in workspaces followed by the saved ones (Window ▸ Workspace ▸ Save as New Workspace).
pub fn workspace_names(&self) -> Vec<String> {
let mut v: Vec<String> = dock::WORKSPACES.iter().map(|s| s.to_string()).collect();
v.extend(self.saved_workspace_names());
v
}
/// The workspaces the user saved under a new name (built-ins whose changes were saved are
/// not repeated), in name order.
pub fn saved_workspace_names(&self) -> Vec<String> {
self.ui.saved_workspaces.keys().filter(|k| !dock::WORKSPACES.contains(&k.as_str())).cloned().collect()
}
/// The open Gradient Editor's draft (`panels::gradient_editor`): what OK would set.
pub fn gradient_draft(&self) -> &panels::gradient_editor::Draft {
&self.dialog_state.gradient
}
/// The open Gradient Editor's draft, to edit (tests and automation; the editor's own fields
/// do the same).
pub fn gradient_draft_mut(&mut self) -> &mut panels::gradient_editor::Draft {
&mut self.dialog_state.gradient
}
/// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and
/// give it the focus: Window ▸ `<panel>`.
pub fn show_panel(&mut self, p: PanelKind) {
if !self.ui.dock.is_floating(p) && self.ui.maximized.is_some_and(|m| m != p) {
self.ui.maximized = None;
}
self.raise_panel(p);
self.ui.focused = p;
}
/// Bring panel `p` to the front of its group, opening it if it is closed, without moving the
/// focus (a comp opening brings up its viewer, an effect applied brings up Effect Controls).
/// A closed panel goes back where it was; one never laid out goes to its usual place: the
/// Composition panel in the centre with the Layer panel and other viewers (with none of
/// those left, above the Timeline), Layer and the viewers beside the Composition panel, and
/// so on.
pub fn raise_panel(&mut self, p: PanelKind) {
if !self.ui.dock.is_open(p) {
let docked = |q: &PanelKind| self.ui.dock.is_docked(*q);
let near = match p {
PanelKind::Composition => self
.ui
.dock
.panels()
.into_iter()
.find(|q| matches!(q, PanelKind::Layer | PanelKind::Viewer(_) | PanelKind::Flowchart | PanelKind::Footage) && docked(q)),
PanelKind::Layer | PanelKind::Flowchart | PanelKind::Viewer(_) => Some(PanelKind::Composition),
PanelKind::RenderQueue => Some(PanelKind::Timeline),
PanelKind::EffectControls | PanelKind::History => Some(PanelKind::Project),
_ => Some(PanelKind::EffectsPresets),
}
.filter(|q| self.ui.dock.is_open(*q));
let mut workspace = self.ui.dock.clone();
let known = workspace.hidden.iter().any(|(q, _)| *q == p);
let opened = if p == PanelKind::Composition && near.is_none() && !known && self.ui.dock.is_docked(PanelKind::Timeline) {
workspace
.apply(
craft_ui::docking::Action::OpenAt {
panel: p,
anchor: PanelKind::Timeline,
placement: craft_ui::docking::Placement::Split(craft_ui::docking::Zone::Top),
},
dock::permissions,
)
.map(|_| ())
} else if known {
workspace.set_visible(p, true, near, dock::permissions)
} else {
workspace.apply(craft_ui::docking::Action::Open { panel: p, anchor: near }, dock::permissions).map(|_| ())
};
match opened {
Ok(()) => self.ui.dock = workspace,
Err(e) => {
self.ui.status = format!("can't open {}: {e}", p.title());
return;
}
}
}
let _ = self.ui.dock.apply(craft_ui::docking::Action::Activate { panel: p }, dock::permissions);
if self.ui.dock.is_floating(p) {
self.pending_panel_raise = Some(p);
}
}
}
/// Maximize a first window that doesn't fit the screen, which fits it to the space beside the
/// taskbar or dock. The 1680 × 1020 window on a smaller screen is only shrunk to the monitor's
/// size, so with its title bar it ran under the taskbar and cut menus off at the bottom of the
/// screen (#269).
pub(super) fn fit_window(ctx: &egui::Context) {
let (monitor, window) = ctx.input(|i| (i.viewport().monitor_size, i.viewport().outer_rect.or(i.viewport().inner_rect)));
if let (Some(monitor), Some(window)) = (monitor, window)
&& overflows_screen(window.size(), monitor)
{
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
}
}
/// Whether a window `size` leaves no room on a `monitor` for a taskbar or dock (points).
fn overflows_screen(size: egui::Vec2, monitor: egui::Vec2) -> bool {
const TASKBAR: f32 = 64.0;
size.x > monitor.x || size.y > monitor.y - TASKBAR
}
#[cfg(test)]
mod fit_window_tests {
use super::overflows_screen;
use egui::vec2;
#[test]
fn a_window_taller_than_the_screen_beside_its_taskbar_overflows() {
// The default window on a 1366 × 768 laptop (shrunk to the monitor) and on 1080p.
assert!(overflows_screen(vec2(1366.0, 799.0), vec2(1366.0, 768.0)));
assert!(overflows_screen(vec2(1680.0, 1051.0), vec2(1920.0, 1080.0)));
assert!(!overflows_screen(vec2(1680.0, 1051.0), vec2(2560.0, 1440.0)));
}
}