Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47fe7f70be | ||
|
|
e2a38c26e0 | ||
|
|
761a91c233 | ||
|
|
a33dc70a3c | ||
|
|
bb4cdeed2a | ||
|
|
805a15ae9b | ||
|
|
69b90ee7c4 | ||
|
|
170f3956a1 | ||
|
|
2bc33268d2 | ||
|
|
0e4b52e29b | ||
|
|
dba63bc9d5 | ||
|
|
69f117632b | ||
|
|
5350a8ed70 | ||
|
|
aa48f6f6e7 | ||
|
|
f9f3fbe4db | ||
|
|
f4d8a428ac | ||
|
|
f9919acc7f | ||
|
|
f8c01f70fb | ||
|
|
8f42f4a96d | ||
|
|
d5d9daa555 | ||
|
|
bda8569f67 | ||
|
|
0a46f615dc | ||
|
|
b23b20638e | ||
|
|
9ecd3309b2 | ||
|
|
5abc39ae7d | ||
|
|
11dd3bd6b3 | ||
|
|
68f75cc07a | ||
|
|
72b02c65b7 | ||
|
|
5bbce8b9fd | ||
|
|
f30ad32d93 | ||
|
|
09461d04c7 | ||
|
|
f98749343f | ||
|
|
95662cfff5 | ||
|
|
14679de823 | ||
|
|
b038c018c2 | ||
|
|
72a7cd80b8 | ||
|
|
a7382aacfb | ||
|
|
353c1cf566 | ||
|
|
2c8520371c | ||
|
|
ad3c7315bb | ||
|
|
385348b258 | ||
|
|
38756b89d0 | ||
|
|
d9d1a5a730 | ||
|
|
21d71feb04 | ||
|
|
aec298c116 | ||
|
|
dac0d4c6e2 | ||
|
|
0ba118de92 | ||
|
|
a7e8f1cc26 | ||
|
|
d76a4121e6 | ||
|
|
7d27cf8bfb | ||
|
|
6807421f64 | ||
|
|
9bfabc98b1 | ||
|
|
40d7b86ecb | ||
|
|
8f3f168802 | ||
|
|
a67d571656 | ||
|
|
7ce13cfd07 | ||
|
|
a940dab727 | ||
|
|
8e9c5aff7e | ||
|
|
5a975d0d75 | ||
|
|
0067879a58 | ||
|
|
0adcb21166 | ||
|
|
bc5c561dd8 | ||
|
|
fe92e844d0 | ||
|
|
e1267a7139 | ||
|
|
a3c33658bb | ||
|
|
dd73753723 | ||
|
|
3cd471b32d | ||
|
|
1064e9847d | ||
|
|
732a2f04fd | ||
|
|
7f966965fc | ||
|
|
9da719c6ed | ||
|
|
19833e56e3 | ||
|
|
0bd3f3715d | ||
|
|
6dca631482 | ||
|
|
61df5d304f | ||
|
|
bbcbbebf8e | ||
|
|
e7f8faf77d | ||
|
|
b59ffcc4c9 | ||
|
|
5bcedbe6fc |
No files matched your search
Generated
+103
@@ -988,6 +988,16 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -1062,6 +1072,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1202,6 +1221,19 @@ dependencies = [
|
||||
"thousands",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dify"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"colored",
|
||||
"getopts",
|
||||
"image",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1447,6 +1479,27 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
|
||||
dependencies = [
|
||||
"dify",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"kittest",
|
||||
"log",
|
||||
"open",
|
||||
"pollster 1.0.1",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
@@ -2230,6 +2283,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -2588,6 +2650,25 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -2909,6 +2990,16 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kittest"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.1"
|
||||
@@ -3343,7 +3434,9 @@ dependencies = [
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -4169,6 +4262,16 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
|
||||
@@ -10,6 +10,7 @@ rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
|
||||
@@ -343,6 +343,8 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
|
||||
|
||||
## Quick start
|
||||
|
||||
Workspace builds, including the desktop, web and CLI applications, require Rust 1.95 or newer.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/lightcraft && cd lightcraft
|
||||
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
|
||||
@@ -571,3 +573,5 @@ Forks and modified versions must remove them.
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
|
||||
|
||||
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
@@ -114,6 +114,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-10 (UI docking):** Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Widget state styling uses the shared `craft-ui` theme helper, retaining the dark palette, typography, spacing and per-state borders. No readiness percentage change.
|
||||
- 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943
|
||||
unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a
|
||||
verified body). Full ~61%, mainstream ~49%, essentials ~64%; beta ~14 points away.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
|
||||
//!
|
||||
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP (see
|
||||
//! Build with `cargo xtask web` (→ `target/web-site/`), then serve that folder over HTTP (see
|
||||
//! `docs/web.md`). Differences from the desktop host:
|
||||
//! - the library lives in browser storage (OPFS, or IndexedDB where OPFS can't be written from the
|
||||
//! main thread): catalog journal, presets, view state and prefs are mirrored in memory and
|
||||
|
||||
+115
-3
@@ -98,6 +98,8 @@ fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
|
||||
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
|
||||
n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options();
|
||||
// no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250)
|
||||
n.instance_descriptor.flags = lightcraft_engine::gpu::backend::instance_flags_from_env(n.instance_descriptor.flags);
|
||||
}
|
||||
c
|
||||
}
|
||||
@@ -183,6 +185,23 @@ fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
load_prefs_at(&path, !in_memory)
|
||||
}
|
||||
|
||||
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands.
|
||||
fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
|
||||
let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
|
||||
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
|
||||
let recovered =
|
||||
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
|
||||
Ok(Some(workspace)) => workspace.validate().is_err(),
|
||||
Ok(None) => false,
|
||||
Err(_) => true,
|
||||
});
|
||||
if recovered {
|
||||
fields.remove("docking");
|
||||
}
|
||||
let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?;
|
||||
Ok((ui, recovered))
|
||||
}
|
||||
|
||||
fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
@@ -192,8 +211,15 @@ fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiS
|
||||
return (None, Some(msg), true);
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<UiState>(&bytes) {
|
||||
Ok(ui) => (Some(ui.sanitized()), None, false),
|
||||
match decode_saved_ui(&bytes) {
|
||||
Ok((ui, false)) => (Some(ui.sanitized()), None, false),
|
||||
Ok((ui, true)) => {
|
||||
let msg = format!(
|
||||
"The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.",
|
||||
path.display()
|
||||
);
|
||||
(Some(ui.sanitized()), Some(msg), true)
|
||||
}
|
||||
Err(e) if !set_aside_damaged => {
|
||||
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
|
||||
(None, Some(msg), true)
|
||||
@@ -270,6 +296,9 @@ impl PrefsWriter {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(path) = self.path.clone() else { return Ok(()) };
|
||||
if let Some(workspace) = &ui.docking {
|
||||
workspace.validate()?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
|
||||
if bytes == self.written {
|
||||
return Ok(());
|
||||
@@ -608,6 +637,7 @@ OPTIONS:
|
||||
--version, --help
|
||||
|
||||
ENVIRONMENT:
|
||||
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
|
||||
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;
|
||||
@@ -663,10 +693,16 @@ fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
|
||||
port
|
||||
}
|
||||
|
||||
/// An isolated profile keeps the emergency crash log with its other app data. With no
|
||||
/// override (or an empty override), retain the historical system-temp location.
|
||||
fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: std::path::PathBuf) -> std::path::PathBuf {
|
||||
env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log")
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is kept for the log file (`logging`).
|
||||
let logger = logging::install();
|
||||
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
|
||||
lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir()));
|
||||
if let Some(logger) = logger {
|
||||
logging::record_panics(logger);
|
||||
}
|
||||
@@ -904,6 +940,59 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docking_survives_native_settings_file_reload() {
|
||||
use lightcraft_ui_egui::docking::Panel;
|
||||
let d = dir("docking-reload");
|
||||
let path = d.join("ui.json");
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
|
||||
app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap();
|
||||
app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap();
|
||||
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
|
||||
writer.save_ui(&app.ui, false).unwrap();
|
||||
let (restored, warning, keep) = load_prefs_at(&path, false);
|
||||
assert!(warning.is_none());
|
||||
assert!(!keep);
|
||||
let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
|
||||
restored_app.ui = restored.unwrap();
|
||||
restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap();
|
||||
assert_eq!(
|
||||
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect,
|
||||
[70.0, 80.0, 310.0, 500.0]
|
||||
);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_docking_preserves_library_settings_and_the_original_preferences_file() {
|
||||
let d = dir("future-docking");
|
||||
let path = d.join("ui.json");
|
||||
let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() };
|
||||
ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into();
|
||||
for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] {
|
||||
let mut saved = serde_json::to_value(&ui).unwrap();
|
||||
saved["docking"] = docking;
|
||||
assert!(serde_json::from_value::<UiState>(saved.clone()).is_err(), "runtime state parsing stays strict");
|
||||
let bytes = serde_json::to_vec(&saved).unwrap();
|
||||
for set_aside in [false, true] {
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let (loaded, warning, keep) = load_prefs_at(&path, set_aside);
|
||||
let loaded = loaded.unwrap();
|
||||
assert_eq!(loaded.settings.library_path, ui.settings.library_path);
|
||||
assert_eq!(loaded.thumb_size, ui.thumb_size);
|
||||
assert!(!loaded.filmstrip);
|
||||
assert!(loaded.docking.is_none());
|
||||
assert!(warning.unwrap().contains("Other settings were loaded"));
|
||||
assert!(keep);
|
||||
let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() };
|
||||
writer.save_ui(&loaded, false).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file");
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #164: the writer saves changed settings to its file — and nothing at all without one
|
||||
/// (`--memory`) or while the session is the temporary one offered when the library can't open.
|
||||
#[test]
|
||||
@@ -999,6 +1088,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline,
|
||||
/// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened).
|
||||
#[test]
|
||||
fn window_has_no_indirect_call_validation_pipeline() {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
|
||||
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
|
||||
#[test]
|
||||
@@ -1056,3 +1155,16 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_path_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_override_keeps_panic_log_in_isolated_profile() {
|
||||
let temp = std::path::PathBuf::from("temp");
|
||||
assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log"));
|
||||
assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log"));
|
||||
assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log"));
|
||||
}
|
||||
}
|
||||
@@ -363,7 +363,7 @@ pub fn rule_date(s: &str) -> Result<String, Issue> {
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if !shaped(&s) && !(s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
|
||||
if !(shaped(&s) || s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
|
||||
return Err(Issue::NeedsDate);
|
||||
}
|
||||
let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::<u32>().ok());
|
||||
|
||||
@@ -411,7 +411,7 @@ pub fn compute(
|
||||
if n == 0 || sdr.len() < n || hdr.len() < n {
|
||||
return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into()));
|
||||
}
|
||||
if !matches!(o.scale, 1 | 2 | 4) || !(o.gamma > 0.0 && o.gamma.is_finite()) || !(o.offset > 0.0 && o.offset.is_finite()) {
|
||||
if !(matches!(o.scale, 1 | 2 | 4) && o.gamma > 0.0 && o.gamma.is_finite() && o.offset > 0.0 && o.offset.is_finite()) {
|
||||
return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into()));
|
||||
}
|
||||
let s = o.scale;
|
||||
|
||||
@@ -833,6 +833,22 @@ fn to_prophoto() -> Mat3 {
|
||||
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
|
||||
}
|
||||
|
||||
/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour
|
||||
/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps
|
||||
/// its colour; in between the two are blended, so noisy shadows show no step.
|
||||
const MIN_LUMINANCE_KEPT: f32 = 0.25;
|
||||
const NO_LUMINANCE_KEPT: f32 = 0.15;
|
||||
|
||||
/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to
|
||||
/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between.
|
||||
fn restore_weight(share: f32) -> f32 {
|
||||
if !share.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes
|
||||
/// hue and saturation only: luminance is restored, the camera tone curve owns it.
|
||||
pub(crate) struct HueSat<'a> {
|
||||
@@ -852,7 +868,19 @@ impl<'a> HueSat<'a> {
|
||||
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
|
||||
let out = mul(&self.from, self.table.apply(mul(&self.to, rgb)));
|
||||
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
|
||||
if before > 0.0 && after > 0.0 && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb }
|
||||
// restoring the luminance divides by the table's: near black, where noise makes some channels
|
||||
// negative, the table can bring it close to 0, and the gain would turn the pixel white (#820)
|
||||
if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) {
|
||||
return rgb;
|
||||
}
|
||||
let w = restore_weight(after / before);
|
||||
if w >= 1.0 {
|
||||
return out.map(|v| v * before / after);
|
||||
}
|
||||
if w <= 0.0 {
|
||||
return rgb;
|
||||
}
|
||||
std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1439,6 +1467,61 @@ mod tests {
|
||||
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_never_turns_near_black_noise_white() {
|
||||
// a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
let hue_sat = HueSat::new(&table).unwrap();
|
||||
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0
|
||||
};
|
||||
let mut worst = 0.0f32;
|
||||
for _ in 0..200_000 {
|
||||
let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()];
|
||||
if luminance_2020(rgb) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let out = hue_sat.apply(rgb);
|
||||
let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale);
|
||||
}
|
||||
assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() {
|
||||
assert_eq!(restore_weight(0.0), 0.0);
|
||||
assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0);
|
||||
assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0);
|
||||
assert_eq!(restore_weight(1.0), 1.0);
|
||||
assert_eq!(restore_weight(f32::NAN), 0.0);
|
||||
// monotonic and without a step: neighbouring shares 0.001 apart differ by little
|
||||
let mut last = 0.0f32;
|
||||
for i in 0..=400 {
|
||||
let w = restore_weight(i as f32 * 0.001);
|
||||
assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001);
|
||||
last = w;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() {
|
||||
// the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
let hue_sat = HueSat::new(&table).unwrap();
|
||||
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
|
||||
for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] {
|
||||
let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb)));
|
||||
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
|
||||
assert!(after >= MIN_LUMINANCE_KEPT * before);
|
||||
assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after));
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage {
|
||||
let area = lightcraft_raw::Rect::new(0, 0, 2, 2);
|
||||
RawImage {
|
||||
|
||||
@@ -239,10 +239,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let fresh = match look {
|
||||
Some(l) => (*l).clone(),
|
||||
None => {
|
||||
// the photo's built-in defaults (raws: capture sharpening and colour noise
|
||||
// reduction, embedded lens corrections), as at import, with the white balance
|
||||
// of its current as-shot reference
|
||||
let info = s.source_info(id);
|
||||
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
|
||||
DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
|
||||
..Default::default()
|
||||
..defaults
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,6 +59,12 @@ fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
|
||||
}
|
||||
}
|
||||
|
||||
// Catalog queries also run in the 32-bit web build. A narrowing cast wraps
|
||||
// an out-of-range offset to zero, returning the first page instead of no photos.
|
||||
fn pagination_usize(value: u64) -> usize {
|
||||
usize::try_from(value).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "catalog.query", "Query Photos", [], None, "{filter?: Filter, sort?: Sort, offset?, limit?} — omit filter to list the current view", always, |s, p| {
|
||||
@@ -69,8 +75,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
s.visible_cloned()
|
||||
};
|
||||
let off = p.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;
|
||||
let off = pagination_usize(p.get("offset").and_then(Value::as_u64).unwrap_or(0));
|
||||
let lim = pagination_usize(p.get("limit").and_then(Value::as_u64).unwrap_or(200));
|
||||
let items: Vec<Value> = ids.iter().skip(off).take(lim).filter_map(|id| s.catalog.photo(*id)).map(|p| photo_summary(p)).collect();
|
||||
Ok(json!({"total": ids.len(), "photos": items}))
|
||||
}),
|
||||
@@ -237,3 +243,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pagination_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offsets_and_limits_saturate_instead_of_wrapping_on_wasm() {
|
||||
assert_eq!(pagination_usize(0), 0);
|
||||
assert_eq!(pagination_usize(200), 200);
|
||||
assert_eq!(pagination_usize(u64::MAX), usize::MAX);
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
assert_eq!(pagination_usize(u64::from(u32::MAX) + 1), usize::MAX);
|
||||
}
|
||||
}
|
||||
@@ -6,21 +6,42 @@
|
||||
//! face- or denoise-models folder until a host asks for one ([`Session::with_default_face_models`],
|
||||
//! [`Session::with_default_denoise_models`]), so tests stay hermetic.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// LightCraft's per-user config folder (`…/LightCraft`), if the platform tells us where.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Platform {
|
||||
Mac,
|
||||
Windows,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides the platform default.
|
||||
/// The override is the complete directory; no application-name suffix is appended. An explicitly
|
||||
/// empty value disables the settings directory instead of falling back to a personal profile.
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
|
||||
Platform::Windows
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("lightcraft"))
|
||||
Platform::Other
|
||||
};
|
||||
config_dir_with(platform, |name| std::env::var_os(name))
|
||||
}
|
||||
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if let Some(dir) = env("LIGHTCRAFT_CONFIG_DIR") {
|
||||
return (!dir.is_empty()).then(|| PathBuf::from(dir));
|
||||
}
|
||||
match platform {
|
||||
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")),
|
||||
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")),
|
||||
Platform::Other => {
|
||||
env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("lightcraft"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,3 +77,56 @@ impl Session {
|
||||
self.denoise.touch();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_tests {
|
||||
use super::*;
|
||||
|
||||
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_override_wins_without_reading_user_profile() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
let actual = config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
});
|
||||
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_empty_override_disables_profile_without_reading_defaults() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(
|
||||
config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_unset_override_keeps_platform_defaults() {
|
||||
let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")];
|
||||
assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/LightCraft")));
|
||||
assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/LightCraft")));
|
||||
assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/lightcraft")));
|
||||
assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/lightcraft")));
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(resolve(platform, &[]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn config_override_preserves_non_unicode_paths() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
let value = OsString::from_vec(b"isolated/\xff".to_vec());
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 42;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 50;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
||||
@@ -37,6 +37,33 @@ fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset Edits on a raw goes back to its import defaults, capture sharpening and colour noise
|
||||
/// reduction included (#763: reset left both at 0 while import sets 40 / 25); a rendered file
|
||||
/// keeps none.
|
||||
#[test]
|
||||
fn reset_edits_restores_a_raws_import_defaults() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
let mut s = demo();
|
||||
for (id, format, raw) in [(200u64, "NEF", true), (201, "JPG", false)] {
|
||||
let name = format!("p{id}.{format}");
|
||||
let mut p = Photo::new(PhotoId(id), Source::File { path: name.clone() }, &name, format, 16, 16, "");
|
||||
if raw {
|
||||
p.as_shot_wb = Some((5200.0, 4.0));
|
||||
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
|
||||
}
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
for (id, want) in [(200, (40.0, 25.0)), (201, (0.0, 0.0))] {
|
||||
s.execute("library.select", &json!({"ids": [id], "active": id})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "detail.sharpenAmount", "value": 90})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "detail.nrColor", "value": 70})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert_eq!((d.detail.sharpen_amount, d.detail.nr_color), want, "photo {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_library_loads() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -32,7 +32,9 @@ impl Scratch {
|
||||
Scratch(dir)
|
||||
}
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().to_string()
|
||||
// Fixtures use `/`; rebuild components so expected paths match the native separators
|
||||
// returned by directory scans, including for files that have already been removed.
|
||||
self.0.join(rel).components().collect::<PathBuf>().to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +105,68 @@ fn an_unchanged_folder_has_no_changes() {
|
||||
assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn importing_and_synchronizing_with_different_windows_separators_keeps_photo_identity() {
|
||||
for forward_import in [true, false] {
|
||||
for import_folder in [true, false] {
|
||||
let dir = Scratch::new(&format!("separators-{forward_import}-{import_folder}"));
|
||||
let folder = dir.path("trip");
|
||||
let a = dir.path("trip/a.png");
|
||||
let b = dir.path("trip/day1/b.png");
|
||||
write_png(&a, 1);
|
||||
write_png(&b, 2);
|
||||
let spelling = |path: &str, forward: bool| if forward { path.replace('\\', "/") } else { path.to_string() };
|
||||
let import_path = spelling(&folder, forward_import);
|
||||
let sync_path = spelling(&folder, !forward_import);
|
||||
assert_ne!(import_path, sync_path, "exercise distinct Windows path spellings");
|
||||
let inputs = if import_folder { vec![import_path.clone()] } else { vec![spelling(&a, forward_import), spelling(&b, forward_import)] };
|
||||
let mut s = Session::new().with_fs();
|
||||
let imported = s.execute("library.import", &json!({"paths": inputs})).unwrap();
|
||||
assert_eq!(imported["imported"].as_array().unwrap().len(), 2, "{imported}");
|
||||
let before = s.execute("catalog.query", &json!({})).unwrap();
|
||||
let photos = before["photos"].as_array().unwrap();
|
||||
let a_id = photos.iter().find(|p| p["fileName"] == "a.png").unwrap()["id"].clone();
|
||||
let b_id = photos.iter().find(|p| p["fileName"] == "b.png").unwrap()["id"].clone();
|
||||
|
||||
let duplicate = s.execute("library.import", &json!({"paths": [sync_path]})).unwrap();
|
||||
assert!(duplicate["imported"].as_array().unwrap().is_empty(), "{duplicate}");
|
||||
assert_eq!(duplicate["duplicates"].as_array().unwrap().len(), 2, "{duplicate}");
|
||||
assert_eq!(s.execute("catalog.query", &json!({})).unwrap(), before, "reimport preserves the photos and their ids");
|
||||
|
||||
std::fs::remove_file(&b).unwrap();
|
||||
let c = dir.path("trip/day2/c.png");
|
||||
write_png(&c, 3);
|
||||
let changes = scan(&mut s, &sync_path);
|
||||
assert_eq!(changes["new"].as_array().unwrap().len(), 1, "{changes}");
|
||||
assert_eq!(Path::new(changes["new"][0]["path"].as_str().unwrap()), Path::new(&c));
|
||||
assert_eq!(changes["missing"].as_array().unwrap().len(), 1, "{changes}");
|
||||
assert_eq!(changes["missing"][0]["id"], b_id, "the missing photo keeps its id");
|
||||
assert!(changes["moved"].as_array().unwrap().is_empty(), "{changes}");
|
||||
|
||||
// Switching spelling again must consume the confirmed scan of this same folder.
|
||||
let synced = s.execute("folder.synchronize", &json!({"path": import_path, "scanned": true, "removeMissing": true})).unwrap();
|
||||
assert_eq!(
|
||||
(synced["imported"].as_u64(), synced["removed"].as_u64(), synced["relinked"].as_u64()),
|
||||
(Some(1), Some(1), Some(0)),
|
||||
"{synced}"
|
||||
);
|
||||
assert!(synced["failed"].as_array().unwrap().is_empty(), "{synced}");
|
||||
let after = s.execute("catalog.query", &json!({})).unwrap();
|
||||
let photos = after["photos"].as_array().unwrap();
|
||||
assert_eq!(photos.len(), 2, "{after}");
|
||||
assert!(photos.iter().any(|p| p["id"] == a_id && p["fileName"] == "a.png"), "{after}");
|
||||
assert!(photos.iter().any(|p| p["fileName"] == "c.png" && p["id"] != a_id && p["id"] != b_id), "{after}");
|
||||
for path in [&import_path, &sync_path] {
|
||||
let changes = scan(&mut s, path);
|
||||
for kind in ["new", "missing", "moved", "metadata"] {
|
||||
assert!(changes[kind].as_array().unwrap().is_empty(), "{path}: {changes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_added_to_the_folder_or_a_folder_inside_it_are_new() {
|
||||
let dir = Scratch::new("new");
|
||||
|
||||
@@ -145,6 +145,25 @@ pub fn backend_options() -> wgpu::BackendOptions {
|
||||
o
|
||||
}
|
||||
|
||||
/// wgpu's instance flags for every instance LightCraft creates, from `flags` (wgpu's defaults with its
|
||||
/// environment variables applied) without `VALIDATION_INDIRECT_CALL`, unless
|
||||
/// `WGPU_VALIDATION_INDIRECT_CALL` asks for it (any value but `0`). That validation builds an internal
|
||||
/// compute pipeline when each device is created; Intel Macs' Metal compiler fails on it ("Compiler
|
||||
/// encountered an internal error"), the device is lost and the window never opens (#250). It only
|
||||
/// guards indirect draws and dispatches, which neither LightCraft nor egui issue.
|
||||
pub fn instance_flags(flags: wgpu::InstanceFlags, env: Option<&str>) -> wgpu::InstanceFlags {
|
||||
if env.is_some_and(|v| v != "0") {
|
||||
flags | wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL
|
||||
} else {
|
||||
flags - wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL
|
||||
}
|
||||
}
|
||||
|
||||
/// [`instance_flags`] with `WGPU_VALIDATION_INDIRECT_CALL` read from the environment.
|
||||
pub fn instance_flags_from_env(flags: wgpu::InstanceFlags) -> wgpu::InstanceFlags {
|
||||
instance_flags(flags, std::env::var("WGPU_VALIDATION_INDIRECT_CALL").ok().as_deref())
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_GPU_BACKEND=off`.
|
||||
pub(crate) fn env_off() -> bool {
|
||||
env_choice() == BackendChoice::Off
|
||||
@@ -195,6 +214,17 @@ pub fn read_init_marker(path: &std::path::Path) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
|
||||
/// #250: the indirect-call validation pipeline is off unless asked for; other flags are kept.
|
||||
#[test]
|
||||
fn indirect_call_validation_is_off_unless_asked_for() {
|
||||
use wgpu::InstanceFlags as F;
|
||||
let base = F::VALIDATION_INDIRECT_CALL | F::DEBUG;
|
||||
assert_eq!(instance_flags(base, None), F::DEBUG);
|
||||
assert_eq!(instance_flags(base, Some("0")), F::DEBUG);
|
||||
assert_eq!(instance_flags(F::empty(), Some("1")), F::VALIDATION_INDIRECT_CALL);
|
||||
assert_eq!(instance_flags(F::empty(), None), F::empty());
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -284,6 +284,8 @@ impl Gpu {
|
||||
desc.backends = backends;
|
||||
// DX12 shaders compile with FXC (issue #471)
|
||||
desc.backend_options = crate::backend::backend_options();
|
||||
// no indirect-call validation pipeline: Intel Metal fails to compile it (#250)
|
||||
desc.flags = crate::backend::instance_flags_from_env(desc.flags);
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapter = pollster::block_on(
|
||||
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
|
||||
|
||||
@@ -86,6 +86,8 @@ fn make_device(backends: wgpu::Backends) -> Result<Dev, String> {
|
||||
desc.backends = backends;
|
||||
// DX12 shaders compile with FXC (issue #471)
|
||||
desc.backend_options = crate::backend::backend_options();
|
||||
// no indirect-call validation pipeline: Intel Metal fails to compile it (#250)
|
||||
desc.flags = crate::backend::instance_flags_from_env(desc.flags);
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let wanted = std::env::var("LIGHTCRAFT_GPU_ADAPTER").ok().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty());
|
||||
let adapter = match &wanted {
|
||||
|
||||
+55
-4
@@ -4,8 +4,11 @@
|
||||
|
||||
use std::io::{BufRead, BufReader, Cursor, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lightcraft_mcp::{Backend, Headless, Remote, Server, base64_decode};
|
||||
use lightcraft_mcp::{Backend, Headless, ProgressHook, Remote, Server, base64_decode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Feed a whole session through `Server::serve` and return the replies by id.
|
||||
@@ -154,8 +157,50 @@ fn remote_unreachable_is_a_tool_error() {
|
||||
assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable"));
|
||||
}
|
||||
|
||||
/// [`Headless`] whose batch export, while `hold` is set, waits once its first photo is written:
|
||||
/// it keeps asking the server's progress hook at that photo boundary (which answers pings and
|
||||
/// reads cancels meanwhile) until the request is cancelled or the test clears `hold`. A ping or
|
||||
/// cancel sent after the first progress then lands mid-batch, however fast the machine exports.
|
||||
struct HoldAfterFirst {
|
||||
inner: Headless,
|
||||
hold: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Backend for HoldAfterFirst {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
self.inner.call(method, params)
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
self.inner.has_ui()
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
self.inner.describe()
|
||||
}
|
||||
|
||||
fn set_progress(&mut self, hook: Option<ProgressHook>) {
|
||||
let hold = self.hold.clone();
|
||||
self.inner.set_progress(hook.map(|mut hook| -> ProgressHook {
|
||||
Box::new(move |done, total, name| {
|
||||
// bounded, so a cancel that is never read fails the test instead of hanging it
|
||||
let until = Instant::now() + Duration::from_secs(20);
|
||||
while done == 1 && done < total && hold.load(Ordering::SeqCst) && Instant::now() < until {
|
||||
if !hook(done, total, name) {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
hook(done, total, name)
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation
|
||||
/// between complete photos, unrelated output preservation, and a usable session afterwards.
|
||||
/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or
|
||||
/// the cancel is read, so neither can finish first on a fast machine.
|
||||
#[test]
|
||||
fn export_progress_and_cancel() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id()));
|
||||
@@ -163,7 +208,9 @@ fn export_progress_and_cancel() {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (input, mut send) = std::io::pipe().unwrap();
|
||||
let (output, write) = std::io::pipe().unwrap();
|
||||
let server = std::thread::spawn(move || Server::new(Box::new(Headless::demo())).serve(BufReader::new(input), write).unwrap());
|
||||
let hold = Arc::new(AtomicBool::new(true));
|
||||
let backend = HoldAfterFirst { inner: Headless::demo(), hold: hold.clone() };
|
||||
let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
for line in BufReader::new(output).lines() {
|
||||
@@ -173,7 +220,7 @@ fn export_progress_and_cancel() {
|
||||
}
|
||||
}
|
||||
});
|
||||
let next = || rx.recv_timeout(std::time::Duration::from_secs(30)).expect("MCP reply/progress");
|
||||
let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress");
|
||||
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
|
||||
let query = next();
|
||||
let ids: Vec<_> = query["result"]["structuredContent"]["photos"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
|
||||
@@ -197,6 +244,8 @@ fn export_progress_and_cancel() {
|
||||
}
|
||||
} else if value["id"] == 3 {
|
||||
answered = true;
|
||||
// the ping was answered mid-batch: let the export run to the end
|
||||
hold.store(false, Ordering::SeqCst);
|
||||
} else {
|
||||
assert_eq!(value["id"], 2, "{value}");
|
||||
assert_eq!(value["result"]["isError"], false, "{value}");
|
||||
@@ -210,6 +259,8 @@ fn export_progress_and_cancel() {
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let decoy = out.join("photo999.png");
|
||||
std::fs::write(&decoy, b"unrelated existing output").unwrap();
|
||||
// this export waits after its first photo until the cancel below is read
|
||||
hold.store(true, Ordering::SeqCst);
|
||||
let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}}));
|
||||
request["params"]["_meta"] = json!({"progressToken":44});
|
||||
writeln!(send, "{request}").unwrap();
|
||||
@@ -234,7 +285,7 @@ fn export_progress_and_cancel() {
|
||||
}
|
||||
assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output");
|
||||
let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect();
|
||||
assert!(!photos.is_empty() && photos.len() < ids.len(), "only completed photos remain: {photos:?}");
|
||||
assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}");
|
||||
for path in &photos {
|
||||
lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file");
|
||||
}
|
||||
|
||||
+135
-2
@@ -170,6 +170,9 @@ fn enumerated(group: &str, tag: u16, v: i64) -> Option<&'static str> {
|
||||
(0x0103, 8) => "Deflate",
|
||||
(0x0103, 32773) => "PackBits",
|
||||
(0x0103, 34892) => "Lossy JPEG",
|
||||
(0x0103, 34713) => "Nikon NEF Compressed",
|
||||
(0x0103, 32767) => "Sony ARW Compressed",
|
||||
(0x0103, 52546) => "JPEG XL",
|
||||
(0x0106, 0) => "WhiteIsZero",
|
||||
(0x0106, 1) => "BlackIsZero",
|
||||
(0x0106, 2) => "RGB",
|
||||
@@ -269,12 +272,56 @@ fn rows_of(group: &'static str, ifd: &Ifd, out: &mut Vec<TagRow>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The IFD holding a raw file's sensor data when it is not IFD0 (a colour-filter-array or linear-raw
|
||||
/// image in a sub-IFD). IFD0 of such files describes a thumbnail, so its Compression is not the raw's.
|
||||
fn raw_image_ifd(ifd0: &Ifd) -> Option<&Ifd> {
|
||||
ifd0.sub_ifds.iter().find(|s| matches!(s.u32(0x0106), Some(32803 | 34892)))
|
||||
}
|
||||
|
||||
/// Nikon maker note `NEFCompression` (0x0093) spelled out, per the public Nikon tag documentation.
|
||||
fn nef_compression(v: u32) -> Option<&'static str> {
|
||||
Some(match v {
|
||||
1 => "Lossy (type 1)",
|
||||
2 => "Uncompressed",
|
||||
3 => "Lossless",
|
||||
4 => "Lossy (type 2)",
|
||||
5 => "Striped packed 12 bits",
|
||||
6 => "Uncompressed (reduced to 12 bit)",
|
||||
7 => "Unpacked 12 bits",
|
||||
8 => "Small",
|
||||
9 => "Packed 12 bits",
|
||||
10 => "Packed 14 bits",
|
||||
13 => "High Efficiency",
|
||||
14 => "High Efficiency*",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Nikon maker note NEFCompression of a raw file, spelled out; `None` for other files or values.
|
||||
fn nikon_nef_compression(tiff: &Tiff, bytes: &[u8]) -> Option<&'static str> {
|
||||
let ifd0 = tiff.ifds.first()?;
|
||||
let make = ifd0.string(0x010f)?;
|
||||
if !make.to_ascii_uppercase().starts_with("NIKON") {
|
||||
return None;
|
||||
}
|
||||
let e = ifd0.exif.as_ref()?.get(0x927c)?;
|
||||
let mn = lightcraft_tiff::makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?;
|
||||
nef_compression(mn.ifd.u32(0x0093)?)
|
||||
}
|
||||
|
||||
/// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0,
|
||||
/// then the EXIF, GPS and interoperability IFDs.
|
||||
/// then the EXIF, GPS and interoperability IFDs. A raw file's Compression row is the sensor
|
||||
/// image's, not IFD0's thumbnail's.
|
||||
pub fn tag_rows(tiff: &Tiff) -> Vec<TagRow> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(ifd0) = tiff.ifds.first() {
|
||||
rows_of("TIFF", ifd0, &mut out);
|
||||
if let Some(c) = raw_image_ifd(ifd0).and_then(|r| r.value(0x0103))
|
||||
&& let Some(row) = out.iter_mut().find(|r| r.tag == 0x0103)
|
||||
{
|
||||
// lossless JPEG (DNG, CR2) on the sensor image; plain "JPEG" would read as lossy
|
||||
row.value = if c.get_u64(0) == Some(7) { "Lossless JPEG".to_string() } else { value_text("TIFF", 0x0103, c) };
|
||||
}
|
||||
if let Some(e) = &ifd0.exif {
|
||||
rows_of("EXIF", e, &mut out);
|
||||
if let Some(i) = &e.interop {
|
||||
@@ -294,7 +341,13 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec<TagRow> {
|
||||
if Tiff::sniff(bytes).is_some()
|
||||
&& let Ok(t) = Tiff::parse(bytes)
|
||||
{
|
||||
return tag_rows(&t);
|
||||
let mut rows = tag_rows(&t);
|
||||
if let Some(words) = nikon_nef_compression(&t, bytes)
|
||||
&& let Some(row) = rows.iter_mut().find(|r| r.group == "TIFF" && r.tag == 0x0103)
|
||||
{
|
||||
row.value = words.to_string();
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() };
|
||||
Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default()
|
||||
@@ -328,4 +381,84 @@ mod tests {
|
||||
assert!(rows.iter().any(|r| r.group == "GPS" && r.name == "GPS Latitude"), "{rows:?}");
|
||||
assert!(!rows.iter().any(|r| r.tag == 0x8769), "IFD pointers are left out");
|
||||
}
|
||||
|
||||
fn entry(tag: u16, ty: u16, count: u32, val: u32) -> Vec<u8> {
|
||||
let mut e = Vec::new();
|
||||
e.extend(tag.to_le_bytes());
|
||||
e.extend(ty.to_le_bytes());
|
||||
e.extend(count.to_le_bytes());
|
||||
e.extend(val.to_le_bytes());
|
||||
e
|
||||
}
|
||||
|
||||
fn ifd(entries: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut b = (entries.len() as u16).to_le_bytes().to_vec();
|
||||
entries.iter().for_each(|e| b.extend(e));
|
||||
b.extend(0u32.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
/// A little-endian raw with a thumbnail IFD0 (`Compression` 1), a CFA sub-IFD (`Compression`
|
||||
/// 34713) and, when `nef` is set, a Nikon maker note whose NEFCompression is that value.
|
||||
fn synthetic_raw(nef: Option<u16>) -> Vec<u8> {
|
||||
let make = b"NIKON CORPORATION | ||||