638 lines
29 KiB
Rust
638 lines
29 KiB
Rust
//! LightCraft desktop app.
|
|
//!
|
|
//! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control <port>] [files or folders…]`
|
|
//!
|
|
//! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else
|
|
//! `$LIGHTCRAFT_LIBRARY`, else the library last opened with Settings → Open Library…, else
|
|
//! `~/Pictures/LightCraft Library`; a new library starts with the
|
|
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
|
|
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
|
|
//! nothing (demo photos unless files are given; used by the README showcase scripts). A library
|
|
//! that can't be opened is never replaced silently: the window says why and asks what to do
|
|
//! (`lightcraft_ui_egui::panels::library_problem`).
|
|
//!
|
|
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
|
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
|
//! See `lightcraft_ui_egui::control` for the methods.
|
|
//!
|
|
//! On Windows, release builds are GUI-subsystem programs: launching the app opens no console
|
|
//! window (issue #7). Their `--help` / `--version` output and diagnostics then have no console to
|
|
//! go to; use `lightcraft-cli` (a console program) from a terminal, or a debug build.
|
|
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
|
#![forbid(unsafe_code)]
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
mod alloc_release;
|
|
mod control_server;
|
|
#[cfg(target_os = "macos")]
|
|
mod native_menu;
|
|
|
|
use lightcraft_engine::Session;
|
|
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
|
|
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
|
|
|
|
/// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name.
|
|
const APP_ID: &str = "ai.storyteller.lightcraft";
|
|
|
|
/// The window, taskbar and Dock icon (assets/app-icon/, regenerated by packaging/icons.sh). macOS
|
|
/// gets the version with Apple's transparent margin so it sits right among the other Dock icons.
|
|
fn app_icon() -> egui::IconData {
|
|
#[cfg(target_os = "macos")]
|
|
let png: &[u8] = include_bytes!("../../../assets/app-icon/lightcraft-macos-512.png");
|
|
#[cfg(not(target_os = "macos"))]
|
|
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.lightcraft.png");
|
|
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
|
}
|
|
|
|
struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
|
|
|
impl eframe::App for App {
|
|
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(m) = self.2.as_mut() {
|
|
m.update(&mut self.0, ctx);
|
|
}
|
|
self.0.logic(ctx);
|
|
self.1.tick(&mut self.0, ctx);
|
|
}
|
|
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
|
self.0.raw_input_hook(raw);
|
|
}
|
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
|
self.0.ui(ui);
|
|
}
|
|
fn on_exit(&mut self) {
|
|
if let Err(e) = self.1.save(&self.0) {
|
|
eprintln!("lightcraft: {e}");
|
|
}
|
|
if let Err(e) = self.0.session.close_library() {
|
|
eprintln!("lightcraft: saving the library failed: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
|
|
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
|
|
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12).
|
|
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
|
let mut c = eframe::egui_wgpu::WgpuConfiguration::default();
|
|
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
|
|
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
|
|
}
|
|
c
|
|
}
|
|
|
|
/// Written while the GPU compute device is created, removed once that returned
|
|
/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts).
|
|
fn gpu_marker_path() -> Option<std::path::PathBuf> {
|
|
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
|
return None;
|
|
}
|
|
config_dir().map(|d| d.join("gpu-init.marker"))
|
|
}
|
|
|
|
/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU
|
|
/// driver while creating the device), the notice to show — GPU rendering then starts off.
|
|
fn gpu_crash_check() -> Option<String> {
|
|
let marker = gpu_marker_path();
|
|
let left = marker.as_deref().and_then(lightcraft_engine::gpu::backend::take_init_marker);
|
|
lightcraft_engine::gpu::backend::set_init_marker(marker);
|
|
left.map(|what| gpu_crash_notice(&what))
|
|
}
|
|
|
|
fn gpu_crash_notice(what: &str) -> String {
|
|
format!(
|
|
"LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \
|
|
To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \
|
|
LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)."
|
|
)
|
|
}
|
|
|
|
fn config_dir() -> Option<std::path::PathBuf> {
|
|
lightcraft_engine::camera_profiles::config_dir()
|
|
}
|
|
|
|
/// The saved UI state and app settings (`<config>/ui.json`), if any, and a warning for the user
|
|
/// when the file exists but can't be used (issue #103): a damaged file is kept as
|
|
/// `ui.json.corrupt-<unix time>` first, so the next save can't lose the library location in it;
|
|
/// one that can't be read at all is not written this session (`keep_file`).
|
|
fn load_prefs() -> (Option<UiState>, Option<String>, bool) {
|
|
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
|
return (None, None, false);
|
|
}
|
|
let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) };
|
|
load_prefs_at(&path)
|
|
}
|
|
|
|
fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bool) {
|
|
let bytes = match std::fs::read(path) {
|
|
Ok(b) => b,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
|
|
Err(e) => {
|
|
let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display());
|
|
return (None, Some(msg), true);
|
|
}
|
|
};
|
|
match serde_json::from_slice::<UiState>(&bytes) {
|
|
Ok(ui) => (Some(ui.sanitized()), None, false),
|
|
Err(e) => {
|
|
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
|
let keep = path.with_file_name(format!("ui.json.corrupt-{secs}"));
|
|
let kept = std::fs::rename(path, &keep);
|
|
let what = match &kept {
|
|
Ok(()) => format!("it was kept as {}", keep.display()),
|
|
Err(r) => format!("it couldn't be set aside ({r})"),
|
|
};
|
|
let msg = format!(
|
|
"The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.",
|
|
path.display()
|
|
);
|
|
(None, Some(msg), kept.is_err())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full
|
|
/// disk leaves the old file or the new one, never a truncated one).
|
|
fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
|
use std::io::Write;
|
|
let tmp = path.with_extension("json.tmp");
|
|
let written = (|| {
|
|
let mut f = std::fs::File::create(&tmp)?;
|
|
f.write_all(bytes)?;
|
|
f.sync_all()
|
|
})();
|
|
if let Err(e) = written {
|
|
let _ = std::fs::remove_file(&tmp);
|
|
return Err(e);
|
|
}
|
|
std::fs::rename(&tmp, path)?;
|
|
if let Some(dir) = path.parent()
|
|
&& let Ok(d) = std::fs::File::open(dir)
|
|
{
|
|
let _ = d.sync_all();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library
|
|
/// changes, every few seconds when anything else changed, and at exit.
|
|
#[derive(Default)]
|
|
struct PrefsWriter {
|
|
written: Vec<u8>,
|
|
library: String,
|
|
checked: f64,
|
|
/// The last write failed (reported once until a write works).
|
|
failing: bool,
|
|
/// `ui.json` couldn't be read at launch: never overwrite it this session.
|
|
keep_file: bool,
|
|
}
|
|
|
|
impl PrefsWriter {
|
|
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
|
|
if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
|
return Ok(());
|
|
}
|
|
let Some(d) = config_dir() else { return Ok(()) };
|
|
let bytes = serde_json::to_vec_pretty(&app.ui).map_err(|e| e.to_string())?;
|
|
if bytes == self.written {
|
|
return Ok(());
|
|
}
|
|
std::fs::create_dir_all(&d)
|
|
.and_then(|()| write_atomic(&d.join("ui.json"), &bytes))
|
|
.map_err(|e| format!("saving the app settings failed: {e}"))?;
|
|
self.written = bytes;
|
|
self.library = app.ui.settings.library_path.clone();
|
|
Ok(())
|
|
}
|
|
|
|
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
|
let now = ctx.input(|i| i.time);
|
|
let moved = app.ui.settings.library_path != self.library;
|
|
if !moved && now - self.checked < 3.0 {
|
|
return;
|
|
}
|
|
self.checked = now;
|
|
match self.save(app) {
|
|
Ok(()) => self.failing = false,
|
|
Err(e) => {
|
|
eprintln!("lightcraft: {e}");
|
|
if !self.failing {
|
|
app.notices.push(format!("{e}. LightCraft keeps trying."));
|
|
}
|
|
self.failing = true;
|
|
// don't retry every frame
|
|
self.library = app.ui.settings.library_path.clone();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn services() -> Services {
|
|
Services {
|
|
pick_folder: Some(Box::new(|| {
|
|
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
|
})),
|
|
open_with: Some(Box::new(|path: &str, app: &str| {
|
|
// spawned, never waited for: the editor runs alongside
|
|
let app = app.trim();
|
|
let mut c = if cfg!(target_os = "macos") {
|
|
let mut c = std::process::Command::new("open");
|
|
if !app.is_empty() {
|
|
c.args(["-a", app]);
|
|
}
|
|
c
|
|
} else if app.is_empty() {
|
|
if cfg!(target_os = "windows") {
|
|
let mut c = std::process::Command::new("cmd");
|
|
c.args(["/C", "start", ""]);
|
|
c
|
|
} else {
|
|
std::process::Command::new("xdg-open")
|
|
}
|
|
} else {
|
|
std::process::Command::new(app)
|
|
};
|
|
c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string())
|
|
})),
|
|
open_url: Some(Box::new(|url: &str| {
|
|
if !url.starts_with("https://") {
|
|
return Err("only https links are opened".into());
|
|
}
|
|
let status = if cfg!(target_os = "macos") {
|
|
std::process::Command::new("open").arg(url).status()
|
|
} else if cfg!(target_os = "windows") {
|
|
std::process::Command::new("explorer").arg(url).status()
|
|
} else {
|
|
std::process::Command::new("xdg-open").arg(url).status()
|
|
};
|
|
status.map(|_| ()).map_err(|e| e.to_string())
|
|
})),
|
|
reveal: Some(Box::new(|path: &str| {
|
|
let status = if cfg!(target_os = "macos") {
|
|
std::process::Command::new("open").args(["-R", path]).status()
|
|
} else if cfg!(target_os = "windows") {
|
|
std::process::Command::new("explorer").arg(format!("/select,{path}")).status()
|
|
} else {
|
|
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into());
|
|
std::process::Command::new("xdg-open").arg(dir).status()
|
|
};
|
|
status
|
|
.map_err(|e| e.to_string())
|
|
.and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) })
|
|
})),
|
|
pick_files: Some(Box::new(|| {
|
|
rfd::FileDialog::new()
|
|
.add_filter(
|
|
lightcraft_ui_egui::i18n::tr("Photos"),
|
|
&[
|
|
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd",
|
|
"jxl", "gif", "bmp",
|
|
],
|
|
)
|
|
.pick_files()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.collect()
|
|
})),
|
|
pick_preset_files: Some(Box::new(|| {
|
|
rfd::FileDialog::new()
|
|
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
|
|
.add_filter(
|
|
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
|
|
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
|
|
)
|
|
.pick_files()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.collect()
|
|
})),
|
|
pick_tracklog: Some(Box::new(|| {
|
|
rfd::FileDialog::new()
|
|
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
|
|
.add_filter(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
|
|
.pick_file()
|
|
.map(|p| vec![p.to_string_lossy().to_string()])
|
|
.unwrap_or_default()
|
|
})),
|
|
save_preset_file: Some(Box::new(|name: &str| {
|
|
rfd::FileDialog::new()
|
|
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
|
|
.add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
|
|
.set_file_name(name)
|
|
.save_file()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
})),
|
|
pick_curve_preset_files: Some(Box::new(|| {
|
|
rfd::FileDialog::new()
|
|
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
|
|
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
|
|
.pick_files()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.collect()
|
|
})),
|
|
save_curve_preset_file: Some(Box::new(|name: &str| {
|
|
rfd::FileDialog::new()
|
|
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
|
|
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
|
|
.set_file_name(name)
|
|
.save_file()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
})),
|
|
// atomic (temp file + sync + rename): a failed write never leaves a truncated file
|
|
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
|
|
write: Some(Box::new(lightcraft_engine::export::write_file)),
|
|
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
|
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
|
})),
|
|
// the library is a folder on disk: backed up with the user's other files
|
|
backup_library: None,
|
|
restore_library: None,
|
|
}
|
|
}
|
|
|
|
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
|
|
///
|
|
/// If the library can't be opened the session is empty and in memory — never seeded with the
|
|
/// demo photos, never written anywhere — and the problem is returned: the window then says so
|
|
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
|
|
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
|
if in_memory {
|
|
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
|
|
}
|
|
let unopened = || Session::new().with_fs().with_system_clock();
|
|
let Some(dir) = dir else {
|
|
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
|
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
|
|
};
|
|
let t0 = std::time::Instant::now();
|
|
let mut s = Session::new().with_fs().with_system_clock();
|
|
match s.open_library(&dir, seed_demo) {
|
|
Ok(r) => {
|
|
let (replayed, torn) = (r.replayed, r.torn_bytes);
|
|
eprintln!(
|
|
"lightcraft: library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
|
|
dir.display(),
|
|
s.catalog.len(),
|
|
if torn > 0 { ", torn tail repaired" } else { "" },
|
|
t0.elapsed().as_secs_f64() * 1000.0
|
|
);
|
|
(s, None)
|
|
}
|
|
Err(e) => {
|
|
eprintln!("lightcraft: can't open library {}: {e}", dir.display());
|
|
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
|
|
}
|
|
}
|
|
}
|
|
|
|
const HELP: &str = "\
|
|
lightcraft — photo library + raw developer
|
|
|
|
USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported)
|
|
|
|
OPTIONS:
|
|
--library DIR open (or create) this library (default: ~/Pictures/LightCraft Library; env LIGHTCRAFT_LIBRARY)
|
|
--no-demo don't seed a new library with the procedural demo photos
|
|
--memory throwaway in-memory session (alias --demo); nothing is saved
|
|
--control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT;
|
|
see docs/control-protocol.md)
|
|
--version, --help
|
|
|
|
ENVIRONMENT:
|
|
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
|
|
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
|
|
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
|
|
optional: LightCraft offers to download it when first needed)
|
|
LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order)
|
|
";
|
|
|
|
/// Warnings and errors (failed commands, AI mask analysis) on stderr; `LIGHTCRAFT_LOG=info`
|
|
/// (or `debug`) shows more.
|
|
struct StderrLog(log::LevelFilter);
|
|
|
|
impl log::Log for StderrLog {
|
|
fn enabled(&self, m: &log::Metadata) -> bool {
|
|
m.level() <= self.0 && (m.level() <= log::Level::Warn || m.target().starts_with("lightcraft"))
|
|
}
|
|
fn log(&self, r: &log::Record) {
|
|
if self.enabled(r.metadata()) {
|
|
eprintln!("lightcraft: {} {}: {}", r.level(), r.target(), r.args());
|
|
}
|
|
}
|
|
fn flush(&self) {}
|
|
}
|
|
|
|
fn install_log() {
|
|
let level = match std::env::var("LIGHTCRAFT_LOG").unwrap_or_default().as_str() {
|
|
"debug" => log::LevelFilter::Debug,
|
|
"info" => log::LevelFilter::Info,
|
|
_ => log::LevelFilter::Warn,
|
|
};
|
|
static LOGGER: std::sync::OnceLock<StderrLog> = std::sync::OnceLock::new();
|
|
if log::set_logger(LOGGER.get_or_init(|| StderrLog(level))).is_ok() {
|
|
log::set_max_level(level);
|
|
}
|
|
}
|
|
|
|
fn main() -> eframe::Result {
|
|
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
|
|
install_log();
|
|
alloc_release::install();
|
|
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
|
let mut files = Vec::new();
|
|
let mut seed_demo = true;
|
|
let mut in_memory = false;
|
|
let mut library_dir: Option<std::path::PathBuf> = 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()),
|
|
"--library" => library_dir = args.next().map(std::path::PathBuf::from),
|
|
"--no-demo" => seed_demo = false,
|
|
"--memory" | "--demo" => in_memory = true,
|
|
"--version" => {
|
|
println!("lightcraft {}", env!("CARGO_PKG_VERSION"));
|
|
return Ok(());
|
|
}
|
|
"-h" | "--help" => {
|
|
print!("{HELP}");
|
|
return Ok(());
|
|
}
|
|
o if o.starts_with("--") => {
|
|
eprintln!("lightcraft: unknown option `{o}`\n\n{HELP}");
|
|
std::process::exit(2);
|
|
}
|
|
_ => files.push(a),
|
|
}
|
|
}
|
|
let (prefs, prefs_warning, keep_prefs_file) = load_prefs();
|
|
// --library, else the library last opened from Settings, else the default location
|
|
let library_dir = library_dir.or_else(|| {
|
|
prefs
|
|
.as_ref()
|
|
.map(|u| u.settings.library_path.clone())
|
|
.filter(|p| !p.is_empty() && std::env::var_os("LIGHTCRAFT_LIBRARY").is_none())
|
|
.map(Into::into)
|
|
});
|
|
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
|
|
// GPU compute: off if the preference says so, or if the last launch died creating the device
|
|
// (issue #136) — before anything can create it
|
|
let gpu_crash = gpu_crash_check();
|
|
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
|
|
lightcraft_engine::gpu::set_enabled(gpu_on);
|
|
let options = eframe::NativeOptions {
|
|
viewport: egui::ViewportBuilder::default()
|
|
.with_title("LightCraft")
|
|
.with_inner_size([1600.0, 1000.0])
|
|
.with_min_inner_size([900.0, 560.0])
|
|
.with_drag_and_drop(true)
|
|
.with_fullsize_content_view(true)
|
|
.with_titlebar_shown(false)
|
|
.with_title_shown(false)
|
|
.with_icon(app_icon())
|
|
// Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id
|
|
.with_app_id(APP_ID),
|
|
wgpu_options: window_wgpu_options(),
|
|
..Default::default()
|
|
};
|
|
eframe::run_native(
|
|
"LightCraft",
|
|
options,
|
|
Box::new(move |cc| {
|
|
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
|
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
|
|
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
|
|
session.segmenter.dir =
|
|
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
|
|
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
|
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
|
|
let mut app = LightcraftApp::new(session, services());
|
|
if let Some(ui) = prefs {
|
|
app.ui = ui;
|
|
}
|
|
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
|
app.integrated_titlebar = cfg!(target_os = "macos");
|
|
app.notices.extend(prefs_warning);
|
|
// what's on disk now: only changes are written
|
|
let writer = PrefsWriter {
|
|
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
|
library: app.ui.settings.library_path.clone(),
|
|
keep_file: keep_prefs_file,
|
|
..Default::default()
|
|
};
|
|
// after the snapshot above, so the writer saves the switched-off preference
|
|
if let Some(notice) = gpu_crash {
|
|
app.ui.settings.gpu = false;
|
|
app.notices.push(notice);
|
|
}
|
|
if let Some(port) = control_port {
|
|
let rx = control_server::start(port, cc.egui_ctx.clone());
|
|
app = app.with_control(rx);
|
|
}
|
|
if let Some(mut p) = problem {
|
|
// imported once the user has chosen where (into the library, or the temporary session)
|
|
p.pending_import = files;
|
|
app.library_problem = Some(p);
|
|
} else if !files.is_empty() {
|
|
let _ = app.run("library.import", serde_json::json!({"paths": files}));
|
|
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
|
}
|
|
// native menu bar generated from the command registry (macOS; elsewhere the menus are
|
|
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
|
|
#[cfg(target_os = "macos")]
|
|
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
|
Ok(Box::new(App(
|
|
app,
|
|
writer,
|
|
#[cfg(target_os = "macos")]
|
|
menu,
|
|
)))
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn dir(tag: &str) -> std::path::PathBuf {
|
|
let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
|
/// reported, not silently replaced by the defaults.
|
|
#[test]
|
|
fn damaged_ui_json_is_kept_and_reported() {
|
|
let d = dir("damaged");
|
|
let path = d.join("ui.json");
|
|
std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap();
|
|
let (ui, warning, keep) = load_prefs_at(&path);
|
|
assert!(ui.is_none() && !keep);
|
|
let warning = warning.unwrap();
|
|
assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}");
|
|
assert!(!path.exists());
|
|
let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
|
assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}");
|
|
// a missing file is just "no settings yet"
|
|
assert_eq!(load_prefs_at(&path).1, None);
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
|
|
/// Issue #136: a GPU init marker left by a crashed launch is reported once and removed.
|
|
#[test]
|
|
fn gpu_crash_marker_is_reported_once() {
|
|
let d = dir("gpu-marker");
|
|
let m = d.join("gpu-init.marker");
|
|
assert_eq!(lightcraft_engine::gpu::backend::take_init_marker(&m), None);
|
|
std::fs::write(&m, "GPU device creation started (backends VULKAN | DX12)").unwrap();
|
|
let what = lightcraft_engine::gpu::backend::take_init_marker(&m).unwrap();
|
|
let notice = gpu_crash_notice(&what);
|
|
assert!(notice.contains("GPU rendering is now off") && notice.contains("Use the GPU for rendering"), "{notice}");
|
|
assert!(!m.exists());
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
|
|
/// Issue #136: Windows windows render with DX12 alone (no Vulkan driver loaded) by default.
|
|
#[test]
|
|
fn window_backends_follow_the_platform_default() {
|
|
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
|
|
let b = n.instance_descriptor.backends;
|
|
if std::env::var_os("LIGHTCRAFT_GPU_BACKEND").is_none() && std::env::var_os("WGPU_BACKEND").is_none() {
|
|
if cfg!(windows) {
|
|
assert_eq!(b, eframe::wgpu::Backends::DX12);
|
|
} else if cfg!(target_os = "macos") {
|
|
assert_eq!(b, eframe::wgpu::Backends::METAL);
|
|
}
|
|
}
|
|
assert!(!b.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn ui_json_is_written_atomically() {
|
|
let d = dir("atomic");
|
|
let path = d.join("ui.json");
|
|
std::fs::write(&path, b"old").unwrap();
|
|
write_atomic(&path, b"{\"new\": true}").unwrap();
|
|
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
|
assert!(!d.join("ui.json.tmp").exists());
|
|
// a failed write leaves the old file
|
|
let missing = d.join("gone").join("ui.json");
|
|
assert!(write_atomic(&missing, b"x").is_err());
|
|
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
|
let ui = UiState::default();
|
|
write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap();
|
|
let (loaded, warning, _) = load_prefs_at(&path);
|
|
assert!(loaded.is_some() && warning.is_none());
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
}
|