Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53584512f4 | ||
|
|
e4737f68c7 | ||
|
|
21de86b3df | ||
|
|
0b101be155 | ||
|
|
7d2bb5e5ec | ||
|
|
2565f893c4 | ||
|
|
4e2584e559 | ||
|
|
cb173c22dc | ||
|
|
98da2a0229 | ||
|
|
1ece9341d6 | ||
|
|
6d52e0744a | ||
|
|
dd783b1d38 | ||
|
|
81e28f25c9 | ||
|
|
b5b6f8caa8 | ||
|
|
7e21d185b8 | ||
|
|
70db2f2320 | ||
|
|
74b9129a05 | ||
|
|
36c6920f55 | ||
|
|
6d22d1235b | ||
|
|
5f9548a7ba | ||
|
|
0e188f6a40 | ||
|
|
a66eebf778 | ||
|
|
e3dc820d89 | ||
|
|
38d2ff07c9 | ||
|
|
69640df8a7 | ||
|
|
b4a192a54e | ||
|
|
88c1e768f1 | ||
|
|
cc425d0cea | ||
|
|
7177b9b9f3 | ||
|
|
c60482f753 | ||
|
|
c1861e5416 | ||
|
|
88d9b7196d | ||
|
|
39e04ae7ab | ||
|
|
ee0b0ef67e | ||
|
|
169a4ed059 | ||
|
|
f7c0b9e49f | ||
|
|
b7e37a388d | ||
|
|
87dd1c201a | ||
|
|
4e012a59a6 | ||
|
|
fc4b054fa4 | ||
|
|
d614dc72bd | ||
|
|
024e983a7e | ||
|
|
1ba0421a85 |
No files matched your search
Generated
+1
@@ -2876,6 +2876,7 @@ name = "lightcraft-engine"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"harfrust",
|
||||
"libc",
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
|
||||
@@ -71,6 +71,8 @@ USAGE:
|
||||
per line: {\"method\": \"ui.set\", \"params\": {\"view\": \"detail\"}}.
|
||||
Replies go to stdout. `ui.screenshot` without a path writes -o (then
|
||||
OUT-2.png, OUT-3.png…); `ui.settle {timeoutMs?}` waits for renders.
|
||||
A failed request (\"ok\": false) does not stop the script, but the exit
|
||||
status is non-zero when any request failed.
|
||||
-o, --output OUT PNG path (a final screenshot is written here if the script took none)
|
||||
--size WxH window size in points (default 1600x1000)
|
||||
--scale S pixels per point (default 1)
|
||||
@@ -659,7 +661,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
for (k, v) in opts {
|
||||
p[k] = v;
|
||||
}
|
||||
let mut o = ExportOptions::from_json(&p);
|
||||
let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
|
||||
if p.get("format").is_none() {
|
||||
let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
o.format = ExportFormat::parse(&ext)
|
||||
@@ -755,6 +757,9 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
Some(p.with_file_name(format!("{stem}-{shots}.{ext}")).to_string_lossy().to_string())
|
||||
};
|
||||
let mut wrote_output = false;
|
||||
// Issue #166: a scripted request that fails still gets its reply printed and the run carries
|
||||
// on (like `run --keep-going`), but the exit status reports it — CI judges by exit status.
|
||||
let mut failed = 0usize;
|
||||
if let Some(script) = &script {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
|
||||
let mut out = std::io::stdout().lock();
|
||||
@@ -787,6 +792,9 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
_ => h.request(&method, params, timeout),
|
||||
};
|
||||
if reply["ok"] != true {
|
||||
failed += 1;
|
||||
}
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
// wall time of the request (incl. the frames it ran), and since the start
|
||||
@@ -823,7 +831,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
eprintln!("lightcraft-cli snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames());
|
||||
Ok(())
|
||||
if failed > 0 { Err(format!("{failed} scripted request(s) failed")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn commands(args: &[String]) -> Result<(), String> {
|
||||
|
||||
@@ -183,6 +183,39 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
assert_eq!((d.width, d.height), (640, 480));
|
||||
}
|
||||
|
||||
/// Issue #166: a scripted request that fails makes `snapshot` exit non-zero. The failed reply is
|
||||
/// still printed, the later lines still run, and the final `-o` screenshot is still written.
|
||||
#[test]
|
||||
fn snapshot_script_failure_exits_non_zero() {
|
||||
let script = tmp("snap-fail.jsonl");
|
||||
let out = tmp("snap-fail.png");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!(
|
||||
"{}\n{}\n",
|
||||
json!({"method": "ui.set", "params": {"view": "grid"}}), // not a view: `photoGrid`, `detail`, …
|
||||
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = std::fs::remove_file(&out);
|
||||
let o = Command::new(BIN)
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(!o.status.success(), "a failed scripted request must fail the run\n{stderr}");
|
||||
assert!(stderr.contains("1 scripted request(s) failed"), "{stderr}");
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 2, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap_or("").contains("grid"), "{replies:?}");
|
||||
assert_eq!(replies[1]["ok"], true, "{replies:?}");
|
||||
// continue-and-report: the final screenshot is still written
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (320, 200));
|
||||
}
|
||||
|
||||
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
|
||||
/// without creating a GPU device (no driver is loaded), and says why.
|
||||
#[test]
|
||||
|
||||
@@ -429,8 +429,17 @@ impl WebApp {
|
||||
cores.saturating_sub(1).clamp(1, 4)
|
||||
});
|
||||
let cache = app.session.media.rendered.clone();
|
||||
let workers =
|
||||
(n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, &cache, cc.egui_ctx.clone()));
|
||||
let workers = (n > 0).then(|| {
|
||||
Workers::start(
|
||||
n,
|
||||
backend.as_ref().map_or("memory", |b| b.kind()),
|
||||
backend.clone(),
|
||||
index,
|
||||
&cache,
|
||||
app.session.cache_bytes(),
|
||||
cc.egui_ctx.clone(),
|
||||
)
|
||||
});
|
||||
if let Some(w) = &workers {
|
||||
app.renderer.set_offload(Box::new(w.clone()));
|
||||
}
|
||||
@@ -606,6 +615,11 @@ impl eframe::App for WebApp {
|
||||
self.run_inbox();
|
||||
self.import_dropped(ctx);
|
||||
self.load_originals(ctx);
|
||||
if let Some(w) = &self.workers {
|
||||
// `library.preferences ▸ cacheMb` is read by the session; the worker index prunes on
|
||||
// its own thread, so it gets the budget every frame and applies it on the next store.
|
||||
w.set_budget(self.app.session.cache_bytes());
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
self.save();
|
||||
if let Some(b) = self.bench.as_mut()
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||
|
||||
use js_sys::{Array, Object, Reflect, Uint8Array, Uint32Array};
|
||||
use lightcraft_engine::catalog::PhotoId;
|
||||
use lightcraft_engine::media::{DISK_CACHE_BYTES, RenderJob, RenderResult, SourceLevel};
|
||||
use lightcraft_engine::media::{RenderJob, RenderResult, SourceLevel};
|
||||
use lightcraft_engine::pipeline::Rendered;
|
||||
use lightcraft_preview::{Hash128, PreviewCache};
|
||||
use lightcraft_raster::{Histogram, Rgba8};
|
||||
@@ -183,6 +183,9 @@ struct Inner {
|
||||
/// (photo, level) → worker that last decoded it.
|
||||
affinity: HashMap<(PhotoId, SourceLevel), usize>,
|
||||
index: ThumbIndex,
|
||||
/// The session's thumbnail cache budget in bytes. Kept here because the prune happens on a
|
||||
/// worker message, where the session is out of reach; the app refreshes it every frame.
|
||||
budget: u64,
|
||||
backend: Option<Backend>,
|
||||
ctx: egui::Context,
|
||||
/// Jobs finished by workers / inline (for diagnostics).
|
||||
@@ -214,7 +217,15 @@ impl Workers {
|
||||
/// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers.
|
||||
/// `index` lists the stored thumbnails of `cache`, the session's rendered-thumbnail cache
|
||||
/// now: watching it from the start, a clear before the first render request still counts.
|
||||
pub fn start(n: usize, store: &str, backend: Option<Backend>, index: ThumbIndex, cache: &Arc<PreviewCache>, ctx: egui::Context) -> Workers {
|
||||
pub fn start(
|
||||
n: usize,
|
||||
store: &str,
|
||||
backend: Option<Backend>,
|
||||
index: ThumbIndex,
|
||||
cache: &Arc<PreviewCache>,
|
||||
budget: u64,
|
||||
ctx: egui::Context,
|
||||
) -> Workers {
|
||||
let w = Workers(Rc::new(RefCell::new(Inner {
|
||||
preview_generation: CacheWatch::new(cache),
|
||||
workers: Vec::new(),
|
||||
@@ -222,6 +233,7 @@ impl Workers {
|
||||
next_id: 0,
|
||||
affinity: HashMap::new(),
|
||||
index,
|
||||
budget,
|
||||
backend,
|
||||
ctx,
|
||||
remote_done: 0,
|
||||
@@ -328,7 +340,10 @@ impl Workers {
|
||||
}
|
||||
if stored > 0 {
|
||||
g.index.insert(&hex, stored);
|
||||
let gone = g.index.prune(DISK_CACHE_BYTES);
|
||||
// The session's own budget (`library.preferences ▸ cacheMb`), not the native
|
||||
// default: the browser accepted the setting, so it has to be the one enforced
|
||||
// (#245).
|
||||
let gone = g.index.prune(g.budget);
|
||||
if !gone.is_empty()
|
||||
&& let Some(be) = g.backend.clone()
|
||||
{
|
||||
@@ -386,6 +401,12 @@ impl Workers {
|
||||
let ready = g.workers.iter().filter(|w| w.ready && !w.dead).count();
|
||||
(alive, ready, g.remote_done, g.inline_done)
|
||||
}
|
||||
|
||||
/// The budget the thumbnail index is pruned to. The app refreshes it from the session every
|
||||
/// frame, so `library.preferences ▸ cacheMb` applies to the next stored thumbnail.
|
||||
pub fn set_budget(&self, bytes: u64) {
|
||||
self.0.borrow_mut().budget = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOffload for Workers {
|
||||
|
||||
+138
-23
@@ -93,10 +93,23 @@ fn gpu_marker_path() -> Option<std::path::PathBuf> {
|
||||
|
||||
/// 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);
|
||||
///
|
||||
/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would
|
||||
/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU
|
||||
/// starts off there too, and the next ordinary launch reports and clears it.
|
||||
fn gpu_crash_check(in_memory: bool) -> Option<String> {
|
||||
gpu_crash_check_at(gpu_marker_path(), in_memory)
|
||||
}
|
||||
|
||||
fn gpu_crash_check_at(marker: Option<std::path::PathBuf>, in_memory: bool) -> Option<String> {
|
||||
use lightcraft_engine::gpu::backend;
|
||||
let left = if in_memory {
|
||||
marker.as_deref().and_then(backend::read_init_marker)
|
||||
} else {
|
||||
let left = marker.as_deref().and_then(backend::take_init_marker);
|
||||
backend::set_init_marker(marker);
|
||||
left
|
||||
};
|
||||
left.map(|what| gpu_crash_notice(&what))
|
||||
}
|
||||
|
||||
@@ -112,19 +125,28 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
||||
lightcraft_engine::camera_profiles::config_dir()
|
||||
}
|
||||
|
||||
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
|
||||
/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder.
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
}
|
||||
config_dir().map(|d| d.join("ui.json"))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
///
|
||||
/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing
|
||||
/// (issue #164), so a damaged file is left as it is there, not set aside.
|
||||
fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
let Some(path) = prefs_path() else { return (None, None, false) };
|
||||
load_prefs_at(&path, !in_memory)
|
||||
}
|
||||
|
||||
fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bool) {
|
||||
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,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
|
||||
@@ -135,6 +157,10 @@ fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bo
|
||||
};
|
||||
match serde_json::from_slice::<UiState>(&bytes) {
|
||||
Ok(ui) => (Some(ui.sanitized()), None, false),
|
||||
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)
|
||||
}
|
||||
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}"));
|
||||
@@ -177,8 +203,15 @@ fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Nothing is written while the session is one that promises to save nothing: `--memory`
|
||||
/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the
|
||||
/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the
|
||||
/// library it opened is saved then).
|
||||
#[derive(Default)]
|
||||
struct PrefsWriter {
|
||||
/// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder).
|
||||
path: Option<std::path::PathBuf>,
|
||||
written: Vec<u8>,
|
||||
library: String,
|
||||
checked: f64,
|
||||
@@ -190,19 +223,26 @@ struct PrefsWriter {
|
||||
|
||||
impl PrefsWriter {
|
||||
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
|
||||
if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
self.save_ui(&app.ui, app.library_problem.is_some())
|
||||
}
|
||||
|
||||
/// Write `ui` if it changed since the last write; `temporary` (the library couldn't be
|
||||
/// opened) saves nothing.
|
||||
fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> {
|
||||
if self.keep_file || temporary {
|
||||
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())?;
|
||||
let Some(path) = self.path.clone() else { return Ok(()) };
|
||||
let bytes = serde_json::to_vec_pretty(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))
|
||||
path.parent()
|
||||
.map_or(Ok(()), std::fs::create_dir_all)
|
||||
.and_then(|()| write_atomic(&path, &bytes))
|
||||
.map_err(|e| format!("saving the app settings failed: {e}"))?;
|
||||
self.written = bytes;
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
self.library = ui.settings.library_path.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -272,7 +312,8 @@ fn services() -> Services {
|
||||
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()
|
||||
let win_path = path.replace('/', "\\");
|
||||
std::process::Command::new("explorer").arg(format!("/select,{win_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()
|
||||
@@ -470,7 +511,7 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs();
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory);
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
@@ -482,7 +523,7 @@ fn main() -> eframe::Result {
|
||||
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_crash = gpu_crash_check(in_memory);
|
||||
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 {
|
||||
@@ -520,6 +561,7 @@ fn main() -> eframe::Result {
|
||||
app.notices.extend(prefs_warning);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
@@ -575,7 +617,7 @@ mod tests {
|
||||
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);
|
||||
let (ui, warning, keep) = load_prefs_at(&path, true);
|
||||
assert!(ui.is_none() && !keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}");
|
||||
@@ -583,7 +625,80 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(load_prefs_at(&path, true).1, None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #164: a `--memory` session reads the settings but writes nothing — a damaged file is
|
||||
/// reported and left where it is, not set aside.
|
||||
#[test]
|
||||
fn memory_session_leaves_a_damaged_ui_json_alone() {
|
||||
let d = dir("damaged-memory");
|
||||
let path = d.join("ui.json");
|
||||
let bytes = br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#;
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let (ui, warning, keep) = load_prefs_at(&path, false);
|
||||
assert!(ui.is_none() && keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("nothing is saved"), "{warning}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn prefs_writer_writes_nothing_in_a_memory_or_temporary_session() {
|
||||
let d = dir("writer");
|
||||
let path = d.join("sub").join("ui.json");
|
||||
let mut ui = UiState::default();
|
||||
// an ordinary session: a change is written, the folder created on the way
|
||||
let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() };
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
let saved = std::fs::read(&path).unwrap();
|
||||
assert!(String::from_utf8_lossy(&saved).contains("thumbSize"), "{}", String::from_utf8_lossy(&saved));
|
||||
// the temporary session (library problem): a further change is not written…
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, true).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), saved);
|
||||
// …until a library opens
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
assert_ne!(std::fs::read(&path).unwrap(), saved);
|
||||
// --memory: no path, so no file and no folder, whatever changes
|
||||
let d2 = dir("writer-memory");
|
||||
let _ = std::fs::remove_dir_all(&d2);
|
||||
let mut w = PrefsWriter { path: None, ..Default::default() };
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
assert!(w.written.is_empty() && !d2.exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #169: a `--memory` session neither arms the GPU crash sentinel (which would create
|
||||
/// the settings folder) nor removes a marker a crashed launch left; it still reports it, and
|
||||
/// the next ordinary launch finds it, reports it and clears it.
|
||||
#[test]
|
||||
fn memory_session_leaves_the_gpu_marker_and_folder_alone() {
|
||||
let d = dir("gpu-marker-memory");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let m = d.join("gpu-init.marker");
|
||||
// nothing left behind: no notice, and the folder is not created
|
||||
assert_eq!(gpu_crash_check_at(Some(m.clone()), true), None);
|
||||
assert!(!d.exists(), "a --memory session must not create the settings folder");
|
||||
// a marker left by a crashed launch: reported, kept
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(&m, "GPU device creation started (backends METAL)").unwrap();
|
||||
let notice = gpu_crash_check_at(Some(m.clone()), true).unwrap();
|
||||
assert!(notice.contains("GPU rendering is now off"), "{notice}");
|
||||
assert!(m.exists(), "the marker stays for the next ordinary launch");
|
||||
// the next ordinary launch reports it once and removes it
|
||||
assert!(gpu_crash_check_at(Some(m.clone()), false).is_some());
|
||||
lightcraft_engine::gpu::backend::set_init_marker(None);
|
||||
assert!(!m.exists());
|
||||
assert_eq!(gpu_crash_check_at(Some(m.clone()), false), None);
|
||||
lightcraft_engine::gpu::backend::set_init_marker(None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -630,7 +745,7 @@ mod tests {
|
||||
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);
|
||||
let (loaded, warning, _) = load_prefs_at(&path, true);
|
||||
assert!(loaded.is_some() && warning.is_none());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
//! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts,
|
||||
//! enabled and checked state, and live labels ("Undo Exposure").
|
||||
//!
|
||||
//! Key handling: a native key equivalent consumes the key press before egui sees it, so
|
||||
//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by
|
||||
//! the egui shortcut handler (nothing fires twice);
|
||||
//! Key handling: AppKit offers a key press to the menu bar only when it carries ⌘ or ⌃, or is a
|
||||
//! function key (F1…). Every other key (`E`, `1`, `⇧P`, `⌥Y`…) goes straight to the window, whose
|
||||
//! winit view always takes it, so a menu item never fires from it. Hence
|
||||
//! - shortcuts the menu bar really receives ([`menu_delivers`]) are listed in
|
||||
//! `LightcraftApp::native_shortcuts` and skipped by the egui shortcut handler (nothing fires
|
||||
//! twice); the others stay on their menu items for display and egui runs them;
|
||||
//! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the
|
||||
//! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come
|
||||
//! back when the field loses focus.
|
||||
@@ -146,6 +149,18 @@ fn yields_to_text(sc: &str) -> bool {
|
||||
!sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc)
|
||||
}
|
||||
|
||||
/// Whether AppKit hands this shortcut to the menu bar: only key presses with ⌘ or ⌃, and function
|
||||
/// keys. A plain `C` or `⇧P` never reaches it (the window's view takes the key), so egui has to run
|
||||
/// those even though their menu item shows the key.
|
||||
fn menu_delivers(sc: &str) -> bool {
|
||||
sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::<u8>().is_ok()))
|
||||
}
|
||||
|
||||
/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`.
|
||||
fn owned_by_menu<'a>(installed: impl Iterator<Item = &'a str>, text_focus: bool) -> HashSet<String> {
|
||||
installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// `&` marks a mnemonic in muda labels.
|
||||
fn label_text(s: &str) -> String {
|
||||
lightcraft_ui_egui::i18n::tr(s).replace('&', "&&")
|
||||
@@ -329,13 +344,8 @@ impl NativeMenu {
|
||||
|
||||
/// Tell the egui shortcut handler which shortcuts the menu bar currently owns.
|
||||
fn publish_shortcuts(&self, app: &mut LightcraftApp) {
|
||||
app.native_shortcuts = self
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| i.accel.is_some())
|
||||
.filter_map(|i| i.shortcut.clone())
|
||||
.filter(|sc| !(self.text_focus && yields_to_text(sc)))
|
||||
.collect::<HashSet<_>>();
|
||||
let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref());
|
||||
app.native_shortcuts = owned_by_menu(installed, self.text_focus);
|
||||
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
|
||||
}
|
||||
|
||||
@@ -467,4 +477,38 @@ mod tests {
|
||||
assert!(accelerator(sc).is_some(), "{sc}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Single keys shown in the menu bar (E, C, ⇧P, ratings…) never reach it on macOS, so egui must
|
||||
/// keep handling them; ⌘ / ⌃ combinations and function keys are the menu bar's own.
|
||||
#[test]
|
||||
fn single_key_shortcuts_stay_with_egui() {
|
||||
assert!(menu_delivers("Cmd+Shift+H") && menu_delivers("Ctrl+H") && menu_delivers("F2") && menu_delivers("Cmd+F11"));
|
||||
assert!(!menu_delivers("E") && !menu_delivers("Shift+P") && !menu_delivers("Alt+Y") && !menu_delivers("1") && !menu_delivers("F"));
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
fn all(n: &[MenuNode], out: &mut Vec<String>) {
|
||||
for x in n {
|
||||
match x {
|
||||
MenuNode::Item { shortcut: Some(s), .. } if accelerator(s).is_some() => out.push(s.clone()),
|
||||
MenuNode::Submenu { children, .. } => all(children, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut installed = Vec::new();
|
||||
for (_, v) in menu_bar(&app) {
|
||||
all(&v, &mut installed);
|
||||
}
|
||||
for sc in ["E", "C", "Shift+P", "Cmd+Shift+H", "F2"] {
|
||||
assert!(installed.iter().any(|s| s == sc), "{sc} is in the menu bar");
|
||||
}
|
||||
let owned = owned_by_menu(installed.iter().map(String::as_str), false);
|
||||
for sc in ["E", "C", "Shift+P"] {
|
||||
assert!(!owned.contains(sc), "{sc} must be left to egui");
|
||||
}
|
||||
for sc in ["Cmd+Shift+H", "F2"] {
|
||||
assert!(owned.contains(sc), "{sc} is run by the menu bar");
|
||||
}
|
||||
// while typing, the menu bar gives up F2 too (the text field has it)
|
||||
assert!(!owned_by_menu(installed.iter().map(String::as_str), true).contains("F2"));
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@ or CMYK profile converted by the CMS straight to linear Rec.2020), `Container` (
|
||||
EXIF `R03` Adobe RGB hint), `Untagged` (assumed sRGB; untagged float data assumed linear),
|
||||
`IccUnsupported` (profile present but unusable → **sRGB fallback**), `Naive` (CMYK without profile).
|
||||
|
||||
`decode_jpeg_with_fallback(bytes, opts, space)` lets an enclosing RAW container supply the colour
|
||||
space of a JPEG with no ICC or EXIF metadata. It applies the transfer curve before linear-light
|
||||
resizing; a JPEG carrying its own metadata retains its existing interpretation.
|
||||
|
||||
ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Display P3,
|
||||
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
|
||||
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
|
||||
|
||||
@@ -146,7 +146,19 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec<u8> {
|
||||
};
|
||||
p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)]));
|
||||
p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())]));
|
||||
align_tags(&p.encode().unwrap_or_default())
|
||||
align_tags(&fixed_date(p.encode().unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Zero the header's creation date/time (ICC.1 §7.2.8, bytes 24..36) so a profile's bytes depend
|
||||
/// only on its colour space and curve. `moxcms` stamps the wall clock there on every `encode`
|
||||
/// (ignoring `ColorProfile::creation_date_time`), which made two exports of one photo differ in
|
||||
/// the embedded profile and nowhere else, so output hashes could not be compared (#180). Readers
|
||||
/// do not interpret the field; all zeros is the conventional "unspecified" value.
|
||||
fn fixed_date(mut b: Vec<u8>) -> Vec<u8> {
|
||||
if let Some(date) = b.get_mut(24..36) {
|
||||
date.fill(0);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Re-lay a profile's tag data on 4-byte boundaries (ICC.1 §7.3.1). The encoder packs tags back to
|
||||
@@ -300,6 +312,23 @@ mod tests {
|
||||
let _ = SRGB;
|
||||
}
|
||||
|
||||
/// Two profiles for one space are byte-identical, and the header date is fixed: exports of the
|
||||
/// same photo with the same settings hash the same (#180).
|
||||
#[test]
|
||||
fn written_profiles_are_deterministic() {
|
||||
for n in NamedSpace::ALL {
|
||||
let a = write_named(n);
|
||||
let b = write_named(n);
|
||||
assert_eq!(a, b, "{n:?}");
|
||||
assert_eq!(&a[24..36], &[0u8; 12], "{n:?} header date/time");
|
||||
// the profile ID is "not computed", not a hash of a timestamp
|
||||
assert_eq!(&a[84..100], &[0u8; 16], "{n:?} profile ID");
|
||||
}
|
||||
let lin = write_matrix_trc(&PROPHOTO, &Trc::Linear);
|
||||
assert_eq!(lin, write_matrix_trc(&PROPHOTO, &Trc::Linear));
|
||||
assert_eq!(&lin[24..36], &[0u8; 12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_none() {
|
||||
assert!(parse(&[]).is_none());
|
||||
|
||||
@@ -105,6 +105,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
decode_with_fallback(bytes, opts, None)
|
||||
}
|
||||
|
||||
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
|
||||
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
|
||||
if m.components == 0 {
|
||||
return Err(Error::Malformed(F, "no frame header".into()));
|
||||
@@ -119,7 +123,10 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
} else {
|
||||
decode_zune(bytes, &m)?
|
||||
};
|
||||
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, ..Default::default() };
|
||||
// A raw container may carry the colour declaration of an otherwise untagged preview.
|
||||
// Apply it before linearization/resizing; existing JPEG metadata always wins.
|
||||
let hint = fallback.filter(|_| m.icc.is_none() && m.exif.is_none()).map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container));
|
||||
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, hint, ..Default::default() };
|
||||
finish(F, raw, meta, (m.width, m.height), opts)
|
||||
}
|
||||
|
||||
@@ -246,6 +253,44 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn container_fallback_matches_tagged_jpeg_before_resizing_and_keeps_own_metadata() {
|
||||
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, SpaceOrigin, encode_jpeg};
|
||||
let pixels: Vec<u8> = (0..16 * 16).flat_map(|i| if i % 2 == 0 { [120, 180, 90] } else { [30, 100, 200] }).collect();
|
||||
let encode = |icc, exif| {
|
||||
encode_jpeg(
|
||||
&EncodeImage::new(16, 16, 3, Samples::U8(&pixels)),
|
||||
100,
|
||||
ChromaSubsampling::S444,
|
||||
&EncodeMeta { icc, exif, ..Default::default() },
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let untagged = encode(None, None);
|
||||
let adobe = crate::icc::write_named(NamedSpace::AdobeRgb);
|
||||
let tagged = encode(Some(&adobe), None);
|
||||
for opts in [DecodeOptions::default(), DecodeOptions::fit(3, 3)] {
|
||||
let with = crate::decode_jpeg_with_fallback(&untagged, opts, NamedSpace::AdobeRgb).unwrap();
|
||||
let expected = crate::decode(&tagged, opts).unwrap();
|
||||
assert_eq!(with.space.origin, SpaceOrigin::Container);
|
||||
let (a, b) = (with.to_working(), expected.to_working());
|
||||
for (p, q) in a.data.iter().zip(&b.data) {
|
||||
assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 0.0001), "{p:?} != {q:?}");
|
||||
}
|
||||
}
|
||||
let srgb = crate::icc::write_named(NamedSpace::Srgb);
|
||||
let exif = crate::exif::minimal_exif(6);
|
||||
for bytes in [encode(Some(&srgb), None), encode(None, Some(&exif)), encode(Some(b"bad ICC"), None)] {
|
||||
let opts = DecodeOptions::fit(5, 5);
|
||||
let with = crate::decode_jpeg_with_fallback(&bytes, opts, NamedSpace::AdobeRgb).unwrap();
|
||||
let expected = crate::decode(&bytes, opts).unwrap();
|
||||
assert_eq!(with.space, expected.space);
|
||||
assert_eq!(with.image, expected.image);
|
||||
assert_eq!(with.orientation, expected.orientation);
|
||||
}
|
||||
assert!(crate::decode_jpeg_with_fallback(b"bad JPEG", DecodeOptions::default(), NamedSpace::AdobeRgb).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_request_covers() {
|
||||
assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171));
|
||||
|
||||
@@ -156,6 +156,14 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
|
||||
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
|
||||
}
|
||||
|
||||
/// Decode a JPEG preview with its enclosing container's colour space as a fallback.
|
||||
/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied
|
||||
/// before transfer decoding and linear-light resizing, never to already-resampled pixels.
|
||||
pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: NamedSpace) -> Result<Decoded> {
|
||||
std::panic::catch_unwind(|| jpeg::decode_with_fallback(bytes, &opts, Some(fallback)))
|
||||
.unwrap_or_else(|_| Err(Error::Malformed(Format::Jpeg, "decoder panicked".into())))
|
||||
}
|
||||
|
||||
/// [`decode`] without the panic guard (for fuzzing our own code paths).
|
||||
#[doc(hidden)]
|
||||
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! TIFF decode via the `tiff` crate: 8/16/32-bit integer, 16/32/64-bit float, gray/RGB/CMYK/palette,
|
||||
//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only.
|
||||
//! TIFF decode via the `tiff` crate: 1/2/4/8/16/32/64-bit integer, 16/32/64-bit float, gray/RGB/CMYK,
|
||||
//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only. Palette
|
||||
//! (indexed-colour) images are refused by the `tiff` crate itself, on every read path.
|
||||
|
||||
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
||||
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
||||
@@ -25,7 +26,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let icc = tag_bytes(&mut d, Tag::IccProfile);
|
||||
let xmp = tag_bytes(&mut d, Tag::from_u16_exhaustive(700)).map(|v| String::from_utf8_lossy(&v).trim_end_matches('\0').to_string());
|
||||
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16);
|
||||
let photometric = d.get_tag_u32(Tag::PhotometricInterpretation).ok();
|
||||
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
|
||||
let extra = d.get_tag_u16_vec(Tag::ExtraSamples).ok().unwrap_or_default();
|
||||
let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok();
|
||||
@@ -45,6 +45,9 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
|
||||
other => return Err(Error::Unsupported(F, unsupported_name(other))),
|
||||
};
|
||||
if bits < 8 && planar && n_samples > 1 {
|
||||
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
|
||||
}
|
||||
let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_));
|
||||
let premultiplied = has_alpha && extra.first() == Some(&1);
|
||||
|
||||
@@ -52,6 +55,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let n = wu * hu;
|
||||
let mut buf = match res {
|
||||
DecodingResult::U8(v) if bits < 8 => Buf::U8(unpack(&v, wu, hu, n_samples, bits)?),
|
||||
DecodingResult::U8(v) => Buf::U8(v),
|
||||
DecodingResult::U16(v) => Buf::U16(v),
|
||||
// Rare integer widths: keep 16 significant bits (plenty for display-referred data).
|
||||
@@ -94,14 +98,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
return finish(F, raw, meta, (w, h), opts);
|
||||
}
|
||||
|
||||
// WhiteIsZero → invert.
|
||||
if photometric == Some(0) {
|
||||
match &mut buf {
|
||||
Buf::U8(v) => v.iter_mut().for_each(|x| *x = 255 - *x),
|
||||
Buf::U16(v) => v.iter_mut().for_each(|x| *x = 65535 - *x),
|
||||
Buf::F32(v) => v.iter_mut().for_each(|x| *x = 1.0 - *x),
|
||||
}
|
||||
}
|
||||
// WhiteIsZero needs nothing here: the `tiff` crate inverts those samples as it reads them (and
|
||||
// refuses WhiteIsZero layouts it can't invert), so inverting again would show a negative.
|
||||
|
||||
// Drop extra samples beyond colour + one alpha.
|
||||
let keep = n_color + has_alpha as usize;
|
||||
@@ -140,6 +138,29 @@ fn unsupported_name(ct: ColorType) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Samples narrower than a byte (1-bit bilevel and fax scans, 2- and 4-bit gray) arrive packed, most
|
||||
/// significant bits first, each row padded to a whole byte. Spread them out one per byte, scaled to
|
||||
/// 0–255 like any 8-bit sample.
|
||||
fn unpack(packed: &[u8], width: usize, height: usize, samples: usize, bits: u8) -> Result<Vec<u8>> {
|
||||
if !matches!(bits, 1 | 2 | 4) {
|
||||
return Err(Error::Unsupported(F, "TIFF samples of 3, 5, 6 or 7 bits"));
|
||||
}
|
||||
let bits = bits as usize;
|
||||
let per_row = width.checked_mul(samples).ok_or_else(|| err("row too wide"))?;
|
||||
let row_bytes = per_row.checked_mul(bits).ok_or_else(|| err("row too wide"))?.div_ceil(8);
|
||||
if row_bytes == 0 || packed.len() / row_bytes < height {
|
||||
return Err(err("sample buffer too short"));
|
||||
}
|
||||
let max = (1u8 << bits) - 1;
|
||||
let scale = 255 / max; // 255, 85, 17: exact
|
||||
let mut out = Vec::with_capacity(per_row.saturating_mul(height));
|
||||
for row in packed.chunks_exact(row_bytes).take(height) {
|
||||
let values = row.iter().flat_map(|&b| (1..=8 / bits).map(move |i| (b >> (8 - bits * i)) & max));
|
||||
out.extend(values.take(per_row).map(|v| v * scale));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn interleave(buf: Buf, n: usize, s: usize) -> Buf {
|
||||
fn go<T: Copy + Default>(v: Vec<T>, n: usize, s: usize) -> Vec<T> {
|
||||
if v.len() < n * s {
|
||||
|
||||
@@ -245,6 +245,80 @@ fn tiff_cmyk_and_orientation() {
|
||||
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
|
||||
}
|
||||
|
||||
/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and
|
||||
/// already packed into `strip`, one padded row after another.
|
||||
fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec<u8> {
|
||||
const STRIP_AT: u32 = 8 + 2 + 9 * 12 + 4;
|
||||
let short = |tag: u16, v: u16| [&tag.to_le_bytes()[..], &3u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes(), &[0, 0]].concat();
|
||||
let long = |tag: u16, v: u32| [&tag.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat();
|
||||
let mut b = b"II*\0".to_vec();
|
||||
b.extend(8u32.to_le_bytes());
|
||||
b.extend(9u16.to_le_bytes());
|
||||
b.extend(short(256, width));
|
||||
b.extend(short(257, height));
|
||||
b.extend(short(258, bits));
|
||||
b.extend(short(259, 1));
|
||||
b.extend(short(262, photometric));
|
||||
b.extend(long(273, STRIP_AT));
|
||||
b.extend(short(277, 1));
|
||||
b.extend(short(278, height));
|
||||
b.extend(long(279, strip.len() as u32));
|
||||
b.extend(0u32.to_le_bytes());
|
||||
assert_eq!(b.len(), STRIP_AT as usize);
|
||||
b.extend_from_slice(strip);
|
||||
b
|
||||
}
|
||||
|
||||
/// WhiteIsZero (photometric 0) gray: the lowest sample is white. The `tiff` crate already inverts these
|
||||
/// samples, and a second inversion here used to show every WhiteIsZero scan as a negative.
|
||||
#[test]
|
||||
fn tiff_white_is_zero_is_inverted_once() {
|
||||
// Two pixels: the lowest sample, then the highest, at 8 and 16 bits.
|
||||
for (bits, strip) in [(8, vec![0u8, 255]), (16, [0u16.to_le_bytes(), 65535u16.to_le_bytes()].concat())] {
|
||||
for (photometric, first) in [(0, 1.0), (1, 0.0)] {
|
||||
let d = decode(&packed_tiff(2, 1, bits, photometric, &strip), DecodeOptions::default()).unwrap();
|
||||
let (a, b) = (d.image.get(0, 0)[1], d.image.get(1, 0)[1]);
|
||||
assert!((a - first).abs() < 1e-6 && (b - (1.0 - first)).abs() < 1e-6, "{bits}-bit, photometric {photometric}: {a} {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-, 2- and 4-bit samples (bilevel scans, fax images, low-depth gray) arrive packed eight, four or
|
||||
/// two to a byte with each row padded to a byte; they used to be rejected as "sample buffer too short".
|
||||
#[test]
|
||||
fn tiff_samples_narrower_than_a_byte() {
|
||||
let gray = |d: &Decoded, x: usize, y: usize| d.image.get(x, y)[1];
|
||||
|
||||
// 10 × 2 bilevel: rows are 2 bytes, the last 6 bits of each are padding (set, to prove they are ignored).
|
||||
// Row 0 alternates set/clear bits; row 1 is clear but for its last pixel.
|
||||
let rows = [0b1010_1010, 0b1011_1111, 0b0000_0000, 0b0111_1111];
|
||||
// What a set bit decodes to: white for BlackIsZero (1), black for WhiteIsZero (0).
|
||||
for (photometric, set_bit) in [(1, 1.0), (0, 0.0)] {
|
||||
let d = decode(&packed_tiff(10, 2, 1, photometric, &rows), DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.image.width, d.image.height, d.bit_depth), (10, 2, 1));
|
||||
assert!(d.grayscale);
|
||||
let clear_bit = 1.0 - set_bit;
|
||||
for x in 0..10 {
|
||||
let want = (if x % 2 == 0 { set_bit } else { clear_bit }, if x == 9 { set_bit } else { clear_bit });
|
||||
assert!((gray(&d, x, 0) - want.0).abs() < 1e-6, "row 0, x {x}, photometric {photometric}");
|
||||
assert!((gray(&d, x, 1) - want.1).abs() < 1e-6, "row 1, x {x}, photometric {photometric}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2-bit: 0, 1, 2, 3 → 0, 85, 170, 255. 4-bit: 0, 15, 8 → 0, 255, 136 (the row's last nibble is padding).
|
||||
let d = decode(&packed_tiff(4, 1, 2, 1, &[0b00_01_10_11]), DecodeOptions::default()).unwrap();
|
||||
for (x, v) in [0, 85, 170, 255].into_iter().enumerate() {
|
||||
assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "2-bit x {x}");
|
||||
}
|
||||
let d = decode(&packed_tiff(3, 1, 4, 1, &[0x0F, 0x8F]), DecodeOptions::default()).unwrap();
|
||||
for (x, v) in [0, 255, 136].into_iter().enumerate() {
|
||||
assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "4-bit x {x}");
|
||||
}
|
||||
|
||||
// A strip shorter than its rows is an error, never a panic.
|
||||
assert!(decode(&packed_tiff(10, 2, 1, 1, &rows[..3]), DecodeOptions::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_lossless_exact() {
|
||||
let img = gradient(31, 19);
|
||||
|
||||
+17
-6
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! The Planckian locus uses the Kim et al. (2002) cubic approximation (1667–25000 K); outside that
|
||||
//! range it is extended linearly in mired space. Tint is a signed offset perpendicular to the locus in
|
||||
//! CIE 1960 (u, v): **positive tint = magenta** (below the locus), `Duv = −tint / TINT_SCALE`.
|
||||
//! CIE 1960 (u, v). Tint describes the **correction**: positive adds magenta, negative adds green.
|
||||
//! A positive correction neutralises a green illuminant above the locus, so its source white uses
|
||||
//! `Duv = tint / TINT_SCALE`. Bradford adaptation removes that source cast.
|
||||
|
||||
use crate::{Mat3, RgbSpace, Xy, bradford};
|
||||
|
||||
@@ -65,11 +67,12 @@ fn locus_normal(t: f64) -> (f64, f64) {
|
||||
if ny < 0.0 { (-nx, -ny) } else { (nx, ny) }
|
||||
}
|
||||
|
||||
/// Chromaticity of the white point for temperature `t` (K) and `tint`.
|
||||
/// Source illuminant chromaticity for temperature `t` (K) and correction `tint`.
|
||||
/// Positive tint means a green source white, whose correction adds magenta to the image.
|
||||
pub fn temp_tint_to_xy(t: f64, tint: f64) -> Xy {
|
||||
let (u, v) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = -tint / TINT_SCALE;
|
||||
let duv = tint / TINT_SCALE;
|
||||
uv_to_xy(u + nu * duv, v + nv * duv)
|
||||
}
|
||||
|
||||
@@ -104,7 +107,7 @@ pub fn xy_to_temp_tint(p: Xy) -> (f64, f64) {
|
||||
let (lu, lv) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = (u - lu) * nu + (v - lv) * nv;
|
||||
(t, -duv * TINT_SCALE)
|
||||
(t, duv * TINT_SCALE)
|
||||
}
|
||||
|
||||
/// Matrix (linear RGB in `space` → same space) that white-balances a scene lit by `src` so that it
|
||||
@@ -141,10 +144,18 @@ mod tests {
|
||||
let warm = temp_tint_to_xy(3000.0, 0.0);
|
||||
let cool = temp_tint_to_xy(9000.0, 0.0);
|
||||
assert!(warm.x > cool.x);
|
||||
// positive tint is magenta: lower v
|
||||
// A positive magenta correction specifies a greener source illuminant: higher v.
|
||||
let (_, v0) = xy_to_uv(temp_tint_to_xy(5000.0, 0.0));
|
||||
let (_, v1) = xy_to_uv(temp_tint_to_xy(5000.0, 50.0));
|
||||
assert!(v1 < v0);
|
||||
assert!(v1 > v0);
|
||||
}
|
||||
|
||||
/// Daylight lies above the Planckian locus, so D65 reads as a small positive tint, as
|
||||
/// Lightroom's Daylight preset (5500 K, +10) has it (#188).
|
||||
#[test]
|
||||
fn daylight_has_a_positive_tint() {
|
||||
let (_, tint) = xy_to_temp_tint(crate::D65);
|
||||
assert!((5.0..15.0).contains(&tint), "{tint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -158,6 +158,7 @@ impl WbMode {
|
||||
pub struct WhiteBalance {
|
||||
pub mode: WbMode,
|
||||
pub temp: f64,
|
||||
/// Correction direction: negative adds green, positive adds magenta.
|
||||
pub tint: f64,
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
ab_glyph = { workspace = true }
|
||||
# Apply the font’s vertical substitutions and origins to Japanese watermarks.
|
||||
harfrust = { version = "0.12", default-features = false, features = ["std"] }
|
||||
lightcraft-segment = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -60,9 +60,8 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
|
||||
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
|
||||
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020).
|
||||
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let edge = (2 * size).max(384) as u32;
|
||||
let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 }).ok()?;
|
||||
let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?;
|
||||
let mut reference = decoded.to_working();
|
||||
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
|
||||
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
|
||||
|
||||
@@ -23,7 +23,8 @@ impl Session {
|
||||
.into_iter()
|
||||
.find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim()))
|
||||
.ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?;
|
||||
let mut out = preset.params;
|
||||
// a preset saved by another version may hold keys this one doesn't know: not the caller's typo
|
||||
let mut out = crate::export::ExportOptions::known_keys_only(&preset.params);
|
||||
if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) {
|
||||
for (k, v) in own.iter().filter(|(k, _)| *k != "preset") {
|
||||
o.insert(k.clone(), v.clone());
|
||||
@@ -54,6 +55,8 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str()));
|
||||
}
|
||||
// a preset that app.export would refuse is refused here, where the typo is on screen
|
||||
crate::export::ExportOptions::validate(ID, ¶ms)?;
|
||||
let preset = ExportPreset { name: name.to_string(), params };
|
||||
match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
|
||||
Some(x) => *x = preset,
|
||||
|
||||
@@ -356,36 +356,50 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
// ---- selection
|
||||
cmd!("library.select", "Select Photos", [], None, "{ids: [id], active?: id, mode?: replace|add|toggle|range}", always, |s, p| {
|
||||
let ids = ids_param(p).unwrap_or_default();
|
||||
let mode = str_param(p, "mode").unwrap_or("replace");
|
||||
s.end_interaction()?;
|
||||
match mode {
|
||||
"replace" => {
|
||||
s.selection =
|
||||
Selection { ids: ids.clone(), active: p.get("active").and_then(Value::as_u64).map(PhotoId).or(ids.first().copied()) };
|
||||
cmd!(
|
||||
"library.select",
|
||||
"Select Photos",
|
||||
[],
|
||||
None,
|
||||
"{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library",
|
||||
always,
|
||||
|s, p| {
|
||||
let ids = ids_param(p).unwrap_or_default();
|
||||
let active = p.get("active").and_then(Value::as_u64).map(PhotoId);
|
||||
let mode = str_param(p, "mode").unwrap_or("replace");
|
||||
// An id that is not in the library is an error, and the selection stays as it was:
|
||||
// selecting it would otherwise report success and leave the session with no usable
|
||||
// active photo (#182).
|
||||
if let Some(id) = ids.iter().chain(active.as_ref()).find(|id| s.catalog.photo(**id).is_none()) {
|
||||
return Err(bad("library.select", format!("no such photo {}", id.0)));
|
||||
}
|
||||
"add" => {
|
||||
for id in ids {
|
||||
if !s.selection.contains(id) {
|
||||
s.selection.ids.push(id);
|
||||
s.end_interaction()?;
|
||||
match mode {
|
||||
"replace" => {
|
||||
s.selection = Selection { ids: ids.clone(), active: active.or(ids.first().copied()) };
|
||||
}
|
||||
"add" => {
|
||||
for id in ids {
|
||||
if !s.selection.contains(id) {
|
||||
s.selection.ids.push(id);
|
||||
}
|
||||
s.selection.active = Some(id);
|
||||
}
|
||||
s.selection.active = Some(id);
|
||||
}
|
||||
}
|
||||
"toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)),
|
||||
"range" => {
|
||||
let vis = s.visible_cloned();
|
||||
if let Some(id) = ids.first() {
|
||||
s.selection.extend_to(*id, &vis);
|
||||
"toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)),
|
||||
"range" => {
|
||||
let vis = s.visible_cloned();
|
||||
if let Some(id) = ids.first() {
|
||||
s.selection.extend_to(*id, &vis);
|
||||
}
|
||||
}
|
||||
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
|
||||
}
|
||||
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
|
||||
s.active_mask = None;
|
||||
s.active_spot = None;
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}
|
||||
s.active_mask = None;
|
||||
s.active_spot = None;
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}),
|
||||
),
|
||||
cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| {
|
||||
let vis = s.visible_cloned();
|
||||
s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis };
|
||||
|
||||
@@ -48,8 +48,13 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va
|
||||
v
|
||||
}
|
||||
|
||||
/// The photo a query is about: `id` when given (which must be in the library), else the active one.
|
||||
fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
|
||||
p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad(c, "no photo"))
|
||||
match p.get("id").and_then(Value::as_u64).map(PhotoId) {
|
||||
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
|
||||
Some(id) => Err(bad(c, format!("no such photo {}", id.0))),
|
||||
None => s.active().ok_or_else(|| bad(c, "no photo")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -258,6 +258,35 @@ fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::F
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The font's vertical form, positioned from its vertical origin within the watermark cell.
|
||||
/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too.
|
||||
fn vertical_watermark_glyph(
|
||||
font: &ab_glyph::FontRef<'_>,
|
||||
data: &harfrust::ShaperData,
|
||||
ch: char,
|
||||
px: f32,
|
||||
left: f32,
|
||||
top: f32,
|
||||
) -> Option<ab_glyph::Glyph> {
|
||||
use ab_glyph::{Font, ScaleFont};
|
||||
let face = harfrust::FontRef::new(font.font_data()).ok()?;
|
||||
let mut buf = harfrust::UnicodeBuffer::new();
|
||||
buf.add(ch, 0);
|
||||
buf.set_direction(harfrust::Direction::TopToBottom);
|
||||
buf.guess_segment_properties();
|
||||
let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new());
|
||||
if shaped.glyph_infos().len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let gid = u16::try_from(shaped.glyph_infos().first()?.glyph_id).ok().filter(|g| *g != 0)?;
|
||||
let pos = shaped.glyph_positions().first()?;
|
||||
let sf = font.as_scaled(px);
|
||||
let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor());
|
||||
let height = -(pos.y_advance as f32) * sy;
|
||||
let pen = ab_glyph::point(left + px / 2.0 + pos.x_offset as f32 * sx, top + (px - height) / 2.0 - pos.y_offset as f32 * sy);
|
||||
Some(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen))
|
||||
}
|
||||
|
||||
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
|
||||
pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
|
||||
let width = img.width;
|
||||
@@ -312,6 +341,11 @@ fn watermark_coverage(
|
||||
}
|
||||
let fonts = watermark_fonts(craft);
|
||||
let Some(latin) = fonts.first() else { return };
|
||||
let vertical_data: Vec<_> = if wm.vertical {
|
||||
fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let short = width.min(height) as f32;
|
||||
let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0);
|
||||
let mut glyphs = Vec::new();
|
||||
@@ -335,6 +369,21 @@ fn watermark_coverage(
|
||||
prev = None;
|
||||
continue;
|
||||
}
|
||||
if wm.vertical
|
||||
&& matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60)
|
||||
&& let Some(face) = fonts.iter().position(|font| font.glyph_id(original).0 != 0)
|
||||
&& let (Some(font), Some(Some(data))) = (fonts.get(face), vertical_data.get(face))
|
||||
{
|
||||
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
|
||||
if let Some(glyph) = vertical_watermark_glyph(font, data, original, px, left, y)
|
||||
&& (glyph.id != font.glyph_id(original)
|
||||
|| !matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】'))
|
||||
{
|
||||
glyphs.push((face, glyph, None));
|
||||
y += px;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let vertical_form = match original {
|
||||
'、' => '︑',
|
||||
'。' => '︒',
|
||||
@@ -562,12 +611,264 @@ impl Default for ExportOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `app.export` params that are not export options: which photos, where to, a preset to start
|
||||
/// from, and the desktop app's background flag.
|
||||
pub const TARGET_PARAMS: &[&str] = &["id", "ids", "path", "dir", "preset", "background"];
|
||||
|
||||
/// The export option params [`ExportOptions::from_json`] reads (and [`ExportOptions::to_json`] writes).
|
||||
pub const OPTION_PARAMS: &[&str] = &[
|
||||
"format",
|
||||
"quality",
|
||||
"resize",
|
||||
"longEdge",
|
||||
"shortEdge",
|
||||
"width",
|
||||
"height",
|
||||
"megapixels",
|
||||
"percent",
|
||||
"dontEnlarge",
|
||||
"ppi",
|
||||
"limitKb",
|
||||
"sharpen",
|
||||
"sharpenAmount",
|
||||
"naming",
|
||||
"startNumber",
|
||||
"subfolder",
|
||||
"conflict",
|
||||
"tiffCompression",
|
||||
"dngCompression",
|
||||
"metadata",
|
||||
"removeLocation",
|
||||
"watermark",
|
||||
"colorSpace",
|
||||
"bitDepth",
|
||||
];
|
||||
|
||||
/// The keys of a `watermark` object ([`Watermark`], camelCase).
|
||||
pub const WATERMARK_PARAMS: &[&str] = &["text", "vertical", "size", "opacity", "anchor", "inset", "color", "shadow", "image", "imageWidth"];
|
||||
|
||||
/// Text height as a fraction of the short edge: the range the renderer draws without clamping.
|
||||
pub const WATERMARK_SIZE_RANGE: (f32, f32) = (0.005, 0.5);
|
||||
/// Margin as a fraction of the short edge.
|
||||
pub const WATERMARK_INSET_RANGE: (f32, f32) = (0.0, 0.4);
|
||||
/// A graphic's width as a fraction of the photo's width.
|
||||
pub const WATERMARK_IMAGE_WIDTH_RANGE: (f32, f32) = (0.01, 1.0);
|
||||
|
||||
/// Levenshtein distance, for "did you mean" on a misspelled key (both inputs are short).
|
||||
fn edit_distance(a: &str, b: &str) -> usize {
|
||||
let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
for (i, ca) in a.iter().enumerate() {
|
||||
let mut cur = vec![i + 1; b.len() + 1];
|
||||
for (j, cb) in b.iter().enumerate() {
|
||||
let sub = prev.get(j).copied().unwrap_or(0) + usize::from(ca != cb);
|
||||
let del = prev.get(j + 1).copied().unwrap_or(0) + 1;
|
||||
let ins = cur.get(j).copied().unwrap_or(0) + 1;
|
||||
if let Some(c) = cur.get_mut(j + 1) {
|
||||
*c = sub.min(del).min(ins);
|
||||
}
|
||||
}
|
||||
prev = cur;
|
||||
}
|
||||
prev.last().copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `unknown parameter \`k\``, naming the closest known key when one is near.
|
||||
fn unknown_key(what: &str, k: &str, known: &[&str]) -> String {
|
||||
let near = known.iter().map(|n| (edit_distance(&k.to_ascii_lowercase(), &n.to_ascii_lowercase()), *n)).filter(|(d, _)| *d <= 2).min();
|
||||
match near {
|
||||
Some((_, n)) => format!("unknown {what} `{k}` (did you mean `{n}`?)"),
|
||||
None => format!("unknown {what} `{k}` (one of {})", known.join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A JSON integer (`92`; never `92.5` or `"92"`), as [`ExportOptions::from_json`] reads them.
|
||||
fn whole(v: &serde_json::Value) -> Option<i64> {
|
||||
v.as_i64()
|
||||
}
|
||||
|
||||
impl ExportOptions {
|
||||
/// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or
|
||||
/// newer version may have written and this one doesn't know dropped, also inside `watermark`.
|
||||
/// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are
|
||||
/// not the caller's typo, and refusing them would make a preset or Export with Previous fail
|
||||
/// for good after an update.
|
||||
pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value {
|
||||
let mut p = p.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str()));
|
||||
if let Some(w) = o.get_mut("watermark").and_then(serde_json::Value::as_object_mut) {
|
||||
w.retain(|k, _| WATERMARK_PARAMS.contains(&k.as_str()));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an
|
||||
/// unknown parameter or a value that cannot be read as what it is for is an error, never a
|
||||
/// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`)
|
||||
/// are accepted and left to the caller.
|
||||
pub fn from_params(p: &serde_json::Value) -> crate::Result<Self> {
|
||||
Self::validate("app.export", p)?;
|
||||
Ok(Self::from_json(p))
|
||||
}
|
||||
|
||||
/// Check `app.export` params for `cmd` (the command named in the error): every key must be an
|
||||
/// export option ([`OPTION_PARAMS`]) or a target ([`TARGET_PARAMS`]), every value must be the
|
||||
/// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`]
|
||||
/// with its sizes in range (issue #183). `null` means "not given", as a dropped key does.
|
||||
pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> {
|
||||
use serde_json::Value;
|
||||
let bad = |msg: String| crate::cmd::bad(cmd, msg);
|
||||
let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) };
|
||||
let known: Vec<&str> = OPTION_PARAMS.iter().chain(TARGET_PARAMS).copied().collect();
|
||||
let int = |k: &str, v: &Value, lo: i64, hi: i64| -> crate::Result<i64> {
|
||||
whole(v).filter(|i| (lo..=hi).contains(i)).ok_or_else(|| bad(format!("`{k}` must be an integer {lo}..{hi}")))
|
||||
};
|
||||
let num = |k: &str, v: &Value, lo: f64, hi: f64| -> crate::Result<f64> {
|
||||
v.as_f64().filter(|f| f.is_finite() && (lo..=hi).contains(f)).ok_or_else(|| bad(format!("`{k}` must be a number {lo}..{hi}")))
|
||||
};
|
||||
fn string<'a>(cmd: &str, k: &str, v: &'a Value) -> crate::Result<&'a str> {
|
||||
v.as_str().ok_or_else(|| crate::cmd::bad(cmd, format!("`{k}` must be a string")))
|
||||
}
|
||||
let boolean = |k: &str, v: &Value| -> crate::Result<bool> { v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))) };
|
||||
let one_of = |k: &str, v: &Value, options: &[&str]| -> crate::Result<()> {
|
||||
let s = string(cmd, k, v)?;
|
||||
if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) }
|
||||
};
|
||||
for (k, v) in obj {
|
||||
if v.is_null() {
|
||||
continue;
|
||||
}
|
||||
match k.as_str() {
|
||||
"format" => {
|
||||
if ExportFormat::parse(string(cmd, k, v)?).is_none() {
|
||||
return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|dng|original")));
|
||||
}
|
||||
}
|
||||
"quality" => {
|
||||
int(k, v, 1, 100)?;
|
||||
}
|
||||
"ppi" => {
|
||||
int(k, v, 1, u16::MAX as i64)?;
|
||||
}
|
||||
"limitKb" => {
|
||||
int(k, v, 0, u32::MAX as i64)?;
|
||||
}
|
||||
"startNumber" => {
|
||||
int(k, v, 0, 999_999)?;
|
||||
}
|
||||
"bitDepth" => {
|
||||
if !matches!(whole(v), Some(8 | 10 | 16 | 32)) {
|
||||
return Err(bad(format!("`{k}` must be 8, 10, 16 or 32")));
|
||||
}
|
||||
}
|
||||
"longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => {
|
||||
num(k, v, 0.0, 1.0e9)?;
|
||||
}
|
||||
"resize" => {
|
||||
let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) };
|
||||
if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) {
|
||||
return Err(bad(unknown_key("resize key", x, &["mode", "value", "height", "dontEnlarge"])));
|
||||
}
|
||||
serde_json::from_value::<Resize>(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?;
|
||||
}
|
||||
"dontEnlarge" | "removeLocation" | "background" => {
|
||||
boolean(k, v)?;
|
||||
}
|
||||
"naming" | "subfolder" | "path" | "dir" | "preset" => {
|
||||
string(cmd, k, v)?;
|
||||
}
|
||||
"sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?,
|
||||
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
|
||||
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
|
||||
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
|
||||
"dngCompression" => {
|
||||
serde_json::from_value::<DngCompression>(v.clone())
|
||||
.map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?;
|
||||
}
|
||||
"metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?,
|
||||
"colorSpace" => {
|
||||
if OutputSpace::parse(string(cmd, k, v)?).is_none() {
|
||||
return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020")));
|
||||
}
|
||||
}
|
||||
"watermark" => Self::validate_watermark(cmd, v)?,
|
||||
"id" => {
|
||||
int(k, v, 0, i64::MAX)?;
|
||||
}
|
||||
"ids" => {
|
||||
let ok = v.as_array().is_some_and(|a| a.iter().all(|x| x.is_u64()));
|
||||
if !ok {
|
||||
return Err(bad(format!("`{k}` must be an array of photo ids")));
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(unknown_key("parameter", k, &known))),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183).
|
||||
fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> {
|
||||
use serde_json::Value;
|
||||
let bad = |msg: String| crate::cmd::bad(cmd, msg);
|
||||
let obj = match v {
|
||||
Value::String(_) => return Ok(()),
|
||||
Value::Object(o) => o,
|
||||
_ => {
|
||||
return Err(bad(
|
||||
"`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let frac = |k: &str, v: &Value, (lo, hi): (f32, f32), unit: &str| -> crate::Result<()> {
|
||||
match v.as_f64() {
|
||||
Some(f) if f.is_finite() && (f64::from(lo)..=f64::from(hi)).contains(&f) => Ok(()),
|
||||
_ => Err(bad(format!("`watermark.{k}` must be a number {lo}..{hi} ({unit})"))),
|
||||
}
|
||||
};
|
||||
for (k, v) in obj {
|
||||
if v.is_null() {
|
||||
continue;
|
||||
}
|
||||
match k.as_str() {
|
||||
"text" | "image" => {
|
||||
v.as_str().ok_or_else(|| bad(format!("`watermark.{k}` must be a string")))?;
|
||||
}
|
||||
"vertical" | "shadow" => {
|
||||
v.as_bool().ok_or_else(|| bad(format!("`watermark.{k}` must be true or false")))?;
|
||||
}
|
||||
"size" => frac(k, v, WATERMARK_SIZE_RANGE, "fraction of the short edge")?,
|
||||
"opacity" => frac(k, v, (0.0, 1.0), "0 = invisible, 1 = solid")?,
|
||||
"inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?,
|
||||
"imageWidth" => frac(k, v, WATERMARK_IMAGE_WIDTH_RANGE, "fraction of the photo's width")?,
|
||||
"anchor" => {
|
||||
serde_json::from_value::<Anchor>(v.clone()).map_err(|_| {
|
||||
bad("`watermark.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight".into())
|
||||
})?;
|
||||
}
|
||||
"color" => {
|
||||
let ok = v.as_array().is_some_and(|a| a.len() == 3 && a.iter().all(|c| matches!(whole(c), Some(0..=255))));
|
||||
if !ok {
|
||||
return Err(bad("`watermark.color` must be [r, g, b] with 0..255 each (sRGB)".into()));
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))),
|
||||
}
|
||||
}
|
||||
serde_json::from_value::<Watermark>(v.clone()).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}")))
|
||||
}
|
||||
|
||||
/// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`,
|
||||
/// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`) and the
|
||||
/// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H),
|
||||
/// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus
|
||||
/// `dontEnlarge` (default true).
|
||||
///
|
||||
/// Lenient: anything unknown or unreadable falls back to the default, which suits stored
|
||||
/// prefs and dialog prefill. A command that is about to export uses
|
||||
/// [`ExportOptions::from_params`], which validates first.
|
||||
pub fn from_json(p: &serde_json::Value) -> Self {
|
||||
use serde_json::Value;
|
||||
let d = Self::default();
|
||||
@@ -1392,6 +1693,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() {
|
||||
use ab_glyph::Font;
|
||||
let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else {
|
||||
eprintln!("skipped: built without BIZ UDMincho from craft-fonts");
|
||||
return;
|
||||
};
|
||||
let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap();
|
||||
// BIZ UDMincho has no Unicode presentation-form characters: the old fallback drew 、。.
|
||||
assert_eq!(font.glyph_id('︑').0, 0);
|
||||
assert_eq!(font.glyph_id('︒').0, 0);
|
||||
let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap());
|
||||
for ch in ['、', '。'] {
|
||||
let glyph = vertical_watermark_glyph(&font, &data, ch, 100.0, 0.0, 0.0).unwrap();
|
||||
assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}");
|
||||
let rect = font.outline_glyph(glyph).unwrap().px_bounds();
|
||||
assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}");
|
||||
}
|
||||
let craft = Box::leak(
|
||||
vec![crate::fonts::CraftFont { family: entry.family, style: entry.style, scripts: entry.scripts, bytes: entry.bytes }].into_boxed_slice(),
|
||||
);
|
||||
let wm = Watermark {
|
||||
text: "、。".into(),
|
||||
vertical: true,
|
||||
size: 0.1,
|
||||
anchor: Anchor::TopLeft,
|
||||
inset: 0.0,
|
||||
shadow: false,
|
||||
opacity: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut pixels = Vec::new();
|
||||
watermark_coverage(400, 300, &wm, craft, |x, y, k, _| {
|
||||
if k > 0.5 {
|
||||
pixels.push((x, y));
|
||||
}
|
||||
});
|
||||
assert!(!pixels.is_empty());
|
||||
for (x, y) in pixels {
|
||||
assert!(x > 15 && y % 30 < 15, "actual export coverage at the upper right of the 30 px cell: {x}, {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_watermarks_support_vertical_columns() {
|
||||
use ab_glyph::Font;
|
||||
@@ -1720,4 +2064,99 @@ mod tests {
|
||||
Err(e) => assert!(e.contains("not available"), "{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #181: a command about to export refuses what `from_json` would silently default —
|
||||
/// unknown keys (naming the nearest known one) and values of the wrong kind or range.
|
||||
#[test]
|
||||
fn export_params_are_validated() {
|
||||
use serde_json::json;
|
||||
let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let e = err(json!({"longEdgee": 400}));
|
||||
assert!(e.starts_with("invalid parameters for `app.export`: unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}");
|
||||
assert!(err(json!({"qualty": 5})).contains("did you mean `quality`"));
|
||||
assert!(err(json!({"longEdge": "banana"})).contains("`longEdge` must be a number"));
|
||||
assert!(err(json!({"quality": 0})).contains("`quality` must be an integer 1..100"));
|
||||
assert!(err(json!({"quality": "5"})).contains("`quality` must be an integer"));
|
||||
assert!(err(json!({"quality": 92.5})).contains("`quality` must be an integer"));
|
||||
assert!(err(json!({"sharpen": "lots"})).contains("`sharpen` must be one of none|screen|matte|glossy"));
|
||||
assert!(err(json!({"format": "bmp"})).contains("`format` must be one of"));
|
||||
assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32"));
|
||||
assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of"));
|
||||
assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false"));
|
||||
assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`"));
|
||||
assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:"));
|
||||
assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids"));
|
||||
assert!(err(json!("jpeg")).contains("params must be an object"));
|
||||
assert!(
|
||||
ExportOptions::validate("export.savePreset", &json!({"qualty": 5}))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.starts_with("invalid parameters for `export.savePreset`")
|
||||
);
|
||||
// everything the presets, the dialog, Export with Previous and `render --opt` send passes
|
||||
for p in builtin_presets() {
|
||||
ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name));
|
||||
}
|
||||
let full = ExportOptions {
|
||||
resize: Some(Resize::default()),
|
||||
limit_kb: Some(500),
|
||||
watermark: Some(Watermark { text: "©".into(), ..Default::default() }),
|
||||
bit_depth: Some(16),
|
||||
..Default::default()
|
||||
};
|
||||
let mut j = full.to_json();
|
||||
for (k, v) in [
|
||||
("dir", json!("/tmp/out")),
|
||||
("background", json!(true)),
|
||||
("preset", json!("x")),
|
||||
("ids", json!([1, 2])),
|
||||
("id", json!(1)),
|
||||
("path", json!("a.jpg")),
|
||||
] {
|
||||
j[k] = v;
|
||||
}
|
||||
assert_eq!(ExportOptions::from_params(&j).unwrap(), full);
|
||||
assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default());
|
||||
// null is "not given", as a dropped key is
|
||||
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in
|
||||
/// the `app.export` param string), instead of unknown keys passing and sizes clamping silently.
|
||||
#[test]
|
||||
fn watermark_params_are_validated() {
|
||||
use serde_json::json;
|
||||
let err = |w: serde_json::Value| ExportOptions::from_params(&json!({"watermark": w})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(
|
||||
err(json!(5))
|
||||
.contains("`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}")
|
||||
);
|
||||
assert!(
|
||||
err(json!({"text": "x", "bogus": 1}))
|
||||
.contains("unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth)")
|
||||
);
|
||||
assert!(err(json!({"text": "x", "sizee": 0.1})).contains("did you mean `size`"));
|
||||
for size in [json!(3), json!(0.5001), json!(0), json!("big")] {
|
||||
let e = err(json!({"text": "x", "size": size}));
|
||||
assert!(e.contains("`watermark.size` must be a number 0.005..0.5 (fraction of the short edge)"), "{size}: {e}");
|
||||
}
|
||||
assert!(err(json!({"text": "x", "opacity": 1.5})).contains("`watermark.opacity` must be a number 0..1"));
|
||||
assert!(err(json!({"text": "x", "inset": -0.1})).contains("`watermark.inset` must be a number 0..0.4"));
|
||||
assert!(err(json!({"image": "logo.png", "imageWidth": 2})).contains("`watermark.imageWidth` must be a number 0.01..1"));
|
||||
assert!(err(json!({"text": "x", "anchor": "middle"})).contains("`watermark.anchor` must be one of"));
|
||||
assert!(err(json!({"text": "x", "color": [255, 255]})).contains("`watermark.color` must be [r, g, b]"));
|
||||
assert!(err(json!({"text": "x", "color": [255, 255, 256]})).contains("`watermark.color`"));
|
||||
assert!(err(json!({"text": "x", "vertical": "yes"})).contains("`watermark.vertical` must be true or false"));
|
||||
assert!(err(json!({"text": 7})).contains("`watermark.text` must be a string"));
|
||||
// the documented shapes pass and read as before
|
||||
let o = ExportOptions::from_params(&json!({"watermark": "© me"})).unwrap();
|
||||
assert_eq!(o.watermark.map(|w| w.text).as_deref(), Some("© me"));
|
||||
let w = json!({"text": "日本語", "vertical": true, "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "inset": 0.05, "color": [0, 0, 0], "shadow": false, "image": "", "imageWidth": 0.3});
|
||||
let wm = ExportOptions::from_params(&json!({"watermark": w})).unwrap().watermark.unwrap();
|
||||
assert!((wm.size - 0.1).abs() < 1e-6 && wm.vertical && wm.anchor == Anchor::TopLeft && wm.color == [0, 0, 0] && !wm.shadow);
|
||||
assert_eq!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "size": 0.5}})).unwrap().watermark.map(|w| w.size), Some(0.5));
|
||||
// the Export dialog's sliders (1–15 % size, 5–100 % opacity, 2–100 % width) stay inside the accepted ranges
|
||||
assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15);
|
||||
assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0);
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,21 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
|
||||
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
|
||||
}
|
||||
|
||||
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
|
||||
pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes) {
|
||||
Some(space) => {
|
||||
let space = match space {
|
||||
lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb,
|
||||
lightcraft_raw::PreviewColorSpace::AdobeRgb => lightcraft_codecs::NamedSpace::AdobeRgb,
|
||||
};
|
||||
lightcraft_codecs::decode_jpeg_with_fallback(&jpeg, opts, space).ok()
|
||||
}
|
||||
None => lightcraft_codecs::decode(&jpeg, opts).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Oriented size of the embedded preview of a raw file.
|
||||
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
@@ -333,8 +348,7 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
|
||||
/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented.
|
||||
pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?;
|
||||
let d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?;
|
||||
let img = d.to_working();
|
||||
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
|
||||
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
|
||||
@@ -343,8 +357,7 @@ pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, S
|
||||
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
|
||||
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
|
||||
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let mut d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?;
|
||||
let mut d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?;
|
||||
if d.image.width.max(d.image.height) > max_edge {
|
||||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||||
d.alpha = None;
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 11;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 12;
|
||||
|
||||
/// 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];
|
||||
|
||||
@@ -817,3 +817,53 @@ fn face_job_follows_rotate_right() {
|
||||
let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w);
|
||||
assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}");
|
||||
}
|
||||
|
||||
/// Selecting an id that is not in the library is an error, and the selection stays as it was
|
||||
/// (#182: `select_photos {"ids": [9999]}` answered `selected: 1` and the session lost its photo).
|
||||
#[test]
|
||||
fn selecting_an_unknown_photo_is_an_error_and_keeps_the_selection() {
|
||||
let mut s = demo();
|
||||
let vis = s.visible_cloned();
|
||||
s.execute("library.select", &json!({"ids": [vis[0].0]})).unwrap();
|
||||
let before = s.selection.clone();
|
||||
for p in [
|
||||
json!({"ids": [9999]}),
|
||||
json!({"ids": [9999], "active": 9999}),
|
||||
json!({"ids": [vis[1].0], "active": 9999}),
|
||||
json!({"ids": [vis[1].0, 9999]}),
|
||||
json!({"ids": [9999], "mode": "add"}),
|
||||
json!({"ids": [9999], "mode": "toggle"}),
|
||||
json!({"ids": [9999], "mode": "range"}),
|
||||
] {
|
||||
let e = s.execute("library.select", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("no such photo 9999"), "{p}: {e}");
|
||||
assert_eq!(s.selection, before, "{p}");
|
||||
}
|
||||
assert_eq!(s.active(), Some(vis[0]));
|
||||
assert!(s.execute("develop.get", &json!({})).is_ok());
|
||||
// queries about an explicit id check it too
|
||||
for c in ["develop.get", "photo.inspect", "photo.allMetadata", "history.list"] {
|
||||
let e = s.execute(c, &json!({"id": 9999})).unwrap_err().to_string();
|
||||
assert!(e.contains("no such photo 9999"), "{c}: {e}");
|
||||
assert!(s.execute(c, &json!({"id": vis[1].0})).is_ok(), "{c}");
|
||||
}
|
||||
// an empty selection is still allowed
|
||||
assert_eq!(s.execute("library.select", &json!({"ids": []})).unwrap()["selected"], 0);
|
||||
}
|
||||
|
||||
/// A preset saved by another version may hold keys this one doesn't know: exporting with it keeps
|
||||
/// working (the stale keys are dropped), while a typo the caller makes is still refused (#181).
|
||||
#[test]
|
||||
fn saved_presets_with_unknown_keys_still_export() {
|
||||
use crate::export::{ExportOptions, ExportPreset};
|
||||
let mut s = demo();
|
||||
s.export_presets.push(ExportPreset {
|
||||
name: "Old".into(),
|
||||
params: json!({"format": "png", "retiredOption": 3, "watermark": {"text": "©", "retiredKey": true}}),
|
||||
});
|
||||
let p = s.export_params(&json!({"preset": "old", "quality": 80})).unwrap();
|
||||
let o = ExportOptions::from_params(&p).unwrap();
|
||||
assert_eq!(o.format, crate::export::ExportFormat::Png);
|
||||
assert_eq!(o.watermark.map(|w| w.text), Some("©".to_string()));
|
||||
assert!(ExportOptions::from_params(&s.export_params(&json!({"preset": "old", "qualty": 80})).unwrap()).is_err());
|
||||
}
|
||||
@@ -155,8 +155,16 @@ pub(crate) fn with_init_marker<T>(backends: Backends, f: impl FnOnce() -> T) ->
|
||||
/// If the init marker `path` is present — the last process died while creating the GPU device —
|
||||
/// remove it and return what it recorded.
|
||||
pub fn take_init_marker(path: &std::path::Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
let text = read_init_marker(path)?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// What the init marker `path` recorded, if it is present — without removing it. For sessions
|
||||
/// that must leave the disk as they found it (`--memory`, issues #164 and #169): GPU rendering
|
||||
/// still starts off, and the marker stays for the next ordinary launch to report and clear.
|
||||
pub fn read_init_marker(path: &std::path::Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
Some(text.trim().to_string())
|
||||
}
|
||||
|
||||
@@ -225,10 +233,13 @@ mod tests {
|
||||
set_init_marker(None);
|
||||
assert!(seen, "the marker exists while the device is created");
|
||||
assert!(!m.exists(), "and is removed afterwards");
|
||||
// a marker left behind by a crashed process is reported once
|
||||
// a marker left behind by a crashed process is reported once; reading it leaves it in place
|
||||
std::fs::write(&m, "GPU device creation started (backends DX12)").unwrap();
|
||||
assert!(read_init_marker(&m).unwrap().contains("DX12"));
|
||||
assert!(m.exists(), "read_init_marker leaves the marker for the next launch");
|
||||
assert!(take_init_marker(&m).unwrap().contains("DX12"));
|
||||
assert_eq!(take_init_marker(&m), None);
|
||||
assert_eq!(read_init_marker(&m), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -905,7 +905,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
||||
Some(2)
|
||||
}
|
||||
MaskShape::ColorRange { samples, refine } => {
|
||||
let tol = 0.04 + 0.16 * (*refine as f32 / 100.0);
|
||||
let tol = lightcraft_pipeline::masks::color_range_tolerance(*refine);
|
||||
p.extend([tol.to_bits(), ev.exp2().to_bits(), samples.len() as u32]);
|
||||
aux.extend(samples.iter().flat_map(|s| s.map(|v| v as f32)));
|
||||
Some(3)
|
||||
|
||||
@@ -639,3 +639,24 @@ fn output_spaces_match() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_directions_match_cpu_for_raw_and_rendered_sources() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let src = Arc::new(Rgb32f::filled(64, 64, [0.18; 3]));
|
||||
for info in [
|
||||
SourceInfo::default(),
|
||||
SourceInfo { raw: true, relative_wb: true, ..Default::default() },
|
||||
SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() },
|
||||
] {
|
||||
for delta in [-50.0, 50.0] {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.wb.mode = WbMode::Custom;
|
||||
s.wb.temp = info.as_shot_temp;
|
||||
s.wb.tint = info.as_shot_tint + delta;
|
||||
check("tint direction", &src, &info, &s, &RenderRequest::fit(64, 64));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ impl Headless {
|
||||
fn export(&mut self, p: &Value) -> Result<Value, String> {
|
||||
use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, export_batch};
|
||||
let p = &self.session.export_params(p)?;
|
||||
let mut opts = ExportOptions::from_json(p);
|
||||
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
|
||||
if !ExportOptions::has_size_param(p) {
|
||||
opts.resize = Some(Resize::long_edge(3000));
|
||||
}
|
||||
@@ -107,7 +107,7 @@ impl Backend for Headless {
|
||||
"engine.commands" => {
|
||||
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?, limitKb?, colorSpace?, bitDepth?, sharpen?, metadata?, watermark?, naming?}",
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"enabled": self.session.active().is_some()}));
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
|
||||
@@ -120,6 +120,25 @@ fn path_writes_never_replace_an_original() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Issue #181: a misspelled or unreadable `app.export` param is an error through the headless
|
||||
/// backend (what `lightcraft-cli run` and the MCP `export` tool use), not a silent default.
|
||||
#[test]
|
||||
fn export_refuses_unknown_params_and_bad_values() {
|
||||
let mut b = Headless::demo();
|
||||
let out = "/nonexistent-lc-test/x.jpg";
|
||||
let e = b.call("app.export", json!({"path": out, "longEdgee": 400})).unwrap_err();
|
||||
assert!(e.contains("unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}");
|
||||
let e = b.call("app.export", json!({"path": out, "longEdge": "banana"})).unwrap_err();
|
||||
assert!(e.contains("`longEdge` must be a number"), "{e}");
|
||||
let e = b.call("app.export", json!({"path": out, "watermark": {"text": "x", "size": 3}})).unwrap_err();
|
||||
assert!(e.contains("`watermark.size` must be a number 0.005..0.5"), "{e}");
|
||||
// `export` tool and preset expansion go through the same check
|
||||
let r = call_tool(&mut b, "export", &json!({"path": out, "quality": 101}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
let e = b.session.execute("export.savePreset", &json!({"name": "Typo", "params": {"qualty": 5}})).unwrap_err().to_string();
|
||||
assert!(e.contains("did you mean `quality`"), "{e}");
|
||||
}
|
||||
|
||||
/// The `import` helper moves: renamed into the folder template, the source removed.
|
||||
#[test]
|
||||
fn import_tool_moves_with_a_folder_template() {
|
||||
@@ -205,3 +224,24 @@ fn resources() {
|
||||
}
|
||||
assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "lightcraft://nope"}))["error"]["code"], -32002);
|
||||
}
|
||||
|
||||
/// `select_photos` with an id that is not in the library is a tool error, and the photo that was
|
||||
/// active stays active (#182).
|
||||
#[test]
|
||||
fn select_photos_rejects_unknown_ids_and_keeps_the_active_photo() {
|
||||
let mut b = Headless::demo();
|
||||
let first = b.session.visible_cloned()[0];
|
||||
let r = call_tool(&mut b, "select_photos", &json!({"ids": [first.0]}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let r = call_tool(&mut b, "select_photos", &json!({"ids": [9999]}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
assert!(r.content[0]["text"].as_str().unwrap().contains("no such photo 9999"), "{r:?}");
|
||||
assert_eq!(b.session.active(), Some(first));
|
||||
let r = call_tool(&mut b, "get_develop", &json!({}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let r = call_tool(&mut b, "get_develop", &json!({"id": 9999}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
let r = call_tool(&mut b, "set_develop", &json!({"id": 9999, "values": {"light.exposure": 1.0}}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
assert_eq!(b.session.active(), Some(first));
|
||||
}
|
||||
@@ -205,7 +205,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"removeLocation": {"type": "boolean"},
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"},
|
||||
"watermark": {"description": "Text, or {text, vertical (boolean; defaults to false), size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"}
|
||||
"watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
|
||||
@@ -191,3 +191,28 @@ mod bw_tests {
|
||||
assert_eq!(auto_bw_mix(&grey, &SourceInfo::default(), &DevelopSettings::default()), [0.0; 8]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tint_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auto_wb_and_picker_correct_green_with_positive_tint() {
|
||||
// The picker delegates a sampled patch to this same auto_wb implementation.
|
||||
for (rgb, sign) in [([0.18, 0.24, 0.18], 1.0), ([0.24, 0.18, 0.24], -1.0)] {
|
||||
let img = Rgb32f::filled(16, 16, rgb);
|
||||
let info = SourceInfo { raw: true, relative_wb: true, ..Default::default() };
|
||||
let (temp, tint) = auto_wb(&img, &info);
|
||||
assert!(tint * sign > 0.0, "{rgb:?}: temp {temp}, tint {tint}");
|
||||
let mut s = DevelopSettings::default();
|
||||
s.wb.mode = lightcraft_develop::WbMode::Custom;
|
||||
s.wb.temp = temp;
|
||||
s.wb.tint = tint;
|
||||
let mut corrected = img;
|
||||
crate::local::white_balance(&mut corrected, &info, &s);
|
||||
let p = corrected.get(0, 0);
|
||||
let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]);
|
||||
assert!(spread < 0.003, "{rgb:?} -> {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
|
||||
}
|
||||
}
|
||||
MaskShape::ColorRange { samples, refine } => {
|
||||
let tol = 0.04 + 0.16 * (*refine as f32 / 100.0);
|
||||
let tol = color_range_tolerance(*refine);
|
||||
let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect();
|
||||
for (v, c) in out.data.iter_mut().zip(&img.data) {
|
||||
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain)));
|
||||
@@ -257,6 +257,21 @@ pub(crate) fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] {
|
||||
c.map(|v| v / (1.0 + v))
|
||||
}
|
||||
|
||||
/// Colour range: the OkLab distance (a, b at full weight, L at a quarter) at which a pixel leaves
|
||||
/// the selection for a Refine amount of 0..100. A pixel is fully selected up to half of it and
|
||||
/// fades out over the other half (CPU here, `mask.wgsl` kind 3; both take this value).
|
||||
///
|
||||
/// Issue #157: `0.04 + 0.16·refine` selected the whole photo. Through [`tonemap_for_select`] a
|
||||
/// slightly teal mid-grey (sRGB 120,150,155) and a neutral light grey (200,198,195) are only ~0.063
|
||||
/// apart — L 0.60 vs 0.72, a/b ≤ 0.02 — so at the default Refine 50 (full selection below 0.06)
|
||||
/// the grey patch was 99 % selected, and at Refine 85 / 100 still most of it. A JND is ~0.01 on
|
||||
/// this scale, so Refine 50 now holds a surface up to ~2 JND from its sample and drops everything
|
||||
/// beyond ~4.5 (full to 0.0225, gone at 0.045); Refine 85 is gone at 0.066, past that grey patch;
|
||||
/// Refine 100 reaches 0.075 and Refine 0 is the sample's own shade (full to 0.0075, gone at 0.015).
|
||||
pub fn color_range_tolerance(refine: f64) -> f32 {
|
||||
0.015 + 0.06 * (refine / 100.0).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
|
||||
/// A brush stroke resolved to output pixels: dab centres (spacing r/4 along the path), radius,
|
||||
/// hard-core radius, flow and density (0..1), and whether it erases.
|
||||
pub struct BrushDabs {
|
||||
@@ -598,4 +613,42 @@ mod tests {
|
||||
let (plain, sharp) = (step(&soft), step(&refined));
|
||||
assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}");
|
||||
}
|
||||
|
||||
/// Issue #157: a colour-range sample of one flat patch must not select a clearly different
|
||||
/// patch. The two patches are the ones from the report (sRGB 120,150,155 and 200,198,195),
|
||||
/// sampled the way `mask.sampleColor` does (3×3 mean of the selection-space OkLab).
|
||||
#[test]
|
||||
fn color_range_keeps_to_the_sampled_colour() {
|
||||
use lightcraft_color::{REC2020, SRGB, transfer::decode_srgb8};
|
||||
let (w, h) = (120usize, 40usize);
|
||||
let m = SRGB.to_space(&REC2020);
|
||||
let patch = |rgb: [u8; 3]| m.apply_f32(rgb.map(decode_srgb8));
|
||||
let (left, right) = (patch([120, 150, 155]), patch([200, 198, 195]));
|
||||
let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { left } else { right });
|
||||
let log_l = img.map(crate::local::log_lum);
|
||||
let f = Frame::new(w, h, &Default::default(), true);
|
||||
let mut sample = [0f64; 3];
|
||||
for y in 19..=21 {
|
||||
for x in 29..=31 {
|
||||
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(img.data[y * w + x]));
|
||||
for k in 0..3 {
|
||||
sample[k] += lab[k] as f64 / 9.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let alpha = |refine| {
|
||||
let shape = MaskShape::ColorRange { samples: vec![sample], refine };
|
||||
let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0);
|
||||
(a.get(30, 20), a.get(90, 20))
|
||||
};
|
||||
for refine in [0.0, 50.0, 85.0] {
|
||||
let (l, r) = alpha(refine);
|
||||
assert!(l > 0.95, "refine {refine}: sampled patch {l}");
|
||||
assert!(r < 0.05, "refine {refine}: other patch {r}");
|
||||
}
|
||||
// Refine 100 reaches furthest (the grey is ~0.063 away, the fade ends at 0.075), but the
|
||||
// other patch stays mostly out
|
||||
let (l, r) = alpha(100.0);
|
||||
assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}");
|
||||
}
|
||||
}
|
||||
@@ -330,3 +330,28 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() {
|
||||
assert!(blue(&pro) > 0);
|
||||
assert!(red(&pro) < n, "ProPhoto holds more than sRGB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_negative_is_green_and_positive_is_magenta() {
|
||||
use lightcraft_develop::WbMode;
|
||||
let src = Rgb32f::filled(16, 16, [0.18; 3]);
|
||||
// Rendered images, uncalibrated RAW and calibrated RAW with a nonzero As Shot tint.
|
||||
for info in [
|
||||
SourceInfo::default(),
|
||||
SourceInfo { raw: true, relative_wb: true, ..Default::default() },
|
||||
SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() },
|
||||
] {
|
||||
let mut s = DevelopSettings::default();
|
||||
let neutral = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8);
|
||||
assert!((neutral[0] as i16 - neutral[1] as i16).abs() <= 1);
|
||||
assert!((neutral[2] as i16 - neutral[1] as i16).abs() <= 1);
|
||||
s.wb.mode = WbMode::Custom;
|
||||
s.wb.temp = info.as_shot_temp;
|
||||
for delta in [-50.0, 50.0] {
|
||||
s.wb.tint = info.as_shot_tint + delta;
|
||||
let p = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8);
|
||||
let magenta = (p[0] as f64 + p[2] as f64) / 2.0 - p[1] as f64;
|
||||
assert!(magenta * delta > 100.0, "delta {delta} must follow the green/magenta track, got {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ pub use lightcraft_geom::Orientation;
|
||||
pub use lightcraft_meta::Metadata;
|
||||
pub use lightcraft_raster::Rgb32f;
|
||||
pub use opcodes::{Opcode, OpcodeLists};
|
||||
pub use preview::embedded_preview;
|
||||
pub use preview::{PreviewColorSpace, embedded_preview, embedded_preview_color_space};
|
||||
|
||||
use lightcraft_color::Xy;
|
||||
use lightcraft_tiff::{Tiff, TiffError};
|
||||
@@ -159,9 +159,9 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
/// measure them from the samples).
|
||||
///
|
||||
/// A few uncompressed vendor formats derive part of this from the samples themselves (Nikon
|
||||
/// NEF: optically masked trailing columns; Olympus ORF: the CFA phase and bit depth; Pentax PEF
|
||||
/// without crop tags: dark borders); for those the samples are read (unpacked, nothing to
|
||||
/// decompress) and dropped.
|
||||
/// NEF: optically masked trailing columns; Olympus ORF: the bit depth of 16-bit files, and the CFA
|
||||
/// phase of files without an Exif `CFAPattern`; Pentax PEF without crop tags: dark borders); for
|
||||
/// those the samples are read (unpacked, nothing to decompress) and dropped.
|
||||
pub fn probe_info(bytes: &[u8]) -> Result<RawInfo> {
|
||||
decode_with(bytes, Mode::Header).map(RawImage::into_info)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
RawFormat::Raf => vendor::raf::decode(bytes, mode),
|
||||
RawFormat::Rw2 => vendor::rw2::decode(bytes, mode),
|
||||
RawFormat::Pef => vendor::pef::decode(bytes, mode),
|
||||
RawFormat::Orf => vendor::orf::decode(bytes),
|
||||
RawFormat::Orf => vendor::orf::decode(bytes, mode),
|
||||
other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,30 @@ use lightcraft_tiff::image::chunk_bytes;
|
||||
use lightcraft_tiff::tags as t;
|
||||
use lightcraft_tiff::{Ifd, Tiff, makernote};
|
||||
|
||||
/// Colour space of an embedded JPEG when the enclosing raw supplies it instead of the JPEG.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PreviewColorSpace {
|
||||
Srgb,
|
||||
AdobeRgb,
|
||||
}
|
||||
|
||||
/// Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB), per Nikon tag documentation.
|
||||
/// This is a fallback only: a JPEG's own ICC/EXIF colour declaration takes precedence.
|
||||
pub fn embedded_preview_color_space(bytes: &[u8]) -> Option<PreviewColorSpace> {
|
||||
let tiff = Tiff::parse(bytes).ok()?;
|
||||
let make = tiff.ifds.first()?.string(t::MAKE)?;
|
||||
if !make.to_ascii_uppercase().starts_with("NIKON") {
|
||||
return None;
|
||||
}
|
||||
let e = tiff.exif()?.get(t::MAKER_NOTE)?;
|
||||
let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?;
|
||||
match mn.ifd.u16(0x001e)? {
|
||||
1 => Some(PreviewColorSpace::Srgb),
|
||||
2 => Some(PreviewColorSpace::AdobeRgb),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `b` looks like a displayable (DCT) JPEG: SOI, and the first SOF marker is not lossless.
|
||||
fn is_dct_jpeg(b: &[u8]) -> bool {
|
||||
if b.len() < 4 || b[0] != 0xff || b[1] != 0xd8 {
|
||||
|
||||
Vendored
+53
-3
@@ -17,6 +17,19 @@ use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote};
|
||||
const WB_RB_LEVELS: u16 = 0x000c;
|
||||
const BLACK_LEVEL: u16 = 0x003d;
|
||||
const LINEARIZATION_TABLE: u16 = 0x0096;
|
||||
const CROP_AREA: u16 = 0x0045;
|
||||
|
||||
/// Nikon CropArea is `[left, top, width, height]` in sensor pixels (maker-note tag documentation).
|
||||
/// Keep the active area/CFA origin unchanged: the default crop is applied after demosaicing.
|
||||
fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect {
|
||||
let fallback = Rect::new(0, 0, w, h);
|
||||
let Some(values) = mn.and_then(|m| m.ifd.u64s(CROP_AREA)) else { return fallback };
|
||||
let [x, y, width, height] = values.as_slice() else { return fallback };
|
||||
if *width == 0 || *height == 0 || x.checked_add(*width).is_none_or(|v| v > w as u64) || y.checked_add(*height).is_none_or(|v| v > h as u64) {
|
||||
return fallback;
|
||||
}
|
||||
Rect::new(*x as usize, *y as usize, *width as usize, *height as usize)
|
||||
}
|
||||
|
||||
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
||||
tiff.all_ifds()
|
||||
@@ -80,9 +93,10 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
};
|
||||
let white = white_from_data(samples, bits);
|
||||
let active_w = trailing_masked_columns(samples, w, h, white);
|
||||
let crop = default_crop(mn.as_ref(), active_w, h);
|
||||
let mut metadata = lightcraft_meta::from_tiff(&tiff);
|
||||
metadata.width = Some(active_w as u32);
|
||||
metadata.height = Some(h as u32);
|
||||
metadata.width = Some(crop.width as u32);
|
||||
metadata.height = Some(crop.height as u32);
|
||||
let img = RawImage {
|
||||
format: RawFormat::Nef,
|
||||
width: w,
|
||||
@@ -94,7 +108,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
black,
|
||||
white: vec![white],
|
||||
active_area: Rect::new(0, 0, active_w, h),
|
||||
crop: Rect::new(0, 0, active_w, h),
|
||||
crop,
|
||||
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: wb,
|
||||
@@ -181,6 +195,42 @@ mod tests {
|
||||
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_color_space_is_read_from_the_nikon_note() {
|
||||
for (value, expected) in [(1, Some(crate::PreviewColorSpace::Srgb)), (2, Some(crate::PreviewColorSpace::AdobeRgb)), (3, None)] {
|
||||
let mut mn = IfdBuilder::new();
|
||||
mn.set(0x001e, Value::Short(vec![value]));
|
||||
let bytes = nef_with_note(1, 14, vec![vec![0; 128]], 8, 8, 8, Some(mn));
|
||||
assert_eq!(crate::embedded_preview_color_space(&bytes), expected);
|
||||
}
|
||||
assert_eq!(crate::embedded_preview_color_space(&nef(1, 14, vec![vec![0; 128]], 8, 8, 8)), None);
|
||||
assert_eq!(crate::embedded_preview_color_space(b"not a TIFF"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nikon_crop_area_preserves_sensor_origin_and_rejects_invalid_rectangles() {
|
||||
let (w, h) = (32, 24);
|
||||
let words: Vec<u8> = (0..w * h).flat_map(|i| (100 + i as u16).to_be_bytes()).collect();
|
||||
for (values, expected) in [
|
||||
(vec![2, 4, 28, 16], Rect::new(2, 4, 28, 16)),
|
||||
(vec![31, 4, 28, 16], Rect::new(0, 0, 32, 24)),
|
||||
(vec![2, 4, 0, 16], Rect::new(0, 0, 32, 24)),
|
||||
(vec![u32::MAX, 4, 28, 16], Rect::new(0, 0, 32, 24)),
|
||||
(vec![2, 4, 28], Rect::new(0, 0, 32, 24)),
|
||||
] {
|
||||
let mut mn = IfdBuilder::new();
|
||||
mn.set(CROP_AREA, Value::Long(values));
|
||||
let bytes = nef_with_note(1, 14, vec![words.clone()], w, h, h, Some(mn));
|
||||
let raw = crate::decode(&bytes).unwrap();
|
||||
assert_eq!(raw.crop, expected);
|
||||
assert_eq!(raw.active_area, Rect::new(0, 0, 32, 24));
|
||||
assert_eq!(raw.metadata.width, Some(expected.width as u32));
|
||||
assert_eq!(raw.metadata.height, Some(expected.height as u32));
|
||||
let developed = raw.develop(crate::Method::Bilinear).unwrap();
|
||||
assert_eq!((developed.width, developed.height), (expected.width, expected.height));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_level_tag_is_in_14_bit_units() {
|
||||
let (w, h) = (16usize, 4usize);
|
||||
|
||||
Vendored
+178
-24
@@ -1,10 +1,10 @@
|
||||
//! Olympus ORF — uncompressed variants.
|
||||
//!
|
||||
//! Sources: TIFF 6.0 (the `IIRO`/`MMOR` container is a TIFF with a different magic number), the ExifTool
|
||||
//! Olympus tag-name documentation (maker-note sub-directories `0x2020` CameraSettings — `0x0101/0x0102`
|
||||
//! PreviewImageStart/Length — and `0x2040` ImageProcessing — `0x0100` WB_RBLevels, `0x0600` BlackLevel2,
|
||||
//! `0x0612–0x0615` CropLeft/Top/Width/Height) and our own black-box analysis of CC0 samples from raw.pixls.us
|
||||
//! (E-1, E-400, XZ-2):
|
||||
//! Sources: TIFF 6.0 (the `IIRO`/`MMOR` container is a TIFF with a different magic number), Exif 2.3 (`CFAPattern`,
|
||||
//! tag `0xa302`), the ExifTool Olympus tag-name documentation (maker-note sub-directories `0x2020` CameraSettings —
|
||||
//! `0x0101/0x0102` PreviewImageStart/Length — and `0x2040` ImageProcessing — `0x0100` WB_RBLevels, `0x0600`
|
||||
//! BlackLevel2, `0x0612–0x0615` CropLeft/Top/Width/Height) and our own black-box analysis of CC0 samples from
|
||||
//! raw.pixls.us (E-1, E-400, XZ-2):
|
||||
//!
|
||||
//! - 16 bits per sample: little-endian words; some bodies (E-1, E-400) store 12-bit values in the top bits (the low
|
||||
//! four bits zero in more than 99% of samples), which we shift down.
|
||||
@@ -12,14 +12,16 @@
|
||||
//! candidate bit orders for the smoothest image).
|
||||
//! - Olympus's compressed ORF (most interchangeable-lens bodies since ~2008) is not decoded: no permissively
|
||||
//! licensed description exists. It reports [`RawError::Unsupported`]; the embedded preview still works.
|
||||
//! - The files carry no CFA tag. The green diagonal is found from the data (the diagonal whose two samples differ
|
||||
//! least); bodies with greens on the main diagonal (E-1, E-400) are GRBG, the others (XZ-2) RGGB — verified by
|
||||
//! colour renders.
|
||||
//! - The colour-filter layout is the file's Exif `CFAPattern`: GRBG on the E-1 and E-400, RGGB on the XZ-2, where
|
||||
//! the colours of the decoded mosaic follow the embedded JPEG best with exactly that layout
|
||||
//! (`corpus_orf_cfa_patterns`). The samples' active areas start at even offsets, so they can't tell whether the
|
||||
//! pattern is anchored at the sensor origin (assumed) or at the active area. Files without a usable tag fall back
|
||||
//! to the green diagonal found from the data, which can't tell red from blue (GRBG or RGGB is assumed).
|
||||
|
||||
use super::white_from_data;
|
||||
use crate::tiffraw::{Packing, read_image};
|
||||
use crate::unpack::unpack_msb;
|
||||
use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_tiff::image::chunk_bytes;
|
||||
use lightcraft_tiff::makernote::MakerNote;
|
||||
@@ -33,6 +35,8 @@ pub(crate) const PREVIEW_LENGTH: u16 = 0x0102;
|
||||
const WB_RB: u16 = 0x0100;
|
||||
const BLACK: u16 = 0x0600;
|
||||
const CROP: [u16; 4] = [0x0612, 0x0613, 0x0614, 0x0615];
|
||||
/// Exif `CFAPattern`.
|
||||
const EXIF_CFA_PATTERN: u16 = 0xa302;
|
||||
|
||||
/// The Olympus maker note.
|
||||
pub(crate) fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option<MakerNote> {
|
||||
@@ -66,23 +70,52 @@ pub(crate) fn unpack_row_le32_msb(src: &[u8], bits: u32, out: &mut [u16]) {
|
||||
unpack_msb(&swapped, bits, out);
|
||||
}
|
||||
|
||||
/// GRBG when the greens sit on the main diagonal of the 2×2 cell at the sensor origin, else RGGB.
|
||||
/// The colour-filter layout from the Exif `CFAPattern` tag (`0xa302`: two 16-bit repeat counts, found in either
|
||||
/// byte order, then one byte per site: 0 = red, 1 = green, 2 = blue), when it describes a 2×2 Bayer cell.
|
||||
fn cfa_from_exif(tiff: &Tiff) -> Option<Cfa> {
|
||||
let &[c0, c1, r0, r1, s0, s1, s2, s3] = tiff.exif()?.bytes(EXIF_CFA_PATTERN)? else { return None };
|
||||
let two = |a: u8, b: u8| matches!((a, b), (2, 0) | (0, 2));
|
||||
let name = match [s0, s1, s2, s3] {
|
||||
[0, 1, 1, 2] => "RGGB",
|
||||
[2, 1, 1, 0] => "BGGR",
|
||||
[1, 0, 2, 1] => "GRBG",
|
||||
[1, 2, 0, 1] => "GBRG",
|
||||
_ => return None,
|
||||
};
|
||||
(two(c0, c1) && two(r0, r1)).then(|| Cfa::bayer_static(name))
|
||||
}
|
||||
|
||||
/// The layout found from the samples, for files without the Exif tag: GRBG when the greens sit on the main
|
||||
/// diagonal of the 2×2 cell at the sensor origin, else RGGB. Over 32×32-pixel blocks of `a` (every fourth in both
|
||||
/// directions) it compares the block totals of the two sites on each diagonal: the two greens of a block see the
|
||||
/// same light, red and blue rarely do. Totals rather than single pixels, so that fine texture, which makes
|
||||
/// neighbouring greens differ, doesn't outweigh a small difference between red and blue.
|
||||
pub(crate) fn cfa_from_data(d: &[u16], w: usize, a: Rect) -> Cfa {
|
||||
let (x0, y0) = (a.x + ((a.width / 8) & !1), a.y + ((a.height / 8) & !1));
|
||||
let (x1, y1) = (a.x + a.width * 7 / 8, a.y + a.height * 7 / 8);
|
||||
const BLOCK: usize = 32;
|
||||
let (x0, y0) = (a.x.saturating_add(1) & !1, a.y.saturating_add(1) & !1);
|
||||
let across = a.x.saturating_add(a.width).min(w).saturating_sub(x0) / BLOCK;
|
||||
let down = a.y.saturating_add(a.height).saturating_sub(y0) / BLOCK;
|
||||
let (mut main, mut anti) = (0u64, 0u64);
|
||||
for y in (y0..y1.saturating_sub(1)).step_by(16) {
|
||||
for x in (x0..x1.saturating_sub(1)).step_by(8) {
|
||||
let (x, y) = (x & !1, y & !1);
|
||||
let p = |dx: usize, dy: usize| d[(y + dy) * w + x + dx] as i64;
|
||||
main += (p(0, 0) - p(1, 1)).unsigned_abs();
|
||||
anti += (p(1, 0) - p(0, 1)).unsigned_abs();
|
||||
for by in (0..down).step_by(4) {
|
||||
for bx in (0..across).step_by(4) {
|
||||
let mut sums = [0i64; 4];
|
||||
for y in 0..BLOCK {
|
||||
let row = (y0 + by * BLOCK + y).checked_mul(w).and_then(|r| r.checked_add(x0 + bx * BLOCK));
|
||||
// rows past the end of the samples: nothing more to read
|
||||
let Some(row) = row.and_then(|start| d.get(start..start.checked_add(BLOCK)?)) else { break };
|
||||
for (x, &v) in row.iter().enumerate() {
|
||||
sums[(y & 1) * 2 + (x & 1)] += v as i64;
|
||||
}
|
||||
}
|
||||
let [s0, s1, s2, s3] = sums;
|
||||
main += (s0 - s3).unsigned_abs();
|
||||
anti += (s1 - s2).unsigned_abs();
|
||||
}
|
||||
}
|
||||
Cfa::bayer_static(if main < anti { "GRBG" } else { "RGGB" })
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let tiff = Tiff::parse(bytes)?;
|
||||
let ifd0 = &tiff.ifds[0];
|
||||
let info = ifd0.image()?;
|
||||
@@ -90,6 +123,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
|
||||
let chunks = info.chunks(bytes.len() as u64);
|
||||
let total: u64 = chunks.iter().map(|c| c.len).sum();
|
||||
let stated = cfa_from_exif(&tiff);
|
||||
let (mut data, bits) = if info.compression != 1 {
|
||||
return Err(RawError::Unsupported(format!("ORF compression {}", info.compression)));
|
||||
} else if total >= (n as u64) * 2 {
|
||||
@@ -97,9 +131,16 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
(d, 16)
|
||||
} else if total * 8 >= (n as u64) * 12 && total * 8 < (n as u64) * 13 && chunks.len() == 1 {
|
||||
let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("ORF strip outside file".into()))?;
|
||||
let stride = src.len() / h;
|
||||
let mut d = vec![0u16; n];
|
||||
d.par_chunks_mut(w).enumerate().for_each(|(y, row)| unpack_row_le32_msb(&src[y * stride..(y + 1) * stride], 12, row));
|
||||
// the depth is fixed here, so a header-only probe needs the samples only when the file doesn't state its
|
||||
// colour-filter layout
|
||||
let d = if mode == Mode::Full || stated.is_none() {
|
||||
let stride = src.len() / h;
|
||||
let mut d = vec![0u16; n];
|
||||
d.par_chunks_mut(w).enumerate().for_each(|(y, row)| unpack_row_le32_msb(&src[y * stride..(y + 1) * stride], 12, row));
|
||||
d
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(RawData::U16(d), 12)
|
||||
} else {
|
||||
return Err(RawError::Unsupported("Olympus compressed ORF".into()));
|
||||
@@ -128,7 +169,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
Some([Some(x), Some(y), Some(cw), Some(ch)]) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= h => Rect::new(x, y, cw, ch),
|
||||
_ => Rect::new(0, 0, w, h),
|
||||
};
|
||||
let cfa = cfa_from_data(samples, w, active);
|
||||
let cfa = stated.unwrap_or_else(|| cfa_from_data(samples, w, active));
|
||||
let black = match ip.as_ref().and_then(|i| i.f64s(BLACK)).as_deref() {
|
||||
Some(v @ [_, _, _, _]) => {
|
||||
let a = cfa.shifted(active.x, active.y);
|
||||
@@ -165,7 +206,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata,
|
||||
};
|
||||
img.validate()?;
|
||||
img.validate_for(mode)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
@@ -185,6 +226,11 @@ mod tests {
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter};
|
||||
|
||||
fn orf(w: u32, h: u32, bits: u16, strip: Vec<u8>) -> Vec<u8> {
|
||||
orf_with(w, h, bits, strip, None)
|
||||
}
|
||||
|
||||
/// A minimal ORF with one strip in IFD0 and, optionally, an Exif `CFAPattern`.
|
||||
fn orf_with(w: u32, h: u32, bits: u16, strip: Vec<u8>, cfa_pattern: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut ifd = IfdBuilder::new();
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
@@ -193,12 +239,120 @@ mod tests {
|
||||
ifd.set(t::PHOTOMETRIC, Value::Short(vec![1]));
|
||||
ifd.set(t::MAKE, Value::Ascii("OLYMPUS IMAGING CORP.".into()));
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] });
|
||||
if let Some(p) = cfa_pattern {
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(EXIF_CFA_PATTERN, Value::Undefined(p.to_vec()));
|
||||
ifd.set_child(t::EXIF_IFD, exif);
|
||||
}
|
||||
let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap();
|
||||
b[2] = b'R';
|
||||
b[3] = b'O';
|
||||
b
|
||||
}
|
||||
|
||||
/// A 12-bit mosaic stored as 16-bit words: `site(cx, cy)` gives the four values of the 2×2 cell at (cx, cy).
|
||||
fn mosaic(w: usize, h: usize, site: impl Fn(usize, usize) -> [u16; 4]) -> Vec<u8> {
|
||||
(0..w * h).flat_map(|i| site(i % w / 2, i / w / 2)[((i / w) & 1) * 2 + ((i % w) & 1)].to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// Smooth scene texture shared by the four sites of a cell.
|
||||
fn shade(cx: usize, cy: usize) -> u16 {
|
||||
((cx * 7 + cy * 13) % 23) as u16
|
||||
}
|
||||
|
||||
const BGGR: &[u8] = &[2, 0, 2, 0, 2, 1, 1, 0];
|
||||
const GRBG: &[u8] = &[2, 0, 2, 0, 1, 0, 2, 1];
|
||||
// repeat counts in the other byte order
|
||||
const RGGB: &[u8] = &[0, 2, 0, 2, 0, 1, 1, 2];
|
||||
const GBRG: &[u8] = &[0, 2, 0, 2, 1, 2, 0, 1];
|
||||
|
||||
fn layout(bytes: &[u8]) -> String {
|
||||
let r = crate::decode(bytes).unwrap();
|
||||
assert_eq!(crate::probe_info(bytes).unwrap(), r.info());
|
||||
r.cfa.unwrap().name()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_cfa_pattern_states_the_layout() {
|
||||
let (w, h) = (160usize, 144usize);
|
||||
// samples whose green diagonal alone would say RGGB
|
||||
let px = mosaic(w, h, |cx, cy| [600, 1000, 1000, 300].map(|v| v + shade(cx, cy)));
|
||||
for (tag, name) in [(BGGR, "BGGR"), (GRBG, "GRBG"), (RGGB, "RGGB"), (GBRG, "GBRG")] {
|
||||
assert_eq!(layout(&orf_with(w as u32, h as u32, 16, px.clone(), Some(tag))), name);
|
||||
}
|
||||
// not a 2×2 Bayer cell (four greens, greens side by side, a fourth colour), other repeat counts, wrong
|
||||
// lengths: the samples decide
|
||||
let unusable: [&[u8]; 8] = [
|
||||
&[2, 0, 2, 0, 1, 1, 1, 1],
|
||||
&[2, 0, 2, 0, 1, 1, 0, 2],
|
||||
&[2, 0, 2, 0, 0, 1, 1, 3],
|
||||
&[3, 0, 3, 0, 0, 1, 1, 2],
|
||||
&[2, 2, 2, 0, 0, 1, 1, 2],
|
||||
&[2, 0, 2, 0, 1, 0],
|
||||
&[2, 0, 2, 0, 2, 1, 1, 0, 0],
|
||||
&[],
|
||||
];
|
||||
for (site, name) in [([600, 1000, 1000, 300], "RGGB"), ([1000, 600, 300, 1000], "GRBG")] {
|
||||
let px = mosaic(w, h, |cx, cy| site.map(|v| v + shade(cx, cy)));
|
||||
assert_eq!(layout(&orf(w as u32, h as u32, 16, px.clone())), name);
|
||||
for tag in unusable {
|
||||
assert_eq!(layout(&orf_with(w as u32, h as u32, 16, px.clone(), Some(tag))), name, "{tag:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_12_bit_probe_agrees_with_decode() {
|
||||
let (w, h) = (16usize, 4usize);
|
||||
let strip: Vec<u8> = (0..w * h * 3 / 2).map(|i| (i * 37 % 251) as u8).collect();
|
||||
for tag in [Some(BGGR), None] {
|
||||
let bytes = orf_with(w as u32, h as u32, 12, strip.clone(), tag);
|
||||
let r = crate::decode(&bytes).unwrap();
|
||||
assert_eq!((r.bits, r.data.len()), (12, w * h));
|
||||
assert_eq!(r.cfa.as_ref().unwrap().name(), if tag.is_some() { "BGGR" } else { "RGGB" });
|
||||
assert_eq!(crate::probe_info(&bytes).unwrap(), r.info());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_fallback_looks_past_fine_texture() {
|
||||
let (w, h) = (160usize, 144usize);
|
||||
// greens on the main diagonal, red and blue close; a checkerboard of detail makes the two greens of a cell
|
||||
// differ by far more than red differs from blue
|
||||
let detail = |cx: usize, cy: usize| if (cx + cy) & 1 == 0 { 150 } else { -150i32 };
|
||||
let site = |cx: usize, cy: usize| {
|
||||
let (g, d) = (1000 + shade(cx, cy) as i32, detail(cx, cy));
|
||||
[(g + d) as u16, 500, 520, (g - d) as u16]
|
||||
};
|
||||
assert_eq!(layout(&orf(w as u32, h as u32, 16, mosaic(w, h, site))), "GRBG");
|
||||
// the same scene with the greens on the other diagonal
|
||||
let px = mosaic(w, h, |cx, cy| {
|
||||
let [g0, r, b, g1] = site(cx, cy);
|
||||
[r, g0, g1, b]
|
||||
});
|
||||
assert_eq!(layout(&orf(w as u32, h as u32, 16, px)), "RGGB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_fallback_survives_any_area() {
|
||||
let d = vec![100u16; 64 * 64];
|
||||
let big = usize::MAX;
|
||||
for a in [
|
||||
Rect::new(0, 0, 64, 64),
|
||||
Rect::new(0, 0, 0, 0),
|
||||
Rect::new(0, 0, 31, 31),
|
||||
Rect::new(63, 63, 1, 1),
|
||||
Rect::new(0, 0, 4096, 4096),
|
||||
Rect::new(big, big, big, big),
|
||||
Rect::new(0, big - 40, 64, 40),
|
||||
] {
|
||||
for w in [64, 0, 1, big] {
|
||||
assert_eq!(cfa_from_data(&d, w, a).name(), "RGGB", "{a:?} width {w}");
|
||||
assert_eq!(cfa_from_data(&[], w, a).name(), "RGGB", "{a:?} width {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word16_shifted_and_packed12() {
|
||||
let (w, h) = (16usize, 4usize);
|
||||
|
||||
Vendored
+25
-3
@@ -43,7 +43,9 @@
|
||||
//! reads as repeats of one code only in this order; MSB-first reading gives code frequencies of exactly
|
||||
//! 2^−length, the signature of random bits]. Tag `0x0040` lists (length, code) for 17 prefix codes, the difference
|
||||
//! categories 0–16 of ITU-T T.81 (category `c`, then `c` additional bits, a leading 0 meaning negative). Pixels
|
||||
//! are coded two rows at a time, cell by 2×2 cell (top-left, top-right, bottom-left, bottom-right); each is
|
||||
//! are coded two rows at a time, cell by 2×2 cell, column by column (top-left, bottom-left, top-right,
|
||||
//! bottom-right) [in row order the two greens trade sites: an edge's even and odd rows of green then sit ~2 px
|
||||
//! apart with red and blue between them]; each is
|
||||
//! predicted from the same site of the previous cell, the first cell of a row pair from the first cell of the
|
||||
//! previous pair, 0 at the top [the strips decode to the camera JPEG's image without seams]. The streams end a
|
||||
//! few padding rows before their stated length. Tags `0x0039`–`0x003f`, `0x0041` and `0x0043` carry the same
|
||||
@@ -401,7 +403,7 @@ fn decode_strip(s: &Strip, codes: &Huffman, mut rows: Vec<&mut [u16]>) -> Result
|
||||
for (k, v) in cell.iter_mut().enumerate() {
|
||||
let d = diff(&mut bits, codes).ok_or_else(|| RawError::Corrupt(format!("RW2 format 8: invalid code in row {}", 2 * pair)))?;
|
||||
*v = v.saturating_add(d);
|
||||
if let Some(px) = rows.get_mut(2 * pair + k / 2).and_then(|r| r.get_mut(2 * cx + k % 2)) {
|
||||
if let Some(px) = rows.get_mut(2 * pair + k % 2).and_then(|r| r.get_mut(2 * cx + k / 2)) {
|
||||
*px = (*v).clamp(0, u16::MAX as i32) as u16;
|
||||
}
|
||||
}
|
||||
@@ -962,7 +964,7 @@ mod tests {
|
||||
let mut cell = first;
|
||||
for cx in 0..w / 2 {
|
||||
for (k, p) in cell.iter_mut().enumerate() {
|
||||
let v = at(2 * cx + k % 2, 2 * pair + k / 2);
|
||||
let v = at(2 * cx + k / 2, 2 * pair + k % 2);
|
||||
let d = v - *p;
|
||||
let c = 32 - d.unsigned_abs().leading_zeros();
|
||||
let (len, code) = table[c as usize];
|
||||
@@ -1026,6 +1028,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format8_cell_is_coded_column_by_column() {
|
||||
// One 2×2 cell coded by hand (not with `encode_strip`, which shares the decoder's site order): the values
|
||||
// 1, 2, 3, 4 in stream order. Row order would put the two greens of an RGGB cell on each other's sites.
|
||||
let mut bits: Vec<bool> = vec![];
|
||||
let mut put = |n: u32, v: u32| bits.extend((0..n).rev().map(|i| v >> i & 1 == 1));
|
||||
for v in [1u32, 2, 3, 4] {
|
||||
let c = 32 - v.leading_zeros();
|
||||
let (len, code) = TABLE14[c as usize];
|
||||
put(len, code);
|
||||
put(c, v);
|
||||
}
|
||||
let n = bits.len() as u64;
|
||||
let mut data: Vec<u8> = bits.chunks(8).map(|b| b.iter().enumerate().fold(0u8, |a, (i, &x)| a | (x as u8) << i)).collect();
|
||||
data.extend([0; 8]);
|
||||
let (mut top, mut bottom) = ([0u16; 2], [0u16; 2]);
|
||||
decode_strip(&Strip { data: &data, width: 2, height: 2, bits: n }, &code_table(&TABLE14).unwrap(), vec![&mut top, &mut bottom]).unwrap();
|
||||
assert_eq!((top, bottom), ([1, 3], [2, 4]), "top-left, bottom-left, top-right, bottom-right");
|
||||
}
|
||||
|
||||
fn replace(tags: &mut Vec<(u16, Value)>, tag: u16, v: Value) {
|
||||
tags.retain(|(t, _)| *t != tag);
|
||||
tags.push((tag, v));
|
||||
|
||||
@@ -427,3 +427,32 @@ fn corpus_panasonic_encodings() {
|
||||
}
|
||||
eprintln!("Panasonic encodings checked on {seen} files");
|
||||
}
|
||||
|
||||
/// Olympus ORF (`crates/raw/src/vendor/orf.rs`): the colour-filter layout comes from the file's Exif `CFAPattern`.
|
||||
/// With it, the decoded mosaic's colours follow the camera's own JPEG better than with any other layout.
|
||||
#[test]
|
||||
fn corpus_orf_cfa_patterns() {
|
||||
let dir = corpus_root().join("raw");
|
||||
let cases = [("orf-olympus-e1.orf", "GRBG"), ("orf-olympus-e400.orf", "GRBG"), ("orf-olympus-xz2.orf", "RGGB")];
|
||||
let mut seen = 0;
|
||||
for (name, cfa) in cases {
|
||||
let Ok(bytes) = std::fs::read(dir.join(name)) else {
|
||||
eprintln!("skip: {name} absent");
|
||||
continue;
|
||||
};
|
||||
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cfa), "{name}: CFA layout");
|
||||
assert_eq!(green_on_main_diagonal(&img), cfa.starts_with('G'), "{name}: mosaic statistics disagree with {cfa}");
|
||||
let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no embedded preview"));
|
||||
let (gw, gh) = (24, 18);
|
||||
let (sites, jpeg) = (raw_sites(&img, gw, gh), jpeg_cells(&jpeg, gw, gh));
|
||||
let agree = |l: &str| chroma_agreement(&sites, &jpeg, &lightcraft_raw::Cfa::bayer(l).unwrap().pattern);
|
||||
let best_other = ["RGGB", "GRBG", "GBRG", "BGGR"].into_iter().filter(|l| *l != cfa).map(agree).fold(f64::MIN, f64::max);
|
||||
let own = agree(cfa);
|
||||
let a = img.active_area;
|
||||
eprintln!("{name:32} {cfa}, active area at ({}, {}): colour agreement {own:.3} (others ≤ {best_other:.3})", a.x, a.y);
|
||||
assert!(own > 0.5 && own > best_other, "{name}: colours follow the JPEG better with another layout ({own} vs {best_other})");
|
||||
seen += 1;
|
||||
}
|
||||
eprintln!("ORF CFA layouts checked on {seen} files");
|
||||
}
|
||||
@@ -803,6 +803,8 @@
|
||||
"Show destination gamut warning": "出力先の色域警告を表示",
|
||||
"Show display gamut warning": "ディスプレイの色域警告を表示",
|
||||
"Show file names": "ファイル名を表示",
|
||||
"Show in Explorer": "エクスプローラーで表示",
|
||||
"Show in File Manager": "ファイルマネージャーで表示",
|
||||
"Show in Finder": "Finderで表示",
|
||||
"Show on Map": "地図で表示",
|
||||
"Show overlay (O)": "重ね表示(O)",
|
||||
|
||||
@@ -1053,6 +1053,8 @@
|
||||
"Show destination gamut warning": "Mostrar aviso de gamut de destino",
|
||||
"Show display gamut warning": "Mostrar aviso de gamut da tela",
|
||||
"Show file names": "Mostrar nomes dos arquivos",
|
||||
"Show in Explorer": "Mostrar no Explorador de Arquivos",
|
||||
"Show in File Manager": "Mostrar no gerenciador de arquivos",
|
||||
"Show in Finder": "Mostrar no Finder",
|
||||
"Show on Map": "Mostrar no mapa",
|
||||
"Show overlay (O)": "Mostrar sobreposição (O)",
|
||||
|
||||
@@ -1045,6 +1045,8 @@
|
||||
"Show destination gamut warning": "显示目标色域警告",
|
||||
"Show display gamut warning": "显示显示器色域警告",
|
||||
"Show file names": "显示文件名",
|
||||
"Show in Explorer": "在文件资源管理器中显示",
|
||||
"Show in File Manager": "在文件管理器中显示",
|
||||
"Show in Finder": "在 Finder 中显示",
|
||||
"Show on Map": "在地图上显示",
|
||||
"Show overlay (O)": "显示叠加 (O)",
|
||||
|
||||
@@ -1045,6 +1045,8 @@
|
||||
"Show destination gamut warning": "顯示目標色域警告",
|
||||
"Show display gamut warning": "顯示螢幕色域警告",
|
||||
"Show file names": "顯示檔案名稱",
|
||||
"Show in Explorer": "在檔案總管中顯示",
|
||||
"Show in File Manager": "在檔案管理員中顯示",
|
||||
"Show in Finder": "在 Finder 中顯示",
|
||||
"Show on Map": "在地圖上顯示",
|
||||
"Show overlay (O)": "顯示疊加(O)",
|
||||
|
||||
@@ -352,7 +352,7 @@ pub fn default_export_dir() -> String {
|
||||
pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
use lightcraft_engine::export::{Destination, ExportOptions, export_batch};
|
||||
let p = &app.session.export_params(p)?;
|
||||
let mut opts = ExportOptions::from_json(p);
|
||||
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
|
||||
if let (Some(path), None) = (p.get("path").and_then(Value::as_str), p.get("format")) {
|
||||
let ext = path.rsplit_once('.').map_or("", |(_, e)| e);
|
||||
opts.format = lightcraft_engine::export::ExportFormat::parse(ext).unwrap_or(opts.format);
|
||||
|
||||
@@ -29,6 +29,8 @@ pub mod widgets;
|
||||
#[cfg(test)]
|
||||
mod tests_curve;
|
||||
#[cfg(test)]
|
||||
mod tests_filmstrip;
|
||||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
#[cfg(test)]
|
||||
mod tests_library_problem;
|
||||
|
||||
@@ -342,6 +342,7 @@ fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String {
|
||||
"photo.delete" if n > 1 => crate::i18n::tr_format!("Delete {n} Photos", n = n),
|
||||
"photo.virtualCopy" if n > 1 => crate::i18n::tr_format!("Create {n} Virtual Copies", n = n),
|
||||
"dialog.rename" if n > 1 => crate::i18n::tr_format!("Rename {n} Photos…", n = n),
|
||||
"app.showInFinder" => crate::i18n::tr(crate::menus::reveal_label()).to_string(),
|
||||
_ => label.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1240,7 +1240,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
crate::links::open(app, url)
|
||||
}
|
||||
"app.exportPrevious" => match app.session.last_export.clone() {
|
||||
Some(prev) => crate::control::export_active(app, &prev),
|
||||
Some(prev) => crate::control::export_active(app, &lightcraft_engine::export::ExportOptions::known_keys_only(&prev)),
|
||||
None => Err("nothing exported yet — use Export…".into()),
|
||||
},
|
||||
_ => return None,
|
||||
@@ -1329,6 +1329,17 @@ pub fn confirm_delete(app: &mut LightcraftApp) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Platform-appropriate label for revealing a file in the system file manager.
|
||||
pub fn reveal_label() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"Show in Finder"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Show in Explorer"
|
||||
} else {
|
||||
"Show in File Manager"
|
||||
}
|
||||
}
|
||||
|
||||
/// Reveal the active photo's original in the system file manager.
|
||||
fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> {
|
||||
let id = app.session.active().ok_or("no photo selected")?;
|
||||
|
||||
@@ -171,23 +171,28 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
|
||||
}
|
||||
let p = ui.painter_at(img_area);
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
let mut display = img;
|
||||
if let Some(tex) = app.renderer.textures.get(&slot).filter(|x| x.photo == id) {
|
||||
p.image(tex.tex.id(), img, uv, Color32::WHITE);
|
||||
let texture_aspect = tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32;
|
||||
display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan);
|
||||
p.image(tex.tex.id(), display, uv, Color32::WHITE);
|
||||
} else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
|
||||
p.image(tex.tex.id(), img, uv, Color32::WHITE);
|
||||
let texture_aspect = tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32;
|
||||
display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan);
|
||||
p.image(tex.tex.id(), display, uv, Color32::WHITE);
|
||||
} else {
|
||||
p.rect_filled(img.intersect(img_area), 0.0, Color32::from_gray(38));
|
||||
}
|
||||
if photo.flag == Flag::Reject {
|
||||
p.rect_filled(img, 0.0, Color32::from_black_alpha(110));
|
||||
p.rect_filled(display, 0.0, Color32::from_black_alpha(110));
|
||||
}
|
||||
let active = app.session.selection.active == Some(id);
|
||||
if active {
|
||||
p.rect_stroke(img.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
|
||||
p.rect_stroke(display.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
|
||||
}
|
||||
// caption: label · file name · stars · flag
|
||||
// caption right under the image (at the bottom when zoomed in)
|
||||
let cap_top = (img.bottom() + 2.0).min(img_area.bottom());
|
||||
let cap_top = (display.bottom() + 2.0).min(img_area.bottom());
|
||||
let cap = Rect::from_min_max(pos2(img.left().max(area.left()), cap_top), pos2(area.right(), cap_top + caption_h));
|
||||
let pt = ui.painter();
|
||||
let mut x = cap.left() + 4.0;
|
||||
@@ -217,8 +222,8 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
|
||||
Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject),
|
||||
_ => {}
|
||||
}
|
||||
register(ui.ctx(), format!("cull:{}", id.0), img);
|
||||
(img, resp)
|
||||
register(ui.ctx(), format!("cull:{}", id.0), display);
|
||||
(display, resp)
|
||||
}
|
||||
|
||||
fn slot_index(s: Slot) -> u8 {
|
||||
|
||||
@@ -125,6 +125,14 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2],
|
||||
Rect::from_center_size(c, vec2(w, h))
|
||||
}
|
||||
|
||||
/// Fit a decoded image inside the frame calculated from catalog metadata. Unsupported RAWs can
|
||||
/// only show their embedded camera JPEG, whose crop and aspect ratio can differ from that metadata.
|
||||
pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect {
|
||||
let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32);
|
||||
let scale = (area.width() / w).min(area.height() / h);
|
||||
Rect::from_center_size(area.center(), vec2(w * scale, h * scale))
|
||||
}
|
||||
|
||||
/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly.
|
||||
fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
|
||||
let t = if *anim { 0.22 } else { 0.0 };
|
||||
@@ -191,7 +199,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
_ => vec![area],
|
||||
};
|
||||
let main_area = *areas.last().unwrap_or(&area);
|
||||
let target_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||||
let visible_texture_aspect = app
|
||||
.renderer
|
||||
.textures
|
||||
.get(&Slot::Main)
|
||||
.filter(|t| t.photo == id)
|
||||
.or_else(|| app.renderer.textures.get(&Slot::Preview).filter(|t| t.photo == id))
|
||||
.or_else(|| app.renderer.thumb(id))
|
||||
.map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32);
|
||||
// Follow the texture only where it really differs (an unsupported raw's embedded JPEG with
|
||||
// another crop): a texture's whole-pixel size is a little off the exact aspect, and the crop
|
||||
// tool's overlays need the frame's own geometry.
|
||||
let display_aspect = visible_texture_aspect.filter(|a| !crop_tool && (a / aspect - 1.0).abs() > 0.02).unwrap_or(aspect);
|
||||
let target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||||
let img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect);
|
||||
app.image_rect = Some(img_rect);
|
||||
// request renders: the loupe at display resolution (drafts during drags)
|
||||
@@ -262,7 +282,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
return "none";
|
||||
};
|
||||
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let image_rect = fit_texture_rect(r, tex.size);
|
||||
p.image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
what
|
||||
};
|
||||
// soft proofing: a paper-white surround and the proof's name, as Lightroom shows it
|
||||
@@ -284,9 +305,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
} else if let Some((_key, label)) = &hover_key
|
||||
&& let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id)
|
||||
{
|
||||
p.image(tex.tex.id(), img_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let hover_rect = fit_texture_rect(img_rect, tex.size);
|
||||
p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE);
|
||||
let bg = Rect::from_min_size(img_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0));
|
||||
let bg = Rect::from_min_size(hover_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0));
|
||||
p.rect_filled(bg, 4.0, Color32::from_black_alpha(160));
|
||||
p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE);
|
||||
shown = "hover";
|
||||
@@ -314,7 +336,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if app.ui.before_after == BeforeAfter::Split {
|
||||
let mid = img_rect.center().x;
|
||||
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
|
||||
let left = Rect::from_min_max(img_rect.min, pos2(mid, img_rect.bottom()));
|
||||
let before_rect = fit_texture_rect(img_rect, tex.size);
|
||||
let left = Rect::from_min_max(before_rect.min, pos2(before_rect.center().x, before_rect.bottom()));
|
||||
p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE);
|
||||
}
|
||||
p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE));
|
||||
@@ -322,7 +345,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if app.ui.before_after == BeforeAfter::SplitTopBottom {
|
||||
let mid = img_rect.center().y;
|
||||
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
|
||||
let top = Rect::from_min_max(img_rect.min, pos2(img_rect.right(), mid));
|
||||
let before_rect = fit_texture_rect(img_rect, tex.size);
|
||||
let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.center().y));
|
||||
p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE);
|
||||
}
|
||||
p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE));
|
||||
@@ -1493,6 +1517,12 @@ fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response
|
||||
|
||||
// ------------------------------------------------------------------------ filmstrip
|
||||
|
||||
/// A filmstrip cell's file-name label: names longer than 14 characters are cut to 13 and an
|
||||
/// ellipsis. Counts characters, not bytes, so a CJK name is never cut inside a character (#266).
|
||||
fn film_label(name: &str) -> String {
|
||||
if name.chars().nth(14).is_some() { format!("{}…", name.chars().take(13).collect::<String>()) } else { name.to_string() }
|
||||
}
|
||||
|
||||
pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(r, 0.0, t.canvas);
|
||||
@@ -1527,8 +1557,10 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
let resp = ui.interact(cr, egui::Id::new(("film", id.0)), Sense::click());
|
||||
register(ui.ctx(), format!("film:{}", id.0), cr);
|
||||
let sel = Some(*id) == active;
|
||||
// as in the grid: every selected photo is filled, the active one framed in white (#187)
|
||||
let selected = sel || app.session.selection.contains(*id);
|
||||
let p = ui.painter();
|
||||
if sel {
|
||||
if selected {
|
||||
p.rect_filled(cr, 0.0, t.cell_selected);
|
||||
} else if resp.hovered() {
|
||||
p.rect_filled(cr, 0.0, t.cell_selected.gamma_multiply(0.6));
|
||||
@@ -1536,8 +1568,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
let names = app.ui.settings.film_names;
|
||||
if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) {
|
||||
let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name);
|
||||
let short: String = if name.len() > 14 { format!("{}…", &name[..13]) } else { name.to_string() };
|
||||
p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, short, t.font(10.0), t.text_dim);
|
||||
p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, film_label(name), t.font(10.0), t.text_dim);
|
||||
p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim);
|
||||
}
|
||||
let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));
|
||||
@@ -1549,6 +1580,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
if sel {
|
||||
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);
|
||||
} else if selected {
|
||||
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::from_gray(170)), StrokeKind::Outside);
|
||||
}
|
||||
if app.ui.settings.film_badges
|
||||
&& let Some(ph) = app.session.catalog.photo(*id)
|
||||
@@ -1608,6 +1641,33 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog:
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod preview_geometry_tests {
|
||||
use super::{fit_rect, fit_texture_rect};
|
||||
use crate::state::Zoom;
|
||||
use egui::{Rect, pos2, vec2};
|
||||
|
||||
#[test]
|
||||
fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() {
|
||||
let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0));
|
||||
let image = fit_texture_rect(frame, [100, 200]);
|
||||
|
||||
assert!((image.width() / image.height() - 0.5).abs() < 1e-6);
|
||||
assert_eq!(image.center(), frame.center());
|
||||
assert!(frame.contains_rect(image));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portrait_preview_aspect_fits_detail_area_without_stretching() {
|
||||
let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0));
|
||||
let image = fit_rect(area, 0.5, Zoom::Fit, [4000, 6000], 1.0, (0.5, 0.5));
|
||||
|
||||
assert!((image.width() / image.height() - 0.5).abs() < 1e-6);
|
||||
assert_eq!(image.size(), vec2(100.0, 200.0));
|
||||
assert_eq!(image.center(), area.center());
|
||||
}
|
||||
}
|
||||
|
||||
/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto.
|
||||
/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle.
|
||||
/// `held`: drawn with ⌘ held in the crop tool, which stays active afterwards.
|
||||
@@ -1648,3 +1708,20 @@ fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::R
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::film_label;
|
||||
|
||||
#[test]
|
||||
fn film_labels_cut_on_characters_not_bytes() {
|
||||
// the name from #266: byte 13 falls inside '限'
|
||||
assert_eq!(film_label("202407層三限定訂閱圖(4)"), "202407層三限定訂閱圖…");
|
||||
assert_eq!(film_label("写真"), "写真");
|
||||
// exactly 14 characters (42 bytes) is shown whole
|
||||
assert_eq!(film_label("一二三四五六七八九十一二三四"), "一二三四五六七八九十一二三四");
|
||||
assert_eq!(film_label("IMG_20240712_153012"), "IMG_20240712_…");
|
||||
assert_eq!(film_label("DSC_0001"), "DSC_0001");
|
||||
assert_eq!(film_label(""), "");
|
||||
}
|
||||
}
|
||||
@@ -499,12 +499,8 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
||||
request_thumb(app, id, size, if onscreen { 10 } else { 5 });
|
||||
if let Some(tex) = app.renderer.thumb(id) {
|
||||
let [tw, th] = tex.size;
|
||||
let fit = if square {
|
||||
let s = (img_rect.width() / tw as f32).min(img_rect.height() / th as f32);
|
||||
Rect::from_center_size(img_rect.center(), vec2(tw as f32 * s, th as f32 * s))
|
||||
} else {
|
||||
img_rect
|
||||
};
|
||||
// Preserve the decoded preview's ratio when it differs from the catalog dimensions.
|
||||
let fit = super::detail::fit_texture_rect(img_rect, [tw, th]);
|
||||
p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
if active {
|
||||
p.rect_stroke(fit.expand(if square { 2.0 } else { 0.0 }), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
|
||||
@@ -904,7 +900,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
if missing && ui.button(crate::i18n::tr("Locate Missing File…")).clicked() {
|
||||
let _ = app.run("photo.locate", json!({}));
|
||||
}
|
||||
if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new(crate::i18n::tr("Show in Finder"))).clicked() {
|
||||
if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new(crate::i18n::tr(crate::menus::reveal_label()))).clicked() {
|
||||
let _ = app.run("app.showInFinder", json!({}));
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Export…")).clicked() {
|
||||
|
||||
@@ -521,7 +521,7 @@ fn folder_tree(
|
||||
}
|
||||
}
|
||||
if app.services.reveal.is_some()
|
||||
&& ui.button(crate::i18n::tr("Show in Finder")).clicked()
|
||||
&& ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked()
|
||||
&& let Some(f) = app.services.reveal.as_mut()
|
||||
{
|
||||
let _ = f(path);
|
||||
|
||||
@@ -482,7 +482,9 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
ui.add(egui::Label::new(egui::RichText::new(path).size(12.0).color(t.text_label)).truncate());
|
||||
if crate::menus::ui_enabled(app, "app.showInFinder") {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::widgets::icon_button(ui, "infoReveal", Icon::Folder, vec2(20.0, 20.0), false, true, "Show in Finder").clicked() {
|
||||
if crate::widgets::icon_button(ui, "infoReveal", Icon::Folder, vec2(20.0, 20.0), false, true, crate::menus::reveal_label())
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("app.showInFinder", json!({}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Headless test of the filmstrip's file-name labels (#266): a name with multi-byte (CJK)
|
||||
//! characters is drawn without panicking.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_catalog::Op;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
#[test]
|
||||
fn filmstrip_shows_a_cjk_file_name() {
|
||||
let mut session = lightcraft_engine::Session::with_demo();
|
||||
let first = session.visible_cloned()[0];
|
||||
let source = session.catalog.photo(first).expect("demo photo").source.clone();
|
||||
// the name from #266: cutting it at byte 13 lands inside '限'
|
||||
session.catalog.apply(Op::SetFile { id: first, file_name: "202407層三限定訂閱圖(4).jpg".into(), source }).unwrap();
|
||||
let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "detail"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.ui.settings.film_names);
|
||||
let film = format!("film:{}", first.0);
|
||||
assert!(h.app.widgets.iter().any(|(w, _)| *w == film), "{film} drawn in the filmstrip");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Headless tests of the photo grid's and the filmstrip's scrolling (issue #11): the user's
|
||||
//! scroll position stays put until the active photo changes; the mouse wheel scrolls the
|
||||
//! filmstrip sideways.
|
||||
//! filmstrip sideways. The filmstrip also shows which photos are selected (issue #187).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -115,3 +115,31 @@ fn filmstrip_wheel_scrolls_and_keeps_its_position() {
|
||||
let cell = widget(&h, &format!("film:{second}"));
|
||||
assert!(cell.left() >= 0.0 && cell.right() <= 1200.0, "the new active photo is in view ({cell:?})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filmstrip_shows_every_selected_photo() {
|
||||
let mut h = demo("detail");
|
||||
let ids = h.app.session.visible_cloned();
|
||||
let (first, second) = (ids[0].0, ids[1].0);
|
||||
// a cell's background, left of its thumbnail
|
||||
let fill = |h: &mut Headless, id: u64| {
|
||||
let r = widget(h, &format!("film:{id}"));
|
||||
let img = h.snapshot(SETTLE);
|
||||
img.pixels[(r.center().y as usize) * img.width() + r.left() as usize + 4]
|
||||
};
|
||||
let active = fill(&mut h, first);
|
||||
let idle = fill(&mut h, second);
|
||||
assert_ne!(active, idle, "only the active photo is selected");
|
||||
// Select All: the other photos are selected too, the first stays active
|
||||
let r = h.request("engine.execute", json!({"command": "library.selectAll"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(h.app.session.active().map(|p| p.0), Some(first));
|
||||
assert_eq!(fill(&mut h, second), active, "a selected photo that is not the active one looks selected");
|
||||
// back to one photo; then what a ⌘-click on a filmstrip cell runs (toggle) adds the second
|
||||
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first]}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(fill(&mut h, second), idle);
|
||||
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [second], "mode": "toggle"}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(fill(&mut h, second), active);
|
||||
}
|
||||
@@ -54,3 +54,12 @@ One photo can show too little of a colour for its own fit to learn it: in a seco
|
||||
|
||||
Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (DRO and picture styles vary per shot); the acceptance gates still apply, and a rejected fit falls back as before. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt.
|
||||
|
||||
|
||||
|
||||
## Nikon crop and preview colour metadata
|
||||
|
||||
Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing.
|
||||
|
||||
An embedded JPEG with neither ICC nor EXIF metadata uses Nikon maker-note `ColorSpace` (0x001e: 1 = sRGB, 2 = Adobe RGB). This fallback is applied before transfer decoding and resizing, and used by quick previews, preview-only RAW and the existing camera-look reference decode. A JPEG's own metadata takes precedence. No fitting algorithm, training split, highlight reconstruction or white-balance control changes are included. Render cache version 12 invalidates older previews.
|
||||
|
||||
Synthetic tests cover crop bounds/CFA preservation, colour-space tags, fallback transfer decoding and metadata precedence. A private Nikon Z 8 sample reports an 8256 × 5504 default crop from an 8280 × 5520 sensor. No private sample or Adobe asset is included.
|
||||
@@ -86,7 +86,10 @@ lightcraft-cli snapshot --library DIR --script tour.jsonl -o shot.png
|
||||
|
||||
`tour.jsonl` holds one request per line (`#` comments allowed), e.g.
|
||||
`{"method": "ui.set", "params": {"view": "detail", "right": "edit", "openSections": ["optics"]}}`
|
||||
then `{"method": "ui.screenshot"}`. Replies are printed to stdout. A `ui.screenshot` without
|
||||
then `{"method": "ui.screenshot"}`. Replies are printed to stdout. A failed request
|
||||
(`"ok": false`) does not stop the script — the remaining lines and the final screenshot still
|
||||
run — but the exit status is non-zero when any request failed, as with `run --keep-going`, so
|
||||
CI and nightly runs can judge a snapshot by its exit status. A `ui.screenshot` without
|
||||
`path` writes `-o` (then `OUT-2.png`, `OUT-3.png`, …); `ui.settle {timeoutMs?}` waits until no
|
||||
renders are in flight. Each request runs frames until it is answered and its injected input
|
||||
(clicks, keys, drags) has played out. Widget ids for `ui.clickWidget` come from `ui.widgets`
|
||||
|
||||
@@ -55,7 +55,9 @@ Settings ▸ Performance ▸ *Use the GPU for rendering* unchecked (applied befo
|
||||
returns, successfully or not. If the marker is still there at the next launch, the process died inside
|
||||
the driver: LightCraft starts with GPU rendering off (the preference is saved unchecked), removes the
|
||||
marker and says so in a notice. Checking *Use the GPU for rendering* again tries the GPU once more
|
||||
(and re-arms the sentinel). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts). Killing the app during
|
||||
(and re-arms the sentinel). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts), and not in a `--memory` session,
|
||||
which writes nothing: it neither arms the sentinel nor removes a marker it finds (it still starts with GPU
|
||||
rendering off when one is there, and the next ordinary launch reports and clears it). Killing the app during
|
||||
the ~0.3 s of device creation, or two instances starting at the same moment, can trip it falsely —
|
||||
harmless: rendering is then on the CPU until the box is checked again. The sentinel only covers the
|
||||
compute device; a crash while the window's renderer starts is avoided by the backend defaults above
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ During development you can also point the client at `cargo run --release -p ligh
|
||||
| `run_command {command, params?}` | Run any command by id |
|
||||
| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), `mode: "move"` moves there (each original and its XMP sidecars are removed from the source only after the copy is verified and catalogued; duplicates and failures keep their sources; the result's `moved` / `kept` list what moved and what stayed, and why; undo leaves the moved files at the destination), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) |
|
||||
| `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) |
|
||||
| `select_photos {ids, active?, mode?}` | Set the selection / active photo |
|
||||
| `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were |
|
||||
| `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value |
|
||||
| `get_develop {id?}` | Full develop-settings JSON |
|
||||
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable |
|
||||
|
||||
+9
-9
@@ -26,7 +26,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
|
||||
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 41 | 2 | 5 | 1 | 27/28 (96%) | 13/14 (93%) |
|
||||
| G. Profiles (PROF) | 6 | 2 | 3 | 0 | 3/4 (75%) | 2/3 (67%) |
|
||||
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
|
||||
| I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) |
|
||||
@@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
|
||||
| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) |
|
||||
| **Total** | 389 | 34 | 86 | 37 | 194/200 (97%) | 139/149 (93%) |
|
||||
| **Total** | 388 | 35 | 86 | 37 | 193/200 (97%) | 139/149 (93%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 509 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.6% of 160.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.7%** of 509 in-scope rows — P0 98.2% of 200 · P1 95.6% of 149 · P2 41.6% of 160.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
@@ -59,7 +59,7 @@ Ordered by user impact, then tier, then effort. The checklist above counts featu
|
||||
that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)).
|
||||
Take the first one nobody is working on.
|
||||
|
||||
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
|
||||
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0); Nikon CropArea and untagged-preview ColorSpace metadata are respected; measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
|
||||
2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, Nikon
|
||||
"lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see
|
||||
`crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`.
|
||||
@@ -124,8 +124,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
|
||||
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode |
|
||||
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
|
||||
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
|
||||
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
|
||||
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
|
||||
| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) |
|
||||
@@ -171,7 +171,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | |
|
||||
| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | |
|
||||
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
|
||||
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put |
|
||||
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid |
|
||||
| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar; a click on the photo (and Z / Space) eases to the click-zoom ratio (1:1 default, 2:1, 3:1, 4:1, 8:1 in the bottom bar) |
|
||||
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
|
||||
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session |
|
||||
@@ -234,7 +234,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | |
|
||||
| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick` | no magnified loupe while picking |
|
||||
| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp` | relative scale for non-raw in the UI |
|
||||
| LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint` | |
|
||||
| LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests |
|
||||
| LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | |
|
||||
| LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | |
|
||||
| LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode |
|
||||
@@ -434,7 +434,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-EXP-HDR | HDR output | P2 | ⬜ | | |
|
||||
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
|
||||
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |
|
||||
| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text or `image` + `imageWidth`), `crates/engine/src/export.rs` (`Watermark`) | text (size, colour, shadow) or a graphic with transparency (width as % of the photo, converted to the output colour space); position, inset, opacity |
|
||||
| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor`, `inset` (fraction of the short edge, 0..0.4), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183) |
|
||||
| LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes |
|
||||
| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) |
|
||||
| LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet |
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Global Tint direction
|
||||
|
||||
Negative (left) Tint adds green; positive (right) adds magenta, as the slider's track shows and as in Lightroom and the DNG specification (issue #188). It is a global white-balance change, not a Nikon-specific decoder fix.
|
||||
|
||||
## Reproducible evidence
|
||||
|
||||
The existing UI already declares `Track::Tint` as green → magenta (`crates/develop/src/controls.rs`) and paints a green left endpoint and purple right endpoint (`crates/ui-egui/src/widgets.rs`). Local mask tint also implements the correction direction: positive tint reduces green (`crates/pipeline/src/finish.rs`). The old global conversion contradicts both.
|
||||
|
||||
The synthetic `tint_negative_is_green_and_positive_is_magenta` regression renders an 18% neutral patch. Before the fix, setting global Tint to -50 produces sRGB `[129, 113, 128]`: red and blue exceed green, so the result is magenta. The test asserts the output direction for rendered sources, relative-WB RAW, and calibrated RAW with a nonzero As Shot reference. The complementary auto/picker test checks that correcting a green patch returns positive Tint and neutralises the patch.
|
||||
|
||||
Run the evidence without private samples:
|
||||
|
||||
```sh
|
||||
cargo test -p lightcraft-pipeline tint_ -- --nocapture
|
||||
cargo test -p lightcraft-gpu --test equivalence tint_directions -- --nocapture
|
||||
cargo test -p lightcraft-color cct
|
||||
```
|
||||
|
||||
## Why the sign changes
|
||||
|
||||
`temp_tint_to_xy` describes the **source illuminant** removed by Bradford adaptation. To add magenta to the output, the source white must be greener (positive Duv), not more magenta. Therefore the correction convention requires `Duv = +tint / TINT_SCALE`, and the inverse conversion must return `tint = +Duv * TINT_SCALE`.
|
||||
|
||||
The CPU and GPU consume the same `wb_matrix_for` result. RAW white-point inference, auto WB and the picker use the inverse conversion. Both directions change together; local mask tint and calibration shadow tint are unchanged. The render-cache version increases to invalidate stale output.
|
||||
|
||||
## Existing edits
|
||||
|
||||
Before this change the sign was the other way round, so a saved non-zero custom Tint (a catalog edit, a preset, history) now renders with the opposite green/magenta shift; As Shot, Auto and the presets that compute their values are unaffected, and re-running Auto or the picker gives values in the new convention. Tint values imported from Lightroom XMP (`crs:Tint`) were rendered the wrong way round before and now render as in Lightroom. No saved values are rewritten.
|
||||
@@ -13,7 +13,19 @@
|
||||
with a counterpart here come along, the rest is listed.
|
||||
- 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.
|
||||
|
||||
### Editing
|
||||
- The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track
|
||||
shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint
|
||||
saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker).
|
||||
|
||||
### Reliability
|
||||
- On macOS, single-key shortcuts that appear in the menu bar now work: E, C, H, M, ⇧P, I, K, D, ratings 0–5,
|
||||
labels 6–9, P / U and the rest did nothing, because macOS only passes ⌘ / ⌃ combinations and function keys
|
||||
to the menu bar and the app ignored those keys, assuming the menu bar would handle them. Keys outside the menus
|
||||
(Space, G, X, ⌫) and ⌘ shortcuts were not affected.
|
||||
- `--memory` sessions keep their promise to save nothing (issues #164, #169): UI changes made in one no longer
|
||||
land in `ui.json` (where they replaced the saved settings), and the GPU crash sentinel no longer creates the
|
||||
settings folder there. The same goes for the temporary session offered when the library can't be opened.
|
||||
- LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD
|
||||
630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless
|
||||
asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering
|
||||
|
||||
Reference in new issue
Block a user