mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
- footage.check: background footage check after File > Open (Progress panel), not an undo step - timeline: track-matte / parent menus built only while open (was O(n^2) per frame); expression-row lookup per layer; panels hold Arc<Comp> snapshots instead of deep clones - Project panel: virtualised vertical scrolling, selection reveal, thumbnails rendered off the UI thread - effectcraft-cli bench --ops (engine::perf + ui-egui::bench) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
9.3 KiB
Rust
208 lines
9.3 KiB
Rust
//! EffectCraft desktop app.
|
|
//!
|
|
//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [project.ecproj | media files…]`
|
|
//!
|
|
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
|
|
//! see `effectcraft_ui_egui::control` for the methods.
|
|
|
|
mod audio_out;
|
|
mod control_server;
|
|
#[cfg(target_os = "macos")]
|
|
mod native_menu;
|
|
|
|
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.
|
|
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] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
|
|
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
|
}
|
|
|
|
fn main() -> eframe::Result {
|
|
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
|
|
effectcraft_engine::logging::install();
|
|
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
|
let mut files = Vec::new();
|
|
let mut demo = true;
|
|
let mut home: Option<bool> = None;
|
|
let mut args = std::env::args().skip(1);
|
|
while let Some(a) = args.next() {
|
|
match a.as_str() {
|
|
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
|
"--demo" => demo = true,
|
|
"--empty" => demo = false,
|
|
"--home" => home = Some(true),
|
|
"--version" => {
|
|
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
|
|
return Ok(());
|
|
}
|
|
_ => files.push(a),
|
|
}
|
|
}
|
|
let options = eframe::NativeOptions {
|
|
viewport: egui::ViewportBuilder::default()
|
|
.with_title("EffectCraft")
|
|
.with_inner_size([1680.0, 1020.0])
|
|
.with_min_inner_size([960.0, 600.0])
|
|
.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("ai.storyteller.effectcraft"),
|
|
event_loop_builder: agent_event_loop(control_port.is_some()),
|
|
..Default::default()
|
|
};
|
|
eframe::run_native(
|
|
"EffectCraft",
|
|
options,
|
|
Box::new(move |cc| {
|
|
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) = 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();
|
|
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
|
|
let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned();
|
|
if let Some(p) = project {
|
|
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
|
eprintln!("effectcraft: {e}");
|
|
}
|
|
} else if demo {
|
|
let _ = session.execute("file.openDemoProject", json!({}));
|
|
}
|
|
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".ecproj")).cloned().collect();
|
|
if !media.is_empty()
|
|
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
|
|
{
|
|
eprintln!("effectcraft: {e}");
|
|
}
|
|
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
|
|
let mut app = EffectcraftApp::new(session);
|
|
app.ui.start_screen = show_home;
|
|
if let Some(r) = recovery {
|
|
app.offer_recovery(r);
|
|
}
|
|
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())
|
|
}));
|
|
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.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| {
|
|
rfd::FileDialog::new().add_filter(ext, &[ext]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
|
}));
|
|
#[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);
|
|
}
|
|
Ok(Box::new(Desktop {
|
|
#[cfg(target_os = "macos")]
|
|
menu: native_menu::NativeBar::new(&cc.egui_ctx),
|
|
app,
|
|
}))
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
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.logic(ctx, frame);
|
|
}
|
|
|
|
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
|
self.app.ui(ui, frame);
|
|
}
|
|
|
|
fn on_exit(&mut self) {
|
|
self.app.on_exit();
|
|
}
|
|
|
|
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
|
self.app.raw_input_hook(ctx, raw_input);
|
|
}
|
|
}
|
|
|
|
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
|
|
/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows),
|
|
/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others).
|
|
fn config_dir() -> Option<std::path::PathBuf> {
|
|
use std::path::PathBuf;
|
|
if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") {
|
|
return Some(PathBuf::from(d));
|
|
}
|
|
let home = || std::env::var_os("HOME").map(PathBuf::from);
|
|
if cfg!(target_os = "macos") {
|
|
return home().map(|h| h.join("Library/Application Support/EffectCraft"));
|
|
}
|
|
if cfg!(target_os = "windows") {
|
|
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft"));
|
|
}
|
|
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft"))
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
}
|