# Conflicts:
#	crates/engine/src/lightroom_job.rs
This commit is contained in:
Brandon Thomas committed 2026-10-14 01:40:32 -04:00
commit 48b9a5e21f
21 files changed
+471 -85

No files matched your search

+14 -6
View File
@@ -308,9 +308,12 @@ pub enum Outcome {
Imported(Imported),
/// Photos (or osascript) failed: why, for the user.
Failed(String),
/// Photos wasn't asked: the export that reserved the import was cancelled, wrote no files or
/// stopped early.
Skipped(String),
/// Photos wasn't asked: the export that reserved the import was cancelled (`cancelled`), wrote
/// no files or stopped early. `why` is for people; code decides on `cancelled`.
Skipped {
why: String,
cancelled: bool,
},
}
/// One import: reserved by an export still writing its files, running, or finished.
@@ -402,9 +405,12 @@ impl ImportJob {
v["running"] = json!(false);
v["error"] = json!(e);
}
Some(Outcome::Skipped(why)) => {
Some(Outcome::Skipped { why, cancelled }) => {
v["running"] = json!(false);
v["skipped"] = json!(why);
if cancelled {
v["cancelled"] = json!(true);
}
}
}
v
@@ -660,7 +666,7 @@ impl Reservation {
None
};
if let Some(why) = skipped {
job.finish(Outcome::Skipped(why.into()));
job.finish(Outcome::Skipped { why: why.into(), cancelled });
return Err(job.json());
}
if let Err(e) = check(&paths, &self.album) {
@@ -700,7 +706,7 @@ impl Reservation {
impl Drop for Reservation {
fn drop(&mut self) {
if let Some(job) = self.job.take() {
job.finish(Outcome::Skipped("the export ended before writing its files".into()));
job.finish(Outcome::Skipped { why: "the export ended before writing its files".into(), cancelled: false });
}
}
}
@@ -1009,8 +1015,10 @@ mod tests {
// nothing written, or cancelled: Photos isn't asked, and the slot is free again
let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"skipped": "x"})], false);
assert!(out["skipped"].as_str().unwrap().contains("wrote no files"), "{out}");
assert_eq!(out.get("cancelled"), None, "{out}");
let out = imports.reserve(runner.clone(), "").unwrap().run(&exported, true);
assert!(out["skipped"].as_str().unwrap().contains("cancelled"), "{out}");
assert_eq!(out["cancelled"], true, "the app decides on the flag, not on the words: {out}");
assert_eq!(calls.lock().unwrap().len(), 1);
assert!(imports.running().is_none());
let _ = std::fs::remove_dir_all(dir);
+35 -9
View File
@@ -18,7 +18,7 @@ use lightcraft_meta::{Region, RegionKind};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_selection};
use crate::{Result, Session};
use crate::{AiModel, EngineError, Result, Session};
const C: &str = "faces.detect";
/// Regions made by this command say so in their description; only those are replaced on a new run.
@@ -34,15 +34,39 @@ const EXISTING_IOU: f64 = 0.3;
/// What to tell someone who has not downloaded the detector yet.
const NOT_INSTALLED: &str = "finding faces needs the YuNet face detector (232 KB, MIT): download it in Settings > Faces";
/// Why [`detector`] has no detector to give.
#[derive(Debug)]
pub(crate) enum NoDetector {
/// Not downloaded yet: a frontend offers the download ([`crate::ErrorKind::ModelMissing`]).
NotInstalled,
/// Nowhere to keep it, or there but unusable: says what to do.
Unusable(String),
}
impl NoDetector {
/// Command `cmd`'s error for it. The message reads as before kinds existed (an invalid-parameters
/// error), so control-channel and MCP clients see the same `error` text.
pub(crate) fn into_error(self, cmd: &str) -> EngineError {
match self {
Self::NotInstalled => EngineError::ModelMissing { model: AiModel::FaceDetector, msg: bad(cmd, NOT_INSTALLED).to_string() },
Self::Unusable(why) => bad(cmd, why),
}
}
}
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String> {
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDetector> {
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
let home = s.face_models_dir.as_deref().ok_or("this build has nowhere to keep face models (the desktop app does)")?.join(YUNET_ID);
let home = s
.face_models_dir
.as_deref()
.ok_or_else(|| NoDetector::Unusable("this build has nowhere to keep face models (the desktop app does)".into()))?
.join(YUNET_ID);
let path = home.join("model.onnx");
// installing writes the manifest last, so a folder without one is not (yet) a model
if !path.is_file() || !home.join("face-model.json").is_file() {
return Err(NOT_INSTALLED.into());
return Err(NoDetector::NotInstalled);
}
let mut loaded = LOADED.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((p, d)) = loaded.as_ref()
@@ -50,12 +74,14 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String
{
return Ok(d.clone());
}
let len = std::fs::metadata(&path).map_err(|e| format!("could not read the detector: {e}"))?.len();
let len = std::fs::metadata(&path).map_err(|e| NoDetector::Unusable(format!("could not read the detector: {e}")))?.len();
if len > MAX_MODEL_BYTES {
return Err("the detector's file is far bigger than YuNet: remove it in Settings > Faces and download it again".into());
return Err(NoDetector::Unusable("the detector's file is far bigger than YuNet: remove it in Settings > Faces and download it again".into()));
}
let bytes = std::fs::read(&path).map_err(|e| format!("could not read the detector: {e}"))?;
let d = Arc::new(Detector::new(&bytes).map_err(|e| format!("{e}: remove the detector in Settings > Faces and download it again"))?);
let bytes = std::fs::read(&path).map_err(|e| NoDetector::Unusable(format!("could not read the detector: {e}")))?;
let d = Arc::new(
Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?,
);
*loaded = Some((path, d.clone()));
Ok(d)
}
@@ -103,7 +129,7 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
nms_iou: p.get("nmsIou").and_then(Value::as_f64).map_or(defaults.nms_iou, |v| v as f32),
max_faces: p.get("maxFaces").and_then(Value::as_u64).map_or(defaults.max_faces, |v| usize::try_from(v).unwrap_or(defaults.max_faces)),
};
let detector = detector(s).map_err(|e| bad(C, e))?;
let detector = detector(s).map_err(|e| e.into_error(C))?;
let started = Instant::now();
let (mut results, mut ops) = (Vec::new(), Vec::new());
for id in s.targets(p) {
+21 -14
View File
@@ -84,29 +84,29 @@ fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result<Later>
match shape {
MaskShape::Object { seg: Some(_), .. } | MaskShape::Prompt { seg: Some(_), .. } => {}
MaskShape::Object { hint, exclude, .. } if !hint.is_empty() && background => {
s.segmenter.model_dir().map_err(ai_err)?;
s.segmenter.require_model()?;
return Ok(Later::Clicks(std::mem::take(hint), std::mem::take(exclude)));
}
MaskShape::Object { hint, exclude, seg, .. } if !hint.is_empty() => {
*seg = Some(s.segment_clicks(id, &clicks_of(hint, exclude)).map_err(ai_err)?);
*seg = Some(segment(s, |s| s.segment_clicks(id, &clicks_of(hint, exclude)))?);
}
MaskShape::Object { .. } => {
// the clicks come later: refuse an Object mask that could never be computed (no
// model) rather than leave an empty one; in the app, analyze the photo meanwhile
s.segmenter.model_dir().map_err(ai_err)?;
s.segmenter.require_model()?;
if background {
s.segment_prepare(id).map_err(ai_err)?;
segment(s, |s| s.segment_prepare(id))?;
}
}
MaskShape::Prompt { text, .. } if background => {
s.segmenter.model_dir().map_err(ai_err)?;
s.segmenter.require_model()?;
if text.trim().is_empty() {
return Err(ai_err("describe what to select"));
}
return Ok(Later::Text(text.clone()));
}
MaskShape::Prompt { text, seg, .. } => {
let found = s.segment_text(id, text).map_err(ai_err)?;
let found = segment(s, |s| s.segment_text(id, text))?;
*seg = Some(found.ok_or_else(|| ai_err(format!("nothing matching “{text}” was found in this photo")))?);
}
_ => {}
@@ -118,7 +118,7 @@ fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result<Later>
fn queue_later(s: &mut Session, later: Later, mask: u32, comp: usize, mut out: Value) -> Result<Value> {
if let Later::Clicks(hint, exclude) = later {
let id = s.active().ok_or_else(|| ai_err("no active photo"))?;
s.segment_clicks_later(id, mask, comp, hint, exclude).map_err(ai_err)?;
segment(s, |s| s.segment_clicks_later(id, mask, comp, hint, exclude))?;
out["pending"] = json!(true);
}
Ok(out)
@@ -129,6 +129,13 @@ fn ai_err(msg: impl Into<String>) -> crate::EngineError {
crate::EngineError::Other(msg.into())
}
/// A segmentation call: the model is checked first, so a missing one is [`crate::EngineError::ModelMissing`]
/// (the segmenter's own errors are text).
fn segment<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> std::result::Result<T, String>) -> Result<T> {
s.segmenter.require_model()?;
f(s).map_err(ai_err)
}
pub(crate) fn clicks_of(include: &[Point], exclude: &[Point]) -> Vec<Click> {
include.iter().map(|p| Click { at: *p, include: true }).chain(exclude.iter().map(|p| Click { at: *p, include: false })).collect()
}
@@ -167,7 +174,7 @@ pub fn specs() -> Vec<CommandSpec> {
let name = str_param(p, "name").map(str::to_string);
if let Later::Text(text) = &later {
let id = s.active().ok_or_else(|| bad("mask.add", "no active photo"))?;
s.segment_text_later(id, None, "add", name, text).map_err(ai_err)?;
segment(s, |s| s.segment_text_later(id, None, "add", name, text))?;
return Ok(json!({"pending": true}));
}
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
@@ -205,7 +212,7 @@ pub fn specs() -> Vec<CommandSpec> {
let later = resolve_ai(s, &mut shape, "mask.addComponent")?;
if let Later::Text(text) = &later {
let op = p.get("op").and_then(Value::as_str).unwrap_or("add");
s.segment_text_later(id, Some(mid), op, None, text).map_err(ai_err)?;
segment(s, |s| s.segment_text_later(id, Some(mid), op, None, text))?;
return Ok(json!({"pending": true}));
}
let mut comp = 0;
@@ -365,10 +372,10 @@ pub fn specs() -> Vec<CommandSpec> {
}
if s.segmenter.background && !hint.is_empty() {
let out = json!({"include": hint.len(), "exclude": ex.len(), "pending": true});
s.segment_clicks_later(id, mid, k, hint, ex).map_err(ai_err)?;
segment(s, |s| s.segment_clicks_later(id, mid, k, hint, ex))?;
return Ok(out);
}
let seg = if hint.is_empty() { None } else { Some(s.segment_clicks(id, &clicks_of(&hint, &ex)).map_err(ai_err)?) };
let seg = if hint.is_empty() { None } else { Some(segment(s, |s| s.segment_clicks(id, &clicks_of(&hint, &ex)))?) };
let out = json!({"include": hint.len(), "exclude": ex.len()});
masks_edit(s, c, "Object Mask", |masks, _| {
let i = find(masks, mid, c)?;
@@ -401,7 +408,7 @@ pub fn specs() -> Vec<CommandSpec> {
.rposition(|x| matches!(x.shape, MaskShape::Object { .. } | MaskShape::Prompt { .. }))
.ok_or_else(|| bad(c, "the mask has no Object or Describe selection"))?,
};
let started = s.segment_detail(id, mid, k).map_err(ai_err)?;
let started = segment(s, |s| s.segment_detail(id, mid, k))?;
Ok(json!({"started": started}))
}
),
@@ -415,7 +422,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, _| {
let c = "segment.prepare";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
s.segment_prepare(id).map_err(ai_err)?;
segment(s, |s| s.segment_prepare(id))?;
Ok(json!({"busy": s.segmenter.busy()}))
}
),
@@ -701,7 +708,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
let c = "spot.findBlemishes";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let detector = super::face_detect::detector(s).map_err(|e| bad(c, e))?;
let detector = super::face_detect::detector(s).map_err(|e| e.into_error(c))?;
// the uncropped photo, so positions are the spots' own coordinates
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
+68
View File
@@ -0,0 +1,68 @@
//! What kind of error a command returned, for code that decides on it (#1046).
//!
//! A frontend that reacts to an error (offers a model download, says "cancelled" in its own words
//! instead of showing a failure) matches [`EngineError::kind`], never the message: messages are
//! for people, get reworded, and will be translated.
use crate::EngineError;
/// A model a feature needs and LightCraft downloads only when the person agrees.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AiModel {
/// YuNet, which finds faces (`faces.detect`; installed in Settings ▸ Faces).
FaceDetector,
/// SAM 3, behind Object and Describe masks (`segment.model.download`).
Sam3,
}
/// The kind of an [`EngineError`] (or of an error a frontend raised itself: [`ErrorKind::Other`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ErrorKind {
UnknownCommand,
Disabled,
BadParams,
Catalog,
NotSaved,
LibraryInUse,
LightroomCatalog,
/// The work was cancelled: not a failure.
Cancelled,
/// The feature needs this model, which is not installed (or still downloading).
ModelMissing(AiModel),
Other,
}
impl EngineError {
/// What kind of error this is: decide on this, not on the message.
pub fn kind(&self) -> ErrorKind {
match self {
Self::UnknownCommand(_) => ErrorKind::UnknownCommand,
Self::Disabled(..) => ErrorKind::Disabled,
Self::BadParams { .. } => ErrorKind::BadParams,
Self::Catalog(_) => ErrorKind::Catalog,
Self::NotSaved(_) => ErrorKind::NotSaved,
Self::LibraryInUse(_) => ErrorKind::LibraryInUse,
Self::LightroomCatalog { .. } => ErrorKind::LightroomCatalog,
Self::Cancelled(_) => ErrorKind::Cancelled,
Self::ModelMissing { model, .. } => ErrorKind::ModelMissing(*model),
Self::Other(_) => ErrorKind::Other,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_kind_does_not_depend_on_the_message() {
let cancelled = EngineError::Cancelled("Lightroom-Import abgebrochen".into());
assert_eq!(cancelled.kind(), ErrorKind::Cancelled);
assert_eq!(cancelled.to_string(), "Lightroom-Import abgebrochen", "the message is shown as is");
let missing = EngineError::ModelMissing { model: AiModel::Sam3, msg: "any wording".into() };
assert_eq!(missing.kind(), ErrorKind::ModelMissing(AiModel::Sam3));
assert_eq!(missing.to_string(), "any wording");
// an `Other` whose text reads like a cancellation or a missing model is still `Other`
assert_eq!(EngineError::Other("The SAM 3 model is not installed; cancelled".into()).kind(), ErrorKind::Other);
}
}
+9
View File
@@ -29,6 +29,7 @@ pub mod demo;
pub mod denoise;
pub mod devices;
pub mod display;
mod errors;
pub mod export;
pub mod face_download;
pub mod face_tiles;
@@ -69,6 +70,7 @@ pub mod walk;
use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use errors::{AiModel, ErrorKind};
pub use fonts::{CRAFT_FONTS, CraftFont};
use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
use lightcraft_develop::DevelopSettings;
@@ -99,6 +101,13 @@ pub enum EngineError {
/// to import with `library.importLightroom`, when one was found.
#[error("{} is a Lightroom Classic catalog, not a LightCraft library; import it into a LightCraft library with File ▸ Import Lightroom Catalog…{}", path.display(), catalog.as_ref().map_or(String::new(), |c| format!(" ({})", c.display())))]
LightroomCatalog { path: std::path::PathBuf, catalog: Option<std::path::PathBuf> },
/// The work was cancelled (by the person, an agent or a newer request): not a failure.
#[error("{0}")]
Cancelled(String),
/// The feature needs `model`, which is not installed (or still downloading): a frontend
/// offers its download (terms first) on [`ErrorKind::ModelMissing`], not on the message.
#[error("{msg}")]
ModelMissing { model: AiModel, msg: String },
#[error("{0}")]
Other(String),
}
+29 -6
View File
@@ -24,6 +24,12 @@ fn error(message: impl Into<String>) -> crate::EngineError {
crate::EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() }
}
/// The import was cancelled ([`crate::ErrorKind::Cancelled`]: not a failure). The message is the
/// one it always had, so control-channel and MCP clients see the same `error` text.
fn cancelled() -> crate::EngineError {
crate::EngineError::Cancelled(error("Lightroom import cancelled").to_string())
}
fn stage_archive(archive_dir: Option<&Path>, data: &mut CatalogImport) -> crate::Result<Option<PathBuf>> {
let archive_path = archive_dir.map(|dir| crate::lightroom_archive::store_best_effort(dir, data).map_err(error)).transpose()?.flatten();
if archive_dir.is_some() && archive_path.is_none() {
@@ -232,13 +238,15 @@ impl LightroomJob {
/// Read/probe/stage all source data off the owner thread.
pub fn prepare(&mut self, cancel: &AtomicBool) -> crate::Result<PreparedLightroom> {
if cancel.load(Ordering::Relaxed) {
return Err(error("Lightroom import cancelled"));
return Err(cancelled());
}
// (reading stops with a message when `cancel` is set: the flag says it was cancelled)
let mut data =
lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done).map_err(error)?;
lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done)
.map_err(|e| if cancel.load(Ordering::Relaxed) { cancelled() } else { error(e) })?;
lightroom_catalog::validate_for_job(&data).map_err(error)?;
if cancel.load(Ordering::Relaxed) {
return Err(error("Lightroom import cancelled"));
return Err(cancelled());
}
let paths: Vec<String> = data
.photos
@@ -259,7 +267,7 @@ impl LightroomJob {
let normal = self.import.prepare(&paths, cancel);
if cancel.load(Ordering::Relaxed) {
normal.rollback();
return Err(error("Lightroom import cancelled"));
return Err(cancelled());
}
let archive_path = stage_archive(self.archive_dir.as_deref(), &mut data)?;
let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json"));
@@ -282,7 +290,7 @@ impl LightroomJob {
pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) -> crate::Result<LightroomCompletion> {
if prepared.cancelled {
prepared.normal.rollback();
return Err(error("Lightroom import cancelled"));
return Err(cancelled());
}
if !prepared.token.matches(s) {
prepared.normal.rollback();
@@ -435,11 +443,26 @@ mod tests {
let mut session = crate::Session::new();
let before = session.catalog.to_snapshot();
let prepared = cancelled_prepared(&session);
assert!(commit_prepared(&mut session, prepared).is_err());
let Err(e) = commit_prepared(&mut session, prepared) else { panic!("a cancelled import commits nothing") };
assert_eq!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
assert_eq!(session.catalog.to_snapshot(), before);
assert!(session.undo.is_empty());
}
/// Cancelling while the catalog is read is [`crate::ErrorKind::Cancelled`] whatever the message
/// says, and a failure that is not a cancellation is not one, even when its text says "cancelled".
#[test]
fn a_cancelled_read_is_cancelled_by_kind() {
let mut session = crate::Session::new();
let mut job = LightroomJob::new(&mut session, PathBuf::from("/nonexistent/cancelled.lrcat"), false).unwrap();
let Err(e) = job.prepare(&AtomicBool::new(true)) else { panic!("cancelled before it started") };
assert_eq!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
// the `error` text clients see is unchanged
assert_eq!(e.to_string(), "invalid parameters for `library.importLightroom`: Lightroom import cancelled");
let Err(e) = job.prepare(&AtomicBool::new(false)) else { panic!("no such catalog") };
assert_ne!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
}
#[test]
fn library_identity_rejects_late_completion_after_reopen() {
let mut session = crate::Session::new();
+11 -2
View File
@@ -20,6 +20,8 @@ pub struct Status {
pub file: String,
/// The last download's error (cleared when a new one starts).
pub error: Option<String>,
/// The last download was cancelled (its `error` says so in words): decide on this, not on the text.
pub cancelled: bool,
/// The last download finished and the files are in place.
pub finished: bool,
}
@@ -28,6 +30,7 @@ pub struct Status {
struct Text {
file: String,
error: Option<String>,
cancelled: bool,
}
#[derive(Default)]
@@ -68,15 +71,17 @@ impl Downloader {
{
last.file.clone_from(&t.file);
last.error.clone_from(&t.error);
last.cancelled = t.cancelled;
}
// (only the session's thread locks `last`)
let (file, error) = self.last.try_lock().map(|l| (l.file.clone(), l.error.clone())).unwrap_or_default();
let (file, error, cancelled) = self.last.try_lock().map(|l| (l.file.clone(), l.error.clone(), l.cancelled)).unwrap_or_default();
Status {
running: s.running.load(Ordering::SeqCst),
done: s.done.load(Ordering::SeqCst),
total: s.total.load(Ordering::SeqCst),
file,
error,
cancelled,
finished: s.finished.load(Ordering::SeqCst),
}
}
@@ -135,6 +140,7 @@ impl Downloader {
};
let r =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fetch::download(files, &mirrors, &dir, &opts, &s.cancel, &mut on_progress)));
let cancelled = matches!(r, Ok(Err(fetch::DownloadError::Cancelled)));
let error = match r {
Ok(Ok(())) => {
s.finished.store(true, Ordering::SeqCst);
@@ -147,7 +153,10 @@ impl Downloader {
if let Some(e) = &error {
log::warn!("SAM 3 download: {e}");
}
lock(&s.text).error = error;
let mut text = lock(&s.text);
text.cancelled = cancelled;
text.error = error;
drop(text);
drop(guard);
});
match spawned {
+31 -12
View File
@@ -35,7 +35,7 @@ pub const MODEL_BYTES: u64 = 3_439_938_512;
/// The model's licence (not LightCraft's): shown before downloading.
pub const LICENSE_NAME: &str = "SAM License (Meta)";
pub const LICENSE_URL: &str = "https://github.com/facebookresearch/sam3/blob/main/LICENSE";
/// How errors about a missing model start (the UI offers the download on it).
/// How errors about a missing model start (frontends decide on [`crate::ErrorKind::ModelMissing`], not on this text).
pub const NOT_INSTALLED: &str = "The SAM 3 model is not installed";
/// Longest a waiting command (CLI, MCP) waits for the model (loading on a CPU can take minutes).
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
@@ -111,6 +111,8 @@ pub struct DownloadStatus {
pub total: u64,
pub file: String,
pub error: Option<String>,
/// The last download was cancelled (it resumes next time): decide on this, not on `error`'s text.
pub cancelled: bool,
pub finished: bool,
}
@@ -215,7 +217,15 @@ impl Segmenter {
#[cfg(feature = "sam")]
{
let s = self.download.status();
DownloadStatus { running: s.running, done: s.done, total: s.total, file: s.file, error: s.error, finished: s.finished }
DownloadStatus {
running: s.running,
done: s.done,
total: s.total,
file: s.file,
error: s.error,
cancelled: s.cancelled,
finished: s.finished,
}
}
#[cfg(not(feature = "sam"))]
DownloadStatus::default()
@@ -234,27 +244,36 @@ impl Segmenter {
/// The model folder, if the model is there; otherwise an error that says what to do.
pub fn model_dir(&self) -> Result<PathBuf, String> {
self.require_model().map_err(|e| e.to_string())
}
/// [`Segmenter::model_dir`] for commands: a model that is not installed (or still downloading) is
/// [`crate::EngineError::ModelMissing`], so a frontend offers the download without reading the message.
pub fn require_model(&self) -> crate::Result<PathBuf> {
let other = |why: &str| crate::EngineError::Other(why.into());
if !Self::AVAILABLE {
return Err("AI masks are not available in this build".into());
return Err(other("AI masks are not available in this build"));
}
#[cfg(feature = "sam")]
if lightcraft_segment::remote::configured().is_some() {
return Ok(PathBuf::new());
}
let dir = self.dir.clone().ok_or("no folder is set for the SAM 3 model")?;
let dir = self.dir.clone().ok_or_else(|| other("no folder is set for the SAM 3 model"))?;
if self.installed() {
return Ok(dir);
}
let d = self.download_status();
if d.running {
let msg = if d.running {
let pct = d.done.saturating_mul(100).checked_div(d.total).unwrap_or(0);
return Err(format!("{NOT_INSTALLED} yet: it is downloading ({pct} %)."));
}
Err(format!(
"{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.",
MODEL_BYTES as f64 / 1e9,
dir.display()
))
format!("{NOT_INSTALLED} yet: it is downloading ({pct} %).")
} else {
format!(
"{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.",
MODEL_BYTES as f64 / 1e9,
dir.display()
)
};
Err(crate::EngineError::ModelMissing { model: crate::AiModel::Sam3, msg })
}
/// Start downloading the model (on a background thread; see `segment.model.download`).
+17 -3
View File
@@ -205,10 +205,24 @@ fn detect_without_the_model_says_where_to_get_it() {
let d = temp("nodetector");
let mut s = Session::with_demo();
// no folder for models at all, then a folder that has none yet
assert!(s.execute("faces.detect", &json!({})).is_err());
let e = s.execute("faces.detect", &json!({})).unwrap_err();
assert_ne!(e.kind(), crate::ErrorKind::ModelMissing(crate::AiModel::FaceDetector), "nothing to download into: {e}");
s.face_models_dir = Some(d.join("models"));
let e = s.execute("faces.detect", &json!({})).unwrap_err().to_string();
assert!(e.contains("Settings > Faces"), "{e}");
let e = s.execute("faces.detect", &json!({})).unwrap_err();
// the app offers the download on the kind (#1046), the person reads the message
assert_eq!(e.kind(), crate::ErrorKind::ModelMissing(crate::AiModel::FaceDetector), "{e}");
assert!(
e.to_string().starts_with("invalid parameters for `faces.detect`: ") && e.to_string().contains("Settings > Faces"),
"the `error` text clients see is unchanged: {e}"
);
// a detector that is there but unusable also points to Settings > Faces, and is not a missing one
let home = d.join("models").join(lightcraft_faces::known::YUNET_ID);
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join("model.onnx"), b"not a model").unwrap();
std::fs::write(home.join("face-model.json"), b"{}").unwrap();
let e = s.execute("faces.detect", &json!({})).unwrap_err();
assert!(e.to_string().contains("Settings > Faces"), "{e}");
assert_eq!(e.kind(), crate::ErrorKind::BadParams, "{e}");
let _ = std::fs::remove_dir_all(&d);
}
+28 -1
View File
@@ -115,6 +115,7 @@ fn a_failing_download_ends_with_an_error_and_never_blocks() {
}
let st = s.segmenter.download_status();
assert!(!st.finished);
assert!(!st.cancelled, "a failure is not a cancel");
let e = st.error.unwrap();
assert!(e.contains("not found"), "{e}");
assert!(!s.segmenter.installed());
@@ -272,9 +273,35 @@ fn a_running_model_download_shows_in_activity_and_cancels() {
assert!(t.elapsed() < Duration::from_secs(10), "the download never stopped");
std::thread::sleep(Duration::from_millis(10));
}
let e = s.segmenter.download_status().error.unwrap_or_default();
let st = s.segmenter.download_status();
assert!(st.cancelled, "the app tells a cancel by the flag (#1046): {st:?}");
let e = st.error.unwrap_or_default();
assert!(e.contains("cancelled"), "{e}");
assert!(!s.segmenter.installed());
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(&mirrors);
}
/// Frontends decide on the error's kind, not its words (#1046): a missing model is
/// `ModelMissing(Sam3)` whether the app queues requests (background) or a command waits (CLI, MCP);
/// a build without AI masks is not a missing model (there is nothing to download).
#[test]
fn a_missing_model_is_model_missing_by_kind() {
use crate::{AiModel, ErrorKind};
for background in [true, false] {
let mut s = Session::with_demo();
s.segmenter.background = background;
s.segmenter.dir = Some(tmp(&format!("kind-{background}")));
for (cmd, p) in
[("mask.add", json!({"kind": "object"})), ("mask.add", json!({"kind": "prompt", "text": "sky"})), ("segment.prepare", json!({}))]
{
let e = s.execute(cmd, &p).unwrap_err();
if Segmenter::AVAILABLE {
assert_eq!(e.kind(), ErrorKind::ModelMissing(AiModel::Sam3), "{cmd} (background {background}): {e}");
assert!(e.to_string().starts_with(NOT_INSTALLED), "the message is unchanged: {e}");
} else {
assert_eq!(e.kind(), ErrorKind::Other, "{cmd}: {e}");
}
}
}
}
+42
View File
@@ -0,0 +1,42 @@
//! A refused or failed command as the UI handles it (#1046): the message for people, and what kind of
//! error it is for code that decides on it. Decide on [`CommandError::kind`], never on the message:
//! messages get reworded and will be translated.
use lightcraft_engine::{EngineError, ErrorKind};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommandError {
pub message: String,
pub kind: ErrorKind,
}
impl std::fmt::Display for CommandError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
/// An error the UI raised itself (its own commands, dialogs): [`ErrorKind::Other`].
impl From<String> for CommandError {
fn from(message: String) -> Self {
Self { message, kind: ErrorKind::Other }
}
}
impl From<&str> for CommandError {
fn from(message: &str) -> Self {
message.to_string().into()
}
}
impl From<EngineError> for CommandError {
fn from(e: EngineError) -> Self {
Self { kind: e.kind(), message: e.to_string() }
}
}
impl From<CommandError> for String {
fn from(e: CommandError) -> Self {
e.message
}
}
+9 -2
View File
@@ -138,7 +138,7 @@ pub fn apple_photos_note(outcome: &Value) -> String {
return crate::i18n::tr_format!("not added to Apple Photos: {e}", e = e);
}
if let Some(why) = outcome.get("skipped").and_then(Value::as_str) {
if why.contains("cancelled") {
if outcome.get("cancelled").and_then(Value::as_bool) == Some(true) {
return crate::i18n::tr("not added to Apple Photos (cancelled)").to_string();
}
return crate::i18n::tr_format!("not added to Apple Photos: {e}", e = why);
@@ -342,7 +342,14 @@ mod tests {
&json!({"error": "LightCraft isn't allowed to control Photos. Allow it in System Settings › Privacy & Security › Automation"}),
);
assert!(denied.starts_with("not added to Apple Photos: ") && denied.contains("Automation"), "{denied}");
assert_eq!(apple_photos_note(&json!({"skipped": "the export was cancelled"})), "not added to Apple Photos (cancelled)");
assert_eq!(apple_photos_note(&json!({"skipped": "the export was cancelled", "cancelled": true})), "not added to Apple Photos (cancelled)");
assert_eq!(apple_photos_note(&json!({"skipped": "the export wrote no files"})), "not added to Apple Photos: the export wrote no files");
// decided on the `cancelled` flag, not the words (#1046): reworded it is still a cancel, and a
// reason that only mentions the word is shown as it is
assert_eq!(apple_photos_note(&json!({"skipped": "Export abgebrochen", "cancelled": true})), "not added to Apple Photos (cancelled)");
assert_eq!(
apple_photos_note(&json!({"skipped": "an earlier, cancelled request is still running"})),
"not added to Apple Photos: an earlier, cancelled request is still running"
);
}
}
+39 -20
View File
@@ -8,6 +8,7 @@
pub mod album_picker;
pub mod brush_live;
mod command_error;
pub mod control;
pub mod credits;
pub mod date_picker;
@@ -64,6 +65,8 @@ mod tests_dialog_fit;
#[cfg(test)]
mod tests_dialogs;
#[cfg(test)]
mod tests_error_kinds;
#[cfg(test)]
mod tests_filmstrip;
#[cfg(test)]
mod tests_grading;
@@ -130,9 +133,10 @@ mod tests_zoom_keys;
use std::sync::mpsc::{Receiver, Sender};
use lightcraft_engine::Session;
use lightcraft_engine::{AiModel, ErrorKind, Session};
use serde_json::Value;
pub use command_error::CommandError;
pub use control::{ControlRequest, ControlResponse};
pub use state::UiState;
@@ -508,14 +512,20 @@ impl LightcraftApp {
/// Run a UI or engine command by id. The single entry point for every frontend path.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
self.run_typed(id, params).map_err(|e| e.message)
}
/// [`LightcraftApp::run`], keeping what kind of error a refusal is: for code that decides on it
/// (offer a model's download, treat a cancel as one) instead of reading the message.
pub fn run_typed(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
if let Some(result) = model_setup::intercept(self, id, &params) {
return result;
return result.map_err(CommandError::from);
}
if let Some(r) = menus::run_ui_command(self, id, &params) {
if r.is_ok() {
docking::reveal_command(self, id)?;
}
return r;
return r.map_err(CommandError::from);
}
let params = if id == "crop.aspect"
&& params.get("aspect").and_then(Value::as_str) == Some("toggle")
@@ -526,7 +536,7 @@ impl LightcraftApp {
} else {
params
};
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
let r = self.session.execute(id, &params).map_err(CommandError::from);
if r.is_ok() && matches!(id, "crop.aspect" | "crop.rotateAspect") {
self.ui.crop_default_aspect = if id == "crop.aspect" && params.get("aspect").and_then(Value::as_str) == Some("original") {
Some(serde_json::json!("original"))
@@ -579,9 +589,9 @@ impl LightcraftApp {
match &r {
Err(e) => {
log::warn!("{id}: {e}");
self.ui.status = e.clone();
self.ui.status = e.message.clone();
// no detector yet: offer it (its terms first) instead of only saying so
if id == "faces.detect" && e.contains("Settings > Faces") {
if id == "faces.detect" && e.kind == ErrorKind::ModelMissing(AiModel::FaceDetector) {
panels::faces::offer_detector(self);
}
}
@@ -761,16 +771,11 @@ impl LightcraftApp {
if self.ui.sam_downloading && !download.running {
self.ui.sam_downloading = false;
let open = matches!(self.ui.dialog, Some(state::Dialog::SamModel { .. }));
match (&download.error, open) {
(_, true) => {}
(Some(e), false) if e.contains("cancelled") => {
self.toast(ctx, crate::i18n::tr("SAM 3 download stopped: it resumes where it left off next time."))
}
(Some(e), false) => self.toast_error(ctx, crate::i18n::tr_format!("The SAM 3 download failed: {e}", e = e)),
(None, false) if download.finished => {
self.toast_error(ctx, crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready."))
}
(None, false) => {}
match sam_download_ended(&download) {
_ if open => {}
Some((text, true)) => self.toast_error(ctx, text),
Some((text, false)) => self.toast(ctx, text),
None => {}
}
}
if let Some((due, mask)) = self.ui.detail_due {
@@ -789,13 +794,14 @@ impl LightcraftApp {
}
/// An AI mask error: when the model isn't installed, the dialog that offers to download it
/// (`then`: the AI mask to start afterwards); otherwise a toast.
pub fn ai_error(&mut self, ctx: &egui::Context, e: impl Into<String>, then: Option<(&str, &str)>) {
/// (`then`: the AI mask to start afterwards); otherwise a toast. Decided on the error's kind
/// ([`ErrorKind::ModelMissing`]), not its words.
pub fn ai_error(&mut self, ctx: &egui::Context, e: impl Into<CommandError>, then: Option<(&str, &str)>) {
let e = e.into();
if lightcraft_engine::segment::Segmenter::AVAILABLE && e.starts_with(lightcraft_engine::segment::NOT_INSTALLED) {
if lightcraft_engine::segment::Segmenter::AVAILABLE && e.kind == ErrorKind::ModelMissing(AiModel::Sam3) {
self.offer_sam_download(then);
} else {
self.toast_error(ctx, e);
self.toast_error(ctx, e.message);
}
}
@@ -1408,6 +1414,19 @@ fn detect_summary(v: &Value) -> String {
}
}
/// What to say when a SAM 3 download ended while its dialog was closed: the text, and whether it is
/// shown as an error toast. A cancel is told by `cancelled`, not by the error's words.
fn sam_download_ended(download: &lightcraft_engine::segment::DownloadStatus) -> Option<(String, bool)> {
match &download.error {
Some(_) if download.cancelled => {
Some((crate::i18n::tr("SAM 3 download stopped: it resumes where it left off next time.").to_string(), false))
}
Some(e) => Some((crate::i18n::tr_format!("The SAM 3 download failed: {e}", e = e), true)),
None if download.finished => Some((crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready.").to_string(), true)),
None => None,
}
}
/// A dropped face model file (`.onnx`).
pub fn is_model_file(path: &str) -> bool {
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx"))
+28 -2
View File
@@ -361,17 +361,19 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
return;
}
};
// The task's cancel flag says whether it was cancelled (the worker only stops for it), not an
// error's words: a real failure that mentions "cancelled" is still reported as a failure.
match message {
Message::Inspected(result) => match result {
Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &task, report),
Ok(_) => terminal(app, ctx, &task, json!({"cancelled": true})),
Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => terminal(app, ctx, &task, json!({"cancelled": true})),
Err(_) if task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &task, json!({"cancelled": true})),
Err(e) => terminal(app, ctx, &task, json!({"error": e})),
},
Message::Prepared(result) => {
let prepared = match result {
Ok(p) => p,
Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => {
Err(_) if task.cancel.load(Ordering::Relaxed) => {
terminal(app, ctx, &task, json!({"cancelled": true}));
return;
}
@@ -527,6 +529,30 @@ mod tests {
assert!(h.app.session.activity.list().is_empty());
}
/// Whether the task was cancelled is the task's flag, not an error's words (#1046): a cancel
/// reads as one whatever the message says, and a failure that mentions "cancelled" is a failure.
#[test]
fn lightroom_cancel_is_the_flag_not_the_words() {
let mut h = demo();
let (task, tx, cancel, _, _) = fake_import(&h);
h.app.lightroom = Some(task);
cancel.store(true, Ordering::Relaxed);
tx.send(Message::Prepared(Err("Lightroom-Import abgebrochen".into()))).unwrap();
h.step();
assert_eq!(h.app.lightroom_last, Some(serde_json::json!({"cancelled": true, "path": "fixture.lrcat"})));
for message in
[Message::Prepared(Err("the drive was ejected: copy cancelled by the system".into())), Message::Inspected(Err("cancelled".into()))]
{
let (task, tx, _, _, _) = fake_import(&h);
h.app.lightroom = Some(task);
tx.send(message).unwrap();
h.step();
let last = h.app.lightroom_last.clone().unwrap_or_default();
assert!(last.get("cancelled").is_none() && last["error"].as_str().is_some_and(|e| e.contains("cancelled")), "{last}");
}
}
#[test]
fn inspect_report_keeps_empty_catalog_shape() {
let report = inspect_report(CatalogImport {
+1 -1
View File
@@ -1936,7 +1936,7 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
{
let n = map.norm(q);
let exclude = ui.input(|i| i.modifiers.alt);
match app.run("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) {
match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) {
Ok(_) => {
if let Some(mid) = app.session.active_mask {
app.ui.detail_due = Some((ui.input(|i| i.time) + 1.0, mid));
+7 -4
View File
@@ -700,8 +700,11 @@ pub(crate) fn start_object(app: &mut LightcraftApp, ctx: &egui::Context, op: &st
if needs_model(app, "object", op) {
return;
}
let r =
if op == "new" { app.run("mask.add", json!({"kind": "object"})) } else { app.run("mask.addComponent", json!({"op": op, "kind": "object"})) };
let r = if op == "new" {
app.run_typed("mask.add", json!({"kind": "object"}))
} else {
app.run_typed("mask.addComponent", json!({"op": op, "kind": "object"}))
};
match r {
Ok(_) => {
app.ui.tool = "object".into();
@@ -779,9 +782,9 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
app.ui.describe = Some((op.clone(), text.clone()));
if submit && !text.trim().is_empty() {
let r = if op == "new" {
app.run("mask.add", json!({"kind": "prompt", "text": text}))
app.run_typed("mask.add", json!({"kind": "prompt", "text": text}))
} else {
app.run("mask.addComponent", json!({"op": op, "kind": "prompt", "text": text}))
app.run_typed("mask.addComponent", json!({"op": op, "kind": "prompt", "text": text}))
};
match r {
// the mask appears when the model has found it (a detail pass follows)
+70
View File
@@ -0,0 +1,70 @@
//! The UI decides on what kind of error a command returned, not on its words (#1046): messages get
//! reworded and translated, and a decision that reads them breaks silently when they do.
use lightcraft_engine::segment::{DownloadStatus, NOT_INSTALLED, Segmenter};
use lightcraft_engine::{AiModel, ErrorKind};
use serde_json::json;
use crate::headless::Headless;
use crate::state::Dialog;
use crate::{CommandError, LightcraftApp, Services};
fn demo() -> Headless {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
Headless::new(app, [1200.0, 800.0], 1.0)
}
/// An AI mask error offers the model on `ModelMissing(Sam3)`, whatever the message says, and a
/// message that merely starts like the engine's is a toast.
#[test]
fn ai_mask_errors_offer_the_model_by_kind() {
let mut h = demo();
let ctx = h.view.ctx.clone();
let missing = CommandError { message: "Das SAM-3-Modell ist nicht installiert".into(), kind: ErrorKind::ModelMissing(AiModel::Sam3) };
h.app.ai_error(&ctx, missing, Some(("object", "new")));
if Segmenter::AVAILABLE {
assert_eq!(h.app.ui.dialog, Some(Dialog::SamModel { then: Some(("object".into(), "new".into())), error: None }));
} else {
assert_eq!(h.app.ui.dialog, None, "a build without AI masks has nothing to offer");
}
h.app.ui.dialog = None;
h.app.ai_error(&ctx, format!("{NOT_INSTALLED}: but this is some other failure"), None);
assert_eq!(h.app.ui.dialog, None, "decided on the kind, not the words");
// another model missing is not SAM 3's
let face = CommandError { message: "x".into(), kind: ErrorKind::ModelMissing(AiModel::FaceDetector) };
h.app.ai_error(&ctx, face, None);
assert_eq!(h.app.ui.dialog, None);
}
/// `run_typed` keeps the engine's kind for the code that decides, and `run` the same message.
#[test]
fn run_typed_keeps_the_kind_and_run_the_message() {
let mut h = demo();
let dir = std::env::temp_dir().join(format!("lc-ui-error-kinds-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
h.app.session.face_models_dir = Some(dir.join("models"));
let typed = h.app.run_typed("faces.detect", json!({})).unwrap_err();
assert_eq!(typed.kind, ErrorKind::ModelMissing(AiModel::FaceDetector), "{typed}");
h.app.ui.dialog = None;
let text = h.app.run("faces.detect", json!({})).unwrap_err();
assert_eq!(text, typed.message);
let unknown = h.app.run_typed("no.such.command", json!({})).unwrap_err();
assert_eq!(unknown.kind, ErrorKind::UnknownCommand, "{unknown}");
// an error the UI raised itself is `Other`
assert_eq!(CommandError::from("Tick the box to accept the model's terms first".to_string()).kind, ErrorKind::Other);
let _ = std::fs::remove_dir_all(&dir);
}
/// A SAM 3 download that ended while its dialog was closed: a cancel is told by the status's
/// `cancelled`, not by the error's words.
#[test]
fn a_cancelled_sam_download_is_told_by_its_flag() {
let ended = |error: &str, cancelled: bool| {
crate::sam_download_ended(&DownloadStatus { error: Some(error.into()), cancelled, ..DownloadStatus::default() }).unwrap()
};
let (text, error) = ended("Download abgebrochen", true);
assert!(!error && text.starts_with("SAM 3 download stopped"), "{text}");
let (text, error) = ended("could not download model.safetensors: the mirror cancelled the transfer", false);
assert!(error && text.starts_with("The SAM 3 download failed: "), "{text}");
assert_eq!(crate::sam_download_ended(&DownloadStatus::default()), None);
}
+5
View File
@@ -191,6 +191,11 @@ Therefore:
| `segment.model.cancel` | `{}` | stop it (it resumes next time) → `{cancelled}` |
`remote` reports the configured endpoint, not its health. `installed` describes local weights only.
`download` is `{running, done, total, file, error, cancelled, finished}`: `cancelled` is true when the last
download was cancelled (its `error` says so in words; decide on the flag). An AI mask command refused because
the model is not installed (or still downloading) fails with an error that starts with "The SAM 3 model is
not installed"; inside LightCraft it is `ErrorKind::ModelMissing(Sam3)`, which is what the app offers the
download on.
Coordinates are normalized to the uncropped, oriented photo, like every mask shape. `seg` passes
a stored segmentation (as `mask.list` / the develop settings hold it): the mask is made without
+1
View File
@@ -28,6 +28,7 @@ An export can hand the files it wrote to Apple Photos, optionally into an album
An import can take minutes, and the first one waits for the user to answer macOS's permission prompt. Each
import is a job: `{job, running, requested, album}`, plus, once Photos is done, `{imported, ids, warning?}`,
`{error}` or `{skipped}` (`exporting: true` while an export that will add its files is still writing them).
`skipped` says why Photos wasn't asked, in words; `cancelled: true` comes with it when the export was cancelled.
While Photos works, the activity stack ([background-tasks.md](background-tasks.md)) shows it. An export adding its
files keeps its own row, which reads "Adding to Apple Photos…" and loses its ✕, since Photos can't be stopped once
+5 -2
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language measurement from the catalogs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the interface no longer decides on error text, a prerequisite for translating lower-layer errors) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
How far each interface language is from `full`. How to add or maintain a language is in
[localization.md](localization.md); per-language notes are in `localization-<code>.md`
@@ -73,7 +73,9 @@ Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norw
## Shared work before any language is `full` (counted once, ~20–35 h)
- Route lower-layer error messages (catalog, raw, codecs, engine) through translatable message ids
instead of English `format!` strings (~12–20 h).
instead of English `format!` strings (~12–20 h). Translating them must not change what the app does:
since #1046 the interface decides on an error's kind (`lightcraft_engine::ErrorKind`, a download's or
Apple Photos import's `cancelled` flag), not its words; new decisions on an error go through a kind too.
- A check in `cargo xtask ci` that every user-visible literal reaches a catalog (~4–8 h).
- A user manual / help pages beyond What's New and the shortcut sheet (Lightroom links to online
help; ~4–8 h for a translatable in-app help index).
@@ -89,4 +91,5 @@ Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norw
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Shared work: the interface decides on error kinds, not English error text (#1046) |
| 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates |
+1 -1
View File
@@ -288,7 +288,7 @@ lightcraft-cli calibrate --max 300 ~/Pictures/2026 # camera colour profiles (d
`app.export` (MCP `export` tool, `lightcraft-cli run`, the desktop app) takes `addToPhotos: true` and
`photosAlbum` to hand the files it wrote to Apple Photos, into that album (a top-level album of exactly that
name, made when missing). The result's `applePhotos` is the import job: `{job, running, requested, album}`
plus `{imported, ids, warning?}`, `{error}` or `{skipped}` once Photos is done; the files are written either
plus `{imported, ids, warning?}`, `{error}` or `{skipped}` (with `cancelled: true` when the export was cancelled) once Photos is done; the files are written either
way. Headless (this server, `lightcraft-cli`) waits for Photos by default; the desktop app (connect mode)
answers with the running job and `export.photosImports {job}` gives the outcome. `export.addToPhotos {paths,
album?, wait?}` does the same for files already on disk (`wait` defaults to true headless, false in the desktop