mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
commit
48b9a5e21f
21 files changed
+471
-85
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, ¶ms) {
|
||||
return result;
|
||||
return result.map_err(CommandError::from);
|
||||
}
|
||||
if let Some(r) = menus::run_ui_command(self, id, ¶ms) {
|
||||
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, ¶ms).map_err(|e| e.to_string());
|
||||
let r = self.session.execute(id, ¶ms).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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user