2077 lines
97 KiB
Rust
2077 lines
97 KiB
Rust
//! The FilmCraft egui frontend.
|
||
//!
|
||
//! Thin by design: all project changes go through `filmcraft_engine::Session::execute`; this crate
|
||
//! owns only presentation state ([`state::UiState`]), GPU textures, the playback clock and the
|
||
//! control-channel handlers. Swap it for another toolkit without touching the engine.
|
||
|
||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||
|
||
/// A literal UI string in the language the interface is drawn in (`i18n::t`). Only literals: the
|
||
/// i18n tests read every `tl!` literal in the crate and require a Spanish translation for it.
|
||
macro_rules! tl {
|
||
($s:literal) => {
|
||
$crate::i18n::t($s)
|
||
};
|
||
}
|
||
|
||
/// A translated template with `{name}` placeholders filled in, for text that `format!` would build.
|
||
/// After the template literal come the values: `n = count`, or just `gain` for a variable of the
|
||
/// same name. Values are formatted with `Display`; the translation may reorder placeholders.
|
||
macro_rules! tlf {
|
||
(@v $k:ident = $v:expr) => {
|
||
$v
|
||
};
|
||
(@v $k:ident) => {
|
||
$k
|
||
};
|
||
($s:literal $(, $k:ident $(= $v:expr)?)* $(,)?) => {
|
||
$crate::i18n::fmt($crate::i18n::t($s), &[$((stringify!($k), ToString::to_string(&tlf!(@v $k $(= $v)?)).as_str())),*])
|
||
};
|
||
}
|
||
|
||
pub mod automation;
|
||
pub mod brand;
|
||
mod cjk;
|
||
pub mod control;
|
||
pub mod crash;
|
||
pub mod credits;
|
||
pub mod dock;
|
||
pub mod frames;
|
||
pub mod header;
|
||
pub mod i18n;
|
||
pub mod icons;
|
||
pub mod links;
|
||
pub mod menus;
|
||
pub mod panels;
|
||
pub mod perf;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub mod play_ahead;
|
||
pub mod scrub;
|
||
pub mod source_playback;
|
||
pub mod state;
|
||
pub mod theme;
|
||
pub mod widgets;
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::Arc;
|
||
use std::sync::mpsc::{Receiver, Sender};
|
||
|
||
use egui::{TextureHandle, TextureOptions};
|
||
use filmcraft_engine::{Services, Session};
|
||
use filmcraft_time::Tick;
|
||
use serde_json::{Value, json};
|
||
|
||
pub use control::ControlRequest;
|
||
use dock::PanelKind;
|
||
use frames::{FrameKey, FrameServer, Target};
|
||
use state::UiState;
|
||
use theme::{ThemeKind, Tokens};
|
||
|
||
/// Audio output provided by the platform layer (cpal on desktop, WebAudio on web).
|
||
pub trait AudioOut {
|
||
/// Start output; `fill(buffer, channels)` is called on the audio thread with interleaved f32.
|
||
fn start(&mut self, fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String>;
|
||
fn stop(&mut self);
|
||
/// Device sample rate.
|
||
fn sample_rate(&self) -> u32;
|
||
/// Device output channels (what `fill` will be called with).
|
||
fn channels(&self) -> usize {
|
||
2
|
||
}
|
||
/// Frames played since `start` (the playback master clock), if the device reports it.
|
||
fn played_frames(&self) -> Option<u64>;
|
||
/// Hosts and devices that can be chosen in Settings ▸ Audio Hardware.
|
||
fn devices(&self) -> AudioDevices {
|
||
AudioDevices::default()
|
||
}
|
||
/// Apply Settings ▸ Audio Hardware (device class/output, buffer size, sample rate). Takes
|
||
/// effect at the next `start`; `document_rate` is the sequence sample rate for "Attempt to
|
||
/// force hardware to document sample rate".
|
||
fn configure(&mut self, _hw: &filmcraft_engine::settings::AudioHardwarePrefs, _document_rate: Option<u32>) {}
|
||
/// A hardware note for the status bar — e.g. the default output device could not be opened
|
||
/// and a fallback device is in use, or no output device was found — or None.
|
||
fn note(&self) -> Option<String> {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// What the platform audio layer can open (Settings ▸ Audio Hardware).
|
||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct AudioDevices {
|
||
/// Audio hosts / device classes (`CoreAudio`, `WASAPI`, `ALSA`…).
|
||
pub hosts: Vec<String>,
|
||
pub inputs: Vec<String>,
|
||
pub outputs: Vec<String>,
|
||
/// Output channels of the selected output device.
|
||
pub output_channels: u16,
|
||
}
|
||
|
||
/// Host hooks for native file dialogs etc.
|
||
#[derive(Default)]
|
||
pub struct HostHooks {
|
||
pub pick_files: Option<Box<dyn FnMut(&[&str]) -> Vec<String>>>,
|
||
/// The cursor in physical screen pixels. Where the windowing layer reports no pointer while files are dragged in from the OS (Windows), a drop is placed where this says it landed.
|
||
pub cursor_screen_position: Option<Box<dyn Fn() -> Option<(i32, i32)>>>,
|
||
pub pick_save: Option<Box<dyn FnMut(&str) -> Option<String>>>,
|
||
pub pick_open_project: Option<Box<dyn FnMut() -> Option<String>>>,
|
||
/// Save dialog with a filter: (filter name, extensions, suggested file name) → path.
|
||
pub pick_save_as: Option<Box<dyn FnMut(&str, &[&str], &str) -> Option<String>>>,
|
||
/// The active keyboard shortcuts changed: update native menu key equivalents.
|
||
pub shortcuts_changed: Option<Box<dyn FnMut(&[menus::MenuItem])>>,
|
||
/// The user's preferred languages (locale tags, most preferred first) for Interface Language ▸
|
||
/// System Language. Without it, System Language is English.
|
||
pub system_languages: Option<Box<dyn Fn() -> Vec<String>>>,
|
||
/// Open dialog for a JSON file (shortcut preset import): filter name, extensions → path.
|
||
pub pick_open_file: Option<Box<dyn FnMut(&str, &[&str]) -> Option<String>>>,
|
||
/// Folder picker (Link Media search, proxy and Project Manager destinations).
|
||
pub pick_folder: Option<Box<dyn FnMut() -> Option<String>>>,
|
||
/// Folder picker starting at the current destination (Export Frame).
|
||
pub pick_folder_at: Option<Box<dyn FnMut(&str) -> Option<String>>>,
|
||
/// Pick one file for a command that relinks to it (Link Media ▸ Locate…, Attach Proxies,
|
||
/// Reconnect Full Resolution) instead of importing it, as [`Self::pick_files`] does. Native
|
||
/// hosts return the path. A host whose picker is asynchronous (the web) returns `None` and
|
||
/// runs `hint.command` with `hint.params` plus `"path"` itself once the user has chosen.
|
||
pub pick_file_for_relink: Option<Box<dyn FnMut(&[&str], Option<RelinkHint>) -> Option<String>>>,
|
||
/// Bring the window on screen for control-channel UI requests *without* taking keyboard focus
|
||
/// (macOS: `orderFrontRegardless`). Without it the app only requests a repaint: it never
|
||
/// activates itself for an agent, because the user's keystrokes would land here.
|
||
pub raise_without_focus: Option<Box<dyn FnMut()>>,
|
||
/// Open a file in its default application, or (`true`) reveal it in the file manager (Edit ▸
|
||
/// Edit Original, Help ▸ Reveal Log Files).
|
||
pub open_path: Option<Box<dyn FnMut(&str, bool) -> Result<(), String>>>,
|
||
/// The operating system's light or dark appearance when egui cannot report it (Linux desktops
|
||
/// whose Wayland compositor sends no theme to winit). Without it, or without an answer, Auto
|
||
/// uses `egui::Context::system_theme`.
|
||
pub system_theme: Option<SystemThemeFn>,
|
||
}
|
||
|
||
/// Reads the system appearance (Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system).
|
||
pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
|
||
|
||
/// The command a [`HostHooks::pick_file_for_relink`] caller runs with the chosen file, for hosts
|
||
/// that can only run it later.
|
||
#[derive(Clone, Debug)]
|
||
pub struct RelinkHint {
|
||
/// `media.relink`, `media.attachProxies` or `media.reconnectFullRes`.
|
||
pub command: String,
|
||
/// Its parameters, without `"path"`.
|
||
pub params: serde_json::Value,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Dialog {
|
||
About,
|
||
Shortcuts,
|
||
NewSequence,
|
||
/// Settings (Preferences window; page in `UiState::settings`).
|
||
Preferences,
|
||
/// "Recover unsaved changes from <time>?" (shown at startup when a dead session left some).
|
||
Recovery,
|
||
/// "Are you sure you want to discard your changes?" (File ▸ Revert).
|
||
RevertConfirm,
|
||
/// Clip ▸ Audio Gain… (G).
|
||
AudioGain,
|
||
/// Sequence ▸ Delete Tracks….
|
||
DeleteTracks,
|
||
/// Sequence ▸ Add Tracks….
|
||
AddTracks,
|
||
/// Sequence ▸ Sequence Settings….
|
||
SequenceSettings,
|
||
/// Set Transition Duration (double-click a transition in the Timeline).
|
||
TransitionDuration,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct Playback {
|
||
pub playing: bool,
|
||
pub speed: f64,
|
||
pub looping: bool,
|
||
/// Wall-clock (egui time, s) and timeline tick when playback (re)started.
|
||
anchor_time: f64,
|
||
anchor_tick: Tick,
|
||
/// Audio frames played at anchor (when the audio clock drives).
|
||
pub audio_clock: bool,
|
||
/// The audio clock's last reading and when (egui time, s) it last moved: a device that stops
|
||
/// consuming samples hands the clock back to the wall clock (see [`AUDIO_STALL_S`]).
|
||
audio_seen: (u64, f64),
|
||
/// Audio underruns for the current (or last) play (desktop: sound is mixed ahead).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub audio_stats: std::sync::Arc<play_ahead::AudioStats>,
|
||
/// Shown / dropped frame accounting for the current (or last) play.
|
||
pub meter: frames::PlaybackMeter,
|
||
/// Waiting for the first frames before starting the clock: when the wait began (egui time,
|
||
/// s; negative = not yet stamped), and whether the Program monitor has them ready.
|
||
pub preroll: Option<f64>,
|
||
pub preroll_ready: bool,
|
||
/// The window was hidden (occluded/minimized) since the monitor last refreshed.
|
||
pub hidden: bool,
|
||
/// Forward playback stops here (Play In to Out, Play from Playhead to Out Point).
|
||
pub stop_at: Option<Tick>,
|
||
}
|
||
|
||
/// How long (s) the audio clock may stand still during playback before the wall clock takes over.
|
||
/// An output stream can open and then never call back (ALSA with a busy or misconfigured device,
|
||
/// #136); playback must not freeze on it. Devices that are slow to start (Bluetooth) stay well
|
||
/// under this.
|
||
pub const AUDIO_STALL_S: f64 = 1.0;
|
||
|
||
/// How long `ui.screenshot` waits for the window to present the frame.
|
||
const SCREENSHOT_TIMEOUT_S: f64 = 10.0;
|
||
/// How long `ui.screenshot` waits for the UI to settle (monitors showing their exact frames, the
|
||
/// timeline zoom animation finished) before capturing what is there.
|
||
const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0;
|
||
|
||
/// Export entries that run from the menus through the save panel: command, filter label,
|
||
/// extension.
|
||
pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 7] = [
|
||
("markers.exportCsv", "Marker report (CSV)", "csv"),
|
||
("file.exportEdl", "EDL", "edl"),
|
||
("file.exportFcp7Xml", "Final Cut Pro XML", "xml"),
|
||
("file.exportFcpxml", "FCPXML", "fcpxml"),
|
||
("file.exportOtio", "OpenTimelineIO", "otio"),
|
||
("file.exportAle", "Avid Log Exchange", "ale"),
|
||
("file.exportSelectionProject", "FilmCraft Project", "fcproj"),
|
||
];
|
||
|
||
pub struct FilmcraftApp {
|
||
pub session: Session,
|
||
pub ui: UiState,
|
||
pub tokens: Tokens,
|
||
pub frames: Arc<FrameServer>,
|
||
pub playback: Playback,
|
||
pub source_playback: source_playback::SourcePlayback,
|
||
pub audio: Option<Box<dyn AudioOut>>,
|
||
/// Audio during scrubbing (#211).
|
||
pub scrub: scrub::ScrubAudio,
|
||
pub hooks: HostHooks,
|
||
pub dialog: Option<Dialog>,
|
||
pub file_dialogs: panels::file_dialogs::FileDialogState,
|
||
pub auto: automation::Registry,
|
||
/// Named textures (monitors, thumbnails) with the key they show.
|
||
textures: HashMap<String, (FrameKey, TextureHandle)>,
|
||
control_rx: Option<Receiver<ControlRequest>>,
|
||
/// Requests waiting for the UI to show an element: (request, give-up time).
|
||
deferred: Vec<(ControlRequest, f64)>,
|
||
last_ui_time: f64,
|
||
pub(crate) synthetic: Vec<egui::Event>,
|
||
/// BS.1770 loudness of the programme as it plays, and the next sample position to feed.
|
||
pub(crate) loudness: Option<(filmcraft_audio_dsp::LoudnessMeter, i64)>,
|
||
/// Status message last shown and when it first appeared (messages expire after a few seconds).
|
||
status_seen: (String, f64),
|
||
/// Screenshots waiting for their frame: (token, path, crop, reply, give-up time).
|
||
pending_screenshots: Vec<(u64, Option<String>, Option<[f32; 4]>, Sender<Value>, f64)>,
|
||
queued_screenshots: Vec<(u64, f64, u32)>,
|
||
/// A paused monitor drew a stand-in (nearest cached) picture last frame: its exact frame is
|
||
/// still decoding.
|
||
pub(crate) monitor_inexact: bool,
|
||
/// Per monitor: the frames it asked for on its latest paused passes, oldest first (stand-ins
|
||
/// while the newest renders, see `panels::monitor`).
|
||
pub(crate) monitor_asked: HashMap<String, std::collections::VecDeque<FrameKey>>,
|
||
/// The frame whose picture the Program monitor drew last: the frame due, or its stand-in.
|
||
pub(crate) program_shown: Option<FrameKey>,
|
||
/// Consecutive frames the timeline zoom / scroll has been at rest. `ui.elements` answers from
|
||
/// the frame before the last one, so its timeline rects are final from 2 on.
|
||
pub(crate) timeline_still: u32,
|
||
input_waiters: Vec<Sender<Value>>,
|
||
next_token: u64,
|
||
styled: bool,
|
||
/// A panic in the UI pass, shown in an error window until dismissed (see [`crash`]).
|
||
pub ui_error: Option<String>,
|
||
/// Fault injection for robustness tests: the next UI pass panics.
|
||
#[doc(hidden)]
|
||
pub panic_next_frame: bool,
|
||
fonts_ready: bool,
|
||
pub integrated_titlebar: bool,
|
||
pub last_timeline_width: f32,
|
||
/// The sequence whose view `ui.timeline` holds, and that view as it was last exchanged with
|
||
/// `session.state.timeline_views` (see `sync_timeline_view`).
|
||
timeline_view_of: Option<filmcraft_engine::project::ItemId>,
|
||
/// Items imported by an OS file drop, to place on the Timeline at the drop point this frame.
|
||
pending_timeline_drop: Option<(Vec<filmcraft_engine::project::ItemId>, egui::Pos2)>,
|
||
/// Files being dragged over the window from the OS (count, pointer): the Timeline previews where they would land.
|
||
file_drag_hover: Option<(usize, egui::Pos2)>,
|
||
timeline_view_last: Option<filmcraft_engine::project::SequenceView>,
|
||
pub fps: f32,
|
||
last_time: f64,
|
||
bindings: Vec<menus::KeyBinding>,
|
||
/// Shortcut-set revision `bindings` (and the native menu) were built from.
|
||
bindings_rev: u64,
|
||
/// Keyboard Shortcuts dialog state.
|
||
pub shortcut_editor: panels::shortcuts_dialog::EditorState,
|
||
pub toast: Option<(String, f64)>,
|
||
pub tl: panels::timeline::TlState,
|
||
/// Commands from outside the UI (native menu bar), invoked on the UI thread.
|
||
pub command_inbox: Option<Receiver<String>>,
|
||
/// GPU compositor (when running on wgpu): device state + compositor + the egui texture it feeds.
|
||
pub gpu: Option<GpuState>,
|
||
/// Preview render job being watched (job id, where to start playing when it completes).
|
||
watched_render: Option<(u64, Tick)>,
|
||
/// Settings last applied to the UI (theme, tooltips, frame cache, audio device).
|
||
applied_prefs: Option<filmcraft_engine::autosave::Preferences>,
|
||
workspace_restored: bool,
|
||
/// Window ▸ Workspaces: saved layouts ([`dock::WORKSPACES_FILE`] in the data directory).
|
||
pub workspaces: dock::WorkspacePrefs,
|
||
/// Workspace names and the current one, as last handed to the native menu.
|
||
menu_workspaces: (Vec<String>, String),
|
||
}
|
||
|
||
pub struct GpuState {
|
||
pub render_state: eframe::egui_wgpu::RenderState,
|
||
pub compositor: filmcraft_gpu::GpuCompositor,
|
||
pub texture: Option<egui::TextureId>,
|
||
pub last_key: Option<FrameKey>,
|
||
pub size: (u32, u32),
|
||
/// Composite time of the last frame (ms).
|
||
pub last_ms: f32,
|
||
/// Set by the device's uncaptured-error handler: the GPU path failed (validation, lost
|
||
/// device, out of memory). The app then drops it and composites on the CPU.
|
||
pub broken: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||
/// Largest texture side the device accepts.
|
||
pub max_texture: u32,
|
||
}
|
||
|
||
/// Largest texture side a plan needs on the GPU (output and every layer).
|
||
fn plan_side(plan: &filmcraft_render::plan::FramePlan) -> usize {
|
||
plan.max_side()
|
||
}
|
||
|
||
/// Whether the GPU compositor can run on this adapter: it renders and blends Rgba16Float
|
||
/// targets and uploads whole frames as textures. Older or OpenGL-backed GPUs (some Intel Macs,
|
||
/// VMs, Linux without Vulkan, WebGL) can't; those use the CPU compositor, which renders the same
|
||
/// frames. Returns the reason when unsupported.
|
||
pub fn gpu_compositor_unsupported(adapter: &eframe::wgpu::Adapter) -> Option<String> {
|
||
use eframe::wgpu::{TextureFormat, TextureFormatFeatureFlags as F, TextureUsages as U};
|
||
let f = adapter.get_texture_format_features(TextureFormat::Rgba16Float);
|
||
if !f.allowed_usages.contains(U::RENDER_ATTACHMENT | U::TEXTURE_BINDING) {
|
||
return Some("Rgba16Float is not renderable".into());
|
||
}
|
||
if !f.flags.contains(F::BLENDABLE | F::FILTERABLE) {
|
||
return Some("Rgba16Float is not blendable/filterable".into());
|
||
}
|
||
let max = adapter.limits().max_texture_dimension_2d;
|
||
if max < 4096 {
|
||
return Some(format!("max texture size {max} < 4096"));
|
||
}
|
||
None
|
||
}
|
||
|
||
impl FilmcraftApp {
|
||
/// Enable the GPU compositor on the eframe wgpu device.
|
||
pub fn set_wgpu(&mut self, rs: eframe::egui_wgpu::RenderState) {
|
||
if let Some(why) = gpu_compositor_unsupported(&rs.adapter) {
|
||
log::warn!("GPU compositor disabled ({why}); compositing on the CPU");
|
||
return;
|
||
}
|
||
// wgpu's default reaction to a validation error or lost device is to panic, which closed
|
||
// the app (or left the monitors blank) on GPUs the compositor doesn't suit. Log instead and
|
||
// fall back to the CPU compositor.
|
||
let broken = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||
let flag = broken.clone();
|
||
rs.device.on_uncaptured_error(std::sync::Arc::new(move |e: eframe::wgpu::Error| {
|
||
log::error!("GPU error, switching to the CPU compositor: {e}");
|
||
flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||
}));
|
||
let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| filmcraft_gpu::GpuCompositor::new(&rs.device, &rs.queue)));
|
||
match made {
|
||
Ok(compositor) if !broken.load(std::sync::atomic::Ordering::Relaxed) => {
|
||
let max_texture = rs.device.limits().max_texture_dimension_2d;
|
||
self.gpu = Some(GpuState { render_state: rs, compositor, texture: None, last_key: None, size: (0, 0), last_ms: 0.0, broken, max_texture });
|
||
}
|
||
_ => log::error!("GPU compositor failed to initialise; compositing on the CPU"),
|
||
}
|
||
}
|
||
|
||
/// Drop the GPU compositor after a GPU error; the monitors composite on the CPU from then on.
|
||
fn disable_gpu(&mut self, why: &str) {
|
||
log::error!("GPU compositor disabled: {why}");
|
||
if let Some(g) = self.gpu.take()
|
||
&& let Some(id) = g.texture
|
||
{
|
||
g.render_state.renderer.write().free_texture(&id);
|
||
}
|
||
self.ui.status = tl!("Graphics problem: switched to software compositing").into();
|
||
}
|
||
|
||
/// Composite a plan on the GPU and return the egui texture showing it.
|
||
pub fn gpu_present(&mut self, key: FrameKey, plan: &frames::GpuPlan) -> Option<(egui::TextureId, (u32, u32))> {
|
||
if self.gpu.as_ref()?.broken.load(std::sync::atomic::Ordering::Relaxed) {
|
||
self.disable_gpu("device error");
|
||
return None;
|
||
}
|
||
let g = self.gpu.as_mut()?;
|
||
if g.last_key == Some(key)
|
||
&& let Some(t) = g.texture
|
||
{
|
||
return Some((t, g.size));
|
||
}
|
||
if plan_side(&plan.plan) > g.max_texture as usize {
|
||
self.disable_gpu("frame larger than the GPU's maximum texture size");
|
||
return None;
|
||
}
|
||
let t0 = web_time::Instant::now();
|
||
let ran = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||
let (view, size) = g.compositor.composite_prepared(&plan.plan, Some(&plan.prepared));
|
||
(view.clone(), size)
|
||
}));
|
||
let Ok((view, size)) = ran else {
|
||
self.disable_gpu("compositor panicked");
|
||
return None;
|
||
};
|
||
let g = self.gpu.as_mut()?;
|
||
g.last_ms = t0.elapsed().as_secs_f32() * 1000.0;
|
||
let mut renderer = g.render_state.renderer.write();
|
||
let id = match g.texture {
|
||
Some(id) => {
|
||
renderer.update_egui_texture_from_wgpu_texture(&g.render_state.device, &view, eframe::wgpu::FilterMode::Linear, id);
|
||
id
|
||
}
|
||
None => renderer.register_native_texture(&g.render_state.device, &view, eframe::wgpu::FilterMode::Linear),
|
||
};
|
||
g.texture = Some(id);
|
||
g.last_key = Some(key);
|
||
g.size = size;
|
||
Some((id, size))
|
||
}
|
||
}
|
||
|
||
impl FilmcraftApp {
|
||
pub fn new(mut session: Session) -> Self {
|
||
session.shortcuts.register_external(menus::external_commands());
|
||
let recovery = !session.recovery_candidates().is_empty();
|
||
let frames = Arc::new(FrameServer::new(session.media.clone(), session.services.clone(), session.previews.clone(), FrameServer::default_workers()));
|
||
let workspaces =
|
||
session.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| dock::WorkspacePrefs::load(&d.join(dock::WORKSPACES_FILE))).unwrap_or_default();
|
||
let language = i18n::Language::parse(&session.prefs.general.interface_language).unwrap_or_default();
|
||
Self {
|
||
session,
|
||
ui: UiState { language, ..UiState::default() },
|
||
tokens: Tokens::for_kind(ThemeKind::Dark),
|
||
frames,
|
||
playback: Playback { speed: 1.0, ..Default::default() },
|
||
source_playback: Default::default(),
|
||
audio: None,
|
||
scrub: Default::default(),
|
||
hooks: HostHooks::default(),
|
||
// Unsaved changes left by a session that died are offered first thing.
|
||
dialog: recovery.then_some(Dialog::Recovery),
|
||
file_dialogs: Default::default(),
|
||
auto: Default::default(),
|
||
textures: HashMap::new(),
|
||
control_rx: None,
|
||
deferred: Vec::new(),
|
||
last_ui_time: 0.0,
|
||
synthetic: Vec::new(),
|
||
loudness: None,
|
||
status_seen: (String::new(), 0.0),
|
||
pending_screenshots: Vec::new(),
|
||
queued_screenshots: Vec::new(),
|
||
monitor_inexact: false,
|
||
monitor_asked: HashMap::new(),
|
||
program_shown: None,
|
||
timeline_still: 0,
|
||
input_waiters: Vec::new(),
|
||
next_token: 1,
|
||
styled: false,
|
||
ui_error: None,
|
||
panic_next_frame: false,
|
||
fonts_ready: false,
|
||
integrated_titlebar: false,
|
||
last_timeline_width: 1000.0,
|
||
timeline_view_of: None,
|
||
pending_timeline_drop: None,
|
||
file_drag_hover: None,
|
||
timeline_view_last: None,
|
||
fps: 60.0,
|
||
last_time: 0.0,
|
||
bindings: Vec::new(),
|
||
bindings_rev: 0,
|
||
shortcut_editor: Default::default(),
|
||
toast: None,
|
||
tl: Default::default(),
|
||
command_inbox: None,
|
||
gpu: None,
|
||
watched_render: None,
|
||
applied_prefs: None,
|
||
workspace_restored: false,
|
||
workspaces,
|
||
menu_workspaces: Default::default(),
|
||
}
|
||
}
|
||
|
||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||
self.control_rx = Some(rx);
|
||
self
|
||
}
|
||
|
||
pub fn services(&self) -> Arc<dyn Services> {
|
||
self.session.services.clone()
|
||
}
|
||
|
||
/// Rebuild the frame server if the session's media pool was replaced (e.g. project opened).
|
||
fn sync_pool(&mut self) {
|
||
if !Arc::ptr_eq(&self.frames.pool, &self.session.media) || !Arc::ptr_eq(&self.frames.previews, &self.session.previews) {
|
||
self.frames = Arc::new(FrameServer::new(
|
||
self.session.media.clone(),
|
||
self.session.services.clone(),
|
||
self.session.previews.clone(),
|
||
FrameServer::default_workers(),
|
||
));
|
||
self.textures.clear();
|
||
self.tl.reset_media_caches();
|
||
// the new frame server needs the Memory settings
|
||
self.applied_prefs = None;
|
||
}
|
||
}
|
||
|
||
/// Show theme `k` with the Settings ▸ Appearance highlight colour and contrast.
|
||
pub fn set_theme(&mut self, ctx: &egui::Context, k: ThemeKind) {
|
||
let a = &self.session.prefs.appearance;
|
||
let highlight = filmcraft_engine::settings::parse_hex(&a.highlight_color);
|
||
self.tokens = Tokens::for_kind(k).with_appearance(highlight, a.accessible_contrast);
|
||
theme::apply_visuals(ctx, &self.tokens);
|
||
self.apply_tooltips(ctx);
|
||
self.ui.dark = k != ThemeKind::Light;
|
||
}
|
||
|
||
/// The system appearance: the host's reading, else what the windowing toolkit reports.
|
||
pub fn system_theme(&self, ctx: &egui::Context) -> Option<egui::Theme> {
|
||
self.hooks.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme())
|
||
}
|
||
|
||
/// The theme Settings ▸ Appearance selects: the light or dark theme of the Appearance Mode,
|
||
/// Auto following the system (dark when it gives no answer).
|
||
pub fn appearance_kind(&self, ctx: &egui::Context) -> ThemeKind {
|
||
let light = self.system_theme(ctx).map(|t| t == egui::Theme::Light);
|
||
ThemeKind::from_pref(self.session.prefs.appearance.shown_theme(light))
|
||
}
|
||
|
||
fn apply_tooltips(&self, ctx: &egui::Context) {
|
||
// Settings ▸ General ▸ Show Tool Tips
|
||
let delay = if self.session.prefs.general.show_tool_tips { 0.5 } else { 1.0e9 };
|
||
ctx.global_style_mut(|s| s.interaction.tooltip_delay = delay);
|
||
}
|
||
|
||
/// Make the UI follow the settings after they change (theme, tooltips, frame cache budget,
|
||
/// play after rendering, audio device).
|
||
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
|
||
// Appearance Mode ▸ Sync with system: follow the system while running. The host reports
|
||
// changes as they happen (no polling) and wakes the UI only when the value changes.
|
||
if self.applied_prefs.is_some() {
|
||
let k = self.appearance_kind(ctx);
|
||
if k != self.tokens.kind {
|
||
self.set_theme(ctx, k);
|
||
}
|
||
}
|
||
if self.applied_prefs.as_ref() == Some(&self.session.prefs) {
|
||
return;
|
||
}
|
||
let p = self.session.prefs.clone();
|
||
let prev = self.applied_prefs.take();
|
||
if prev.as_ref().is_none_or(|q| q.appearance != p.appearance || q.general.show_tool_tips != p.general.show_tool_tips) {
|
||
let k = self.appearance_kind(ctx);
|
||
self.set_theme(ctx, k);
|
||
}
|
||
if prev.as_ref().is_none_or(|q| q.general.interface_language != p.general.interface_language) {
|
||
let language = match i18n::Language::parse(&p.general.interface_language) {
|
||
Some(l) => l,
|
||
None if p.general.interface_language == "system" => {
|
||
i18n::Language::from_locales(&self.hooks.system_languages.as_ref().map(|f| f()).unwrap_or_default())
|
||
}
|
||
None => i18n::Language::default(),
|
||
};
|
||
if language == i18n::Language::Ja && !i18n::install_japanese_font(ctx) {
|
||
self.ui.language = i18n::Language::En;
|
||
self.ui.status = tl!("no Japanese font is installed on this system; the interface stays in English").into();
|
||
} else if language == i18n::Language::ZhCn && !i18n::chinese_font_available() {
|
||
self.ui.language = i18n::Language::En;
|
||
self.ui.status = tl!("no Chinese font is installed on this system; the interface stays in English").into();
|
||
} else {
|
||
self.ui.language = language;
|
||
}
|
||
i18n::set_current(self.ui.language);
|
||
let items = menus::menu_items(self);
|
||
if let Some(hook) = self.hooks.shortcuts_changed.as_mut() {
|
||
hook(&items);
|
||
}
|
||
}
|
||
self.frames.set_cache_budget(p.memory.frame_cache_mb as usize * (1 << 20));
|
||
self.ui.play_after_render = p.timeline.play_after_rendering;
|
||
if prev.as_ref().is_none_or(|q| q.audio_hardware != p.audio_hardware) {
|
||
if let Some(input) = self.session.voiceover.input.as_mut() {
|
||
input.configure_host(&p.audio_hardware.device_class);
|
||
}
|
||
let rate = self.session.active_sequence().map(|q| q.settings.sample_rate);
|
||
let playing = self.playback.playing && self.playback.preroll.is_none();
|
||
if let Some(a) = self.audio.as_mut() {
|
||
a.stop();
|
||
a.configure(&p.audio_hardware, rate);
|
||
}
|
||
if playing {
|
||
self.playback.anchor_tick = self.session.playhead();
|
||
self.playback.anchor_time = ctx.input(|i| i.time);
|
||
self.start_audio();
|
||
}
|
||
}
|
||
if let Some(note) = self.audio.as_ref().and_then(|a| a.note()) {
|
||
self.ui.status = note;
|
||
}
|
||
if prev.is_none() && !self.workspace_restored {
|
||
// reopen the workspace in use when the app last closed
|
||
self.workspace_restored = true;
|
||
if let Some(w) = dock::find(&self.workspaces, &self.workspaces.current) {
|
||
self.set_workspace(&w);
|
||
}
|
||
}
|
||
self.applied_prefs = Some(p);
|
||
}
|
||
|
||
/// Every sequence has its own Timeline view (zoom, scroll, track heights), as Premiere's
|
||
/// sequence tabs do. `ui.timeline` holds the active sequence's; the views of all sequences are
|
||
/// in `session.state.timeline_views`, which is saved with the project. Each frame this
|
||
/// - gives `ui.timeline` the view of a sequence that has just become active (a sequence shown
|
||
/// for the first time is fitted, with default track heights),
|
||
/// - writes a change made in the panel to the session,
|
||
/// - and takes over a view that was changed in the session (by a command or the control
|
||
/// channel).
|
||
fn sync_timeline_view(&mut self) {
|
||
let Some(active) = self.session.state.active_sequence else {
|
||
self.timeline_view_of = None;
|
||
return;
|
||
};
|
||
let stored = self.session.state.timeline_views.get(&active).copied();
|
||
let v = &mut self.ui.timeline;
|
||
let shown = filmcraft_engine::project::SequenceView {
|
||
pps: v.target_pps,
|
||
scroll: v.target_scroll,
|
||
v_scroll: v.v_scroll,
|
||
a_scroll: v.a_scroll,
|
||
video_track_h: v.video_track_h,
|
||
audio_track_h: v.audio_track_h,
|
||
};
|
||
let show = |v: &mut state::TimelineView, s: filmcraft_engine::project::SequenceView| {
|
||
(v.pps, v.target_pps, v.scroll, v.target_scroll) = (s.pps, s.pps, s.scroll, s.scroll);
|
||
(v.v_scroll, v.a_scroll, v.video_track_h, v.audio_track_h) = (s.v_scroll, s.a_scroll, s.video_track_h, s.audio_track_h);
|
||
(v.fit_pending, v.fit_empty) = (false, None);
|
||
};
|
||
if self.timeline_view_of != Some(active) {
|
||
self.timeline_view_of = Some(active);
|
||
match stored.and_then(|s| s.checked()) {
|
||
Some(s) => {
|
||
show(v, s);
|
||
self.timeline_view_last = Some(s);
|
||
}
|
||
None => {
|
||
let d = state::TimelineView::default();
|
||
(v.v_scroll, v.a_scroll, v.video_track_h, v.audio_track_h) = (0.0, 0.0, d.video_track_h, d.audio_track_h);
|
||
(v.fit_pending, v.fit_empty) = (true, None);
|
||
self.timeline_view_last = None;
|
||
}
|
||
}
|
||
} else if v.fit_pending {
|
||
// the panel has not fitted the sequence yet: nothing to keep
|
||
} else if self.timeline_view_last != Some(shown) {
|
||
self.session.state.timeline_views.insert(active, shown);
|
||
self.timeline_view_last = Some(shown);
|
||
} else if let Some(s) = stored.filter(|s| *s != shown).and_then(|s| s.checked()) {
|
||
show(v, s);
|
||
self.timeline_view_last = Some(s);
|
||
self.session.state.timeline_views.insert(active, s);
|
||
}
|
||
}
|
||
|
||
fn workspaces_path(&self) -> Option<std::path::PathBuf> {
|
||
self.session.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| d.join(dock::WORKSPACES_FILE))
|
||
}
|
||
|
||
/// Replace the saved workspaces and write them to the data directory.
|
||
pub fn set_workspaces(&mut self, w: dock::WorkspacePrefs) -> Result<(), String> {
|
||
self.workspaces = w;
|
||
match self.workspaces_path() {
|
||
Some(p) => self.workspaces.save(&p).map_err(|e| format!("saving workspaces: {e}")),
|
||
None => Ok(()),
|
||
}
|
||
}
|
||
|
||
pub fn set_workspace(&mut self, name: &str) {
|
||
self.ui.workspace = name.to_string();
|
||
self.ui.dock = dock::saved_layout(&self.workspaces, name);
|
||
if self.workspaces.current != name {
|
||
let mut next = self.workspaces.clone();
|
||
next.current = name.to_string();
|
||
if let Err(e) = self.set_workspaces(next) {
|
||
self.ui.status = e;
|
||
}
|
||
}
|
||
if name == "Color" {
|
||
self.ui.show_scopes = false;
|
||
}
|
||
}
|
||
|
||
pub fn show_panel(&mut self, p: PanelKind) {
|
||
if p == PanelKind::Timeline {
|
||
self.ui.dock.restore_timeline();
|
||
}
|
||
if !self.ui.dock.contains(p) {
|
||
let near = match p {
|
||
// a side panel beside Properties (or Essential Sound), never over the Program monitor
|
||
PanelKind::TextToSpeech => [PanelKind::Properties, PanelKind::EssentialSound, PanelKind::EssentialGraphics]
|
||
.into_iter()
|
||
.find(|k| self.ui.dock.contains(*k))
|
||
.unwrap_or(PanelKind::Program),
|
||
PanelKind::LumetriColor | PanelKind::EssentialGraphics | PanelKind::EssentialSound | PanelKind::Properties => PanelKind::Program,
|
||
PanelKind::Source
|
||
| PanelKind::EffectControls
|
||
| PanelKind::AudioClipMixer
|
||
| PanelKind::Metadata
|
||
| PanelKind::LumetriScopes
|
||
| PanelKind::AudioTrackMixer
|
||
| PanelKind::Text
|
||
| PanelKind::ReferenceMonitor
|
||
| PanelKind::Timecode => PanelKind::Source,
|
||
_ => PanelKind::Project,
|
||
};
|
||
self.ui.dock.open_near(p, near);
|
||
}
|
||
self.ui.dock.activate(p);
|
||
self.ui.focused = p;
|
||
}
|
||
|
||
/// Reveal in Project: bring the Project panel forward, showing the bin that holds `item` with
|
||
/// the search cleared, so the (already selected) item is on show.
|
||
fn reveal_in_project(&mut self, item: filmcraft_project::ItemId) {
|
||
// the bins on the way down to the item (bounded: a project file can nest bins arbitrarily deep)
|
||
fn path(b: &filmcraft_project::Bin, item: filmcraft_project::ItemId, depth: usize, out: &mut Vec<u64>) -> bool {
|
||
if depth > 256 {
|
||
return false;
|
||
}
|
||
for c in &b.children {
|
||
match c {
|
||
filmcraft_project::BinEntry::Item(i) if *i == item => return true,
|
||
filmcraft_project::BinEntry::Bin(inner) => {
|
||
out.push(inner.id.0);
|
||
if path(inner, item, depth + 1, out) {
|
||
return true;
|
||
}
|
||
out.pop();
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
let mut bins = Vec::new();
|
||
let found = path(&self.session.project.root, item, 0, &mut bins);
|
||
// the list shows the whole tree: open the bins on the way. Icons and freeform show one bin
|
||
// at a time: go into the one that holds the item.
|
||
let list = self.session.prefs.project_panel.view.mode == filmcraft_engine::project_panel::ViewMode::List;
|
||
self.ui.project_panel.bin = if list || !found { None } else { bins.last().copied() };
|
||
if list {
|
||
for b in bins {
|
||
if !self.ui.expanded_bins.contains(&b) {
|
||
self.ui.expanded_bins.push(b);
|
||
}
|
||
}
|
||
}
|
||
self.ui.project_panel.active_tab = None;
|
||
self.ui.project_panel.selected_bin = None;
|
||
self.ui.project_search.clear();
|
||
self.show_panel(PanelKind::Project);
|
||
}
|
||
|
||
pub fn status(&mut self, s: impl Into<String>) {
|
||
self.ui.status = s.into();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- playback
|
||
|
||
pub fn toggle_play(&mut self, speed: f64) {
|
||
if self.playback.playing {
|
||
self.stop();
|
||
} else {
|
||
self.play(speed);
|
||
}
|
||
}
|
||
|
||
pub fn play(&mut self, speed: f64) {
|
||
self.stop_source();
|
||
if self.session.active_sequence().is_none() {
|
||
return;
|
||
}
|
||
// restart from the end → from the start (Settings ▸ Timeline ▸ "At playback end, return to
|
||
// beginning when restarting playback")
|
||
let dur = self.session.active_sequence().map(|q| q.duration()).unwrap_or_default();
|
||
if speed > 0.0 && self.session.prefs.timeline.return_to_beginning && self.session.playhead() >= dur - self.session.sequence_rate().frame_duration() {
|
||
self.session.set_playhead(Tick::ZERO);
|
||
}
|
||
// A loop restart keeps counting into the same meter.
|
||
if !self.playback.playing || self.playback.speed != speed {
|
||
self.playback.meter.start(speed);
|
||
}
|
||
self.playback.playing = true;
|
||
self.playback.speed = speed;
|
||
self.playback.anchor_tick = self.session.playhead();
|
||
self.playback.anchor_time = -1.0; // set when the preroll ends
|
||
self.playback.preroll = Some(-1.0);
|
||
self.playback.preroll_ready = false;
|
||
if let Some(a) = self.audio.as_mut() {
|
||
a.stop();
|
||
}
|
||
self.playback.audio_clock = false;
|
||
}
|
||
|
||
/// Start the clock (and audio) once the first frames are ready or the preroll timed out.
|
||
fn end_preroll(&mut self, now: f64) {
|
||
self.playback.preroll = None;
|
||
self.playback.anchor_time = now;
|
||
self.playback.anchor_tick = self.session.playhead();
|
||
self.playback.audio_seen = (0, now);
|
||
self.start_audio();
|
||
// Audio Track Mixer: an automation pass runs while playing forward in real time
|
||
if (self.playback.speed - 1.0).abs() < 1e-9 && !self.session.mixrec.active() {
|
||
let t = self.session.playhead();
|
||
let _ = self.session.execute("mixer.recordStart", json!({"time": t.0}));
|
||
}
|
||
// Multi-Camera view: playing records live cuts (keys 1–9 / clicking angles)
|
||
panels::multicam::on_play(self);
|
||
// voice-over: the capture starts with the audio clock
|
||
panels::voiceover::on_play(self);
|
||
}
|
||
|
||
/// Play the last Text to Speech preview (`Session::tts_preview`) on the audio output, stopping
|
||
/// timeline playback first. Starting playback again takes the output back.
|
||
pub fn play_tts_preview(&mut self) -> Result<f64, String> {
|
||
let audio = self.session.tts_preview.clone().ok_or("nothing to preview")?;
|
||
if self.playback.playing {
|
||
self.stop();
|
||
}
|
||
let a = self.audio.as_mut().ok_or("no audio output (check Settings ▸ Audio Hardware)")?;
|
||
let sr = a.sample_rate().max(1);
|
||
a.stop();
|
||
let step = f64::from(audio.sample_rate.max(1)) / f64::from(sr);
|
||
let seconds = audio.seconds();
|
||
let mut pos = 0.0f64;
|
||
let fill = Box::new(move |buf: &mut [f32], ch: usize| {
|
||
for frame in buf.chunks_mut(ch.max(1)) {
|
||
let i = pos as usize;
|
||
let f = (pos - i as f64) as f32;
|
||
let x0 = audio.samples.get(i).copied().unwrap_or(0.0);
|
||
let x1 = audio.samples.get(i + 1).copied().unwrap_or(0.0);
|
||
frame.fill(x0 + (x1 - x0) * f);
|
||
pos += step;
|
||
}
|
||
});
|
||
a.start(fill)?;
|
||
self.playback.audio_clock = false;
|
||
Ok(seconds)
|
||
}
|
||
|
||
pub fn stop(&mut self) {
|
||
self.playback.playing = false;
|
||
self.playback.stop_at = None;
|
||
self.playback.preroll = None;
|
||
self.playback.meter.finish();
|
||
self.frames.stop_prefetch();
|
||
if let Some(a) = self.audio.as_mut() {
|
||
a.stop();
|
||
}
|
||
self.playback.audio_clock = false;
|
||
if self.session.mixrec.active() {
|
||
let t = self.session.playhead();
|
||
if let Err(e) = self.session.execute("mixer.recordStop", json!({"time": t.0})) {
|
||
self.ui.status = e.to_string();
|
||
}
|
||
}
|
||
panels::multicam::on_stop(self);
|
||
panels::voiceover::on_stop(self);
|
||
}
|
||
|
||
fn start_audio(&mut self) {
|
||
let speed = self.playback.speed;
|
||
if (speed - 1.0).abs() > 1e-9 {
|
||
if let Some(a) = self.audio.as_mut() {
|
||
a.stop();
|
||
}
|
||
return;
|
||
}
|
||
let Some(seq_id) = self.session.state.active_sequence else { return };
|
||
let start_tick = self.session.playhead();
|
||
let Some(a) = self.audio.as_mut() else { return };
|
||
let sr = a.sample_rate();
|
||
let cursor = start_tick.to_units_floor(sr as i64);
|
||
let mix = playback_mix(&self.session, seq_id, sr);
|
||
// the old stream stops before the new mixer resets the underrun counters
|
||
a.stop();
|
||
// Desktop: mix ahead on a thread so the device callback never waits on decoding.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let fill = play_ahead::spawn(mix, cursor, sr, a.channels(), self.playback.audio_stats.clone());
|
||
#[cfg(target_arch = "wasm32")]
|
||
let fill = {
|
||
let (mut mix, mut cursor) = (mix, cursor);
|
||
Box::new(move |buf: &mut [f32], ch: usize| {
|
||
mix(cursor, buf, ch);
|
||
cursor += (buf.len() / ch.max(1)) as i64;
|
||
})
|
||
};
|
||
match a.start(fill) {
|
||
Ok(_) => {
|
||
self.playback.audio_clock = true;
|
||
if let Some(note) = self.audio.as_ref().and_then(|audio| audio.note()) {
|
||
self.ui.status = note;
|
||
}
|
||
}
|
||
Err(e) => {
|
||
log::warn!("audio output unavailable: {e}");
|
||
self.playback.audio_clock = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn advance_playback(&mut self, ctx: &egui::Context) {
|
||
if !self.playback.playing {
|
||
return;
|
||
}
|
||
let now = ctx.input(|i| i.time);
|
||
if let Some(since) = self.playback.preroll {
|
||
let since = if since < 0.0 { now } else { since };
|
||
self.playback.preroll = Some(since);
|
||
if self.playback.preroll_ready || now - since >= frames::PREROLL_TIMEOUT_S {
|
||
self.end_preroll(now);
|
||
} else {
|
||
ctx.request_repaint();
|
||
return;
|
||
}
|
||
}
|
||
if self.playback.anchor_time < 0.0 {
|
||
self.playback.anchor_time = now;
|
||
}
|
||
let rate = self.session.sequence_rate();
|
||
let reading = if self.playback.audio_clock { self.audio.as_ref().and_then(|a| a.played_frames().map(|f| (f, a.sample_rate()))) } else { None };
|
||
if self.playback.audio_clock && reading.is_none() {
|
||
// A device error relinquishes its clock immediately. Resume from the displayed
|
||
// frame, rather than jumping to the old wall-clock anchor after buffered playback.
|
||
self.playback.anchor_tick = self.session.playhead();
|
||
self.playback.anchor_time = now;
|
||
self.playback.audio_clock = false;
|
||
if let Some(audio) = self.audio.as_mut() {
|
||
audio.stop();
|
||
}
|
||
log::warn!("audio output lost its playback clock; playing without sound");
|
||
self.ui.status = tl!("Audio output failed: playing without sound (check Settings ▸ Audio Hardware)").into();
|
||
}
|
||
if let Some((f, sr)) = reading {
|
||
if f != self.playback.audio_seen.0 {
|
||
self.playback.audio_seen = (f, now);
|
||
} else if now - self.playback.audio_seen.1 >= AUDIO_STALL_S {
|
||
// the device stopped consuming samples: continue from where the audio got to on
|
||
// the wall clock, without sound
|
||
let played = if sr > 0 { f as f64 / sr as f64 } else { 0.0 };
|
||
self.playback.anchor_tick += Tick::from_seconds_f64(played * self.playback.speed);
|
||
self.playback.anchor_time = now;
|
||
self.playback.audio_clock = false;
|
||
if let Some(a) = self.audio.as_mut() {
|
||
a.stop();
|
||
}
|
||
log::warn!("audio output stalled (no samples consumed for {AUDIO_STALL_S} s); playing without sound");
|
||
self.ui.status = tl!("Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware)").into();
|
||
}
|
||
}
|
||
let elapsed = match reading {
|
||
Some((f, sr)) if self.playback.audio_clock && sr > 0 => f as f64 / sr as f64,
|
||
_ => now - self.playback.anchor_time,
|
||
};
|
||
let t = self.playback.anchor_tick + Tick::from_seconds_f64(elapsed * self.playback.speed);
|
||
let seq = self.session.active_sequence();
|
||
let dur = seq.map(|q| q.duration()).unwrap_or_default();
|
||
let (lo, hi) = if self.playback.looping {
|
||
(seq.and_then(|q| q.mark_in).unwrap_or(Tick::ZERO), seq.and_then(|q| q.mark_out).map(|o| o + rate.frame_duration()).unwrap_or(dur))
|
||
} else {
|
||
(Tick::ZERO, dur)
|
||
};
|
||
if let Some(end) = self.playback.stop_at.filter(|e| t >= *e && self.playback.speed > 0.0 && !self.playback.looping) {
|
||
self.session.set_playhead(end);
|
||
self.stop();
|
||
} else if t >= hi && self.playback.speed > 0.0 {
|
||
if self.playback.looping {
|
||
self.session.set_playhead(lo);
|
||
self.play(self.playback.speed);
|
||
} else {
|
||
self.session.set_playhead(hi - rate.frame_duration());
|
||
self.stop();
|
||
}
|
||
} else if t <= Tick::ZERO && self.playback.speed < 0.0 {
|
||
self.session.set_playhead(Tick::ZERO);
|
||
self.stop();
|
||
} else {
|
||
self.session.set_playhead(t);
|
||
}
|
||
ctx.request_repaint();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- textures
|
||
|
||
/// Upload a rendered frame into a named texture (only when the key changed).
|
||
pub fn texture_for(&mut self, ctx: &egui::Context, name: &str, key: FrameKey, img: &frames::Rgba) -> egui::TextureId {
|
||
if let Some((k, tex)) = self.textures.get_mut(name) {
|
||
if *k != key {
|
||
tex.set(egui::ColorImage::from_rgba_unmultiplied([img.w, img.h], &img.px), TextureOptions::LINEAR);
|
||
*k = key;
|
||
}
|
||
return tex.id();
|
||
}
|
||
let tex = ctx.load_texture(name, egui::ColorImage::from_rgba_unmultiplied([img.w, img.h], &img.px), TextureOptions::LINEAR);
|
||
let id = tex.id();
|
||
self.textures.insert(name.to_string(), (key, tex));
|
||
id
|
||
}
|
||
|
||
/// Like [`Self::texture_for`], uploading `map(img)` (computed only when the key changed).
|
||
pub fn texture_for_mapped(
|
||
&mut self,
|
||
ctx: &egui::Context,
|
||
name: &str,
|
||
key: FrameKey,
|
||
img: &frames::Rgba,
|
||
map: impl FnOnce(&frames::Rgba) -> frames::Rgba,
|
||
) -> egui::TextureId {
|
||
if let Some((k, tex)) = self.textures.get_mut(name) {
|
||
if *k != key {
|
||
let m = map(img);
|
||
tex.set(egui::ColorImage::from_rgba_unmultiplied([m.w, m.h], &m.px), TextureOptions::LINEAR);
|
||
*k = key;
|
||
}
|
||
return tex.id();
|
||
}
|
||
let m = map(img);
|
||
let tex = ctx.load_texture(name, egui::ColorImage::from_rgba_unmultiplied([m.w, m.h], &m.px), TextureOptions::LINEAR);
|
||
let id = tex.id();
|
||
self.textures.insert(name.to_string(), (key, tex));
|
||
id
|
||
}
|
||
|
||
pub fn texture_existing(&self, name: &str) -> Option<(egui::TextureId, egui::Vec2)> {
|
||
self.textures.get(name).map(|(_, t)| (t.id(), t.size_vec2()))
|
||
}
|
||
|
||
/// The frame a named texture shows.
|
||
pub fn texture_key(&self, name: &str) -> Option<FrameKey> {
|
||
self.textures.get(name).map(|(k, _)| *k)
|
||
}
|
||
|
||
/// The frame whose picture the Program monitor drew last: the frame due at the playhead, or
|
||
/// the stand-in shown while that one renders (`perf.stats`, tests).
|
||
pub fn program_picture(&self) -> Option<FrameKey> {
|
||
self.program_shown
|
||
}
|
||
|
||
/// Get a thumbnail texture for an item at a media time (requested at low priority).
|
||
/// Cache revision of a project item's own frames: media changes only when its file does
|
||
/// (relink, Make Offline, proxies on/off), so its frames survive unrelated edits; other items
|
||
/// (sequences) follow the project revision.
|
||
pub fn item_revision(&self, item: filmcraft_project::ItemId) -> u64 {
|
||
let p = &self.session.project;
|
||
let target = match p.item(item).map(|i| &i.kind) {
|
||
Some(filmcraft_project::ItemKind::Subclip { parent, .. }) => *parent,
|
||
_ => item,
|
||
};
|
||
match p.item(target).map(|i| &i.kind) {
|
||
Some(filmcraft_project::ItemKind::Media(m)) => {
|
||
use std::hash::{Hash, Hasher};
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
filmcraft_engine::media_pool::media_key(m).hash(&mut h);
|
||
(m.proxy.is_some() && self.session.media.use_proxies()).hash(&mut h);
|
||
h.finish()
|
||
}
|
||
_ => self.session.revision,
|
||
}
|
||
}
|
||
|
||
pub fn thumbnail(&mut self, ctx: &egui::Context, item: filmcraft_project::ItemId, t: Tick, width: u32) -> Option<(egui::TextureId, egui::Vec2)> {
|
||
let pi = self.session.project.item(item)?;
|
||
let src_w = match &pi.kind {
|
||
filmcraft_project::ItemKind::Media(m) => m.info.video.as_ref()?.width,
|
||
filmcraft_project::ItemKind::Sequence(s) => s.settings.width,
|
||
_ => return None,
|
||
};
|
||
let rate = pi.frame_rate();
|
||
let frame = rate.frame_at(t);
|
||
let rev = self.item_revision(item);
|
||
let key = FrameKey { target: Target::Item(item), frame, size: width, revision: rev, draft: false };
|
||
let name = format!("thumb-{}-{}-{}", item.0, frame, width);
|
||
if let Some(img) = self.frames.get(&key) {
|
||
let id = self.texture_for(ctx, &name, key, &img);
|
||
return Some((id, egui::vec2(img.w as f32, img.h as f32)));
|
||
}
|
||
let scale = width as f32 / src_w.max(1) as f32;
|
||
let project = self.session.project.clone();
|
||
self.frames.request(key, rate.tick_of(frame), scale, &project, 50);
|
||
self.texture_existing(&name)
|
||
}
|
||
|
||
// ---------------------------------------------------------------- files
|
||
|
||
/// Destination of imports started from the currently shown Project panel view.
|
||
pub fn import_bin(&self) -> filmcraft_project::BinId {
|
||
panels::project::view_of(self, panels::project::shown_inst(self)).bin
|
||
}
|
||
|
||
pub fn file_dialog(&mut self, id: &str, params: &Value) -> Result<Value, String> {
|
||
match id {
|
||
"file.import" => {
|
||
let exts: Vec<&str> = filmcraft_media::VIDEO_EXTENSIONS
|
||
.iter()
|
||
.chain(filmcraft_media::AUDIO_EXTENSIONS)
|
||
.chain(filmcraft_media::STILL_EXTENSIONS)
|
||
.chain(&["srt", "vtt", "scc", "edl", "xml", "fcpxml", "otio", "aaf", "omf"])
|
||
.copied()
|
||
.collect();
|
||
let paths = self.hooks.pick_files.as_mut().map(|f| f(&exts)).unwrap_or_default();
|
||
if paths.is_empty() {
|
||
return Ok(Value::Null);
|
||
}
|
||
let bin = params.get("bin").cloned().unwrap_or_else(|| json!(self.import_bin().0));
|
||
let r = self.session.execute("file.import", json!({"paths": paths, "bin": bin})).map_err(|e| e.to_string());
|
||
if let Ok(v) = &r
|
||
&& let Some(errs) = v.get("errors").and_then(Value::as_array)
|
||
&& !errs.is_empty()
|
||
{
|
||
self.ui.status = errs.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("; ");
|
||
}
|
||
r
|
||
}
|
||
// File ▸ Import with Image Sequence: choose the first numbered still
|
||
"file.importImageSequence" => {
|
||
let paths = self.hooks.pick_files.as_mut().map(|f| f(filmcraft_media::STILL_EXTENSIONS)).unwrap_or_default();
|
||
let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) };
|
||
let r = self.session.execute("file.importImageSequence", json!({"path": path})).map_err(|e| e.to_string());
|
||
if let Err(e) = &r {
|
||
self.ui.status = e.clone();
|
||
}
|
||
r
|
||
}
|
||
"file.saveAs" | "file.save" | "file.saveCopy" => {
|
||
let suggested =
|
||
if id == "file.saveCopy" { format!("{} copy.fcproj", self.session.project.name) } else { format!("{}.fcproj", self.session.project.name) };
|
||
let Some(path) = self.hooks.pick_save.as_mut().and_then(|f| f(&suggested)) else { return Ok(Value::Null) };
|
||
let cmd = if id == "file.saveCopy" { "file.saveCopy" } else { "file.saveAs" };
|
||
self.session.execute(cmd, json!({"path": path})).map_err(|e| e.to_string())
|
||
}
|
||
"file.open" => {
|
||
let Some(path) = self.hooks.pick_open_project.as_mut().and_then(|f| f()) else { return Ok(Value::Null) };
|
||
self.session.execute("file.open", json!({"path": path})).map_err(|e| e.to_string())
|
||
}
|
||
"graphics.newFromFile" => {
|
||
let exts: Vec<&str> = filmcraft_media::STILL_EXTENSIONS.iter().chain(filmcraft_media::VIDEO_EXTENSIONS).copied().collect();
|
||
let paths = self.hooks.pick_files.as_mut().map(|f| f(&exts)).unwrap_or_default();
|
||
let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) };
|
||
self.session.execute("graphics.newFromFile", json!({"path": path})).map_err(|e| e.to_string())
|
||
}
|
||
"captions.import" => {
|
||
let paths = self.hooks.pick_files.as_mut().map(|f| f(&["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp", "xml"])).unwrap_or_default();
|
||
let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) };
|
||
self.session.execute("captions.import", json!({"path": path})).map_err(|e| e.to_string())
|
||
}
|
||
"captions.export" => {
|
||
let name = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone()).unwrap_or_default();
|
||
let suggested = format!("{}.srt", name.replace(' ', "_"));
|
||
let Some(path) =
|
||
self.hooks.pick_save_as.as_mut().and_then(|f| {
|
||
f("Captions (SRT, WebVTT, SCC, MCC, EBU STL, TTML, DFXP)", &["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp"], &suggested)
|
||
})
|
||
else {
|
||
return Ok(Value::Null);
|
||
};
|
||
let mut p = params.clone();
|
||
p["path"] = json!(path);
|
||
self.session.execute("captions.export", p).map_err(|e| e.to_string())
|
||
}
|
||
_ => match EXPORT_SAVE_DIALOGS.iter().find(|(c, ..)| *c == id) {
|
||
Some(&(_, filter, ext)) => self.export_save_dialog(id, filter, ext, params),
|
||
None => Err(format!("no dialog for {id}")),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// File ▸ Export ▸ EDL…, Final Cut Pro XML… etc. from the menus (#382): ask where to save, then
|
||
/// run the command with that `path` and the rest of `params`.
|
||
fn export_save_dialog(&mut self, id: &str, filter: &str, ext: &str, params: &Value) -> Result<Value, String> {
|
||
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&self.session))?;
|
||
let Some(pick) = self.hooks.pick_save_as.as_mut() else {
|
||
return Err("no save dialog available: run the command with a `path`".into());
|
||
};
|
||
// Timelines are named after the sequence; a selection must not suggest the open project's file.
|
||
let sequence = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone());
|
||
let stem = match id {
|
||
"file.exportAle" => self.session.project.name.clone(),
|
||
"file.exportSelectionProject" => format!("{} selection", self.session.project.name),
|
||
_ => sequence.unwrap_or_else(|| self.session.project.name.clone()),
|
||
};
|
||
let stem: String = stem.chars().map(|c| if matches!(c, '/' | '\\') || c.is_control() { '-' } else { c }).collect();
|
||
let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem };
|
||
let Some(path) = pick(filter, &[ext], &format!("{stem}.{ext}")) else { return Ok(Value::Null) };
|
||
let mut p = params.as_object().cloned().unwrap_or_default();
|
||
p.insert("path".into(), json!(path));
|
||
let r = self.session.execute(id, Value::Object(p)).map_err(|e| e.to_string());
|
||
self.ui.status = match &r {
|
||
Ok(_) => tlf!("Exported {path}", path),
|
||
Err(e) => e.clone(),
|
||
};
|
||
r
|
||
}
|
||
|
||
/// Import dropped files. With Timeline ▸ drop imports to the timeline, the new items are also
|
||
/// queued to be placed at the pointer (see `timeline::interact`).
|
||
fn handle_drops(&mut self, ctx: &egui::Context) {
|
||
// While files hover, the OS sends no cursor events: poll the cursor each frame to draw the Timeline preview.
|
||
let hovering = ctx.input(|i| i.raw.hovered_files.len());
|
||
self.file_drag_hover = if hovering > 0 && self.session.prefs.timeline.drop_import_to_timeline {
|
||
ctx.request_repaint();
|
||
self.drop_pointer(ctx).map(|p| (hovering, p))
|
||
} else {
|
||
None
|
||
};
|
||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||
let mut paths = Vec::new();
|
||
for f in dropped {
|
||
let p = f.path();
|
||
if p.exists() {
|
||
paths.push(p.to_string_lossy().to_string());
|
||
}
|
||
}
|
||
if !paths.is_empty() {
|
||
let pointer = self.drop_pointer(ctx);
|
||
let imported = self.session.execute("file.import", json!({"paths": paths, "bin": self.import_bin().0}));
|
||
if let Ok(v) = imported
|
||
&& let Some(pos) = pointer
|
||
&& self.session.prefs.timeline.drop_import_to_timeline
|
||
{
|
||
let items: Vec<filmcraft_engine::project::ItemId> =
|
||
v["items"].as_array().map(|a| a.iter().filter_map(|i| i.as_u64()).map(filmcraft_engine::project::ItemId).collect()).unwrap_or_default();
|
||
if !items.is_empty() {
|
||
self.pending_timeline_drop = Some((items, pos));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Where files dragged in from the OS are over the window. The OS reports no cursor motion during such a drag
|
||
/// (winit, Windows), so ask it through the host hook, else use the last pointer position egui saw.
|
||
fn drop_pointer(&self, ctx: &egui::Context) -> Option<egui::Pos2> {
|
||
let os_pointer = self
|
||
.hooks
|
||
.cursor_screen_position
|
||
.as_ref()
|
||
.and_then(|f| f())
|
||
.and_then(|px| ctx.input(|i| i.viewport().inner_rect.map(|r| screen_px_to_ui(px, i.pixels_per_point, r.min))));
|
||
os_pointer.or_else(|| ctx.input(|i| i.pointer.hover_pos().or(i.pointer.latest_pos())))
|
||
}
|
||
|
||
// ---------------------------------------------------------------- input
|
||
|
||
fn handle_shortcuts(&mut self, ctx: &egui::Context) {
|
||
let workspaces = (dock::names(&self.workspaces), self.ui.workspace.clone());
|
||
if self.bindings_rev != self.session.shortcuts.revision || workspaces != self.menu_workspaces {
|
||
self.menu_workspaces = workspaces;
|
||
self.bindings = menus::bindings(self);
|
||
self.bindings_rev = self.session.shortcuts.revision;
|
||
let items = menus::menu_items(self);
|
||
if let Some(hook) = self.hooks.shortcuts_changed.as_mut() {
|
||
hook(&items);
|
||
}
|
||
}
|
||
if ctx.egui_wants_keyboard_input() || self.dialog == Some(Dialog::Shortcuts) {
|
||
return;
|
||
}
|
||
// Esc cancels a dynamic trim in progress
|
||
if self.session.trim_play.dynamic.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||
let _ = self.session.execute("trim.cancelDynamic", json!({}));
|
||
}
|
||
// Panel shortcuts of the focused panel first: they override application shortcuts.
|
||
let focused = self.ui.focused.title();
|
||
let mut fire = Vec::new();
|
||
ctx.input_mut(|i| {
|
||
let modifiers = i.modifiers;
|
||
clipboard_events_as_keys(&mut i.events, modifiers);
|
||
let panel = self.bindings.iter().filter(|b| b.3.as_deref() == Some(focused));
|
||
let app_wide = self.bindings.iter().filter(|b| b.3.is_none());
|
||
for (m, k, id, _) in panel.chain(app_wide) {
|
||
if i.consume_key(*m, *k) {
|
||
fire.push(id.clone());
|
||
}
|
||
}
|
||
});
|
||
for mut id in fire {
|
||
// Select All / Deselect All act on the Project panel's items when it has focus (#168).
|
||
if self.ui.focused == PanelKind::Project && matches!(id.as_str(), "edit.selectAll" | "edit.deselectAll") {
|
||
id = id.replacen("edit.", "project.", 1);
|
||
}
|
||
// Mark In/Out in the Source monitor when it has focus.
|
||
let params = if self.ui.focused == PanelKind::Source
|
||
&& (matches!(id.as_str(), "markers.markIn" | "markers.markOut" | "markers.clearInOut")
|
||
|| id.starts_with("markers.markSplit")
|
||
|| id.starts_with("markers.goToSplit"))
|
||
{
|
||
json!({"target": "source"})
|
||
} else {
|
||
json!({})
|
||
};
|
||
if let Err(e) = menus::invoke(self, ctx, &id, params) {
|
||
self.ui.status = e;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- control channel
|
||
|
||
/// Make the UI pass run for a control request without stealing the user's keyboard focus.
|
||
pub(crate) fn raise_for_control(&mut self, ctx: &egui::Context) {
|
||
if let Some(raise) = self.hooks.raise_without_focus.as_mut() {
|
||
raise();
|
||
}
|
||
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() {
|
||
// UI requests need rendered frames: raise the window if `ui` hasn't run recently
|
||
// (occluded macOS windows stop running `ui`).
|
||
if req.method.starts_with("ui.") && now - self.last_ui_time > 0.25 {
|
||
self.raise_for_control(ctx);
|
||
}
|
||
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::Screenshot { path, crop } => {
|
||
let token = self.next_token;
|
||
self.next_token += 1;
|
||
let settle = ctx.input(|i| i.time) + 0.25;
|
||
self.queued_screenshots.push((token, settle, 0));
|
||
self.pending_screenshots.push((token, path, crop, reply, settle + SCREENSHOT_TIMEOUT_S));
|
||
}
|
||
}
|
||
}
|
||
self.control_rx = Some(rx);
|
||
}
|
||
|
||
/// Capture once the UI shows the current state (`settled`): after a seek the monitor shows the
|
||
/// nearest cached picture until the exact frame is decoded, and an agent must not be handed
|
||
/// the stand-in.
|
||
fn issue_screenshots(&mut self, ctx: &egui::Context, settled: bool) {
|
||
let now = ctx.input(|i| i.time);
|
||
let mut any = false;
|
||
self.queued_screenshots.retain_mut(|(token, at, frames)| {
|
||
*frames += 1;
|
||
any = true;
|
||
if now >= *at && *frames >= 3 && (settled || now >= *at + SCREENSHOT_SETTLE_MAX_S) {
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
|
||
false
|
||
} else {
|
||
true
|
||
}
|
||
});
|
||
// A hidden window (or a sleeping display) never presents, so its screenshot never
|
||
// arrives: give up instead of waiting (and repainting) forever.
|
||
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 frame(&mut self, ui: &mut egui::Ui) {
|
||
i18n::set_current(self.ui.language);
|
||
if std::mem::take(&mut self.panic_next_frame) {
|
||
crash::injected_fault("injected UI fault");
|
||
}
|
||
let ctx = ui.ctx().clone();
|
||
self.auto.begin_frame();
|
||
self.frames.set_context(&ctx);
|
||
self.session.poll_persistence();
|
||
panels::trim_monitor::advance(self, &ctx);
|
||
if self.session.persistence.is_some() && self.session.is_dirty() {
|
||
// Keep polling the auto-save worker (status, "also save the project" results).
|
||
ctx.request_repaint_after(std::time::Duration::from_secs(1));
|
||
}
|
||
self.sync_pool();
|
||
self.apply_prefs(&ctx);
|
||
for ev in self.session.drain_events() {
|
||
match ev {
|
||
filmcraft_engine::Event::OpenSequence(_) => {
|
||
// show the sequence with its own view (or fitted, the first time)
|
||
self.timeline_view_of = None;
|
||
self.ui.dock.restore_timeline();
|
||
self.ui.dock.activate(PanelKind::Timeline);
|
||
}
|
||
filmcraft_engine::Event::OpenSource(_) => {
|
||
self.ui.dock.activate(PanelKind::Source);
|
||
}
|
||
filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item),
|
||
filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))),
|
||
filmcraft_engine::Event::ProjectChanged { .. } => {}
|
||
}
|
||
}
|
||
self.sync_timeline_view();
|
||
self.handle_drops(&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);
|
||
}
|
||
self.handle_shortcuts(&ctx);
|
||
self.advance_playback(&ctx);
|
||
self.advance_source_playback(&ctx);
|
||
self.scrub_audio(&ctx);
|
||
// Text to Speech: a selected narration clip loads into the panel
|
||
panels::tts::follow_selection(self);
|
||
if self.ui.tts.pending.is_some() || self.ui.tts.download_job.is_some() {
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||
}
|
||
let t = self.tokens;
|
||
let full = ui.max_rect();
|
||
ui.painter().rect_filled(full, 0.0, t.app_bg);
|
||
let header_h = if self.ui.show_header { 38.0 } else { 0.0 };
|
||
let header = egui::Rect::from_min_size(full.min, egui::vec2(full.width(), header_h));
|
||
if self.ui.show_header {
|
||
header::show(self, ui, header);
|
||
}
|
||
let status_h = if self.ui.show_status_bar { 20.0 } else { 0.0 };
|
||
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 1.0, header.max.y + 1.0), egui::pos2(full.max.x - 1.0, full.max.y - status_h - 2.0));
|
||
match self.ui.mode {
|
||
state::Mode::Edit => self.dock_area(ui, body),
|
||
state::Mode::Import => panels::import_mode::show(self, ui, body),
|
||
state::Mode::Export => panels::export_mode::show(self, ui, body),
|
||
}
|
||
// the Timeline has had its chance to place a file drop this frame; anything left stays a bin import
|
||
self.pending_timeline_drop = None;
|
||
panels::dialogs::show(self, &ctx);
|
||
if !self.ui.show_status_bar {
|
||
// no bar to draw the job in, but a finished preview render still plays
|
||
self.watch_jobs(ui.ctx());
|
||
return;
|
||
}
|
||
// Status / hint bar
|
||
let sb = egui::Rect::from_min_max(egui::pos2(full.min.x, full.max.y - status_h), full.max);
|
||
ui.painter().rect_filled(sb, 0.0, t.header_bg);
|
||
let now = ui.input(|i| i.time);
|
||
if self.ui.status != self.status_seen.0 {
|
||
self.status_seen = (self.ui.status.clone(), now);
|
||
} else if !self.ui.status.is_empty() {
|
||
if now - self.status_seen.1 > 8.0 {
|
||
self.ui.status.clear();
|
||
} else {
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
|
||
}
|
||
}
|
||
let hint = if !self.ui.status.is_empty() { self.ui.status.clone() } else { self.hint_text() };
|
||
ui.painter().text(egui::pos2(sb.min.x + 10.0, sb.center().y), egui::Align2::LEFT_CENTER, hint, Tokens::ui(11.0), t.text_dim);
|
||
let resp = ui.interact(sb, egui::Id::new("status-bar"), egui::Sense::click());
|
||
if resp.clicked() {
|
||
self.ui.status.clear();
|
||
}
|
||
self.job_status(ui, sb, &t);
|
||
}
|
||
|
||
/// The running job (export / render previews), if any; plays the rendered range when a preview
|
||
/// render completes. Runs every frame, with or without the status bar.
|
||
fn watch_jobs(&mut self, ctx: &egui::Context) -> Option<filmcraft_engine::Job> {
|
||
use std::sync::atomic::Ordering;
|
||
let running = self.session.jobs.iter().rev().find(|j| !j.progress.finished.load(Ordering::Relaxed)).cloned();
|
||
// Play after rendering previews.
|
||
if let Some((id, from)) = self.watched_render
|
||
&& let Some(j) = self.session.jobs.iter().find(|j| j.id == id)
|
||
&& j.progress.finished.load(Ordering::Relaxed)
|
||
{
|
||
self.watched_render = None;
|
||
let ok = j.progress.error.lock().map(|e| e.is_none()).unwrap_or(false);
|
||
if ok && self.ui.play_after_render && !self.playback.playing {
|
||
self.session.set_playhead(from);
|
||
self.play(1.0);
|
||
}
|
||
}
|
||
let job = running?;
|
||
if job.label.starts_with("Rendering ") && !job.label.contains("audio") && self.watched_render.is_none_or(|w| w.0 != job.id) {
|
||
let from = self.session.active_sequence().and_then(|q| q.mark_in).unwrap_or(Tick::ZERO);
|
||
self.watched_render = Some((job.id, from));
|
||
}
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(150));
|
||
Some(job)
|
||
}
|
||
|
||
/// Right side of the status bar: the running job (export / render previews) with a progress
|
||
/// bar and a cancel button.
|
||
fn job_status(&mut self, ui: &mut egui::Ui, sb: egui::Rect, t: &Tokens) {
|
||
use std::sync::atomic::Ordering;
|
||
let Some(job) = self.watch_jobs(ui.ctx()) else { return };
|
||
let f = job.progress.fraction().clamp(0.0, 1.0);
|
||
let left = job.progress.eta().map(panels::left_text).unwrap_or_default();
|
||
let cancel = egui::Rect::from_center_size(egui::pos2(sb.max.x - 14.0, sb.center().y), egui::vec2(14.0, 14.0));
|
||
let bar = egui::Rect::from_min_size(egui::pos2(cancel.min.x - 128.0, sb.center().y - 3.0), egui::vec2(120.0, 6.0));
|
||
let p = ui.painter();
|
||
p.rect_filled(bar, 3.0, t.separator);
|
||
p.rect_filled(egui::Rect::from_min_size(bar.min, egui::vec2(bar.width() * f, bar.height())), 3.0, t.accent);
|
||
let verb = if job.label.starts_with("Rendering") { crate::i18n::t(&job.label).to_string() } else { tl!("Exporting").to_string() };
|
||
p.text(
|
||
egui::pos2(bar.min.x - 8.0, sb.center().y),
|
||
egui::Align2::RIGHT_CENTER,
|
||
format!("{verb}… {:.0}%{left}", f * 100.0),
|
||
Tokens::ui(11.0),
|
||
t.text_dim,
|
||
);
|
||
let resp = ui.interact(cancel, egui::Id::new(("job-cancel", job.id)), egui::Sense::click());
|
||
let c = if resp.hovered() { t.hot_text } else { t.text_dim };
|
||
let k = 3.5;
|
||
p.line_segment([cancel.center() - egui::vec2(k, k), cancel.center() + egui::vec2(k, k)], egui::Stroke::new(1.4, c));
|
||
p.line_segment([cancel.center() + egui::vec2(-k, k), cancel.center() + egui::vec2(k, -k)], egui::Stroke::new(1.4, c));
|
||
self.auto.add("status.job.cancel", cancel, &format!("Cancel {}", job.label));
|
||
self.auto.add("status.job.progress", bar, &format!("{:.0}%{left}", f * 100.0));
|
||
if resp.on_hover_text(tl!("Cancel")).clicked() {
|
||
job.progress.cancel.store(true, Ordering::Relaxed);
|
||
self.watched_render = None;
|
||
}
|
||
}
|
||
|
||
/// Contextual hint for the status bar (Premiere shows tool/gesture hints here).
|
||
fn hint_text(&self) -> String {
|
||
let mac = cfg!(target_os = "macos");
|
||
match self.ui.tool {
|
||
state::Tool::Selection if mac => {
|
||
tl!("Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options.")
|
||
}
|
||
state::Tool::Selection => tl!("Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options."),
|
||
state::Tool::TrackSelectForward => tl!("Click to select all clips to the right in all tracks. Shift-click for a single track."),
|
||
state::Tool::TrackSelectBackward => tl!("Click to select all clips to the left in all tracks. Shift-click for a single track."),
|
||
state::Tool::Ripple => tl!("Drag an edit point to ripple trim; later clips move to keep the gap closed."),
|
||
state::Tool::Rolling => tl!("Drag an edit point to roll it: the out of one clip and the in of the next move together."),
|
||
state::Tool::RateStretch => tl!("Drag an edge to change the clip's speed so it fills the new duration."),
|
||
state::Tool::Remix => tl!("Drag the edge of a music clip to remix it to the new duration at musically matching beats."),
|
||
state::Tool::Razor => tl!("Click to split a clip. Shift-click to split all tracks."),
|
||
state::Tool::Slip => tl!("Drag a clip to slip its source in/out without moving it."),
|
||
state::Tool::Slide => tl!("Drag a clip to slide it between its neighbours."),
|
||
state::Tool::Hand => tl!("Drag to scroll the timeline."),
|
||
state::Tool::Zoom if mac => tl!("Click to zoom in; Opt-click to zoom out."),
|
||
state::Tool::Zoom => tl!("Click to zoom in; Alt-click to zoom out."),
|
||
_ => "",
|
||
}
|
||
.to_string()
|
||
}
|
||
|
||
fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) {
|
||
let t = self.tokens;
|
||
// Maximize or Restore Frame (` / Shift+`): the maximized panel fills the dock area.
|
||
let maximized = self.ui.keys.maximized.filter(|p| self.ui.dock.contains(*p));
|
||
let mut dock = match maximized {
|
||
Some(p) => dock::DockNode::Tabs { panels: vec![p], active: 0 },
|
||
None => std::mem::replace(&mut self.ui.dock, dock::DockNode::Tabs { panels: vec![], active: 0 }),
|
||
};
|
||
let mut groups = Vec::new();
|
||
dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto);
|
||
let mut actions = Vec::new();
|
||
let seqs = dock::SeqTabs {
|
||
open: self
|
||
.session
|
||
.state
|
||
.open_sequences
|
||
.iter()
|
||
.filter_map(|id| {
|
||
self.session.project.item(*id).filter(|i| matches!(i.kind, filmcraft_project::ItemKind::Sequence(_))).map(|i| (id.0, i.name.clone()))
|
||
})
|
||
.collect(),
|
||
active: self.session.state.active_sequence.map(|i| i.0),
|
||
};
|
||
for g in &groups {
|
||
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &seqs, &t, &mut self.auto));
|
||
}
|
||
if maximized.is_none() {
|
||
self.ui.dock = dock;
|
||
}
|
||
for g in &groups {
|
||
let Some(p) = g.panels.get(g.active).copied() else { continue };
|
||
self.auto.add(&format!("panel.{}", p.id()), g.content, p.title());
|
||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.content).id_salt(("panel", p.id())));
|
||
child.set_clip_rect(g.content);
|
||
panels::show(self, &mut child, p, g.content);
|
||
}
|
||
for a in actions {
|
||
match a {
|
||
dock::DockAction::Activate(p) => {
|
||
self.ui.dock.activate(p);
|
||
}
|
||
dock::DockAction::Focus(p) => self.ui.focused = p,
|
||
dock::DockAction::Close(p) => self.ui.dock.close(p),
|
||
dock::DockAction::OpenSequence(id) => {
|
||
if self.session.state.active_sequence.map(|i| i.0) != Some(id)
|
||
&& let Err(e) = self.session.execute("sequence.open", json!({"item": id}))
|
||
{
|
||
self.ui.status = e.to_string();
|
||
}
|
||
}
|
||
dock::DockAction::CloseSequence(id) => {
|
||
if let Err(e) = self.session.execute("sequence.close", json!({"item": id})) {
|
||
self.ui.status = e.to_string();
|
||
}
|
||
}
|
||
dock::DockAction::MoveSequence(id, index) => {
|
||
if let Err(e) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) {
|
||
self.ui.status = e.to_string();
|
||
}
|
||
}
|
||
dock::DockAction::PanelMenu(p, pos) => {
|
||
// (with the frame it opened in: the click that opens it is not a click elsewhere)
|
||
let frame = ui.ctx().cumulative_frame_nr();
|
||
ui.ctx().data_mut(|d| {
|
||
d.insert_temp(egui::Id::new("panel-menu"), (p, pos));
|
||
d.insert_temp(egui::Id::new("panel-menu-opened"), frame);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
panels::panel_menu_popup(self, ui);
|
||
}
|
||
}
|
||
|
||
impl FilmcraftApp {
|
||
/// The "FilmCraft hit an error" window after a caught UI panic. Automation ids:
|
||
/// `error.dismiss`, `error.save`.
|
||
fn error_window(&mut self, ctx: &egui::Context) {
|
||
let Some(msg) = self.ui_error.clone() else { return };
|
||
let mut close = false;
|
||
egui::Window::new(tl!("FilmCraft hit an error"))
|
||
.id(egui::Id::new("FilmCraft hit an error"))
|
||
.collapsible(false)
|
||
.resizable(false)
|
||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||
.show(ctx, |ui| {
|
||
ui.set_max_width(460.0);
|
||
ui.label(tl!("Something went wrong while drawing the window. Your project is still open; save it to be safe."));
|
||
ui.add_space(6.0);
|
||
ui.label(egui::RichText::new(&msg).monospace().small());
|
||
if let Some(p) = crash::log_path() {
|
||
ui.label(egui::RichText::new(tlf!("Details: {path}", path = p.display())).small());
|
||
}
|
||
ui.add_space(8.0);
|
||
ui.horizontal(|ui| {
|
||
let s = ui.button(tl!("Save Project"));
|
||
self.auto.add("error.save", s.rect, "Save Project");
|
||
if s.clicked() {
|
||
let _ = crate::menus::invoke(self, ctx, "file.save", serde_json::json!({}));
|
||
}
|
||
let d = ui.button(tl!("Continue"));
|
||
self.auto.add("error.dismiss", d.rect, "Continue");
|
||
close |= d.clicked();
|
||
});
|
||
});
|
||
if close {
|
||
self.ui_error = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
impl eframe::App for FilmcraftApp {
|
||
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
|
||
// (all in one frame reads as a click); key/text runs go together up to a key release.
|
||
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);
|
||
// theme::install replaces the fonts: add the system Japanese font back (or fall back to
|
||
// English when a saved Japanese setting meets a system without one)
|
||
// (likewise Chinese without a Chinese face)
|
||
let japanese_missing = self.ui.language == i18n::Language::Ja && !i18n::install_japanese_font(ctx);
|
||
if japanese_missing || (self.ui.language == i18n::Language::ZhCn && !i18n::chinese_font_available()) {
|
||
self.ui.language = i18n::Language::En;
|
||
}
|
||
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;
|
||
if self.playback.playing && ctx.input(|i| i.viewport().visible()) == Some(false) {
|
||
// Nothing is shown while the window is hidden: not a dropped frame.
|
||
self.playback.hidden = true;
|
||
}
|
||
self.timeline_still = if self.ui.timeline.animating() { 0 } else { self.timeline_still.saturating_add(1) };
|
||
let had_synthetic = !self.synthetic.is_empty();
|
||
self.drain_control(ctx);
|
||
if !self.synthetic.is_empty() && !had_synthetic {
|
||
// Occluded macOS windows stop running `ui`; bring the window forward (without taking
|
||
// keyboard focus) so the input is processed.
|
||
self.raise_for_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}));
|
||
}
|
||
}
|
||
let settled = !std::mem::take(&mut self.monitor_inexact) && self.timeline_still > 0;
|
||
self.issue_screenshots(ctx, settled);
|
||
self.collect_screenshots(ctx);
|
||
}
|
||
|
||
fn on_exit(&mut self) {
|
||
// Flush the recovery journal and stop the auto-save worker; a session with nothing unsaved
|
||
// removes its journal, one with unsaved changes keeps it for the next launch.
|
||
self.session.shutdown();
|
||
}
|
||
|
||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||
if !self.fonts_ready {
|
||
ui.ctx().request_repaint();
|
||
return;
|
||
}
|
||
// No frame worker threads on the web: render queued frames here, within a time budget
|
||
// that leaves room for the UI pass (a no-op where workers run).
|
||
self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 }));
|
||
// A panic in one panel must not close the app (losing unsaved work) or leave a blank
|
||
// window: catch it, keep the session, and say what happened.
|
||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.frame(ui))).is_err() {
|
||
self.ui_error = Some(crash::take_last().unwrap_or_else(|| "unknown error".into()));
|
||
self.playback.playing = false;
|
||
}
|
||
self.error_window(ui.ctx());
|
||
let ctx = ui.ctx().clone();
|
||
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.queue_len() > 0 {
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The program mix that playback plays: `mix(device_frame, interleaved, channels)` renders the
|
||
/// active sequence at device rate `sr` from `device_frame`, with the live project snapshot (edits
|
||
/// made while playing are heard), Output Mapping, the 5.1 mixdown and voice-over cues.
|
||
pub fn playback_mix(session: &Session, seq_id: filmcraft_project::ItemId, sr: u32) -> impl FnMut(i64, &mut [f32], usize) + Send + 'static {
|
||
let project = session.project.clone();
|
||
let provider = session.media.provider(project.clone(), session.services.clone());
|
||
let previews = session.previews.clone();
|
||
// Settings ▸ Audio Hardware ▸ Output Mapping
|
||
let map = [session.prefs.audio_hardware.map_left, session.prefs.audio_hardware.map_right];
|
||
// Preferences ▸ Audio ▸ 5.1 Mixdown Type: how a 5.1 Mix plays on a stereo device
|
||
let mixdown = filmcraft_audio_dsp::channels::Mixdown::from_id(&session.prefs.audio.mixdown_type).unwrap_or_default();
|
||
let cues = panels::voiceover::cues(session, sr);
|
||
previews.live.publish_project(project.clone());
|
||
let mut resampler = None;
|
||
move |cursor: i64, buf: &mut [f32], ch: usize| {
|
||
// the newest project snapshot: mixer moves and other edits are heard while playing
|
||
let project = previews.live.project().filter(|p| p.sequence(seq_id).is_some()).unwrap_or_else(|| project.clone());
|
||
let Some(seq) = project.sequence(seq_id) else {
|
||
buf.fill(0.0);
|
||
return;
|
||
};
|
||
let n = buf.len() / ch.max(1);
|
||
let seq_sr = seq.settings.sample_rate;
|
||
// a 5.1 Mix plays as six channels (L, R, C, LFE, Ls, Rs) on a device with at least six
|
||
use filmcraft_audio_dsp::channels::Layout;
|
||
let layout = if ch >= 6 && seq.settings.audio_master == filmcraft_project::AudioChannels::Surround51 { Layout::Surround51 } else { Layout::Stereo };
|
||
let mix = if seq_sr == sr {
|
||
previews.mix_layout(&project, seq_id, cursor, n, &provider, layout, mixdown)
|
||
} else {
|
||
// Mix at the sequence rate as one continuous stream, interpolated to the device rate
|
||
// (started afresh when the rate or the channel layout changes while playing).
|
||
let key = (seq_sr, layout);
|
||
if resampler.as_ref().is_none_or(|(k, _)| *k != key) {
|
||
resampler = Some((key, filmcraft_audio_dsp::resample::StreamResampler::new(seq_sr, sr)));
|
||
}
|
||
let Some((_, r)) = resampler.as_mut() else {
|
||
buf.fill(0.0);
|
||
return;
|
||
};
|
||
let channels = r.process(cursor, n, |s0, m| previews.mix_layout(&project, seq_id, s0, m, &provider, layout, mixdown).channels);
|
||
filmcraft_frame::AudioBuffer { sample_rate: sr, channels }
|
||
};
|
||
if layout == Layout::Surround51 {
|
||
buf.fill(0.0);
|
||
for (i, frame) in buf.chunks_mut(ch).enumerate() {
|
||
for (c, x) in frame.iter_mut().take(6).enumerate() {
|
||
*x = mix.channels[c][i];
|
||
}
|
||
}
|
||
} else {
|
||
filmcraft_engine::settings::map_output(&mix.channels[0], &mix.channels[1.min(mix.channels.len() - 1)], buf, ch, map);
|
||
}
|
||
panels::voiceover::mix_cues(buf, ch, cursor, &cues);
|
||
}
|
||
}
|
||
|
||
/// The windowing layer (egui-winit, and egui's web backend) reports the clipboard shortcuts as
|
||
/// `Event::Copy` / `Event::Cut` / `Event::Paste` instead of key presses: Ctrl+C/X/V on Windows and
|
||
/// Linux, plus Ctrl+Insert, Shift+Insert and Shift+Delete on Windows. The shortcut bindings only
|
||
/// match key presses, so Copy, Cut, Paste, Paste Insert, Paste Attributes and (on Windows) Ripple
|
||
/// Delete never fired from the keyboard (#199). macOS was unaffected because its native menu bar
|
||
/// takes the key equivalents first.
|
||
///
|
||
/// Add the key press each event stands for, with the modifiers held, so the bindings see it. The
|
||
/// original event stays for anything else that reads it. Only called when no text field has
|
||
/// keyboard focus; text fields keep handling the clipboard themselves.
|
||
fn clipboard_events_as_keys(events: &mut Vec<egui::Event>, modifiers: egui::Modifiers) {
|
||
use egui::{Event, Key};
|
||
let keys: Vec<Key> = events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
Event::Copy if modifiers.command => Some(Key::C),
|
||
Event::Copy => Some(Key::Copy),
|
||
Event::Cut if modifiers.command => Some(Key::X),
|
||
Event::Cut if modifiers.shift => Some(Key::Delete),
|
||
Event::Cut => Some(Key::Cut),
|
||
Event::Paste(_) if modifiers.command => Some(Key::V),
|
||
Event::Paste(_) if modifiers.shift => Some(Key::Insert),
|
||
Event::Paste(_) => Some(Key::Paste),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
events.extend(keys.into_iter().map(|key| Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers }));
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod clipboard_key_tests {
|
||
use egui::{Event, Key, Modifiers};
|
||
|
||
fn keys(events: Vec<Event>, m: Modifiers) -> Vec<(Key, Modifiers)> {
|
||
let mut events = events;
|
||
super::clipboard_events_as_keys(&mut events, m);
|
||
events.into_iter().filter_map(|e| if let Event::Key { key, modifiers, pressed: true, .. } = e { Some((key, modifiers)) } else { None }).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_c_x_v_become_key_presses() {
|
||
let c = Modifiers::COMMAND;
|
||
assert_eq!(keys(vec![Event::Copy], c), vec![(Key::C, c)]);
|
||
assert_eq!(keys(vec![Event::Cut], c), vec![(Key::X, c)]);
|
||
assert_eq!(keys(vec![Event::Paste("x".into())], c), vec![(Key::V, c)]);
|
||
}
|
||
|
||
#[test]
|
||
fn extra_modifiers_are_kept() {
|
||
// Paste Insert (Ctrl+Shift+V) and Paste Attributes (Ctrl+Alt+V) also arrive as Paste
|
||
let cs = Modifiers::COMMAND | Modifiers::SHIFT;
|
||
assert_eq!(keys(vec![Event::Paste("x".into())], cs), vec![(Key::V, cs)]);
|
||
let ca = Modifiers::COMMAND | Modifiers::ALT;
|
||
assert_eq!(keys(vec![Event::Paste("x".into())], ca), vec![(Key::V, ca)]);
|
||
}
|
||
|
||
#[test]
|
||
fn windows_insert_and_delete_variants() {
|
||
// Shift+Delete (Ripple Delete) arrives as Cut on Windows; Shift+Insert as Paste
|
||
assert_eq!(keys(vec![Event::Cut], Modifiers::SHIFT), vec![(Key::Delete, Modifiers::SHIFT)]);
|
||
assert_eq!(keys(vec![Event::Paste("x".into())], Modifiers::SHIFT), vec![(Key::Insert, Modifiers::SHIFT)]);
|
||
}
|
||
|
||
#[test]
|
||
fn other_events_are_left_alone() {
|
||
let mut events = vec![Event::Text("a".into()), Event::Copy];
|
||
super::clipboard_events_as_keys(&mut events, Modifiers::COMMAND);
|
||
assert_eq!(events.len(), 3);
|
||
assert!(matches!(events[0], Event::Text(_)));
|
||
assert!(matches!(events[1], Event::Copy));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod gpu_fallback_tests {
|
||
use filmcraft_render::plan::{FramePlan, PlanLayer};
|
||
|
||
#[test]
|
||
fn plan_side_covers_output_and_every_layer() {
|
||
let frame = |w: u32, h: u32| std::sync::Arc::new(filmcraft_frame::VideoFrame::rgba_f32(w, h, vec![0.0; (w * h * 4) as usize]));
|
||
let layer =
|
||
|w, h| PlanLayer { frame: frame(w, h), matrix: filmcraft_geom::Affine::IDENTITY, opacity: 1.0, blend: filmcraft_render::Blend::Normal, fx: None };
|
||
// a 4K source in an HD sequence still needs a 3840-wide texture on the GPU
|
||
let p = FramePlan::Layers { width: 1920, height: 1080, layers: vec![layer(64, 64), layer(3840, 2160)] };
|
||
assert_eq!(super::plan_side(&p), 3840);
|
||
let p = FramePlan::Layers { width: 1920, height: 1080, layers: vec![] };
|
||
assert_eq!(super::plan_side(&p), 1920);
|
||
let p = FramePlan::Image(filmcraft_render::Image::new(800, 4000));
|
||
assert_eq!(super::plan_side(&p), 4000);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod audio_recovery_tests {
|
||
use super::*;
|
||
use std::sync::{
|
||
Arc,
|
||
atomic::{AtomicUsize, Ordering},
|
||
};
|
||
|
||
struct Device {
|
||
ready: bool,
|
||
starts: Arc<AtomicUsize>,
|
||
reading: Option<u64>,
|
||
}
|
||
|
||
impl AudioOut for Device {
|
||
fn sample_rate(&self) -> u32 {
|
||
48000
|
||
}
|
||
fn channels(&self) -> usize {
|
||
2
|
||
}
|
||
fn configure(&mut self, hardware: &filmcraft_engine::settings::AudioHardwarePrefs, _: Option<u32>) {
|
||
self.ready = hardware.default_output == "available";
|
||
}
|
||
fn start(&mut self, _: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
|
||
self.starts.fetch_add(1, Ordering::Relaxed);
|
||
if self.ready { Ok(48000) } else { Err("device unavailable".into()) }
|
||
}
|
||
fn stop(&mut self) {}
|
||
fn played_frames(&self) -> Option<u64> {
|
||
self.reading
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn changing_hardware_recovers_wall_clock_playback_without_rewinding() {
|
||
let mut session = Session::default();
|
||
session.execute("file.newSequence", json!({"width":16,"height":16})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let mut app = FilmcraftApp::new(session);
|
||
let starts = Arc::new(AtomicUsize::new(0));
|
||
app.audio = Some(Box::new(Device { ready: false, starts: starts.clone(), reading: Some(0) }));
|
||
app.apply_prefs(&ctx);
|
||
app.play(1.0);
|
||
app.end_preroll(0.0);
|
||
assert!(!app.playback.audio_clock);
|
||
app.session.set_playhead(Tick::from_seconds_f64(2.0));
|
||
app.session.prefs.audio_hardware.default_output = "available".into();
|
||
app.apply_prefs(&ctx);
|
||
assert_eq!(starts.load(Ordering::Relaxed), 2);
|
||
assert!(app.playback.audio_clock);
|
||
assert_eq!(app.playback.anchor_tick, app.session.playhead());
|
||
app.stop();
|
||
}
|
||
|
||
#[test]
|
||
fn a_failed_device_clock_resumes_from_the_displayed_frame() {
|
||
let mut session = Session::default();
|
||
session.execute("file.openDemoProject", json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let mut app = FilmcraftApp::new(session);
|
||
app.audio = Some(Box::new(Device { ready: true, starts: Arc::new(AtomicUsize::new(0)), reading: None }));
|
||
let displayed = app.session.sequence_rate().tick_of(48);
|
||
app.session.set_playhead(displayed);
|
||
app.playback.playing = true;
|
||
app.playback.audio_clock = true;
|
||
app.playback.anchor_tick = Tick::ZERO;
|
||
app.playback.anchor_time = 0.0;
|
||
let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx()));
|
||
output.textures_delta.clear();
|
||
assert!(!app.playback.audio_clock);
|
||
assert_eq!(app.playback.anchor_tick, displayed);
|
||
assert_eq!(app.session.playhead(), displayed);
|
||
assert!(app.ui.status.contains("Audio output failed"));
|
||
app.stop();
|
||
}
|
||
}
|
||
|
||
/// A physical-pixel screen position as a point in the window's UI space, given the window's content origin in points.
|
||
///
|
||
/// Assumes the whole virtual screen uses the window's `pixels_per_point`: with monitors of mixed
|
||
/// DPI the point can be off when the cursor and the window's origin are on different monitors.
|
||
fn screen_px_to_ui(px: (i32, i32), pixels_per_point: f32, content_min: egui::Pos2) -> egui::Pos2 {
|
||
let ppp = if pixels_per_point.is_finite() && pixels_per_point > 0.0 { pixels_per_point } else { 1.0 };
|
||
egui::pos2(px.0 as f32 / ppp - content_min.x, px.1 as f32 / ppp - content_min.y)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod screen_px_tests {
|
||
use super::screen_px_to_ui;
|
||
|
||
#[test]
|
||
fn maps_screen_pixels_into_the_window() {
|
||
let p = screen_px_to_ui((1300, 900), 2.0, egui::pos2(100.0, 50.0));
|
||
assert_eq!((p.x, p.y), (550.0, 400.0));
|
||
}
|
||
|
||
#[test]
|
||
fn a_hostile_scale_does_not_divide_by_zero() {
|
||
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||
let p = screen_px_to_ui((10, 20), ppp, egui::pos2(0.0, 0.0));
|
||
assert!(p.x.is_finite() && p.y.is_finite());
|
||
}
|
||
}
|
||
}
|