Files
effectcraft/apps/effectcraft/src/main.rs
T
skylartaylor 9ee853715d M0.37: migrate EffectCraft to egui 0.37
Reconcile main 991904888a and preserve the reviewed lifecycle, IME, rendering and lockfile changes.

Formatting, strict lint, layers, assets and WASM passed. Full CI retained 3,961 passes, 14 baseline-matching GPU failures and 31 ignored executions. The local review commit has explicit approval to retain these documented failures; native/browser acceptance and paired app performance remain open.
2026-10-25 02:01:16 +01:00

650 lines
33 KiB
Rust

//! EffectCraft desktop app.
//!
//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [--] [project.ecproj | media files…]`
//!
//! An unknown option or a bad port prints an error and exits with status 1 before anything starts.
//!
//! Without a project the app starts with an empty Untitled Project, as After Effects does;
//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).
//!
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! see `effectcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
mod appearance;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod appimage;
#[cfg(target_os = "macos")]
mod apple_events;
mod audio_out;
mod control_server;
mod launch_guard;
#[cfg(target_os = "macos")]
mod native_menu;
// Built everywhere so it stays checked; the macOS and Linux apps use it.
#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
mod sheet_dialogs;
mod window;
use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by
/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock.
/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too).
#[cfg(not(target_os = "macos"))]
const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
fn app_icon() -> egui::IconData {
#[cfg(target_os = "macos")]
let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png");
#[cfg(not(target_os = "macos"))]
let png: &[u8] = ICON_PNG;
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// Both native JSON projects and Save a Copy As XML projects are openable on launch.
/// Keep this classification shared by project opening and the subsequent media-import pass.
fn is_project_file(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
}
/// winit 0.30 cannot deliver native Wayland file drops. XWayland can.
#[cfg(target_os = "linux")]
fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> {
(wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11")))
.then_some("Native Wayland file drops are unavailable. Use File ▸ Import ▸ File… or run via XWayland (see README, Linux).")
}
/// The command line.
#[derive(Debug, Default, PartialEq)]
struct Args {
control_port: Option<u16>,
files: Vec<String>,
/// Open the demo project when no project is given (`--demo`).
demo: bool,
/// Show or skip the Home screen (`--home`), else the Startup preference decides.
home: Option<bool>,
}
/// Parse the arguments after the program name; `Ok(None)` for `--version`. `env_port` is
/// `EFFECTCRAFT_CONTROL_PORT`, used when `--control` isn't given. An unknown option or a bad port
/// is an error, so it is neither imported as a file nor dropped; after `--` every argument is a file.
fn parse_args(args: impl IntoIterator<Item = String>, env_port: Result<String, std::env::VarError>) -> Result<Option<Args>, String> {
let mut out = Args::default();
let mut args = args.into_iter();
let mut options = true;
while let Some(a) = args.next() {
if !options {
out.files.push(a);
continue;
}
match a.as_str() {
"--" => options = false,
"--control" => out.control_port = Some(control_port("--control", args.next().as_deref())?),
"--demo" => out.demo = true,
"--empty" => out.demo = false,
"--home" => out.home = Some(true),
"--version" => return Ok(None),
_ => match a.strip_prefix("--control=") {
Some(v) => out.control_port = Some(control_port("--control", Some(v))?),
// macOS LaunchServices may pass a process serial number (`-psn_0_1234`), notably on
// the first launch of a downloaded app; it is not an option of ours.
None if a.starts_with("-psn_") => {}
None if a.starts_with('-') && a != "-" => return Err(format!("unknown option `{a}`")),
None => out.files.push(a),
},
}
}
if out.control_port.is_none() {
out.control_port = match env_port {
Ok(v) if v.is_empty() => None,
Ok(v) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v))?),
Err(std::env::VarError::NotPresent) => None,
Err(std::env::VarError::NotUnicode(v)) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v.to_string_lossy()))?),
};
}
Ok(Some(out))
}
/// A control-channel port from 1 to 65535 (0 would listen on a port nobody is told); `name` is
/// the flag or variable it came from.
fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
let need = format!("{name} needs a port number from 1 to 65535");
match value {
None => Err(need),
Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),
}
}
/// The commands' file dialogs open without blocking the frame where the host's backend would:
/// on macOS as sheets instead of nested modal loops (#511), on Linux through the desktop
/// portal from a worker thread — a blocking portal dialog leaves the main thread waiting on its
/// answer without pumping events, so the window stops answering the compositor and the desktop
/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps
/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.
fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
let ctx = ctx.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let _ = (app, ctx);
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
// feed the compatibility report.
effectcraft_engine::logging::install();
effectcraft_engine::logging::install_panic_hook();
let Args { control_port, files, demo, home } = match parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT")) {
Ok(Some(args)) => args,
Ok(None) => {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
// Before the session log, preferences and the window: nothing has started yet.
Err(e) => {
eprintln!("effectcraft: {e}");
std::process::exit(1);
}
};
// The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
// kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
// `--version` leaves no file behind; records logged until now are written to it first.
if let Some(dir) = effectcraft_host::config_dir() {
match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
Err(e) => log::warn!("no log file: {e}"),
}
}
// The font menus list the installed fonts: read their names while the window opens.
effectcraft_engine::text::fonts::scan_system_in_background();
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
#[cfg(target_os = "linux")]
appimage::integrate_from_env(ICON_PNG);
// Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch
// stopped as its window opened (a crashing graphics driver, #243).
let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
let notice = launch.notice.take();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("EffectCraft")
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(control_port.is_none())
.with_icon(app_icon())
// Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.
.with_app_id(appimage::APP_ID),
event_loop_builder: agent_event_loop(control_port.is_some()),
wgpu_options: wgpu_options(launch.backends),
..Default::default()
};
// Start-up milestones go to stderr and the session log, so a window that never appears
// shows how far start-up got (#234).
log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
let result = eframe::run_native(
"EffectCraft",
options,
Box::new(move |cc| {
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
match &cc.wgpu_render_state {
Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
None => log::info!("graphics: no wgpu device"),
}
// This executable owns the shared device's handlers. Install them before
// EffectCraft's compositor pipelines are built, without changing handlers from
// inside Gpu::new. eframe has already constructed its presentation renderer here.
let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
if let Some(rs) = &cc.wgpu_render_state {
let errors = gpu_failures.clone();
rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {
let message = format!("uncaptured GPU error: {error}");
if errors.report(&message, false) {
log::error!("{message}");
}
}));
let lost = gpu_failures.clone();
rs.device.set_device_lost_callback(move |reason, message| {
lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
});
}
let mut session = effectcraft_host::session();
// Lazy open and non-blocking auto-save: footage is checked and auto-saves are
// written on background threads (M13.14).
session.check_footage_on_open = true;
session.autosave.background = true;
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
// config directory. Agent-driven runs (`--control`) skip crash recovery.
if let Some(dir) = effectcraft_host::config_dir() {
// WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.
let plugins = dir.join("Plug-ins");
if plugins.is_dir() {
let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()}));
}
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
}
session.load_settings();
// Fit the window to its monitor, or put it back where it was (still hidden).
let saved_window = session.config.as_deref().and_then(window::load);
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
let project = files.iter().find(|f| is_project_file(f)).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::warn!("{p}: {e}");
}
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
let media: Vec<String> = files.iter().filter(|f| !is_project_file(f)).cloned().collect();
if !media.is_empty()
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
{
log::warn!("import: {e}");
}
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
// The ScriptUI panels open when the app last quit open again (a failing one is
// reported in a toast and forgotten).
let _ = session.execute("window.restoreScriptPanels", json!({}));
let mut app = EffectcraftApp::new(session);
app.set_gpu_failure_bridge(gpu_failures);
app.ui.start_screen = show_home;
if let Some(n) = notice {
app.ui.status = n;
}
if let Some(r) = recovery {
app.offer_recovery(r);
}
#[cfg(target_os = "linux")]
if let Some(note) = wayland_drop_notice(std::env::var_os("WAYLAND_DISPLAY").as_deref(), std::env::var_os("WINIT_UNIX_BACKEND").as_deref())
&& app.ui.status.is_empty()
{
app.ui.status = note.into();
}
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}));
app.hooks.pick_save = Some(Box::new(|name: &str| {
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
// The window closes after a lost graphics device (#591); say why, unless an agent drives
// the app through the control channel.
if control_port.is_none() {
app.hooks.device_lost = Some(Box::new(|saved: Option<Result<String, String>>| {
let saved = match saved {
Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."),
Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."),
None => String::new(),
};
rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("EffectCraft: graphics device lost")
.set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}"))
.show();
}));
}
app.hooks.pick_open_project = Some(Box::new(|| {
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())
}));
app.hooks.system_theme = appearance::service();
app.hooks.audio_device = Some(Box::new(audio_out::open));
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
// A default with a folder that exists opens the dialog there.
let path = std::path::Path::new(name);
// (No filter for a name without an extension, such as a folder to create.)
let mut d = if ext.is_empty() { rfd::FileDialog::new() } else { rfd::FileDialog::new().add_filter(ext, &[ext]) };
if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
d = d.set_directory(dir);
}
let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
}));
// The commands' file dialogs don't block the frame where the platform's backend
// would (#511, #1275); see [`install_file_dialogs`].
install_file_dialogs(&mut app, &cc.egui_ctx);
#[cfg(target_os = "macos")]
{
app.hooks.app_action = Some(Box::new(native_menu::app_action));
app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
}
if let Some(port) = control_port {
disable_app_nap();
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
log::info!("app created; the window shows after its first frame");
Ok(Box::new(Desktop {
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar::new(&cc.egui_ctx),
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&cc.egui_ctx),
app,
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
launch,
drawn: false,
}))
}),
);
match &result {
Ok(()) => log::info!("the window closed"),
Err(e) => {
if on_gl {
launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
}
startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
}
}
result
}
/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)
/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
/// #198) still opens the app, and the compositor then checks what it needs and falls back to
/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.
fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {
use eframe::egui_wgpu::WgpuSetup;
let mut config = eframe::WgpuConfiguration::default();
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
if let Some(b) = backends {
setup.instance_descriptor.backends = b;
}
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs
// a compute shader when the device is created, one that Metal can't compile on an Intel
// Iris Pro: the device is lost and the window never opens.
// `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on.
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits()));
}
config
}
/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`,
/// so memory the compositor frees on the shared device goes back to the driver.
fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> {
eframe::wgpu::DeviceDescriptor {
label: Some("EffectCraft device"),
required_limits: device_limits(adapter),
memory_hints: eframe::wgpu::MemoryHints::MemoryUsage,
..Default::default()
}
}
/// The limits to request from an adapter that offers `adapter`: all of them, with textures
/// capped at 16384 px like the headless renderer's device.
fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }
}
/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting
/// silently, since release builds on Windows have no console. `gl_next`: the launch guard has
/// the next launch try OpenGL.
fn startup_failed(e: &eframe::Error, gl_next: bool) {
let next = if gl_next { "The next launch will try OpenGL instead. " } else { "" };
let message = format!(
"EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
);
// Standard error and the session log; standard error alone when RUST_LOG turned errors off.
if log::log_enabled!(log::Level::Error) {
log::error!("{message}");
} else {
eprintln!("effectcraft: {message}");
}
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("EffectCraft")
.set_description(message)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
/// The desktop app: the egui app plus, on macOS, the system menu bar.
struct Desktop {
app: EffectcraftApp,
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar,
/// Documents opened from Finder, the Dock or `open` (macOS).
#[cfg(target_os = "macos")]
apple_events: apple_events::Opened,
/// The window's size and position, saved for the next launch.
window: window::Tracker,
launch: launch_guard::Launch,
/// The first frame was drawn (logged once).
drawn: bool,
}
impl eframe::App for Desktop {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
self.menu.update(&mut self.app, ctx);
self.app.tick(ctx, frame);
self.app.finish_ime_logic(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
self.window.frame(ui.ctx(), self.app.session.config.as_deref());
if !self.drawn {
self.drawn = true;
log::info!("first frame drawn");
}
// The launch guard's marker goes once the window has kept drawing for a few seconds: a
// driver may still crash as the window is shown and its surface re-created.
if let Some(left) = self.launch.frame_drawn() {
ui.ctx().request_repaint_after(left);
}
}
fn on_exit(&mut self) {
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
#[cfg(target_os = "macos")]
self.apple_events.feed(self.app.is_ready(), ctx, raw_input);
self.app.raw_input_hook(ctx, raw_input);
}
}
/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
/// focus stays where it is (macOS; winit activates ignoring other apps by default).
fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
#[cfg(target_os = "macos")]
if agent {
return Some(Box::new(|b| {
use winit::platform::macos::EventLoopBuilderExtMacOS;
b.with_activate_ignoring_other_apps(false);
}));
}
let _ = agent;
None
}
/// Agents drive the app while its window is covered or on another Space. macOS App Nap would
/// throttle the event loop then, stalling the control channel; opt out for `--control` runs.
fn disable_app_nap() {
#[cfg(target_os = "macos")]
{
use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString};
let info = NSProcessInfo::processInfo();
let reason = NSString::from_str("EffectCraft control channel");
let opts = NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical;
// Leaked on purpose: the activity lasts for the life of the process.
std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));
}
}
#[cfg(test)]
mod tests {
use super::{Args, parse_args};
use std::env::VarError;
fn parse_env(args: &[&str], env_port: Result<String, VarError>) -> Result<Option<Args>, String> {
parse_args(args.iter().map(|a| a.to_string()), env_port)
}
fn parse(args: &[&str]) -> Option<Args> {
parse_env(args, Err(VarError::NotPresent)).unwrap()
}
fn fails(args: &[&str]) -> String {
parse_env(args, Err(VarError::NotPresent)).unwrap_err()
}
#[cfg(target_os = "linux")]
#[test]
fn wayland_drop_notice_recommends_import_or_xwayland() {
use std::ffi::OsStr;
let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap();
assert!(warning.contains("File ▸ Import") && warning.contains("XWayland"));
assert!(super::wayland_drop_notice(None, None).is_none());
assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none());
assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none());
}
/// The window's wgpu instance skips indirect-call validation (Intel Metal, #1153).
#[test]
fn the_window_instance_skips_indirect_call_validation() {
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {
return;
}
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = super::wgpu_options(None).wgpu_setup else {
panic!("the default setup creates its own instance");
};
// Intel Metal can't compile the validation's compute shader, and the device is lost.
assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
/// The window's device never asks for more than the adapter offers (#198).
#[test]
fn device_limits_fit_the_adapter() {
use eframe::wgpu::Limits;
for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {
let l = super::device_limits(adapter.clone());
assert!(l.check_limits(&adapter), "{l:?}");
assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);
}
let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
}
/// The window device asks for `MemoryUsage`: the adapter's limits, unchanged.
#[test]
fn the_window_device_favours_memory_usage() {
use eframe::wgpu::Limits;
let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
let d = super::device_descriptor(big);
assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage));
assert_eq!(d.required_limits.max_texture_dimension_2d, 16384);
assert_eq!(d.label, Some("EffectCraft device"));
}
/// A positional .ecprojx is a project file, not an unsupported media import (#528).
#[test]
fn launch_classifies_xml_and_json_projects_as_projects() {
for path in ["Copy.ecprojx", "/tmp/Scene.ecproj", "C:\\\\work\\\\Shot.ECPROJX"] {
assert!(super::is_project_file(path), "{path}");
}
for path in ["scene.ecprojx.png", "scene.mp4", "scene", "copy.ecprojx.bak"] {
assert!(!super::is_project_file(path), "{path}");
}
let files = ["scene.ecprojx".to_string(), "clip.mov".to_string()];
assert_eq!(files.iter().find(|f| super::is_project_file(f)).map(String::as_str), Some("scene.ecprojx"));
assert_eq!(files.iter().filter(|f| !super::is_project_file(f)).map(String::as_str).collect::<Vec<_>>(), vec!["clip.mov"],);
}
/// Launching without a project opens an empty project, not the demo (#204).
#[test]
fn no_arguments_start_an_empty_project() {
assert_eq!(parse(&[]), Some(Args::default()));
assert!(parse(&["--demo"]).is_some_and(|a| a.demo));
assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));
let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();
assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));
assert_eq!(parse(&["--version"]), None);
assert_eq!(parse_env(&[], Ok("9000".into())).unwrap().map(|a| a.control_port), Some(Some(9000)));
}
/// A missing, non-numeric or zero `--control` value stops the launch instead of starting
/// without a control server, and the argument after `--control` isn't reinterpreted (#997).
#[test]
fn control_needs_a_port_from_1_to_65535() {
let need = "--control needs a port number from 1 to 65535";
assert_eq!(fails(&["--control"]), need);
assert_eq!(fails(&["--control", "--demo"]), format!("{need}, not `--demo`"));
assert_eq!(fails(&["--control", "--version"]), format!("{need}, not `--version`"));
for bad in ["abc", "0", "70000", "-1", ""] {
assert_eq!(fails(&["--control", bad]), format!("{need}, not `{bad}`"));
assert_eq!(fails(&[&format!("--control={bad}")]), format!("{need}, not `{bad}`"));
}
assert_eq!(parse(&["--control=9877"]).map(|a| a.control_port), Some(Some(9877)));
assert_eq!(parse(&["--control", "65535"]).map(|a| a.control_port), Some(Some(65535)));
}
/// An unknown option is an error, not a file to import (#997); `--` ends the options.
#[test]
fn unknown_options_are_errors_not_files() {
for bad in ["--automation-read-root", "--automation-write-root=/tmp", "-h", "--Demo", "--demo=yes"] {
assert_eq!(fails(&[bad, "/tmp/folder"]), format!("unknown option `{bad}`"));
}
// Parsed left to right: an error before `--version` wins.
assert_eq!(fails(&["--nope", "--version"]), "unknown option `--nope`");
// Finder's process serial number is ignored, not imported or refused.
assert_eq!(parse(&["-psn_0_12345"]).map(|a| a.files), Some(vec![]));
let a = parse(&["clip.mov", "-", "--", "--demo", "-name.png"]).unwrap_or_default();
assert_eq!(a.files, ["clip.mov", "-", "--demo", "-name.png"]);
assert!(!a.demo);
}
/// `--control` wins over `EFFECTCRAFT_CONTROL_PORT`; the variable is checked only when it is
/// used, so a bad value doesn't stop `--control` or `--version`.
#[test]
fn control_port_variable_is_checked_when_used() {
let need = "EFFECTCRAFT_CONTROL_PORT needs a port number from 1 to 65535";
assert_eq!(parse_env(&[], Ok("abc".into())).unwrap_err(), format!("{need}, not `abc`"));
assert_eq!(parse_env(&[], Ok("0".into())).unwrap_err(), format!("{need}, not `0`"));
assert_eq!(parse_env(&[], Ok(String::new())), Ok(Some(Args::default())));
assert_eq!(parse_env(&["--control", "9877"], Ok("abc".into())).unwrap().map(|a| a.control_port), Some(Some(9877)));
assert_eq!(parse_env(&["--version"], Ok("abc".into())), Ok(None));
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
let not_utf8 = VarError::NotUnicode(std::ffi::OsString::from_vec(vec![0x39, 0xff]));
assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));
}
}
/// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the
/// compositor while it is open, so the commands' file dialogs go through the non-blocking
/// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.
#[test]
fn commands_file_dialogs_do_not_block_the_frame() {
let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());
super::install_file_dialogs(&mut app, &egui::Context::default());
#[cfg(any(target_os = "macos", target_os = "linux"))]
assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
}
}