mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
1242 lines
52 KiB
Rust
1242 lines
52 KiB
Rust
//! 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.
|
|
|
|
pub mod audio;
|
|
pub mod automation;
|
|
pub mod bench;
|
|
pub mod control;
|
|
pub mod dock;
|
|
pub mod dock_ui;
|
|
pub mod frames;
|
|
pub mod header;
|
|
pub mod icons;
|
|
pub mod menus;
|
|
pub mod native_menu;
|
|
pub mod panels;
|
|
pub mod prefs_live;
|
|
pub mod state;
|
|
pub mod theme;
|
|
pub mod widgets;
|
|
|
|
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;
|
|
|
|
const SCREENSHOT_TIMEOUT_S: f64 = 4.0;
|
|
|
|
/// 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,
|
|
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,
|
|
}
|
|
|
|
/// Host hooks provided by the native app (file pickers etc.).
|
|
#[derive(Default)]
|
|
pub struct Hooks {
|
|
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, extension).
|
|
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>>,
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
pub struct EffectcraftApp {
|
|
pub session: Session,
|
|
pub ui: UiState,
|
|
pub tokens: Tokens,
|
|
pub auto: automation::Registry,
|
|
pub frames: Frames,
|
|
pub playback: Playback,
|
|
pub dialog: Option<Dialog>,
|
|
pub hooks: Hooks,
|
|
pub fps: f32,
|
|
last_time: f64,
|
|
styled: bool,
|
|
fonts_ready: bool,
|
|
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>)>,
|
|
queued_screenshots: Vec<(u64, f64, u32)>,
|
|
pending_screenshots: Vec<(u64, Option<String>, Option<[f32; 4]>, Sender<serde_json::Value>, f64)>,
|
|
next_token: u64,
|
|
pub(crate) last_ui_time: f64,
|
|
pub(crate) toast: Option<(String, f64)>,
|
|
/// Texture of the last CPU frame shown in the viewer and the key it came from.
|
|
pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey)>,
|
|
/// 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,
|
|
/// 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 panel VU meters.
|
|
pub meter: audio::Meter,
|
|
/// Waveform peak summaries per footage item (see `panels::waveform`).
|
|
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.
|
|
applied_prefs: Option<u64>,
|
|
/// 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)>,
|
|
/// 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>)>,
|
|
}
|
|
|
|
impl EffectcraftApp {
|
|
pub fn new(session: Session) -> Self {
|
|
EffectcraftApp {
|
|
session,
|
|
ui: UiState::default(),
|
|
tokens: Tokens::for_kind(Default::default()),
|
|
auto: Default::default(),
|
|
frames: Frames::default(),
|
|
playback: Playback::default(),
|
|
dialog: None,
|
|
hooks: Hooks::default(),
|
|
fps: 60.0,
|
|
last_time: 0.0,
|
|
styled: false,
|
|
fonts_ready: false,
|
|
control_rx: None,
|
|
deferred: vec![],
|
|
synthetic: vec![],
|
|
input_waiters: vec![],
|
|
job_waiters: vec![],
|
|
queued_screenshots: vec![],
|
|
pending_screenshots: vec![],
|
|
next_token: 1,
|
|
last_ui_time: 0.0,
|
|
toast: None,
|
|
viewer_tex: None,
|
|
viewer_native: None,
|
|
viewer_shown: None,
|
|
viewer_image: None,
|
|
viewer_readback: Default::default(),
|
|
wgpu: None,
|
|
gpu: None,
|
|
gpu_checked: false,
|
|
command_inbox: None,
|
|
pointer_comp: None,
|
|
dialog_state: Default::default(),
|
|
integrated_titlebar: false,
|
|
audio: None,
|
|
meter: Default::default(),
|
|
waveforms: Default::default(),
|
|
last_reveal: None,
|
|
applied_prefs: None,
|
|
recovery: None,
|
|
dock_rects: vec![],
|
|
home_thumbs: Default::default(),
|
|
home_thumb_saved: None,
|
|
template_thumbs: Default::default(),
|
|
template_list: None,
|
|
}
|
|
.with_ui_commands()
|
|
}
|
|
|
|
/// 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) {
|
|
if self.applied_prefs == Some(self.session.prefs_revision) {
|
|
return;
|
|
}
|
|
self.applied_prefs = Some(self.session.prefs_revision);
|
|
let p = &self.session.prefs;
|
|
self.ui.theme = theme::ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
|
|
self.tokens = Tokens::from_prefs(p);
|
|
theme::apply_visuals(ctx, &self.tokens);
|
|
let tips = p.general.show_tool_tips;
|
|
ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY });
|
|
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
|
|
}
|
|
|
|
pub fn set_theme(&mut self, ctx: &egui::Context, k: theme::ThemeKind) {
|
|
let name = match k {
|
|
theme::ThemeKind::Dark => "dark",
|
|
theme::ThemeKind::Darker => "darker",
|
|
theme::ThemeKind::Light => "light",
|
|
};
|
|
let _ = self.set_pref("appearance.theme", json!(name));
|
|
self.apply_prefs(ctx);
|
|
}
|
|
|
|
pub fn set_workspace(&mut self, name: &str) {
|
|
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;
|
|
// Learn: the Home screen's Learn tab (tutorials) in the Composition panel.
|
|
if name == "Learn" {
|
|
self.ui.start_screen = true;
|
|
self.ui.home_learn = true;
|
|
self.ui.home_templates = false;
|
|
}
|
|
}
|
|
|
|
/// 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.ui.saved_workspaces.keys().filter(|k| !dock::WORKSPACES.contains(&k.as_str())).cloned());
|
|
v
|
|
}
|
|
|
|
pub fn show_panel(&mut self, p: PanelKind) {
|
|
if let Some(f) = self.ui.floating.iter_mut().find(|f| f.panels.contains(&p)) {
|
|
f.active = f.panels.iter().position(|x| *x == p).unwrap_or(0);
|
|
self.ui.focused = p;
|
|
return;
|
|
}
|
|
if self.ui.maximized.is_some_and(|m| m != p) {
|
|
self.ui.maximized = None;
|
|
}
|
|
if !self.ui.dock.contains(p) {
|
|
let near = match p {
|
|
PanelKind::Layer | PanelKind::Flowchart => 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);
|
|
self.ui.focused = 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()
|
|
}
|
|
|
|
/// 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;
|
|
let Some(rs) = frame.wgpu_render_state() else { return };
|
|
match effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()) {
|
|
Ok(g) => {
|
|
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());
|
|
}
|
|
Err(e) => log::info!("GPU compositor unavailable: {e}"),
|
|
}
|
|
}
|
|
|
|
/// 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 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.chunks_exact(4).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 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
|
|
{
|
|
let r = match f {
|
|
1 => state::Resolution::Full,
|
|
2 => state::Resolution::Half,
|
|
3 => state::Resolution::Third,
|
|
4 => state::Resolution::Quarter,
|
|
n => state::Resolution::Custom(n.min(40) as u8),
|
|
};
|
|
return r.scale(zoom, ppp);
|
|
}
|
|
if self.playback.playing && self.ui.viewer.res == state::Resolution::Auto {
|
|
// Keep previews snappy: half the displayed size, but no lower than Settings ▸
|
|
// Previews ▸ Adaptive Resolution Limit.
|
|
let full = self.ui.viewer.res.scale(zoom, ppp);
|
|
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
|
|
}
|
|
let full = self.ui.viewer.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);
|
|
if k < 1.0 && self.ui.viewer.res == state::Resolution::Auto {
|
|
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
|
|
}
|
|
full
|
|
}
|
|
|
|
pub fn frame_key(&self, comp: ItemId, frame: i64, scale: f64) -> FrameKey {
|
|
FrameKey { revision: self.session.revision, comp: comp.0, frame, scale: (scale * 1000.0).round() as u32, view: self.view_hash(comp) }
|
|
}
|
|
|
|
/// 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);
|
|
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);
|
|
if preset.full_screen && self.ui.dock.contains(PanelKind::Composition) {
|
|
self.ui.maximized = Some(PanelKind::Composition);
|
|
}
|
|
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),
|
|
};
|
|
if !self.playback.caching {
|
|
self.start_audio(fr.tick_of(plan.first));
|
|
}
|
|
}
|
|
|
|
/// Start audio preview from comp time `t` when the preview includes audio at the comp's
|
|
/// frame rate, the comp has something audible and an output device opens.
|
|
fn start_audio(&mut self, t: Tick) {
|
|
self.audio = None;
|
|
self.meter.clipped = [false; 2];
|
|
let Some(pl) = self.playback.plan else { return };
|
|
if !pl.audio {
|
|
return;
|
|
}
|
|
let Some(cid) = self.session.active_comp_id() else { return };
|
|
let Some(c) = self.session.project.comp_arc(cid) else { return };
|
|
// A preview at another frame rate plays slower or faster than real time: silent.
|
|
if (pl.fps - c.frame_rate.as_f64()).abs() > 0.01 {
|
|
return;
|
|
}
|
|
if !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) {
|
|
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}"),
|
|
}
|
|
}
|
|
|
|
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 mut last = None;
|
|
let mut f = pl.first;
|
|
while f <= pl.end && self.frames.is_cached(&self.frame_key(cid, f, scale)) {
|
|
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
|
|
}
|
|
|
|
/// 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) {
|
|
let Some(cid) = self.session.active_comp_id() else { return };
|
|
let Some(c) = self.session.project.comp_arc(cid) else { return };
|
|
let now = ctx.input(|i| i.time);
|
|
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());
|
|
// While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few
|
|
// frames, and only once it is done, so prefetch never delays what is on screen.
|
|
let ahead = if self.playback.playing {
|
|
(self.frames_parallelism() * 2).max(4) as i64
|
|
} else if self.frames.urgent_pending() {
|
|
0
|
|
} else {
|
|
(self.frames_parallelism() / 2).max(2) as i64
|
|
};
|
|
let mut queued = self.frames.inflight();
|
|
if self.playback.caching
|
|
&& let Some(p) = plan
|
|
{
|
|
// Cache Before Playback: the whole range in play order, then start the clock.
|
|
let mut all = true;
|
|
let n = (p.end - p.start) / p.step + 1;
|
|
let order: Vec<i64> = (0..n).filter_map(|k| p.frame_after(p.first, k)).collect();
|
|
for f in order {
|
|
let key = self.frame_key(cid, f, scale);
|
|
if self.frames.is_cached(&key) {
|
|
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();
|
|
return;
|
|
}
|
|
let step = plan.map_or(1, |p| p.step);
|
|
for k in 0..ahead * 3 {
|
|
if queued >= ahead as usize || !video {
|
|
break;
|
|
}
|
|
let f = match plan {
|
|
Some(p) => match p.frame_after(cur, k) {
|
|
Some(f) => f,
|
|
None => break,
|
|
},
|
|
None => {
|
|
let mut f = cur + k;
|
|
if f > wb {
|
|
f = wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1));
|
|
}
|
|
f
|
|
}
|
|
};
|
|
let key = self.frame_key(cid, f, scale);
|
|
if !self.frames.is_cached(&key) {
|
|
self.request_frame(cid, f, scale);
|
|
queued += 1;
|
|
}
|
|
}
|
|
if let Some(a) = &mut self.audio {
|
|
a.pump();
|
|
self.meter.update(a.feed.take_peaks(), now);
|
|
} else if self.meter.active() {
|
|
self.meter.update([0.0; 2], now);
|
|
ctx.request_repaint();
|
|
}
|
|
if !self.playback.playing {
|
|
return;
|
|
}
|
|
let snap = |f: i64| wa + (f - wa).div_euclid(step) * step;
|
|
// Audio clock drives playback: show the frame under the audible sample (frames that
|
|
// are not rendered yet are dropped, never delayed).
|
|
if let Some(a) = &self.audio {
|
|
if a.finished() {
|
|
if video {
|
|
self.session.set_time(fr.tick_of(wb));
|
|
} else {
|
|
self.playback.audio_frame = Some(wb);
|
|
}
|
|
self.stop();
|
|
return;
|
|
}
|
|
let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb));
|
|
if !video {
|
|
self.playback.audio_frame = Some(target);
|
|
} else {
|
|
self.playback.waiting = !self.frames.is_cached(&self.frame_key(cid, target, scale));
|
|
if target != cur {
|
|
self.session.set_time(fr.tick_of(target));
|
|
self.playback.shown += 1;
|
|
}
|
|
}
|
|
ctx.request_repaint();
|
|
return;
|
|
}
|
|
let elapsed = now - self.playback.start_wall;
|
|
let n = ((elapsed * self.playback.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;
|
|
}
|
|
let key = self.frame_key(cid, target, scale);
|
|
if self.frames.is_cached(&key) {
|
|
self.playback.waiting = false;
|
|
if target != cur {
|
|
self.session.set_time(fr.tick_of(target));
|
|
self.playback.shown += 1;
|
|
}
|
|
} 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(&self.frame_key(cid, next, scale)) {
|
|
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();
|
|
}
|
|
|
|
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);
|
|
}
|
|
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- control channel
|
|
|
|
pub(crate) fn raise_for_control(&mut self, ctx: &egui::Context) {
|
|
ctx.request_repaint();
|
|
}
|
|
|
|
fn drain_control(&mut self, ctx: &egui::Context) {
|
|
let Some(rx) = self.control_rx.take() else { return };
|
|
let now = ctx.input(|i| i.time);
|
|
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) {
|
|
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),
|
|
control::Outcome::AwaitJobs(w) => {
|
|
self.job_waiters.push((w, reply));
|
|
ctx.request_repaint();
|
|
}
|
|
control::Outcome::Screenshot { path, crop } => {
|
|
// A covered or minimized window presents no frames (macOS skips its redraws), so
|
|
// a screenshot would never arrive and nothing would tick its timeout.
|
|
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;
|
|
}
|
|
let token = self.next_token;
|
|
self.next_token += 1;
|
|
let settle = now + 0.35;
|
|
self.queued_screenshots.push((token, settle, 0));
|
|
self.pending_screenshots.push((token, path, crop, reply, settle + SCREENSHOT_TIMEOUT_S));
|
|
}
|
|
}
|
|
}
|
|
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));
|
|
}
|
|
}
|
|
|
|
fn issue_screenshots(&mut self, ctx: &egui::Context) {
|
|
let now = ctx.input(|i| i.time);
|
|
let mut any = false;
|
|
let busy = self.frames.inflight() > 0;
|
|
self.queued_screenshots.retain_mut(|(token, at, frames)| {
|
|
*frames += 1;
|
|
any = true;
|
|
// Wait for the viewer's frame to finish rendering (bounded by the timeout below).
|
|
if now >= *at && *frames >= 3 && (!busy || now > *at + 2.5) {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
});
|
|
self.pending_screenshots.retain(|(.., reply, deadline)| {
|
|
if now > *deadline {
|
|
let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden or display asleep)"}));
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
});
|
|
if any {
|
|
ctx.request_repaint();
|
|
} else if !self.pending_screenshots.is_empty() {
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
|
}
|
|
}
|
|
|
|
fn collect_screenshots(&mut self, ctx: &egui::Context) {
|
|
if self.pending_screenshots.is_empty() {
|
|
return;
|
|
}
|
|
let events: Vec<_> = ctx.input(|i| {
|
|
i.raw
|
|
.events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
egui::Event::Screenshot { user_data, image, .. } => {
|
|
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
|
|
Some((token, image.clone()))
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
});
|
|
for (token, image) in events {
|
|
if let Some(i) = self.pending_screenshots.iter().position(|(t, ..)| *t == token) {
|
|
let (_, path, crop, reply, _) = self.pending_screenshots.remove(i);
|
|
let r = control::save_screenshot(ctx, &image, path.as_deref(), crop);
|
|
let _ = reply.send(r);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- frame
|
|
|
|
fn handle_events(&mut self, ctx: &egui::Context) {
|
|
for ev in self.session.drain_events() {
|
|
match ev {
|
|
effectcraft_engine::Event::OpenComp(_) => {
|
|
if !self.ui.locked_tabs.contains(&PanelKind::Composition.id()) {
|
|
self.ui.dock.activate(PanelKind::Composition);
|
|
}
|
|
self.ui.timeline.pps = None;
|
|
}
|
|
effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))),
|
|
effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn frame(&mut self, ui: &mut egui::Ui) {
|
|
let ctx = ui.ctx().clone();
|
|
self.auto.begin_frame();
|
|
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).
|
|
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));
|
|
}
|
|
// 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.apply_prefs(&ctx);
|
|
self.handle_events(&ctx);
|
|
self.tick_autosave(&ctx);
|
|
prefs_live::frame(self, &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);
|
|
}
|
|
menus::handle_shortcuts(self, &ctx);
|
|
let t = self.tokens;
|
|
let full = ui.max_rect();
|
|
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);
|
|
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);
|
|
self.draw_toast(ui, full);
|
|
}
|
|
|
|
fn draw_toast(&mut self, ui: &mut egui::Ui, full: egui::Rect) {
|
|
let now = ui.input(|i| i.time);
|
|
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, 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), galley.size() + egui::vec2(24.0, 14.0));
|
|
ui.painter().rect_filled(r, 6.0, egui::Color32::from_rgba_premultiplied(30, 30, 30, 235));
|
|
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);
|
|
let resp = ui.interact(r, egui::Id::new("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::new("status-since")).unwrap_or(now));
|
|
ui.data_mut(|d| d.insert_temp(egui::Id::new("status-since"), since));
|
|
if now - since > 5.0 {
|
|
self.ui.status.clear();
|
|
ui.data_mut(|d| d.remove::<f64>(egui::Id::new("status-since")));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl EffectcraftApp {
|
|
/// Auto-save a dirty project when the interval has passed (Settings ▸ Project ▸ Auto-Save).
|
|
fn tick_autosave(&mut self, ctx: &egui::Context) {
|
|
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);
|
|
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::new("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));
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
// Clean exit: no crash recovery next launch.
|
|
self.session.end_recovery();
|
|
}
|
|
|
|
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
|
if !self.synthetic.is_empty() {
|
|
// 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 })
|
|
};
|
|
raw_input.events.extend(self.synthetic.drain(..n));
|
|
}
|
|
}
|
|
|
|
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
if !self.styled {
|
|
theme::install(ctx, &self.tokens);
|
|
self.styled = true;
|
|
ctx.request_repaint();
|
|
} else {
|
|
self.fonts_ready = true;
|
|
}
|
|
let now = ctx.input(|i| i.time);
|
|
let dt = (now - self.last_time) as f32;
|
|
if dt > 0.0 {
|
|
self.fps = self.fps * 0.9 + (1.0 / dt).min(480.0) * 0.1;
|
|
}
|
|
self.last_time = now;
|
|
self.drain_control(ctx);
|
|
if !self.synthetic.is_empty() {
|
|
ctx.request_repaint();
|
|
} else if !self.input_waiters.is_empty() {
|
|
for w in self.input_waiters.drain(..) {
|
|
let _ = w.send(json!({"ok": true, "result": null}));
|
|
}
|
|
}
|
|
self.issue_screenshots(ctx);
|
|
self.collect_screenshots(ctx);
|
|
}
|
|
|
|
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
|
self.init_gpu(frame);
|
|
if !self.fonts_ready {
|
|
ui.ctx().request_repaint();
|
|
return;
|
|
}
|
|
self.frame(ui);
|
|
let ctx = ui.ctx().clone();
|
|
// 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();
|
|
}
|
|
self.last_ui_time = ctx.input(|i| i.time);
|
|
if !self.synthetic.is_empty() {
|
|
ctx.request_repaint();
|
|
} else if !self.input_waiters.is_empty() {
|
|
ctx.request_repaint();
|
|
for w in self.input_waiters.drain(..) {
|
|
let _ = w.send(json!({"ok": true, "result": null}));
|
|
}
|
|
}
|
|
if self.frames.inflight() > 0 {
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Advance playback with the viewer's scale (called by the viewer panel each frame).
|
|
pub(crate) fn tick_playback(app: &mut EffectcraftApp, ctx: &egui::Context, scale: f64) {
|
|
app.advance_playback(ctx, scale);
|
|
}
|