Files
effectcraft/crates/ui-egui/src/lib.rs
T

3406 lines
159 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The EffectCraft egui frontend (swappable): docking panels, the Composition viewer, the
//! Timeline with property twirl-downs and keyframes, Effect Controls, Effects & Presets, Preview,
//! Info, Character/Paragraph/Align, menus, dialogs and the control channel.
//!
//! Everything goes through `effectcraft_engine::Session::execute` (or `menus::invoke` for
//! frontend-only commands) so every gesture is also available to agents.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod audio;
pub mod automation;
pub mod bench;
pub mod color_picker;
pub mod control;
pub mod credits;
pub mod dock;
mod dock_shared;
pub mod dock_ui;
mod expression_diagnostics;
pub mod frames;
pub mod gpu_failure;
pub mod header;
pub mod i18n;
pub mod icons;
pub mod menu_keys;
pub mod menus;
pub mod native_menu;
pub mod panels;
mod pointer_wrap;
pub mod prefs_live;
pub mod state;
pub mod theme;
pub mod widgets;
mod workspace_state;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender};
pub use control::ControlRequest;
use dock::PanelKind;
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemId;
use effectcraft_engine::render::RenderOpts;
use effectcraft_engine::time::Tick;
use frames::{FrameKey, Frames, RenderSource};
use serde_json::json;
use state::UiState;
use theme::Tokens;
/// How long a screenshot request waits for a frame, from admission (app clock seconds).
const SCREENSHOT_TIMEOUT_S: f64 = 8.0;
/// Seconds between a screenshot's admission and the earliest capture: the UI settles first.
const SCREENSHOT_SETTLE_S: f64 = 0.35;
/// Final (non-discarded) UI passes a screenshot waits for after admission before it is issued.
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).
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;
/// Seconds without a project change before paused prefetch resumes (see
/// [`EffectcraftApp::editing`]).
const EDIT_QUIET: f64 = 0.3;
/// The application's monotonic clock, in seconds.
///
/// Neither `ctx.input(|i| i.time)` nor `RawInput::time` is valid in a logic-only tick:
/// `Context::run_logic` copies only the viewport information, so both are the previous full
/// pass's values. Playback, auto-save, screenshot deadlines and the control channel measure time
/// with this clock instead. Native and web use the same [`web_time::Instant`] source; tests
/// inject [`AppClock::manual`] and advance it.
#[derive(Clone, Debug)]
pub struct AppClock {
origin: web_time::Instant,
manual: Option<f64>,
}
impl AppClock {
/// The host's monotonic clock.
pub fn real() -> Self {
Self { origin: web_time::Instant::now(), manual: None }
}
/// A clock tests advance explicitly with [`AppClock::advance`].
pub fn manual() -> Self {
Self { origin: web_time::Instant::now(), manual: Some(0.0) }
}
/// Seconds since the clock started (monotonic on native and in the browser).
pub fn now(&self) -> f64 {
match self.manual {
Some(t) => t,
None => self.origin.elapsed().as_secs_f64(),
}
}
/// Move a manual clock forward; a real clock ignores this.
pub fn advance(&mut self, seconds: f64) {
if let Some(t) = &mut self.manual {
*t = (*t + seconds.max(0.0)).max(0.0);
}
}
/// Whether the clock is test-driven.
pub fn is_manual(&self) -> bool {
self.manual.is_some()
}
}
impl Default for AppClock {
fn default() -> Self {
Self::real()
}
}
/// The root surface geometry of the last completed (non-discarded) UI pass.
///
/// The window position is deliberately excluded: moving the window does not change the pixels
/// a screenshot captures. Size and scale-factor changes advance [`Self::epoch`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DrawGeometry {
pub rect: egui::Rect,
pub ppp: f32,
/// Bumped whenever `rect.size()` or `ppp` differs from the previous recorded pass.
pub epoch: u64,
}
/// 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)
}
/// What a `ui.screenshot` captures.
///
/// A panel or element is an identity, not a rectangle: it is resolved again when the capture is
/// issued, after the settle passes, so a panel that moved or resized while the UI settled is
/// captured where it is now (never with the rectangle from request admission).
#[derive(Clone, Debug, PartialEq)]
pub enum ShotTarget {
/// The whole root surface.
Window,
/// A fixed rectangle in logical points, relative to the root surface's top-left corner.
Rect([f32; 4]),
/// One dock panel's automation rectangle (`panel.<id>`).
Panel(PanelKind),
/// One automation element id's rectangle.
Id(String),
}
/// One `ui.screenshot` request: its reply, its admission reservation and its completion slot.
struct PendingShot {
path: Option<String>,
/// What to capture; resolved afresh at every issuance.
target: ShotTarget,
/// The crop resolved at issuance, **relative to the issued rect's origin** (the image's
/// origin); `None` captures the whole surface.
crop: Option<[f32; 4]>,
reply: Sender<serde_json::Value>,
/// App-clock instant after which the request fails instead of waiting.
deadline: f64,
/// App-clock instant before which the UI must have settled.
settle_at: f64,
/// Root geometry and geometry epoch the reservation was admitted against.
admitted: DrawGeometry,
/// Final UI passes completed when the request was admitted.
admitted_pass: u64,
/// The geometry epoch bound to the issued capture command, if it was issued.
issued: Option<DrawGeometry>,
/// The shared completion slot; dropping it cancels a late callback.
slot: Option<craft_ui::capture::CaptureSlot>,
/// Bytes reserved against [`EffectcraftApp::shot_bytes`].
reserved_bytes: usize,
/// Geometry retries used.
attempts: u32,
}
/// Modal dialogs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dialog {
About,
NewComp,
CompSettings,
SolidSettings,
CommandPalette,
/// Settings (Preferences) on `dialog_state.settings_page`.
Settings,
/// Edit ▸ Keyboard Shortcuts.
Shortcuts,
/// A generic parameter form for a command (`dialog_state.form`).
Form,
/// A message (`dialog_state.info`).
Info,
/// View ▸ View Options.
ViewOptions,
CameraSettings,
LightSettings,
NewPrimitive,
KeyVelocity,
KeyInterpolation,
TimeStretch,
/// Tracker ▸ Options…
TrackOptions,
/// Tracker ▸ Edit Target…
TrackTarget,
/// Tracker ▸ Apply (Transform / Stabilize): Apply Dimensions.
TrackApply,
/// Crash recovery: offer the latest auto-save (`EffectcraftApp::recovery`).
Recovery,
/// Composition/Layer Marker (double-click a marker).
Marker,
/// Layer ▸ Pre-compose.
Precompose,
/// Layer ▸ Layer Styles ▸ Layer Style Options…
LayerStyles,
/// Edit ▸ Templates ▸ Render Settings… / Output Module….
RenderTemplates,
/// Save changes before closing the project? (`panels::unsaved`).
UnsavedChanges,
/// Delete Project items that compositions use? (`panels::delete_items`).
DeleteItems,
/// A gradient property's Gradient Editor (`panels::gradient_editor`, `prop.editGradient`).
GradientEditor,
/// Delete a user template from Home? (`panels::templates`).
DeleteTemplate,
}
/// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send
/// winit no theme). `None` = the host does not know.
pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
/// A file dialog a host shows without blocking the frame ([`Hooks::file_dialog_async`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileDialog {
/// Files to open or import with these extensions; several when `many`.
Open { exts: Vec<String>, many: bool },
/// An EffectCraft project to open.
OpenProject,
/// A folder.
Folder,
/// Where to save `default` (a file name or path) with extension `ext` (empty: no filter);
/// `project`: the project save dialog, given the file name.
Save { default: String, ext: String, project: bool },
}
impl FileDialog {
/// The save dialog for `default`, as [`Hooks::save_dialog`] picks it.
pub fn save(default: &str) -> Self {
let ext = std::path::Path::new(default).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
Self::Save { default: default.to_string(), project: ext.eq_ignore_ascii_case("ecproj"), ext }
}
}
/// Called with the paths a [`FileDialog`] picked (empty: cancelled), from any thread.
pub type DialogDone = Box<dyn FnOnce(Vec<String>) + Send>;
/// A non-blocking file dialog's answer for the command that asked (`menus::dialog_answered`).
pub(crate) struct DialogAnswer {
pub command: String,
pub key: &'static str,
pub params: serde_json::Value,
pub paths: Vec<String>,
}
/// Host hooks provided by the native app (file pickers etc.).
#[derive(Default)]
pub struct Hooks {
/// The system appearance for Settings ▸ Appearance ▸ Sync with System (the desktop app reads
/// the Linux desktop portal); without it, what egui reports.
pub system_theme: Option<SystemThemeFn>,
pub pick_files: Option<Box<dyn Fn(&[&str]) -> Vec<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
pub pick_open_project: Option<Box<dyn Fn() -> Option<String>>>,
/// Opens the audio output for preview playback (the desktop app uses cpal).
pub audio_device: Option<audio::AudioDeviceFactory>,
/// Lists audio output devices (Settings ▸ Audio).
pub audio_devices: Option<Box<dyn Fn() -> Vec<String>>>,
/// Picks a folder (Settings paths).
pub pick_folder: Option<Box<dyn Fn() -> Option<String>>>,
/// Save dialog for other file kinds: (default name or path, extension; empty: no filter). A
/// default path opens the dialog in its folder.
pub pick_save_file: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
/// The system clipboard's text (native menu Edit ▸ Paste into a text field).
pub clipboard_text: Option<Box<dyn Fn() -> Option<String>>>,
/// Application actions the OS performs (`app.hide`, `app.hideOthers`, `app.showAll` on
/// macOS). Returns false when the host doesn't handle the id.
pub app_action: Option<Box<dyn Fn(&str) -> bool>>,
/// Shows a command's file dialog without blocking the frame and calls `done` with the
/// answer (the host then asks for a repaint). Used in place of the blocking pickers above for
/// the commands' dialogs (File ▸ Import, Open, Save As…) where blocking the frame is unsafe or
/// leaves the window unresponsive: on macOS a nested modal event loop there aborted the app on
/// a drag over the window (#511), and on Linux a blocking desktop-portal dialog never pumps
/// events, so the compositor marks the window as not responding (#1275).
pub file_dialog_async: Option<Box<dyn Fn(FileDialog, DialogDone)>>,
/// Tells the user the graphics device was lost and the window is closing, once. `saved` is
/// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed.
pub device_lost: Option<Box<dyn Fn(Option<Result<String, String>>)>>,
}
impl Hooks {
/// The host's save dialog for `default` (a file name or path), offering its extension: the
/// project dialog for project files, else [`Hooks::pick_save_file`]. The project dialog is
/// the fallback for hosts without that (the web, whose dialog only names the file): on macOS
/// a dialog filtered to projects appends `.ecproj` to any other name (#293). `None` when the
/// host has no save dialog; `Some(None)` when it was cancelled.
pub fn save_dialog(&self, default: &str) -> Option<Option<String>> {
let path = std::path::Path::new(default);
let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
match (&self.pick_save_file, &self.pick_save) {
(Some(pick), _) if !ext.eq_ignore_ascii_case("ecproj") => Some(pick(default, &ext)),
(_, Some(pick)) => Some(pick(&path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())),
(Some(pick), None) => Some(pick(default, &ext)),
(None, None) => None,
}
}
}
#[derive(Default)]
pub struct Playback {
pub playing: bool,
/// Wall-clock start and the comp time it corresponds to.
pub start_wall: f64,
pub start_time: Tick,
pub start_frame: i64,
/// Frames shown / dropped (for the Info/Preview readout).
pub shown: u64,
pub waiting: bool,
pub fps: f64,
/// The Preview panel shortcut that started this preview and what it plays.
pub shortcut: effectcraft_engine::preview::PreviewShortcut,
pub plan: Option<effectcraft_engine::preview::PreviewPlan>,
/// Cache Before Playback: rendering the range before the clock starts.
pub caching: bool,
/// Resolution override while playing (pixel step), `None` = the viewer's.
pub res: Option<u32>,
/// Include Overlays / Include Layer Controls.
pub overlays: bool,
pub layer_controls: bool,
/// Full Screen: the maximized panel to restore when the preview stops.
pub restore_max: Option<Option<PanelKind>>,
/// Audio-only previews: the frame under the audible sample (the current time stays put).
pub audio_frame: Option<i64>,
/// The preview's sound waits for frames: while the frames ahead aren't cached, every frame
/// shows as it renders, silently; the sound starts once the rest of the preview (or what
/// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or
/// sooner once the silent preview keeps real time with the frames ahead cached (#208).
pub audio_held: bool,
/// When the last frames were shown (seconds, the last second's worth): the achieved rate.
pub shown_at: std::collections::VecDeque<f64>,
}
impl Playback {
/// A new frame went on screen at `now`.
pub fn frame_shown(&mut self, now: f64) {
self.shown += 1;
self.shown_at.push_back(now);
while self.shown_at.front().is_some_and(|t| now - t > 1.0) {
self.shown_at.pop_front();
}
}
/// Frames per second shown over the last second while the preview plays (`None` before two
/// frames were shown, while caching, or stopped).
pub fn achieved_fps(&self) -> Option<f64> {
let (a, b) = (self.shown_at.front()?, self.shown_at.back()?);
(self.playing && !self.caching && self.shown_at.len() > 1 && b > a).then(|| (self.shown_at.len() - 1) as f64 / (b - a))
}
/// The preview keeps up with its frame rate (within 5 %; previews that skip frames count
/// the frames they mean to show).
pub fn real_time(&self) -> Option<bool> {
let step = self.plan.map_or(1, |p| p.step).max(1) as f64;
self.achieved_fps().map(|f| f * step >= self.fps * 0.95)
}
/// The preview has kept real time for the last quarter second (at least two frames) up to
/// `now`: rendering keeps up, so sound can start without waiting for the whole preview.
pub fn keeps_up(&self, now: f64) -> bool {
let step = self.plan.map_or(1, |p| p.step).max(1) as f64;
let frame = step / self.fps.max(1.0);
let (Some(a), Some(b)) = (self.shown_at.front(), self.shown_at.back()) else { return false };
self.shown_at.len() >= 3 && b - a >= (2.0 * frame).max(0.25) && now - b <= 2.0 * frame && self.real_time() == Some(true)
}
}
pub struct EffectcraftApp {
pub session: Session,
pub ui: UiState,
workspace_store: workspace_state::WorkspaceStore,
pub tokens: Tokens,
pub auto: automation::Registry,
pub frames: Frames,
pub playback: Playback,
pub dialog: Option<Dialog>,
pub hooks: Hooks,
pub fps: f32,
/// The app clock and the value it had at the last [`EffectcraftApp::tick`].
clock: AppClock,
now: f64,
last_time: f64,
styled: bool,
fonts_ready: bool,
/// The interface language the fonts were installed for; `""` until the first frame.
styled_language: &'static str,
/// The root viewport's focus, from the viewport information a logic tick refreshes.
focused: bool,
/// Whether the last UI pass saw events or a moving/held pointer.
input_active: bool,
/// The pointer was down in the last UI pass (workspace auto-save waits for it to lift).
last_pointer_down: bool,
/// Geometry of the last completed, non-discarded UI pass.
draw_geom: Option<DrawGeometry>,
/// Bumped when the root surface size or pixels-per-point changes.
geometry_epoch: u64,
/// Per-viewport IME owner, generation and change-cause state for the on-canvas text editor.
pub(crate) ime_tracker: egui::ImeContextTracker,
/// Completed, non-discarded UI passes since launch.
ui_pass_seq: u64,
/// A hidden window must run a UI pass (queued input, screenshots, playback children, …).
want_paint: bool,
/// The synthetic batch moved into raw input has not been applied by a UI pass yet.
synthetic_in_flight: bool,
/// The events of that batch while egui applies them (never fed to a second pass).
synthetic_in_flight_events: Vec<egui::Event>,
/// The viewer scale the last UI pass drew at: playback prefetches without a viewer pass.
last_viewer_scale: Option<f64>,
pub(crate) control_rx: Option<Receiver<ControlRequest>>,
pub(crate) deferred: Vec<(ControlRequest, f64)>,
pub(crate) synthetic: Vec<egui::Event>,
pub(crate) input_waiters: Vec<Sender<serde_json::Value>>,
/// Control requests waiting for an offloaded job (`wait: true` in the browser).
pub(crate) job_waiters: Vec<(control::JobWaiter, Sender<serde_json::Value>)>,
pending_screenshots: Vec<PendingShot>,
/// Pixels reserved by queued and in-flight screenshot requests.
shot_bytes: usize,
/// When the user last did something (input or an edit): Cache Frames When Idle waits for
/// a second of quiet. (time, project revision then)
last_activity: (f64, u64),
/// When the project last changed, and its revision then (see [`Self::editing`]).
last_edit: (f64, u64),
pub(crate) toast: Option<(String, f64)>,
/// Texture of the last CPU frame shown in the viewer, the key it came from and the factor it
/// was averaged down by for the magnification (`panels::viewer::minify_factor`).
pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey, usize)>,
/// The frames the other (passive) Composition viewers show, by viewer id.
pub(crate) passive_tex: std::collections::HashMap<u32, (FrameKey, panels::viewers::PassiveTexture)>,
/// The last GPU frame shown: its egui texture id (registered with egui-wgpu), key and texture.
pub(crate) viewer_native: Option<(egui::TextureId, FrameKey, Arc<effectcraft_gpu::DisplayFrame>)>,
/// The texture the viewer draws and its frame key (CPU or GPU frame).
pub(crate) viewer_shown: Option<(egui::TextureId, FrameKey)>,
/// Last displayed image (Info panel colour, eyedroppers, histograms). GPU frames fill it on
/// demand ([`EffectcraftApp::viewer_pixels`]).
pub(crate) viewer_image: Option<Arc<egui::ColorImage>>,
/// wasm32: an asynchronous readback of a GPU viewer frame (key asked for, result).
viewer_readback: (Option<FrameKey>, Arc<std::sync::Mutex<Option<(FrameKey, Arc<egui::ColorImage>)>>>),
/// egui-wgpu's device, once the first frame arrives (desktop and WebGPU).
pub(crate) wgpu: Option<eframe::egui_wgpu::RenderState>,
/// The GPU compositor on that device (None: no usable adapter → CPU only).
pub(crate) gpu: Option<effectcraft_gpu::Gpu>,
gpu_checked: bool,
gpu_failures: Option<gpu_failure::GpuFailureBridge>,
gpu_failure_applied: Option<bool>,
/// The graphics device was lost and the window is closing (see `close_after_device_loss`);
/// `true` keeps crash recovery for the next launch.
device_lost_close: Option<bool>,
/// Answers of file dialogs shown without blocking ([`Hooks::file_dialog_async`]).
pub(crate) dialog_answers: (Sender<DialogAnswer>, Receiver<DialogAnswer>),
/// Commands from the native menu bar.
pub command_inbox: Option<Receiver<String>>,
/// Pointer position in comp pixels (Info panel).
pub(crate) pointer_comp: Option<[f32; 2]>,
pub(crate) dialog_state: panels::dialogs::DialogState,
pub integrated_titlebar: bool,
/// Audio preview while playing (audio clock drives playback).
pub audio: Option<audio::AudioPlayback>,
/// Audio scrubbing's open output (Ctrl/Cmd-drag the current time, `playback.scrubAudio`).
pub scrub: Option<audio::AudioScrub>,
/// Audio panel VU meters.
pub meter: audio::Meter,
/// Waveform peak summaries per footage item (see `panels::waveform`).
pub(crate) expression_diagnostics: expression_diagnostics::Diagnostics,
pub(crate) waveforms: panels::waveform::Cache,
/// Last reveal shortcut and when (double-press shortcuts such as LL).
pub(crate) last_reveal: Option<(String, f64)>,
/// Settings revision applied to the theme, tooltips and caches.
/// The settings revision and system appearance (light?) last applied.
applied_prefs: Option<(u64, Option<bool>)>,
/// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`).
pub recovery: Option<effectcraft_engine::autosave::Recovery>,
/// Docked groups laid out last frame: (active panel, group rect) — `~` maximizes the one
/// under the pointer.
pub(crate) dock_rects: Vec<(PanelKind, egui::Rect)>,
/// Home screen: recent-project thumbnail textures by path (None = no thumbnail).
pub(crate) home_thumbs: std::collections::HashMap<String, Option<egui::TextureHandle>>,
/// The (project path, saved revision) whose thumbnail was stored last.
pub(crate) home_thumb_saved: Option<(String, u64)>,
/// The window title last sent (project name, `*` while modified).
window_title: String,
/// Home ▸ Templates: thumbnail textures by template id (None = not renderable).
pub(crate) template_thumbs: std::collections::HashMap<String, Option<egui::TextureHandle>>,
/// Home ▸ Templates: the gallery, and the user template files it was listed from.
pub(crate) template_list: Option<(Vec<String>, Vec<effectcraft_engine::templates::TemplateInfo>)>,
}
/// The saved workspaces in the settings store ([`effectcraft_engine::config::ConfigStore`]).
pub const WORKSPACES_FILE: &str = "workspaces.json";
#[derive(serde::Serialize, serde::Deserialize)]
struct SavedWorkspaces {
#[serde(default)]
workspaces: std::collections::BTreeMap<String, dock::DockNode>,
#[serde(default)]
floating: std::collections::BTreeMap<String, Vec<dock::Floating>>,
}
/// The comp frames a playback pass covers (`first..=last`).
#[derive(Clone, Copy, Debug)]
struct FrameWindow {
first: i64,
last: i64,
}
/// The inputs of one playback pass: the viewer series it renders, the preview plan and
/// position, and the frame range and clock settings its steps share. `queued` is a snapshot of
/// the in-flight render count that each step copies. Built once in `advance_playback`.
#[derive(Clone, Copy)]
struct PlaybackPass<'a> {
ctx: &'a egui::Context,
comp: &'a effectcraft_engine::project::Comp,
cid: ItemId,
scale: f64,
now: f64,
frame_rate: effectcraft_engine::time::FrameRate,
series: FrameKey,
plan: Option<effectcraft_engine::preview::PreviewPlan>,
window: FrameWindow,
cur: i64,
video: bool,
looping: bool,
ahead: i64,
queued: usize,
}
impl EffectcraftApp {
pub fn new(session: Session) -> Self {
let mut ui = UiState::default();
let workspace_store = workspace_state::WorkspaceStore::load(session.config.as_deref(), &mut ui);
EffectcraftApp {
session,
ui,
workspace_store,
tokens: Tokens::for_kind(Default::default()),
auto: Default::default(),
frames: Frames::default(),
playback: Playback::default(),
dialog: None,
hooks: Hooks::default(),
fps: 60.0,
clock: AppClock::default(),
now: 0.0,
last_time: 0.0,
styled: false,
fonts_ready: false,
styled_language: "",
focused: true,
input_active: false,
last_pointer_down: false,
draw_geom: None,
geometry_epoch: 0,
ime_tracker: egui::ImeContextTracker::new(),
ui_pass_seq: 0,
want_paint: false,
synthetic_in_flight: false,
synthetic_in_flight_events: vec![],
last_viewer_scale: None,
control_rx: None,
deferred: vec![],
synthetic: vec![],
input_waiters: vec![],
job_waiters: vec![],
pending_screenshots: vec![],
shot_bytes: 0,
last_activity: (0.0, 0),
last_edit: (0.0, 0),
toast: None,
viewer_tex: None,
passive_tex: std::collections::HashMap::new(),
viewer_native: None,
viewer_shown: None,
viewer_image: None,
viewer_readback: Default::default(),
wgpu: None,
gpu: None,
gpu_checked: false,
gpu_failures: None,
gpu_failure_applied: None,
device_lost_close: None,
dialog_answers: std::sync::mpsc::channel(),
command_inbox: None,
pointer_comp: None,
dialog_state: Default::default(),
integrated_titlebar: false,
audio: None,
scrub: None,
meter: Default::default(),
expression_diagnostics: Default::default(),
waveforms: Default::default(),
last_reveal: None,
applied_prefs: None,
recovery: None,
dock_rects: vec![],
home_thumbs: Default::default(),
home_thumb_saved: None,
window_title: String::new(),
template_thumbs: Default::default(),
template_list: None,
}
.with_ui_commands()
}
/// 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)
}
/// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts.
fn with_ui_commands(mut self) -> Self {
let cmds = menus::UI_COMMANDS
.iter()
.map(|c| effectcraft_engine::shortcuts::UiCommand { id: c.id.into(), label: c.label.into(), shortcut: c.shortcut.map(str::to_string) })
.collect();
self.session.set_ui_commands(cmds);
self
}
/// 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);
}
}
/// 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(())
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
self
}
/// 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);
}
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(name));
self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_else(|| dock::workspace_floating(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
}
/// 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
}
/// The app clock's value at the last tick.
pub fn now(&self) -> f64 {
self.now
}
/// The clock itself (tests advance a manual clock).
pub fn clock_mut(&mut self) -> &mut AppClock {
&mut self.clock
}
/// Use a test-driven clock from now on.
pub fn use_manual_clock(&mut self) {
self.clock = AppClock::manual();
self.now = 0.0;
}
/// Completed, non-discarded UI passes since launch.
pub fn ui_passes(&self) -> u64 {
self.ui_pass_seq
}
/// The geometry of the last completed UI pass, if one ran.
pub fn draw_geometry(&self) -> Option<DrawGeometry> {
self.draw_geom
}
/// 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.floating.iter().any(|f| f.panels.contains(&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 in its usual place if it is closed,
/// without moving the focus (a comp opening brings up its viewer, an effect applied brings up
/// Effect Controls).
pub fn raise_panel(&mut self, p: PanelKind) {
if self.ui.floating.iter().any(|group| group.panels.contains(&p)) {
self.edit_layout(|layout| layout.apply(craft_ui::docking::Action::Activate { panel: p }).is_ok());
return;
}
if !self.ui.dock.contains(p) {
// The Composition panel lives in the centre, with the Layer panel and other viewers;
// with none of those left, above the Timeline.
let centre = |d: &dock::DockNode| {
let mut v = Vec::new();
d.panels(&mut v);
v.into_iter().find(|q| matches!(q, PanelKind::Layer | PanelKind::Viewer(_) | PanelKind::Flowchart | PanelKind::Footage))
};
let near = match p {
PanelKind::Composition => match centre(&self.ui.dock) {
Some(q) => q,
None if self.ui.dock.dock_panel(p, PanelKind::Timeline, dock::Zone::Top) => {
self.ui.dock.activate(p);
return;
}
None => PanelKind::EffectsPresets,
},
PanelKind::Layer | PanelKind::Flowchart | PanelKind::Viewer(_) => PanelKind::Composition,
PanelKind::RenderQueue => PanelKind::Timeline,
PanelKind::EffectControls | PanelKind::History => PanelKind::Project,
_ => PanelKind::EffectsPresets,
};
self.ui.dock.open_near(p, near);
}
self.ui.dock.activate(p);
}
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);
}
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.
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() {
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)
}
/// 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);
}
}
// ---------------------------------------------------------------- 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.contains(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.
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)
}
/// 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).
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;
}
}
}
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()
}
// ---------------------------------------------------------------- control channel
pub(crate) fn raise_for_control(&mut self, ctx: &egui::Context) {
ctx.request_repaint();
}
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));
}
}
// ---------------------------------------------------------------- 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.
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.
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;
}
}
// ---------------------------------------------------------------- frame
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);
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.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);
}
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);
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));
}
}
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));
}
}
/// 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)
}
}
impl eframe::App for EffectcraftApp {
fn on_exit(&mut self) {
self.save_workspace_state();
// Clean exit: no crash recovery next launch (unless the graphics device was lost with
// unsaved changes, which are in the auto-save it offers).
if self.device_lost_close != Some(true) {
self.session.end_recovery();
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
let injected = self.synthetic_in_flight || !self.synthetic.is_empty();
// Only a pass that can draw may receive synthetic input: `Context::run_ui` applies
// everything the hook feeds to its input state, so a pass that cannot draw (fonts still
// installing) would consume a batch it could not put back — putting it back would apply
// the click/key state twice. The queue waits here for the first pass that draws.
if self.fonts_ready {
self.feed_synthetic(raw_input);
}
if pointer_wrap::prepare_input(ctx, raw_input, injected) {
self.session.history.merge_key = None;
}
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
// Never `ctx.input(|i| i.time)` here: `Context::run_logic` leaves events, pointer state and
// time at the previous full pass's values (only the viewport information is refreshed).
self.tick(ctx, frame);
self.finish_ime_logic(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.draw(ui, frame);
}
}
/// Record the scale the viewer drew at (called by the viewer panel each pass).
///
/// Playback itself advances in [`EffectcraftApp::tick`], so it keeps running while the window is
/// hidden; using the last drawn scale keeps prefetch keys consistent with the visible viewer.
pub(crate) fn note_viewer_scale(app: &mut EffectcraftApp, scale: f64) {
app.last_viewer_scale = Some(scale);
}
/// 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.
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(())
}
/// 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).
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)));
}
}
#[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());
}
}
#[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);
}
}
#[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);
}
}
#[cfg(test)]
mod lifecycle_tests {
//! The logic-only / UI-pass split: queue work in `tick`, input, layout and setup in `draw`.
//!
//! The driver mirrors eframe's order for a hidden window: `raw_input_hook` prepares the raw
//! input, a logic-only iteration runs `tick`, and a forced pass runs `draw` with the same
//! prepared input. Nothing may depend on `current_pass_index`, which is zero in both.
use super::*;
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
fn app_with_comp() -> EffectcraftApp {
let mut session = Session::default();
session.execute("comp.new", json!({"name": "Lifecycle", "width": 64, "height": 36, "duration": 1})).unwrap();
session.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
app.ui.show_menu_bar = false;
app.use_manual_clock();
app
}
fn raw(size: egui::Vec2) -> egui::RawInput {
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }
}
fn tick_with(app: &mut EffectcraftApp, ctx: &egui::Context, frame: &eframe::Frame, raw: &egui::RawInput) -> egui::LogicOutput {
ctx.run_logic(raw, |ctx| app.tick(ctx, frame))
}
fn hidden_tick(app: &mut EffectcraftApp, ctx: &egui::Context, frame: &eframe::Frame) {
let _ = tick_with(app, ctx, frame, &raw(egui::vec2(800.0, 480.0)));
}
/// One host iteration that ends in a UI pass (the forced hidden pass, or a visible frame).
fn pass(app: &mut EffectcraftApp, ctx: &egui::Context, frame: &eframe::Frame, raw: &mut egui::RawInput) -> egui::FullOutput {
eframe::App::raw_input_hook(app, ctx, raw);
let mut out = ctx.run_ui(raw.take(), |ui| app.draw(ui, frame));
out.textures_delta.clear();
out
}
fn ready(app: &mut EffectcraftApp, ctx: &egui::Context, frame: &eframe::Frame) -> DrawGeometry {
// The first pass installs the fonts, the second draws with them.
for _ in 0..3 {
let mut raw = raw(egui::vec2(800.0, 480.0));
pass(app, ctx, frame, &mut raw);
}
assert!(app.is_ready(), "fonts are ready after the warm-up passes");
app.draw_geometry().expect("a completed pass records its geometry")
}
fn send(app: &mut EffectcraftApp, method: &str, params: serde_json::Value) -> std::sync::mpsc::Receiver<serde_json::Value> {
let (request, reply) = ControlRequest::new(method, params);
let (tx, rx) = std::sync::mpsc::channel();
tx.send(request).unwrap();
app.control_rx = Some(rx);
reply
}
#[test]
fn a_logic_only_tick_installs_no_fonts_and_records_no_geometry() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
hidden_tick(&mut app, &ctx, &frame);
assert!(!app.is_ready(), "fonts need a real pass, not a logic-only tick");
assert!(app.draw_geometry().is_none(), "a logic-only tick is not a frame");
assert_eq!(app.ui_passes(), 0);
}
#[test]
fn a_control_command_that_needs_no_ui_completes_without_a_pass() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
let reply = send(&mut app, "engine.execute", json!({"command": "comp.new", "params": {"name": "Queued", "width": 32, "height": 18, "duration": 1}}));
hidden_tick(&mut app, &ctx, &frame);
let value = reply.try_recv().expect("a command that needs no UI is answered by the tick");
assert_eq!(value["ok"], true, "{value}");
assert_eq!(app.ui_passes(), 0);
}
#[test]
fn a_pass_that_cannot_draw_receives_no_synthetic_input() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
assert!(!app.is_ready(), "a fresh app installs its fonts in its first pass");
let reply = send(&mut app, "ui.click", json!({"x": 40, "y": 40}));
hidden_tick(&mut app, &ctx, &frame);
assert!(reply.try_recv().is_err(), "a click is not answered before its UI pass");
// The font-install pass cannot draw. It must not receive the click: `run_ui` applies what
// the hook feeds, and a batch cannot be put back without applying its pointer state twice.
let mut setup = raw(egui::vec2(800.0, 480.0));
eframe::App::raw_input_hook(&mut app, &ctx, &mut setup);
assert!(setup.events.is_empty(), "a pass that cannot draw gets no synthetic input");
assert_eq!(app.synthetic.len(), 5, "the whole gesture still waits for a drawing pass");
assert!(app.pending_synthetic_events().is_empty());
let mut setup_pass = ctx.run_ui(setup.take(), |ui| app.draw(ui, &frame));
setup_pass.textures_delta.clear();
assert!(!app.is_ready(), "the pass that installs the fonts draws nothing");
// Once fonts are ready the click reaches the UI exactly once, one event per pass.
let mut passes = 0;
while reply.try_recv().is_err() {
passes += 1;
assert!(passes < 40, "the click must be applied within a few passes");
let mut raw = raw(egui::vec2(800.0, 480.0));
pass(&mut app, &ctx, &frame, &mut raw);
}
assert!(reply.try_recv().is_err(), "the reply is sent once");
assert!(app.pending_synthetic_events().is_empty());
assert!(app.synthetic.is_empty(), "every event was applied once");
}
#[test]
fn one_pending_synthetic_batch_is_never_duplicated_while_hidden() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
ready(&mut app, &ctx, &frame);
let reply = send(&mut app, "ui.click", json!({"x": 40, "y": 40}));
hidden_tick(&mut app, &ctx, &frame);
assert!(reply.try_recv().is_err(), "a click is not answered before its UI pass");
// Several hidden host iterations: eframe keeps the first prepared input; only its one
// event may reach the raw input, and a later hook must not add another.
let mut first = raw(egui::vec2(800.0, 480.0));
eframe::App::raw_input_hook(&mut app, &ctx, &mut first);
assert_eq!(first.events.len(), 1, "the first event of the gesture is fed");
let mut second = raw(egui::vec2(800.0, 480.0));
eframe::App::raw_input_hook(&mut app, &ctx, &mut second);
assert!(second.events.is_empty(), "an unapplied batch blocks the next event");
assert_eq!(app.pending_synthetic_events().len(), 1);
// The pass that applies the batch acknowledges it once; it is never fed again.
pass(&mut app, &ctx, &frame, &mut first);
assert!(app.pending_synthetic_events().is_empty());
// Drive real passes until the gesture is applied: exactly one reply, no lost events.
let mut passes = 0;
while reply.try_recv().is_err() {
passes += 1;
assert!(passes < 40, "the click must be applied within a few passes");
let mut raw = raw(egui::vec2(800.0, 480.0));
pass(&mut app, &ctx, &frame, &mut raw);
}
assert!(app.pending_synthetic_events().is_empty());
assert!(app.synthetic.is_empty());
assert!(reply.try_recv().is_err(), "the reply is sent once");
}
#[test]
fn geometry_epoch_tracks_size_and_scale_but_not_window_position() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
let first = ready(&mut app, &ctx, &frame);
let mut moved = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::pos2(120.0, 80.0), first.rect.size())), ..Default::default() };
pass(&mut app, &ctx, &frame, &mut moved);
assert_eq!(app.draw_geometry().unwrap().epoch, first.epoch, "moving the window does not change a capture");
let mut resized = raw(egui::vec2(1024.0, 640.0));
pass(&mut app, &ctx, &frame, &mut resized);
let after_resize = app.draw_geometry().unwrap();
assert!(after_resize.epoch > first.epoch, "a resize advances the epoch");
let mut back = raw(egui::vec2(800.0, 480.0));
pass(&mut app, &ctx, &frame, &mut back);
assert!(app.draw_geometry().unwrap().epoch > after_resize.epoch, "changing back is another change, not the old epoch");
}
#[test]
fn only_a_viewer_that_draws_records_a_prefetch_scale() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
ready(&mut app, &ctx, &frame);
assert!(app.last_viewer_scale.is_some(), "the visible viewer recorded the scale it drew at");
// The Home screen draws no viewer: the old comp's scale must not keep idle prefetch alive.
app.ui.start_screen = true;
let mut input = raw(egui::vec2(800.0, 480.0));
pass(&mut app, &ctx, &frame, &mut input);
assert!(app.last_viewer_scale.is_none(), "no viewer drew, so there is no scale to prefetch at");
// Back to the comp: the next pass records it again.
app.ui.start_screen = false;
let mut input = raw(egui::vec2(800.0, 480.0));
pass(&mut app, &ctx, &frame, &mut input);
assert!(app.last_viewer_scale.is_some());
}
#[test]
fn playback_advances_on_a_logic_only_tick() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
app.playback.playing = true;
app.playback.plan = Some(effectcraft_engine::preview::PreviewPlan {
start: 1,
end: 1,
first: 1,
step: 1,
fps: 24.0,
looping: false,
cache_first: false,
video: false,
audio: false,
origin: Tick::ZERO,
});
app.playback.start_wall = 0.0;
app.playback.start_frame = 1;
app.clock_mut().advance(1.0);
hidden_tick(&mut app, &ctx, &frame);
assert!(!app.playback.playing, "a one-frame preview ends without any UI pass");
assert_eq!(app.playback.audio_frame, Some(1), "the audio-only playhead reached the plan's end");
assert_eq!(app.ui_passes(), 0);
}
#[test]
fn workspace_layout_autosave_writes_on_a_hidden_tick() {
let store = Arc::new(MemoryConfig::default());
let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() });
app.ui.start_screen = false;
app.use_manual_clock();
app.ui.focused = PanelKind::Timeline;
app.clock_mut().advance(2.0);
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
hidden_tick(&mut app, &ctx, &frame);
assert!(store.read(WORKSPACES_FILE).is_some(), "the layout is saved while the window is hidden");
assert_eq!(app.ui_passes(), 0);
}
#[test]
fn the_close_guard_answers_a_close_request_without_a_pass() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
assert!(app.session.is_dirty(), "creating a comp and a layer leaves unsaved changes");
let mut raw = raw(egui::vec2(800.0, 480.0));
raw.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo { events: vec![egui::ViewportEvent::Close], ..Default::default() });
let out = tick_with(&mut app, &ctx, &frame, &raw);
assert!(app.dialog.is_some(), "a hidden close request opens the unsaved-changes prompt");
let cancel = out.viewport_commands.values().flatten().any(|c| matches!(c, egui::ViewportCommand::CancelClose));
assert!(cancel, "the window stays open while the prompt is asking");
assert_eq!(app.ui_passes(), 0);
}
// ---------------------------------------------------------------- screenshots
fn screenshot_request(
app: &mut EffectcraftApp,
ctx: &egui::Context,
path: Option<&str>,
target: ShotTarget,
) -> std::sync::mpsc::Receiver<serde_json::Value> {
let (tx, rx) = std::sync::mpsc::channel();
app.admit_screenshot(ctx, path.map(str::to_string), target, tx);
rx
}
fn shot_callbacks(out: &egui::LogicOutput) -> Vec<egui::ScreenshotCallback> {
out.viewport_commands
.values()
.flatten()
.filter_map(|c| match c {
egui::ViewportCommand::Screenshot(cb) => Some(cb.clone()),
_ => None,
})
.collect()
}
/// Three settled passes and the settle delay, then the tick that issues the capture.
fn issue_shot(app: &mut EffectcraftApp, ctx: &egui::Context, frame: &eframe::Frame) -> egui::ScreenshotCallback {
issue_shot_at(app, ctx, frame, egui::vec2(800.0, 480.0))
}
fn issue_shot_at(app: &mut EffectcraftApp, ctx: &egui::Context, frame: &eframe::Frame, size: egui::Vec2) -> egui::ScreenshotCallback {
for _ in 0..SCREENSHOT_SETTLE_PASSES {
let mut raw = raw(size);
pass(app, ctx, frame, &mut raw);
}
app.clock_mut().advance(SCREENSHOT_SETTLE_S + 0.01);
let out = tick_with(app, ctx, frame, &raw(size));
let cbs = shot_callbacks(&out);
assert_eq!(cbs.len(), 1, "one settled screenshot is issued once");
cbs.into_iter().next().unwrap()
}
fn image(width: usize, height: usize) -> Arc<egui::ColorImage> {
Arc::new(egui::ColorImage::new([width, height], vec![egui::Color32::from_rgb(10, 20, 30); width.checked_mul(height).unwrap()]))
}
#[test]
fn a_screenshot_is_issued_only_after_settled_passes_and_writes_from_the_tick() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
ready(&mut app, &ctx, &frame);
let path = std::env::temp_dir().join(format!("effectcraft-shot-{}-ok.png", std::process::id()));
let _ = std::fs::remove_file(&path);
let reply = screenshot_request(&mut app, &ctx, path.to_str(), ShotTarget::Window);
// Not settled: a tick issues nothing and asks for passes instead.
let out = tick_with(&mut app, &ctx, &frame, &raw(egui::vec2(800.0, 480.0)));
assert!(shot_callbacks(&out).is_empty(), "no capture before the UI settles");
assert_eq!(out.viewport_commands.values().flatten().filter(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)).count(), 1);
let cb = issue_shot(&mut app, &ctx, &frame);
assert!(reply.try_recv().is_err(), "the callback must not write or reply itself");
cb.complete(image(800, 480));
hidden_tick(&mut app, &ctx, &frame);
let value = reply.try_recv().expect("the tick drains the completed slot and replies");
assert_eq!(value["ok"], true, "{value}");
assert!(path.exists(), "{value}");
assert_eq!(value["result"]["width"], 800);
assert_eq!(value["result"]["height"], 480);
assert_eq!(app.shot_bytes, 0, "the reservation is released");
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_resize_between_issue_and_completion_rejects_the_pixels_and_retries() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
ready(&mut app, &ctx, &frame);
let path = std::env::temp_dir().join(format!("effectcraft-shot-{}-retry.png", std::process::id()));
let _ = std::fs::remove_file(&path);
let reply = screenshot_request(&mut app, &ctx, path.to_str(), ShotTarget::Window);
let first = issue_shot(&mut app, &ctx, &frame);
let size = egui::vec2(1024.0, 640.0);
let mut resized = raw(size);
pass(&mut app, &ctx, &frame, &mut resized);
first.complete(image(800, 480));
let _ = tick_with(&mut app, &ctx, &frame, &raw(size));
assert!(reply.try_recv().is_err(), "pixels from the old surface epoch are discarded");
assert!(!path.exists(), "a stale image is never written");
// The retry is issued against the new geometry and the new image is accepted.
let second = issue_shot_at(&mut app, &ctx, &frame, size);
second.complete(image(1024, 640));
let _ = tick_with(&mut app, &ctx, &frame, &raw(size));
let value = reply.try_recv().expect("the retried capture replies");
assert_eq!(value["ok"], true, "{value}");
assert_eq!(value["result"]["width"], 1024);
let _ = std::fs::remove_file(&path);
}
#[test]
fn screenshot_admission_bounds_requests_and_a_timeout_releases_its_reservation() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
ready(&mut app, &ctx, &frame);
let mut replies = Vec::new();
for _ in 0..MAX_PENDING_SHOTS {
replies.push(screenshot_request(&mut app, &ctx, None, ShotTarget::Rect([0.0, 0.0, 4.0, 4.0])));
}
let refused = screenshot_request(&mut app, &ctx, None, ShotTarget::Rect([0.0, 0.0, 4.0, 4.0]));
let value = refused.try_recv().expect("the fifth request is refused immediately");
assert_eq!(value["ok"], false, "{value}");
// Every queued request times out on a hidden window that presents no frame; each one
// releases its reservation exactly once.
app.clock_mut().advance(SCREENSHOT_TIMEOUT_S + 1.0);
hidden_tick(&mut app, &ctx, &frame);
for reply in replies {
let value = reply.try_recv().expect("a timed-out request gets an actionable reply");
assert_eq!(value["ok"], false, "{value}");
assert!(value["error"].as_str().is_some_and(|e| e.contains("no frame")), "{value}");
}
assert_eq!(app.shot_bytes, 0);
assert!(app.pending_screenshots.is_empty());
}
#[test]
fn a_late_callback_after_cancellation_writes_nothing_and_replies_once() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
ready(&mut app, &ctx, &frame);
let path = std::env::temp_dir().join(format!("effectcraft-shot-{}-late.png", std::process::id()));
let _ = std::fs::remove_file(&path);
let reply = screenshot_request(&mut app, &ctx, path.to_str(), ShotTarget::Window);
let late = issue_shot(&mut app, &ctx, &frame);
app.clock_mut().advance(SCREENSHOT_TIMEOUT_S + 1.0);
hidden_tick(&mut app, &ctx, &frame);
let timeout = reply.try_recv().expect("the request times out");
assert_eq!(timeout["ok"], false, "{timeout}");
late.complete(image(800, 480));
hidden_tick(&mut app, &ctx, &frame);
assert!(reply.try_recv().is_err(), "a late completion sends no second reply");
assert!(!path.exists(), "a late completion writes no file");
assert_eq!(app.shot_bytes, 0);
}
/// Every repaint delay requested on `ctx` (what a host uses to wake the app).
fn wake_requests(ctx: &egui::Context) -> Arc<std::sync::Mutex<Vec<std::time::Duration>>> {
let wakes: Arc<std::sync::Mutex<Vec<std::time::Duration>>> = Arc::default();
let record = Arc::clone(&wakes);
ctx.set_repaint_observer(move |i| record.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(i.delay));
wakes
}
#[test]
fn an_issued_capture_schedules_a_wake_up_for_its_deadline() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
ready(&mut app, &ctx, &frame);
let reply = screenshot_request(&mut app, &ctx, None, ShotTarget::Window);
for _ in 0..SCREENSHOT_SETTLE_PASSES {
let mut raw = raw(egui::vec2(800.0, 480.0));
pass(&mut app, &ctx, &frame, &mut raw);
}
app.clock_mut().advance(SCREENSHOT_SETTLE_S + 0.01);
// Record the repaint requests of the tick that issues the capture only.
let wakes = wake_requests(&ctx);
let out = tick_with(&mut app, &ctx, &frame, &raw(egui::vec2(800.0, 480.0)));
assert_eq!(shot_callbacks(&out).len(), 1, "the settled capture is issued once");
let delays = wakes.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
// The deadline is 8 s after admission; the capture was issued ~0.31 s in, so the app must
// ask for a wake-up close to that timeout, not only for the next paint.
let deadline_wake = delays.iter().any(|d| d.as_secs_f64() > 7.0 && d.as_secs_f64() <= SCREENSHOT_TIMEOUT_S);
assert!(deadline_wake, "an issued capture asks the host to wake for its timeout: {delays:?}");
// The callback never arrives (a hidden window or a display asleep). The host wakes at the
// requested delay; without this wake-up the timeout below could never be evaluated.
app.clock_mut().advance(SCREENSHOT_TIMEOUT_S + 1.0);
hidden_tick(&mut app, &ctx, &frame);
let value = reply.try_recv().expect("the deadline wake-up produces the timeout reply");
assert_eq!(value["ok"], false, "{value}");
assert!(value["error"].as_str().is_some_and(|e| e.contains("no frame")), "{value}");
assert_eq!(app.shot_bytes, 0);
assert!(app.pending_screenshots.is_empty());
}
#[test]
fn a_downscaled_capture_is_cropped_with_the_images_own_scale() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
let geom = ready(&mut app, &ctx, &frame);
assert_eq!((geom.rect.width(), geom.rect.height(), geom.ppp), (800.0, 480.0, 1.0));
// The adapter-limit fit (E2) returned half the requested pixels. Left red, right blue.
let fitted =
Arc::new(egui::ColorImage::new([400, 240], (0..400 * 240).map(|i| if i % 400 < 200 { egui::Color32::RED } else { egui::Color32::BLUE }).collect()));
let dir = std::env::temp_dir();
for (name, crop, want) in [("left", [0.0, 0.0, 400.0, 240.0], egui::Color32::RED), ("right", [400.0, 0.0, 400.0, 240.0], egui::Color32::BLUE)] {
let path = dir.join(format!("effectcraft-shot-{}-scale-{name}.png", std::process::id()));
let _ = std::fs::remove_file(&path);
let reply = screenshot_request(&mut app, &ctx, path.to_str(), ShotTarget::Rect(crop));
let cb = issue_shot(&mut app, &ctx, &frame);
cb.complete(Arc::clone(&fitted));
hidden_tick(&mut app, &ctx, &frame);
let value = reply.try_recv().expect("the downscaled capture replies");
assert_eq!(value["ok"], true, "{value}");
assert_eq!(value["result"]["width"], 200, "{value}");
assert_eq!(value["result"]["height"], 120, "{value}");
let png = std::fs::read(&path).expect("the PNG is written");
let decoded = image::load_from_memory(&png).expect("our PNG decodes").to_rgba8();
assert_eq!((decoded.width(), decoded.height()), (200, 120), "{value}");
let px = decoded.get_pixel(100, 60).0;
assert!(px[0] == want.r() && px[1] == want.g() && px[2] == want.b(), "{name}: {px:?} is not from its half of the fitted image");
let _ = std::fs::remove_file(&path);
}
}
#[test]
fn a_capture_before_any_pass_never_uses_the_window_frame_rect() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
// Only the outer (title bar included) rectangle is known: it is not the captured surface.
let outer = egui::Rect::from_min_size(egui::pos2(0.0, -30.0), egui::vec2(800.0, 510.0));
let mut raw = egui::RawInput::default();
raw.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo { outer_rect: Some(outer), ..Default::default() });
let _ = tick_with(&mut app, &ctx, &frame, &raw);
let (tx, reply) = std::sync::mpsc::channel();
app.admit_screenshot(&ctx, None, ShotTarget::Window, tx);
let value = reply.try_recv().expect("the request is answered now");
assert_eq!(value["ok"], false, "{value}");
assert!(value["error"].as_str().is_some_and(|e| e.contains("geometry is not known")), "{value}");
assert!(app.pending_screenshots.is_empty(), "nothing was reserved against the wrong rectangle");
assert_eq!(app.shot_bytes, 0);
assert!(app.want_paint, "the pass that records the geometry is requested");
}
#[test]
fn an_element_that_moves_while_the_ui_settles_is_captured_where_it_is() {
let ctx = egui::Context::default();
let frame = eframe::Frame::_new_kittest();
let mut app = app_with_comp();
let geom = ready(&mut app, &ctx, &frame);
// Any registered panel: the crop follows this element's rectangle.
let panel = app.auto.previous.iter().chain(app.auto.elements.iter()).find(|e| e.id.starts_with("panel.")).map(|e| e.id.clone());
let panel = panel.expect("a dock panel is on screen after a completed pass");
let reply = screenshot_request(&mut app, &ctx, None, ShotTarget::Id(panel.clone()));
for _ in 0..SCREENSHOT_SETTLE_PASSES {
let mut raw = raw(egui::vec2(800.0, 480.0));
pass(&mut app, &ctx, &frame, &mut raw);
}
// The panel moves after its last draw: the crop must follow it, not the admission rect.
let moved = [40.0, 30.0, 300.0, 200.0];
for list in [&mut app.auto.previous, &mut app.auto.elements] {
if let Some(e) = list.iter_mut().find(|e| e.id == panel) {
e.rect = moved;
}
}
app.clock_mut().advance(SCREENSHOT_SETTLE_S + 0.01);
let out = tick_with(&mut app, &ctx, &frame, &raw(egui::vec2(800.0, 480.0)));
assert_eq!(shot_callbacks(&out).len(), 1, "the settled capture is issued once");
let crop = app.pending_screenshots.first().and_then(|s| s.crop).expect("the element crop is resolved at issuance");
assert!((crop[0] - (moved[0] - geom.rect.min.x)).abs() < 0.01, "{crop:?}");
assert!((crop[1] - (moved[1] - geom.rect.min.y)).abs() < 0.01, "{crop:?}");
assert!((crop[2] - moved[2]).abs() < 0.01 && (crop[3] - moved[3]).abs() < 0.01, "{crop:?}");
// Let it complete and reply, so the request does not outlive the test.
if let Some(cb) = shot_callbacks(&out).into_iter().next() {
cb.complete(image(800, 480));
}
hidden_tick(&mut app, &ctx, &frame);
let value = reply.try_recv().expect("the capture replies");
assert_eq!(value["ok"], true, "{value}");
assert_eq!((value["result"]["width"].as_u64(), value["result"]["height"].as_u64()), (Some(300), Some(200)), "{value}");
}
}