mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-03 06:59:53 -06:00
Merge pr/raw-erf into pr/raw-mef
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -346,6 +346,7 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
|
||||
let decoded = crate::files::decode_raw_preview(
|
||||
bytes,
|
||||
lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS },
|
||||
None,
|
||||
)?;
|
||||
let mut reference = decoded.to_working();
|
||||
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
|
||||
|
||||
@@ -26,8 +26,11 @@ fn section_param(p: &Value, c: &str) -> Result<Section> {
|
||||
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
|
||||
}
|
||||
|
||||
fn groups_param(p: &Value) -> Option<Vec<SettingsGroup>> {
|
||||
p.get("groups").and_then(|g| serde_json::from_value(g.clone()).ok())
|
||||
fn groups_param(p: &Value, c: &str) -> Result<Option<Vec<SettingsGroup>>> {
|
||||
match p.get("groups") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(c, format!("invalid `groups`: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
|
||||
@@ -497,13 +500,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("develop.curve", "Set Point Curve", [], None, "{channel: master|red|green|blue, points: [[x,y],...] in 0..1}", has_active, |s, p| {
|
||||
let ch = str_param(p, "channel").unwrap_or("master").to_string();
|
||||
let pts: Vec<Point> = p
|
||||
let mut pts: Vec<Point> = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| bad("develop.curve", "missing points"))?
|
||||
.iter()
|
||||
.filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?.clamp(0.0, 1.0), q.get(1)?.as_f64()?.clamp(0.0, 1.0))))
|
||||
.collect();
|
||||
// stored in the order the curve runs, as the editor draws and drags them
|
||||
pts.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
edit(s, "develop.curve", "Tone Curve", |d| {
|
||||
match ch.as_str() {
|
||||
"master" | "rgb" => d.curve.master = pts,
|
||||
@@ -782,8 +787,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{groups?: [settingsGroup]} (default: all but crop, masks, remove)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "develop.copy")?;
|
||||
if let Some(g) = groups_param(p) {
|
||||
let c = "develop.copy";
|
||||
let id = active(s, c)?;
|
||||
if let Some(g) = groups_param(p, c)? {
|
||||
s.copy_groups = g;
|
||||
}
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
@@ -802,7 +808,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = "develop.pastePrevious";
|
||||
let prev =
|
||||
s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;
|
||||
let groups = groups_param(p).unwrap_or_else(|| s.copy_groups.clone());
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());
|
||||
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect();
|
||||
let ops = ids
|
||||
@@ -823,8 +829,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?, groups?: [settingsGroup] (paste only these of the copied groups)}",
|
||||
has_clipboard,
|
||||
|s, p| {
|
||||
let c = "develop.paste";
|
||||
let mut clip = s.clipboard.clone().unwrap_or_default();
|
||||
if let Some(only) = groups_param(p) {
|
||||
if let Some(only) = groups_param(p, c)? {
|
||||
// the clipboard holds whole groups: rebuild it with just the chosen (copied) ones
|
||||
let only: Vec<_> = only.into_iter().filter(|g| s.copy_groups.contains(g)).collect();
|
||||
let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);
|
||||
@@ -849,8 +856,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{groups?} — copy the active photo's settings to all selected photos",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let id = active(s, "develop.sync")?;
|
||||
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
|
||||
let c = "develop.sync";
|
||||
let id = active(s, c)?;
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let partial = lightcraft_develop::extract_groups(&d, &groups);
|
||||
let ops = s
|
||||
@@ -979,10 +987,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
cmd!("preset.create", "Create Preset", ["Photo"], None, "{name, group?, groups?: [settingsGroup]}", has_active, |s, p| {
|
||||
let id = active(s, "preset.create")?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("preset.create", "missing name"))?;
|
||||
let c = "preset.create";
|
||||
let id = active(s, c)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(c, "missing name"))?;
|
||||
let group = str_param(p, "group").unwrap_or("User Presets");
|
||||
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let pid = format!("user.{}.{}", s.presets.len(), name.to_lowercase().replace(' ', "-"));
|
||||
s.presets.push(Preset::from_settings(&pid, name, group, &d, &groups));
|
||||
@@ -1026,7 +1035,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = "preset.update";
|
||||
let id = active(s, c)?;
|
||||
let current = s.develop_of(id).unwrap_or_default().to_json();
|
||||
let groups = groups_param(p);
|
||||
let groups = groups_param(p, c)?;
|
||||
let pr = user_preset(s, p, c)?;
|
||||
pr.settings = match groups {
|
||||
Some(g) => lightcraft_develop::extract_groups(&serde_json::from_value(current).unwrap_or_default(), &g),
|
||||
|
||||
@@ -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()}))
|
||||
}
|
||||
),
|
||||
@@ -639,7 +646,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let i = find(&mut masks, mid, "mask.move")?;
|
||||
let to = match (p.get("to").and_then(Value::as_i64), p.get("delta").and_then(Value::as_i64)) {
|
||||
(Some(t), _) => t,
|
||||
(None, Some(d)) => i as i64 + d,
|
||||
// (an agent's delta can be anything: saturate rather than overflow)
|
||||
(None, Some(d)) => (i as i64).saturating_add(d),
|
||||
(None, None) => return Err(bad("mask.move", "give `to` (index) or `delta`")),
|
||||
}
|
||||
.clamp(0, masks.len() as i64 - 1) as usize;
|
||||
@@ -700,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;
|
||||
|
||||
@@ -31,14 +31,24 @@ pub fn photo_summary(p: &Photo) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Value {
|
||||
/// How deep [`album_json`] lists folders inside folders: a catalog that loops (an album inside
|
||||
/// itself, which [`lightcraft_catalog::Catalog::repair`] puts right on load) lists no further
|
||||
/// instead of overflowing the stack.
|
||||
const MAX_ALBUM_DEPTH: usize = 64;
|
||||
|
||||
fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth: usize) -> Value {
|
||||
let children: Vec<Value> = if depth < MAX_ALBUM_DEPTH {
|
||||
all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat, depth + 1)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut v = json!({
|
||||
"id": a.id.0,
|
||||
"name": a.name,
|
||||
"folder": a.folder,
|
||||
"count": cat.album_count(a.id),
|
||||
"cover": a.cover.map(|c| c.0),
|
||||
"children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat)).collect::<Vec<_>>(),
|
||||
"children": children,
|
||||
});
|
||||
if let Some(rules) = &a.smart {
|
||||
v["smart"] = json!(true);
|
||||
@@ -139,7 +149,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
|
||||
let all: Vec<Album> = s.catalog.albums().cloned().collect();
|
||||
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect()))
|
||||
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog, 0)).collect()))
|
||||
}),
|
||||
cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| {
|
||||
let id = photo_arg(s, p, "photo.inspect")?;
|
||||
|
||||
@@ -12,8 +12,9 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut merged = Vec::new();
|
||||
let mut backups = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let owners = crate::sidecar::StemOwners::of(&s.catalog);
|
||||
for id in s.targets(p) {
|
||||
let ids = s.targets(p);
|
||||
let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids);
|
||||
for id in ids {
|
||||
match s.save_sidecar_with(id, &owners) {
|
||||
Ok(r) => {
|
||||
let path = r.path.display().to_string();
|
||||
|
||||
+129
-16
@@ -1,16 +1,31 @@
|
||||
//! Where LightCraft keeps per-user data on this machine: the config folder (app settings, camera
|
||||
//! profiles, face and denoise models).
|
||||
//!
|
||||
//! [`config_dir`] picks it, in order:
|
||||
//! 1. `$LIGHTCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
|
||||
//! 2. **Portable mode** (issue #1200, as in PhotoCraft): a marker file ([`PORTABLE_MARKERS`]) beside the
|
||||
//! executable puts everything in `<exe dir>/LightCraftData`, so a copy on a USB stick leaves nothing in
|
||||
//! `%APPDATA%`. If that folder can't be written (read-only medium, Program Files), a warning is logged
|
||||
//! and the platform folder is used.
|
||||
//! 3. The platform convention: macOS `~/Library/Application Support/LightCraft`, Windows
|
||||
//! `%APPDATA%\LightCraft`, Linux `$XDG_CONFIG_HOME/lightcraft` or `~/.config/lightcraft`.
|
||||
//!
|
||||
//! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model
|
||||
//! installed from one is there for the others. Nothing here is applied automatically: a [`Session`] has no
|
||||
//! face-, denoise- or SAM 3 model folder until a host asks for one ([`Session::with_default_face_models`],
|
||||
//! [`Session::with_default_denoise_models`], [`Session::with_default_sam3_model`]), so tests stay hermetic.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Files beside the executable that switch on portable mode (either one; contents are ignored).
|
||||
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "LightCraft.portable"];
|
||||
/// The data folder created beside the executable in portable mode.
|
||||
pub const PORTABLE_DATA_DIR: &str = "LightCraftData";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Platform {
|
||||
Mac,
|
||||
@@ -18,9 +33,9 @@ enum Platform {
|
||||
Other,
|
||||
}
|
||||
|
||||
/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides the platform default.
|
||||
/// The override is the complete directory; no application-name suffix is appended. An explicitly
|
||||
/// empty value disables the settings directory instead of falling back to a personal profile.
|
||||
/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides portable mode and the
|
||||
/// platform default. The override is the complete directory; no application-name suffix is appended. An
|
||||
/// explicitly empty value disables the settings directory instead of falling back to a personal profile.
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
@@ -29,13 +44,17 @@ pub fn config_dir() -> Option<PathBuf> {
|
||||
} else {
|
||||
Platform::Other
|
||||
};
|
||||
config_dir_with(platform, |name| std::env::var_os(name))
|
||||
config_dir_with(platform, |name| std::env::var_os(name), portable_dir)
|
||||
}
|
||||
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
/// `portable` is asked only without an override, so an isolated run never probes beside the executable.
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, portable: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
|
||||
if let Some(dir) = env("LIGHTCRAFT_CONFIG_DIR") {
|
||||
return (!dir.is_empty()).then(|| PathBuf::from(dir));
|
||||
}
|
||||
if let Some(dir) = portable() {
|
||||
return Some(dir);
|
||||
}
|
||||
match platform {
|
||||
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")),
|
||||
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")),
|
||||
@@ -45,6 +64,50 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -
|
||||
}
|
||||
}
|
||||
|
||||
/// This process's portable data folder, if a marker sits beside its executable and the folder can be
|
||||
/// written. Resolved once: the check creates the folder and writes a probe file.
|
||||
fn portable_dir() -> Option<PathBuf> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
match portable_dir_in(exe.parent()?) {
|
||||
Ok(dir) => dir,
|
||||
Err(why) => {
|
||||
log::warn!("{why}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// `<exe_dir>/LightCraftData` when `exe_dir` holds a portable marker; `Err` says why a marked folder
|
||||
/// can't be used (the caller falls back to the platform folder).
|
||||
fn portable_dir_in(exe_dir: &Path) -> Result<Option<PathBuf>, String> {
|
||||
let Some(marker) = PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let data = exe_dir.join(PORTABLE_DATA_DIR);
|
||||
match ensure_writable(&data) {
|
||||
Ok(()) => Ok(Some(data)),
|
||||
Err(e) => Err(format!(
|
||||
"portable mode ({} found) but {} isn't writable ({e}); settings are kept in the usual folder instead",
|
||||
marker.display(),
|
||||
data.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
|
||||
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
// A leftover probe is harmless; don't fail portable mode over it.
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where face models are kept: `$LIGHTCRAFT_FACE_MODELS` if set, else `<config>/models`.
|
||||
pub fn default_face_models_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("LIGHTCRAFT_FACE_MODELS").filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("models")))
|
||||
@@ -101,16 +164,62 @@ mod config_tests {
|
||||
use super::*;
|
||||
|
||||
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), || None)
|
||||
}
|
||||
|
||||
/// An override is used as is: portable mode is not even looked for.
|
||||
fn no_portable_probe() -> Option<PathBuf> {
|
||||
panic!("an override must not probe for portable mode")
|
||||
}
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-config-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A marker beside the executable moves the settings folder next to it, ahead of the platform folder (issue #1200).
|
||||
#[test]
|
||||
fn portable_marker_puts_settings_beside_the_executable() {
|
||||
for marker in PORTABLE_MARKERS {
|
||||
let exe = temp(&format!("portable-{marker}"));
|
||||
assert_eq!(portable_dir_in(&exe), Ok(None), "no marker, no portable folder");
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists());
|
||||
std::fs::write(exe.join(marker), b"").unwrap();
|
||||
let data = exe.join(PORTABLE_DATA_DIR);
|
||||
assert_eq!(portable_dir_in(&exe), Ok(Some(data.clone())));
|
||||
assert!(data.is_dir());
|
||||
assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0, "the write probe is removed");
|
||||
let found = portable_dir_in(&exe).unwrap();
|
||||
let actual = config_dir_with(Platform::Windows, |name| (name == "APPDATA").then(|| OsString::from("/roaming")), || found);
|
||||
assert_eq!(actual, Some(data));
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
}
|
||||
|
||||
/// A marked folder that can't hold the data folder (here: a file is in its place) is reported, not used.
|
||||
#[test]
|
||||
fn portable_folder_that_cannot_be_written_is_refused() {
|
||||
let exe = temp("portable-blocked");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"").unwrap();
|
||||
let why = portable_dir_in(&exe).unwrap_err();
|
||||
assert!(why.contains("portable.txt") && why.contains(PORTABLE_DATA_DIR), "{why}");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_override_wins_without_reading_user_profile() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
let actual = config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
});
|
||||
let actual = config_dir_with(
|
||||
platform,
|
||||
|name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
},
|
||||
no_portable_probe,
|
||||
);
|
||||
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
|
||||
}
|
||||
}
|
||||
@@ -119,10 +228,14 @@ mod config_tests {
|
||||
fn config_empty_override_disables_profile_without_reading_defaults() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(
|
||||
config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
}),
|
||||
config_dir_with(
|
||||
platform,
|
||||
|name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
},
|
||||
no_portable_probe
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -145,7 +258,7 @@ mod config_tests {
|
||||
fn config_override_preserves_non_unicode_paths() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
let value = OsString::from_vec(b"isolated/\xff".to_vec());
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone()), no_portable_probe), Some(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,8 @@ fn curve(props: &Props, k: &str) -> Option<Value> {
|
||||
let mut pts = Vec::new();
|
||||
for it in items {
|
||||
let (x, y) = it.split_once(',')?;
|
||||
let (x, y) = (x.trim().parse::<f64>().ok()?, y.trim().parse::<f64>().ok()?);
|
||||
// (`parse` reads "NaN" and "inf" too: a curve with such a point is not a curve)
|
||||
let (x, y) = (x.trim().parse::<f64>().ok().filter(|v| v.is_finite())?, y.trim().parse::<f64>().ok().filter(|v| v.is_finite())?);
|
||||
pts.push(json!({"x": (x / 255.0).clamp(0.0, 1.0), "y": (y / 255.0).clamp(0.0, 1.0)}));
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
@@ -617,6 +618,19 @@ mod tests {
|
||||
assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5));
|
||||
}
|
||||
|
||||
/// A sidecar curve with a `NaN` or `inf` point (Rust's `parse` reads both) is not a curve:
|
||||
/// it is left out, rather than stored with a point the curve editor can't place.
|
||||
#[test]
|
||||
fn curves_with_non_finite_points_are_left_out() {
|
||||
for bad in ["NaN, 0", "64, inf", "-inf, 3"] {
|
||||
let x = SIDECAR.replace("<rdf:li>64, 52</rdf:li>", &format!("<rdf:li>{bad}</rdf:li>"));
|
||||
let partial = to_partial(&props(&x), Target::RawAbsolute);
|
||||
let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
|
||||
assert!(s.curve.master.is_empty(), "{bad}: {:?}", s.curve.master);
|
||||
assert!(s.curve.master.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_present_fields_are_in_the_partial() {
|
||||
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,10 @@ impl Default for Resize {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most pixels an export enlarged past its photo's own size can have: 512 megapixels, also
|
||||
/// Lightroom's ceiling. A full-size or smaller export is never held to it.
|
||||
pub const MAX_ENLARGED_PIXELS: f64 = 512e6;
|
||||
|
||||
impl Resize {
|
||||
pub fn long_edge(px: u32) -> Self {
|
||||
Self { value: px as f32, ..Default::default() }
|
||||
@@ -131,8 +135,12 @@ impl Resize {
|
||||
ResizeMode::Percent => v / 100.0,
|
||||
};
|
||||
let k = if self.dont_enlarge { k.min(1.0) } else { k };
|
||||
// at least one pixel, at most the encoders' 65535 limit
|
||||
k.clamp(1.0 / short, 65_535.0 / long)
|
||||
// an enlargement stops at 512 megapixels (Lightroom's ceiling too): 20000 typed for 2000
|
||||
// with Don't Enlarge off asked for gigapixels, more memory than a computer has
|
||||
let k = k.min((MAX_ENLARGED_PIXELS / (w * h)).sqrt().max(1.0));
|
||||
// at least one pixel, at most the encoders' 65535 limit; the limit wins for a photo more
|
||||
// than 65535 times as long as it is short (where `clamp` would panic: min above max)
|
||||
k.max(1.0 / short).min(65_535.0 / long)
|
||||
}
|
||||
|
||||
/// Output size for a `w × h` (cropped, full-resolution) photo.
|
||||
@@ -1670,10 +1678,16 @@ fn prepare_guarded(
|
||||
let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize);
|
||||
let (work, weight) = if o.format.is_rendered() {
|
||||
let (w, h) = output_size(p, o);
|
||||
let (nw, nh) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop);
|
||||
let native_px = (nw.round().max(1.0) * nh.round().max(1.0)) as usize;
|
||||
let label = p.file_name.clone();
|
||||
let meta = export_metadata(p, &session.catalog, o);
|
||||
let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg;
|
||||
let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?;
|
||||
let weight = render_weight(full_px, long, job.level, w.saturating_mul(h));
|
||||
if let Some(why) = too_big_for_memory((w, h), native_px, weight, crate::memory::total_ram()) {
|
||||
return Err(format!("{label}: {why}"));
|
||||
}
|
||||
let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None };
|
||||
(Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight)
|
||||
} else {
|
||||
@@ -1697,6 +1711,26 @@ fn prepare_guarded(
|
||||
Ok(PreparedExport { photo: id, file_name, work, guard, weight })
|
||||
}
|
||||
|
||||
/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size,
|
||||
/// can't be made on a computer with `ram` bytes, given its working memory `weight`: an export
|
||||
/// enlarged past the photo's size that needs more memory than the computer has would fail to
|
||||
/// allocate, which ends the process rather than the export. Full-size and smaller exports, and
|
||||
/// computers whose memory isn't known, are never refused.
|
||||
fn too_big_for_memory(out: (usize, usize), native_px: usize, weight: usize, ram: Option<usize>) -> Option<String> {
|
||||
let ram = ram?;
|
||||
if out.0.saturating_mul(out.1) <= native_px || weight <= ram {
|
||||
return None;
|
||||
}
|
||||
let gb = |b: usize| b as f64 / (1u64 << 30) as f64;
|
||||
Some(format!(
|
||||
"exporting at {} × {} needs about {:.0} GB of memory, more than this computer has ({:.0} GB); choose a smaller size",
|
||||
out.0,
|
||||
out.1,
|
||||
gb(weight).ceil(),
|
||||
gb(ram)
|
||||
))
|
||||
}
|
||||
|
||||
/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level
|
||||
/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the
|
||||
/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export
|
||||
@@ -2738,10 +2772,44 @@ mod tests {
|
||||
assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None);
|
||||
assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300);
|
||||
// never larger than the encoders' limit, never empty
|
||||
assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((65535, 43690)));
|
||||
assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((27713, 18475)), "512 MP at most");
|
||||
assert_eq!(size(json!({"width": 70000, "dontEnlarge": false}), 60000.0, 100.0), Some((65535, 109)), "65535 px an edge");
|
||||
assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1));
|
||||
}
|
||||
|
||||
/// Typing 20000 for 2000 (or percent 10000) with Don't Enlarge off asked for gigapixels: the
|
||||
/// render's first plane alone (34 GB at 65535 × 43690) failed to allocate, which aborts the
|
||||
/// process. An enlargement stops at 512 megapixels; full-size and smaller exports of photos
|
||||
/// larger than that are untouched.
|
||||
#[test]
|
||||
fn enlargements_stop_at_512_megapixels() {
|
||||
let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)).unwrap();
|
||||
for p in [json!({"longEdge": 60000, "dontEnlarge": false}), json!({"megapixels": 5000}), json!({"percent": 10000, "dontEnlarge": false})] {
|
||||
let (w, h) = size(p.clone(), 6000.0, 4000.0);
|
||||
assert!((w * h) as f64 <= MAX_ENLARGED_PIXELS * 1.001, "{p}: {w}×{h}");
|
||||
assert!((w as f64 / h as f64 - 1.5).abs() < 0.001, "{p}: keeps the shape");
|
||||
}
|
||||
// below the ceiling an enlargement is as asked
|
||||
assert_eq!(size(json!({"longEdge": 12000, "dontEnlarge": false}), 6000.0, 4000.0), (12000, 8000));
|
||||
// a 600 MP panorama exports at full size, and smaller, as before; it is never enlarged
|
||||
assert_eq!(size(json!({"percent": 100, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000));
|
||||
assert_eq!(size(json!({"percent": 50}), 30000.0, 20000.0), (15000, 10000));
|
||||
assert_eq!(size(json!({"percent": 200, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000));
|
||||
}
|
||||
|
||||
/// An enlarged export that needs more memory than the computer has is refused with a message,
|
||||
/// before it starts; everything else goes ahead.
|
||||
#[test]
|
||||
fn enlarged_exports_must_fit_the_computers_memory() {
|
||||
let gb = 1usize << 30;
|
||||
let why = too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(16 * gb)).unwrap();
|
||||
assert!(why.contains("27713 × 18475") && why.contains("20 GB") && why.contains("16 GB") && why.contains("smaller size"), "{why}");
|
||||
assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(32 * gb)), None, "fits");
|
||||
assert_eq!(too_big_for_memory((6000, 4000), 24_000_000, 20 * gb, Some(gb)), None, "full size is never refused");
|
||||
assert_eq!(too_big_for_memory((3000, 2000), 24_000_000, 20 * gb, Some(gb)), None, "nor a smaller export");
|
||||
assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, None), None, "unknown memory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_size_survives_the_render() {
|
||||
// the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312)
|
||||
@@ -2783,6 +2851,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A photo more than 65535 times as long as it is short (a 70000 × 1 strip) made `scale`
|
||||
/// clamp with its lower bound above its upper one, which panics, in the export and in the
|
||||
/// Export dialog's preview on the UI thread. The encoders' limit wins, the short edge keeps a
|
||||
/// pixel.
|
||||
#[test]
|
||||
fn resize_survives_photos_too_long_for_the_encoders() {
|
||||
let modes = [
|
||||
json!({"longEdge": 2000}),
|
||||
json!({"shortEdge": 1}),
|
||||
json!({"width": 500, "height": 500}),
|
||||
json!({"megapixels": 1}),
|
||||
json!({"percent": 50}),
|
||||
json!({"percent": 1000, "dontEnlarge": false}),
|
||||
];
|
||||
for p in modes {
|
||||
for (w, h) in [(70_000.0, 1.0), (1.0, 70_000.0), (1e9, 3.0), (65_536.0, 1.0)] {
|
||||
let r = ExportOptions::from_json(&p).resize.unwrap();
|
||||
let (ow, oh) = r.apply(w, h);
|
||||
assert!((1..=65_535).contains(&ow) && (1..=65_535).contains(&oh), "{p} on {w}×{h}: {ow}×{oh}");
|
||||
assert!(r.scale(w, h).is_finite());
|
||||
}
|
||||
}
|
||||
// (a short edge under a pixel would be scaled up to one, which the limit then caps)
|
||||
assert_eq!(Resize::long_edge(2000).apply(70_000.0, 1.0), (65_535, 1));
|
||||
assert_eq!(Resize::long_edge(2000).apply(1.0, 70_000.0), (1, 65_535));
|
||||
}
|
||||
|
||||
/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`.
|
||||
fn p3_green(space: OutputSpace) -> Rgba8 {
|
||||
use lightcraft_color::{DISPLAY_P3, REC2020};
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Face tiles cut from a raw's embedded preview (issue #1440).
|
||||
//!
|
||||
//! A People card's face picture ([`crate::Session::face_job`]) is a small square around the face.
|
||||
//! Developing the raw for it reads and demosaics most of a 24–50 MB file for a ~170 px picture; the
|
||||
//! camera's own JPEG inside the file (read through the planned preview path,
|
||||
//! [`crate::files::preview_window`], which reads the header and the preview and not the sensor
|
||||
//! data) usually holds that square at the tile's size already. A [`PreviewCut`] says which preview
|
||||
//! to read and how large it must be; [`crate::media::RenderJob::run`] tries it before the job's own
|
||||
//! source and falls back to that source when the preview can't serve.
|
||||
//!
|
||||
//! Only a raw that still looks the way it was imported gets one: that is the photo whose grid
|
||||
//! thumbnail is the same embedded JPEG ([`crate::Session::embedded_thumb_is_final`]). An edited raw
|
||||
//! needs its edits rendered, which the camera's display-referred JPEG can't take the way the raw
|
||||
//! does, so it keeps the full develop render.
|
||||
|
||||
use lightcraft_geom::Rect;
|
||||
use lightcraft_raster::Rgba8;
|
||||
use lightcraft_raster::resample::{Filter, resize};
|
||||
|
||||
use crate::media::PreviewLoader;
|
||||
|
||||
/// How much smaller than asked an embedded preview may be and still serve a face tile: at least
|
||||
/// three quarters of the pixels the tile asks for across the face. The square is then enlarged by
|
||||
/// at most 4⁄3 on its way to the tile, which at a 56–180 pt tile is not visible (the camera's JPEG
|
||||
/// is sharpened and denoised, so its pixels hold more detail than a binned raw's), and it is the
|
||||
/// same bound the raw path already accepts for an X-Trans sensor binned 3× for a preview
|
||||
/// ([`crate::files::bin_factor`]). Below it the tile would look soft: the job's own source renders it.
|
||||
pub const SOFT_LIMIT: f64 = 0.75;
|
||||
|
||||
/// Largest relative difference between the preview's and the photo's aspect ratios: a preview of
|
||||
/// another shape (letterboxed, 16:9 from a 3:2 sensor, a 4:3 sensor's 3:2 JPEG) does not show the
|
||||
/// same frame, and the face box would land beside the face. Real previews of the same frame differ
|
||||
/// by rounding only (Sony A7S III: 1616 × 1080 against 4240 × 2832, 0.06 %).
|
||||
const ASPECT_TOLERANCE: f64 = 0.02;
|
||||
|
||||
/// A face tile to cut from the photo's embedded preview.
|
||||
#[derive(Clone)]
|
||||
pub struct PreviewCut {
|
||||
/// The raw file.
|
||||
pub path: String,
|
||||
/// Reads its largest embedded preview, display-ready and upright, no larger than the edge given.
|
||||
pub loader: PreviewLoader,
|
||||
/// The preview's long edge that gives the tile its own size across the face.
|
||||
pub want: usize,
|
||||
/// The smallest long edge accepted ([`SOFT_LIMIT`] of `want`).
|
||||
pub min: usize,
|
||||
/// The square around the face, normalized on the upright photo.
|
||||
pub rect: Rect,
|
||||
/// The photo's width / height, upright.
|
||||
pub aspect: f64,
|
||||
/// The tile's edge, pixels.
|
||||
pub edge: usize,
|
||||
}
|
||||
|
||||
impl PreviewCut {
|
||||
/// The tile, or `None` when the preview can't give it: none could be read (no preview, an
|
||||
/// offline or damaged file), it is smaller than [`Self::min`], or it shows another frame.
|
||||
pub fn cut(&self) -> Option<Rgba8> {
|
||||
let preview = (self.loader)(&self.path, self.want)?;
|
||||
self.cut_from(&preview)
|
||||
}
|
||||
|
||||
/// [`Self::cut`] from a preview already read.
|
||||
pub fn cut_from(&self, img: &Rgba8) -> Option<Rgba8> {
|
||||
let (w, h) = (img.width, img.height);
|
||||
if w == 0 || h == 0 || w.max(h) < self.min || img.data.len() != w.checked_mul(h)? {
|
||||
return None;
|
||||
}
|
||||
let aspect = w as f64 / h as f64;
|
||||
if !(self.aspect.is_finite() && self.aspect > 0.0) || ((aspect - self.aspect) / self.aspect).abs() > ASPECT_TOLERANCE {
|
||||
return None;
|
||||
}
|
||||
let r = self.rect;
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// pixel bounds inside the preview (the clamps keep every value in 0..=w / 0..=h)
|
||||
let px = |v: f64, n: usize| (v.clamp(0.0, 1.0) * n as f64).round() as usize;
|
||||
let (x0, x1, y0, y1) = (px(r.x0, w), px(r.x1, w), px(r.y0, h), px(r.y1, h));
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return None;
|
||||
}
|
||||
let edge = self.edge.clamp(1, crate::media::SourceLevel::Thumb.max_edge());
|
||||
let square = img.crop(x0, y0, x1 - x0, y1 - y0).to_linear();
|
||||
Some(resize(&square, edge, edge, Filter::Mitchell).to_srgb8())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn cut(rect: Rect, aspect: f64, min: usize) -> PreviewCut {
|
||||
PreviewCut { path: String::new(), loader: Arc::new(|_, _| None), want: 300, min, rect, aspect, edge: 32 }
|
||||
}
|
||||
|
||||
fn picture(w: usize, h: usize) -> Rgba8 {
|
||||
let mut img = Rgba8::new(w, h);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
*p = [(i % w * 255 / w) as u8, (i / w * 255 / h) as u8, 90, 255];
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// The square comes out the tile's size, from the place the box names.
|
||||
#[test]
|
||||
fn cuts_the_square_at_the_tiles_size() {
|
||||
let img = picture(300, 200);
|
||||
let out = cut(Rect { x0: 0.5, y0: 0.5, x1: 0.6, y1: 0.65 }, 1.5, 200).cut_from(&img).unwrap();
|
||||
assert_eq!((out.width, out.height), (32, 32));
|
||||
// red follows x: the box starts at half the width
|
||||
let red = out.data[16 * 32 + 16][0];
|
||||
assert!((125..=160).contains(&red), "{red}");
|
||||
}
|
||||
|
||||
/// A preview too small, of another shape, empty, or a box that is no box: no tile, no panic.
|
||||
#[test]
|
||||
fn declines_what_it_cannot_cut() {
|
||||
let img = picture(300, 200);
|
||||
let ok = Rect { x0: 0.4, y0: 0.4, x1: 0.5, y1: 0.55 };
|
||||
assert!(cut(ok, 1.5, 301).cut_from(&img).is_none(), "below the soft limit");
|
||||
assert!(cut(ok, 16.0 / 9.0, 200).cut_from(&img).is_none(), "another frame");
|
||||
assert!(cut(ok, f64::NAN, 200).cut_from(&img).is_none());
|
||||
assert!(cut(ok, 0.0, 200).cut_from(&img).is_none());
|
||||
assert!(cut(ok, 1.5, 0).cut_from(&Rgba8::new(0, 0)).is_none(), "empty preview");
|
||||
let short = Rgba8 { width: 300, height: 200, data: vec![[0; 4]; 10] };
|
||||
assert!(cut(ok, 1.5, 0).cut_from(&short).is_none(), "a buffer shorter than its size");
|
||||
for bad in [
|
||||
Rect { x0: f64::NAN, y0: 0.4, x1: 0.5, y1: 0.5 },
|
||||
Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
|
||||
Rect { x0: 0.6, y0: 0.6, x1: 0.4, y1: 0.4 },
|
||||
Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 },
|
||||
] {
|
||||
assert!(cut(bad, 1.5, 0).cut_from(&img).is_none(), "{bad:?}");
|
||||
}
|
||||
// a box past the edges is clamped to the picture
|
||||
let edge = cut(Rect { x0: -1.0, y0: -1.0, x1: 0.1, y1: 0.15 }, 1.5, 0).cut_from(&img).unwrap();
|
||||
assert_eq!((edge.width, edge.height), (32, 32));
|
||||
// and a loader with nothing to give leaves the job to its source
|
||||
assert!(cut(ok, 1.5, 0).cut().is_none());
|
||||
}
|
||||
}
|
||||
+152
-48
@@ -450,7 +450,8 @@ fn load_bytes_now(
|
||||
stages.push(("highlights", t0.elapsed()));
|
||||
colour.apply(&mut img);
|
||||
stages.push(("colour", t0.elapsed()));
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||||
// a full-size render keeps the picture (fitting it to its own size would copy it, ~390 MB at 33 MP)
|
||||
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Box) } else { img };
|
||||
stages.push(("fit", t0.elapsed()));
|
||||
let img = img.into_oriented(raw.orientation);
|
||||
stages.push(("orient", t0.elapsed()));
|
||||
@@ -622,11 +623,16 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
|
||||
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
|
||||
}
|
||||
|
||||
/// What stands in for a raw file we can't decode: its largest embedded JPEG, else, for a TIFF-based
|
||||
/// raw, the file's own first image when the TIFF reader can show it (a reduced RGB copy: the only
|
||||
/// preview some containers carry). Returns image bytes for [`lightcraft_codecs::decode`].
|
||||
fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
|
||||
if let Some(jpeg) = lightcraft_raw::embedded_preview(bytes) {
|
||||
/// What stands in for a raw file we can't decode: its largest embedded JPEG (with `covering`, the smallest one that is
|
||||
/// at least that big, [`lightcraft_raw::embedded_preview_covering`]), else, for a TIFF-based raw, the file's own first
|
||||
/// image when the TIFF reader can show it (a reduced RGB copy: the only preview some containers carry). Returns image
|
||||
/// bytes for [`lightcraft_codecs::decode`].
|
||||
fn stand_in_image(bytes: &[u8], covering: Option<usize>) -> Option<std::borrow::Cow<'_, [u8]>> {
|
||||
let jpeg = match covering {
|
||||
Some(edge) => lightcraft_raw::embedded_preview_covering(bytes, u32::try_from(edge).unwrap_or(u32::MAX)),
|
||||
None => lightcraft_raw::embedded_preview(bytes),
|
||||
};
|
||||
if let Some(jpeg) = jpeg {
|
||||
return Some(std::borrow::Cow::Owned(jpeg));
|
||||
}
|
||||
let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported());
|
||||
@@ -634,9 +640,14 @@ fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
|
||||
.then_some(std::borrow::Cow::Borrowed(bytes))
|
||||
}
|
||||
|
||||
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
|
||||
pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = stand_in_image(bytes)?;
|
||||
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting (`covering`: see
|
||||
/// [`stand_in_image`]).
|
||||
pub(crate) fn decode_raw_preview(
|
||||
bytes: &[u8],
|
||||
opts: lightcraft_codecs::DecodeOptions,
|
||||
covering: Option<usize>,
|
||||
) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = stand_in_image(bytes, covering)?;
|
||||
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
|
||||
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
|
||||
@@ -653,7 +664,7 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp
|
||||
|
||||
/// Oriented size of the embedded preview of a raw file.
|
||||
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let jpeg = stand_in_image(bytes)?;
|
||||
let jpeg = stand_in_image(bytes, None)?;
|
||||
// headers only, as for other images (issue #367): decoding a full-size preview just for its
|
||||
// size took about a second per file in an import; a decode remains the fallback
|
||||
let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) {
|
||||
@@ -671,16 +682,18 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
|
||||
/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented.
|
||||
pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> {
|
||||
let d = decode_raw_preview(bytes, fit_box(max_edge))?;
|
||||
// always the largest JPEG: a source's geometry must not depend on its size level (previews match exports)
|
||||
let d = decode_raw_preview(bytes, fit_box(max_edge), None)?;
|
||||
let img = d.to_working();
|
||||
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
|
||||
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
|
||||
}
|
||||
|
||||
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
|
||||
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
|
||||
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). The smallest
|
||||
/// embedded JPEG that covers `max_edge` is decoded, not the largest.
|
||||
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let mut d = decode_raw_preview(bytes, fit_box(max_edge))?;
|
||||
let mut d = decode_raw_preview(bytes, fit_box(max_edge), Some(max_edge))?;
|
||||
if d.image.width.max(d.image.height) > max_edge {
|
||||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||||
d.alpha = None;
|
||||
@@ -708,7 +721,11 @@ pub fn fs_preview_loader() -> PreviewLoader {
|
||||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||||
let gate = crate::memory::work_gate();
|
||||
let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) };
|
||||
if let Some(image) = preview_window(path).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge)) {
|
||||
if let Some(image) = preview_window(path, max_edge)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|(header, window, read)| quick_image_srgb(&header, &window, read.adobe_rgb, max_edge))
|
||||
{
|
||||
return Some(image);
|
||||
}
|
||||
embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)
|
||||
@@ -729,6 +746,9 @@ pub struct PreviewRead {
|
||||
/// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on
|
||||
/// the header path, and on the whole-file fallback).
|
||||
pub planned: bool,
|
||||
/// Adobe RGB when the container says the preview is in it and the JPEG doesn't say otherwise
|
||||
/// (`decode_raw_preview` applies the same declaration): the quick decode applies it too.
|
||||
pub adobe_rgb: bool,
|
||||
}
|
||||
|
||||
impl PreviewRead {
|
||||
@@ -743,7 +763,9 @@ impl PreviewRead {
|
||||
|
||||
/// The file's header and its embedded preview, read without the rest of the file: `(header bytes,
|
||||
/// preview bytes, what was read)`. `None` when the header points at no preview this build can find,
|
||||
/// which is the caller's cue to read the file whole.
|
||||
/// which is the caller's cue to read the file whole. `max_edge` is the size the preview is for: where a file keeps
|
||||
/// several JPEGs (the plan route), the window is the one [`embedded_preview_srgb`] decodes for that size, the smallest
|
||||
/// that covers it ([`lightcraft_raw::embedded_preview_covering`]).
|
||||
///
|
||||
/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`),
|
||||
/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten
|
||||
@@ -751,7 +773,7 @@ impl PreviewRead {
|
||||
/// still needs the whole container, so the header comes back with the window; for a preview taken
|
||||
/// out of a container that carries no preview colour space of its own (Nikon's maker note) the
|
||||
/// whole-file fallback stays in charge.
|
||||
pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
pub fn preview_window(path: &str, max_edge: usize) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
@@ -773,7 +795,7 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
|
||||
// preview tag sits past the prefix, the containers whose preview only a scan finds: ask the
|
||||
// sparse planner which ranges `embedded_preview` reads before handing the caller the
|
||||
// whole-file fallback.
|
||||
return planned_preview(&mut file, path, total);
|
||||
return planned_preview(&mut file, path, total, max_edge);
|
||||
};
|
||||
// A preview at the file's end may have been padded past it: clamp rather than fail.
|
||||
let end = range.offset.saturating_add(range.window_len).min(total);
|
||||
@@ -796,7 +818,7 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
|
||||
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false };
|
||||
let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false, adobe_rgb: false };
|
||||
// On a network share the bytes are the wait, so say what this preview actually cost. A window
|
||||
// taken from the prefix cost nothing beyond the prefix itself.
|
||||
if lightcraft_pipeline::profiling() {
|
||||
@@ -838,7 +860,7 @@ const PLAN_PREFIX: u64 = 64 * 1024;
|
||||
/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded
|
||||
/// gives what the whole file gives), so a window that still fails to decode ends in the same
|
||||
/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture.
|
||||
fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
fn planned_preview(file: &mut std::fs::File, path: &str, total: u64, max_edge: usize) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
if total > MAX_PREVIEW_PLAN_BYTES {
|
||||
@@ -867,19 +889,20 @@ fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<O
|
||||
}
|
||||
}
|
||||
lightcraft_raw::sparse::HeaderPlan::Ready => {
|
||||
let Some(window) = lightcraft_raw::embedded_preview(&buf) else { return Ok(None) };
|
||||
// the plan loads every preview's strips, so the JPEG picked for `max_edge` is the whole file's pick
|
||||
let edge = u32::try_from(max_edge).unwrap_or(u32::MAX);
|
||||
let Some(window) = lightcraft_raw::embedded_preview_covering(&buf, edge) else { return Ok(None) };
|
||||
// The quick path hands the JPEG to the decoder alone, so what the *container* says
|
||||
// about the preview has to be something the bytes it is handed can show. Two container
|
||||
// facts change the pixels and are not in the JPEG: the preview's colour space (which
|
||||
// the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries
|
||||
// no orientation of its own, the file's EXIF orientation. Where either is present and
|
||||
// the header cannot answer for it, the whole-file path keeps the preview — exactly as
|
||||
// it already does for a container-declared colour space. A container that says sRGB
|
||||
// agrees with the JPEG-only decode (its own declaration, else sRGB), so only the other
|
||||
// space declines.
|
||||
if lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb) {
|
||||
return Ok(None);
|
||||
}
|
||||
// no orientation of its own, the file's EXIF orientation. The colour space is read
|
||||
// here and handed on ([`PreviewRead::adobe_rgb`]: a Sony α1 writes Adobe RGB previews,
|
||||
// which used to send each of them to the whole-file path, issue #1440); a container
|
||||
// that says sRGB agrees with the JPEG-only decode (its own declaration, else sRGB).
|
||||
// Where the orientation is present and the header cannot answer for it, the
|
||||
// whole-file path keeps the preview.
|
||||
let adobe_rgb = lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb);
|
||||
// `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side
|
||||
// first: a JPEG that carries its own orientation needs nothing from the container.
|
||||
if lightcraft_meta::extract(&window).orientation.is_none()
|
||||
@@ -888,7 +911,7 @@ fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<O
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true };
|
||||
let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true, adobe_rgb };
|
||||
// A plan-driven preview is the one that used to cost the whole file, so it is worth
|
||||
// saying what it cost now too.
|
||||
if lightcraft_pipeline::profiling() {
|
||||
@@ -937,9 +960,15 @@ fn raf_preview_extent(prefix: &[u8]) -> Option<u64> {
|
||||
|
||||
/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of
|
||||
/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container.
|
||||
/// `None` when the bytes are not a decodable image.
|
||||
fn quick_image_srgb(header: &[u8], window: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let mut d = lightcraft_codecs::decode(window, fit_box(max_edge)).ok()?;
|
||||
/// `adobe_rgb`: the container declares the preview Adobe RGB ([`PreviewRead::adobe_rgb`]), applied
|
||||
/// as [`decode_raw_preview`] applies it. `None` when the bytes are not a decodable image.
|
||||
fn quick_image_srgb(header: &[u8], window: &[u8], adobe_rgb: bool, max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let decoded = if adobe_rgb && window.starts_with(&[0xff, 0xd8]) {
|
||||
lightcraft_codecs::decode_jpeg_with_fallback(window, fit_box(max_edge), lightcraft_codecs::NamedSpace::AdobeRgb)
|
||||
} else {
|
||||
lightcraft_codecs::decode(window, fit_box(max_edge))
|
||||
};
|
||||
let mut d = decoded.ok()?;
|
||||
if d.image.width.max(d.image.height) > max_edge {
|
||||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||||
d.alpha = None;
|
||||
@@ -1125,7 +1154,7 @@ mod tests {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let max_edge = 512;
|
||||
|
||||
let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
|
||||
let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("it has a preview");
|
||||
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
|
||||
assert!(read.header_path, "the header path is what answered");
|
||||
assert!(!read.planned, "a RAF pointer in the header needs no planner");
|
||||
@@ -1134,7 +1163,7 @@ mod tests {
|
||||
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
|
||||
|
||||
// The quick path and the whole-file path agree byte for byte.
|
||||
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
|
||||
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview");
|
||||
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
|
||||
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
|
||||
assert_eq!(quick.data, whole.data, "byte for byte");
|
||||
@@ -1142,8 +1171,8 @@ mod tests {
|
||||
// A file with no locatable preview declines (the caller then reads it whole), a missing file errors.
|
||||
let plain = dir.join("plain.jpg");
|
||||
std::fs::write(&plain, &jpeg).unwrap();
|
||||
assert!(preview_window(&plain.to_string_lossy()).expect("a plain file reads").is_none());
|
||||
assert!(preview_window(&dir.join("nope.raf").to_string_lossy()).is_err());
|
||||
assert!(preview_window(&plain.to_string_lossy(), max_edge).expect("a plain file reads").is_none());
|
||||
assert!(preview_window(&dir.join("nope.raf").to_string_lossy(), max_edge).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1163,6 +1192,12 @@ mod tests {
|
||||
/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than
|
||||
/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach.
|
||||
fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec<u8> {
|
||||
tiff_with_preview_strips(strip, &[], tail, what)
|
||||
}
|
||||
|
||||
/// [`tiff_with_preview_strip`] that also keeps the `smaller` JPEGs in sub-IFDs of IFD0, as an ARW keeps its
|
||||
/// 1616 px preview beside the full-size one.
|
||||
fn tiff_with_preview_strips(strip: &[u8], smaller: &[&[u8]], tail: usize, what: Fixture) -> Vec<u8> {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let mut preview = IfdBuilder::new();
|
||||
if what.dng {
|
||||
@@ -1189,6 +1224,15 @@ mod tests {
|
||||
preview.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
|
||||
preview.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
|
||||
preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] });
|
||||
for jpeg in smaller {
|
||||
let mut sub = IfdBuilder::new();
|
||||
sub.set(t::COMPRESSION, Value::Short(vec![7]));
|
||||
sub.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
|
||||
sub.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
|
||||
sub.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
|
||||
sub.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![jpeg.to_vec()] });
|
||||
preview.add_sub_ifd(sub);
|
||||
}
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
|
||||
@@ -1216,7 +1260,7 @@ mod tests {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let max_edge = 512;
|
||||
|
||||
let (header, window, read) = preview_window(&path).expect("the file reads").expect("the plan locates its preview");
|
||||
let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("the plan locates its preview");
|
||||
assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded");
|
||||
assert!(read.planned, "the planner is what answered");
|
||||
assert!(read.header_path, "and it is still a bounded read");
|
||||
@@ -1225,7 +1269,7 @@ mod tests {
|
||||
assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len());
|
||||
|
||||
// The plan path and the whole-file path agree byte for byte.
|
||||
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
|
||||
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview");
|
||||
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
|
||||
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
|
||||
assert_eq!(quick.data, whole.data, "byte for byte");
|
||||
@@ -1234,7 +1278,39 @@ mod tests {
|
||||
// caller's whole-file read is what answers (no guessing at what a file might hold).
|
||||
let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default());
|
||||
std::fs::write(&path, ¬_a_raw).unwrap();
|
||||
assert!(preview_window(&path).expect("the file reads").is_none(), "an unknown container falls back");
|
||||
assert!(preview_window(&path, max_edge).expect("the file reads").is_none(), "an unknown container falls back");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A raw that keeps several JPEGs is shown, on the plan route as on the whole-file path, from the smallest one that
|
||||
/// covers the box (`embedded_preview_covering`). The plan route used to hand on the largest whatever the size: an
|
||||
/// A7R VI ARW's 9984 × 6656 JPEG for a 512 px thumbnail instead of its 1616 × 1080 one.
|
||||
#[test]
|
||||
fn preview_loading_plans_the_smallest_covering_jpeg() {
|
||||
let jpeg = |w: u32, h: u32| {
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 31 % 247) as u8).collect();
|
||||
encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&rgb)), 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap()
|
||||
};
|
||||
let (big, small) = (jpeg(96, 64), jpeg(48, 32));
|
||||
assert!(big.len() > small.len(), "the full-size JPEG is the largest");
|
||||
let file = tiff_with_preview_strips(&big, &[&small], 400_000, Fixture { dng: true, ..Default::default() });
|
||||
let dir = crate::tests_xmp::temp_dir("preview-plan-covering");
|
||||
let path = dir.join("two-previews.dng");
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
|
||||
for (edge, want, which) in [(48, &small, "small"), (49, &big, "full-size"), (usize::MAX, &big, "full-size")] {
|
||||
let (header, window, read) = preview_window(&path, edge).expect("the file reads").expect("the plan locates its preview");
|
||||
assert!(read.planned, "the planner is what answered");
|
||||
assert!(&window == want, "a {edge} px box takes the {which} JPEG");
|
||||
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, edge).expect("the window holds a decodable preview");
|
||||
let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too");
|
||||
assert_eq!(
|
||||
(quick.width, quick.height, quick.data),
|
||||
(whole.width, whole.height, whole.data),
|
||||
"{edge} px: the plan and the whole file agree"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1262,17 +1338,17 @@ mod tests {
|
||||
assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation");
|
||||
assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see");
|
||||
assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own");
|
||||
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path keeps it");
|
||||
assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path keeps it");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A container can declare what its preview JPEG is in, and the whole-file path applies that
|
||||
/// declaration (`decode_raw_preview`). A JPEG-only decode has the JPEG's own space, else sRGB, so
|
||||
/// a container that declares Adobe RGB must keep the whole-file path — while one that declares
|
||||
/// sRGB (what most bodies write) must **not** take the fast path away: that half is a regression
|
||||
/// test, since a guard that declined every declared space did exactly that to a real Sony ARW.
|
||||
/// declaration (`decode_raw_preview`). The planned path reads it too and the quick decode applies
|
||||
/// it, so a container that declares Adobe RGB (a Sony α1's ARW) is read in part like any other and
|
||||
/// shows the same pixels as the whole-file path (issue #1440; it used to be read whole) — and one
|
||||
/// that declares sRGB (what most bodies write) keeps the fast path as before.
|
||||
#[test]
|
||||
fn preview_loading_declines_only_a_container_colour_space_it_cannot_apply() {
|
||||
fn preview_loading_applies_a_container_colour_space() {
|
||||
use lightcraft_raw::PreviewColorSpace;
|
||||
let (w, h) = (48u32, 32u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect();
|
||||
@@ -1285,13 +1361,20 @@ mod tests {
|
||||
let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() });
|
||||
assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB");
|
||||
std::fs::write(&path, &adobe).unwrap();
|
||||
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path applies the space");
|
||||
let (header, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("the plan answers for Adobe RGB too");
|
||||
assert!(read.planned && read.adobe_rgb, "{read:?}");
|
||||
assert!(read.fraction() < 0.5, "the sensor strip is not read: {read:?}");
|
||||
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, 512).expect("the window decodes");
|
||||
let whole = embedded_preview_srgb(&adobe, 512).expect("the whole file decodes too");
|
||||
assert_eq!(quick.data, whole.data, "the space is applied as the whole-file path applies it");
|
||||
let plain = quick_image_srgb(&header, &window, false, 512).expect("the window decodes");
|
||||
assert_ne!(plain.data, whole.data, "and it changes the pixels (the fixture's colours are saturated enough)");
|
||||
|
||||
let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() });
|
||||
assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB");
|
||||
std::fs::write(&path, &srgb).unwrap();
|
||||
let (_, window, read) = preview_window(&path).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
|
||||
assert!(read.planned, "the plan still answers");
|
||||
let (_, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
|
||||
assert!(read.planned && !read.adobe_rgb, "the plan still answers");
|
||||
assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG");
|
||||
}
|
||||
|
||||
@@ -2296,4 +2379,25 @@ mod tests {
|
||||
assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #1448: the grid renders nothing behind an unedited raw's quick thumbnail, so when the
|
||||
/// embedded preview can't be read (and nothing is cached) the quick job itself must render the
|
||||
/// raw, or the cell stays empty for good ("no quick preview").
|
||||
#[test]
|
||||
fn a_quick_thumbnail_without_an_embedded_preview_renders_the_raw() {
|
||||
use crate::media::QuickSource;
|
||||
let dir = std::env::temp_dir().join(format!("lc-quick-none-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("a.cr3");
|
||||
std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
|
||||
s.media.preview_loader = Some(std::sync::Arc::new(|_: &str, _| None));
|
||||
let job = s.thumb_job(id, 128).unwrap();
|
||||
let q = s.quick_thumb_job(&job).unwrap().run();
|
||||
assert_eq!((q.quick, q.key), (Some(QuickSource::Small), job.key));
|
||||
assert!(q.rendered.is_ok(), "{:?}", q.rendered.err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
+103
-45
@@ -548,12 +548,62 @@ fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>
|
||||
pub struct ScanInput {
|
||||
probe: Option<crate::media::FileProbe>,
|
||||
skip: Option<PathBuf>,
|
||||
/// path → (photo, its summary) for files already in the library.
|
||||
by_path: HashMap<PathBuf, (PhotoId, ImportCandidate)>,
|
||||
by_hash: HashMap<String, PhotoId>,
|
||||
/// The library as it is now (photos are shared with the session, not copied): looked up for
|
||||
/// the files the scan finds ([`library_files`]) and the contents it probes
|
||||
/// ([`library_hashes`]), on the scan's thread.
|
||||
library: std::sync::Arc<lightcraft_catalog::Catalog>,
|
||||
cache: HashMap<String, ProbeInfo>,
|
||||
}
|
||||
|
||||
/// The library's photos of `files`: path → (photo, its summary as a candidate that is there
|
||||
/// already). One pass over the library, and a record only for a file that was found: a folder of
|
||||
/// a hundred files in a library of millions costs a hundred records.
|
||||
fn library_files(library: &lightcraft_catalog::Catalog, files: &[String]) -> HashMap<PathBuf, (PhotoId, ImportCandidate)> {
|
||||
let found: std::collections::HashSet<&Path> = files.iter().map(Path::new).collect();
|
||||
let mut by_path = HashMap::new();
|
||||
if found.is_empty() {
|
||||
return by_path;
|
||||
}
|
||||
for p in library.photos() {
|
||||
if let Source::File { path } = &p.source
|
||||
&& found.contains(Path::new(path))
|
||||
{
|
||||
let c = ImportCandidate {
|
||||
path: path.clone(),
|
||||
format: p.format.clone(),
|
||||
kind: p.kind,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
file_size: p.file_size,
|
||||
captured: p.captured.clone(),
|
||||
duplicate: Some("path".into()),
|
||||
existing: Some(p.id.0),
|
||||
..Default::default()
|
||||
};
|
||||
by_path.insert(PathBuf::from(path), (p.id, c));
|
||||
}
|
||||
}
|
||||
by_path
|
||||
}
|
||||
|
||||
/// Which photo of the library has each of the contents `hashes` (the later one where several
|
||||
/// have it). One pass over the library, and an entry only for a content that was asked about.
|
||||
fn library_hashes<'a>(library: &lightcraft_catalog::Catalog, hashes: impl Iterator<Item = &'a str>) -> HashMap<String, PhotoId> {
|
||||
let wanted: std::collections::HashSet<&str> = hashes.collect();
|
||||
let mut by_hash = HashMap::new();
|
||||
if wanted.is_empty() {
|
||||
return by_hash;
|
||||
}
|
||||
for p in library.photos() {
|
||||
if let Some(h) = &p.content_hash
|
||||
&& wanted.contains(h.as_str())
|
||||
{
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
}
|
||||
by_hash
|
||||
}
|
||||
|
||||
/// Progress and cancellation of a running [`scan_with`].
|
||||
#[derive(Default)]
|
||||
pub struct ScanProgress {
|
||||
@@ -573,31 +623,17 @@ pub struct ScanOutput {
|
||||
impl ScanInput {
|
||||
pub fn new(s: &mut Session, paths: &[String]) -> (Self, Vec<String>) {
|
||||
let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
let mut by_path = HashMap::new();
|
||||
let mut by_hash = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
let c = ImportCandidate {
|
||||
path: path.clone(),
|
||||
format: p.format.clone(),
|
||||
kind: p.kind,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
file_size: p.file_size,
|
||||
captured: p.captured.clone(),
|
||||
duplicate: Some("path".into()),
|
||||
existing: Some(p.id.0),
|
||||
..Default::default()
|
||||
};
|
||||
by_path.insert(PathBuf::from(path), (p.id, c));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
}
|
||||
let input = ScanInput { probe: s.media.file_probe.clone(), skip, by_path, by_hash, cache: std::mem::take(&mut s.import_probes) };
|
||||
// (no table of the library here: this is the session's thread, and the library may hold
|
||||
// millions of photos. The scan looks up what it finds)
|
||||
let library = std::sync::Arc::new(s.catalog.clone());
|
||||
let input = ScanInput { probe: s.media.file_probe.clone(), skip, library, cache: std::mem::take(&mut s.import_probes) };
|
||||
(input, paths.to_vec())
|
||||
}
|
||||
|
||||
/// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one).
|
||||
pub(crate) fn library(&self) -> std::sync::Arc<lightcraft_catalog::Catalog> {
|
||||
std::sync::Arc::clone(&self.library)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content
|
||||
@@ -615,7 +651,8 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
|
||||
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let files = expand(paths, input.skip.as_deref());
|
||||
let todo: Vec<String> = files.iter().filter(|f| !input.by_path.contains_key(Path::new(f))).cloned().collect();
|
||||
let by_path = library_files(&input.library, &files);
|
||||
let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect();
|
||||
progress.total.store(todo.len(), Relaxed);
|
||||
// probes from a preceding `scan` are reused when the file is unchanged (same size)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo
|
||||
@@ -633,11 +670,14 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
let mut probes: HashMap<String, Result<ProbeInfo, String>> = todo.into_iter().zip(probed).collect();
|
||||
let mut seen_hash: HashMap<String, Option<u64>> = input.by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
|
||||
// (who in the library has a content that was probed: asked once the probes are in)
|
||||
let probed_hashes = probes.values().filter_map(|r| r.as_ref().ok()).filter_map(|info| info.content_hash.as_deref());
|
||||
let mut seen_hash: HashMap<String, Option<u64>> =
|
||||
library_hashes(&input.library, probed_hashes).into_iter().map(|(h, id)| (h, Some(id.0))).collect();
|
||||
let mut out = Vec::with_capacity(files.len());
|
||||
let mut kept = HashMap::new();
|
||||
for f in files {
|
||||
if let Some((_, c)) = input.by_path.get(Path::new(&f)) {
|
||||
if let Some((_, c)) = by_path.get(Path::new(&f)) {
|
||||
let mut c = c.clone();
|
||||
c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
|
||||
out.push(c);
|
||||
@@ -701,6 +741,12 @@ pub struct ImportJob {
|
||||
mode: ImportMode,
|
||||
lib_dir: Option<PathBuf>,
|
||||
copy_root: Option<PathBuf>,
|
||||
/// The library as it was when the import was set up, until it is looked up
|
||||
/// ([`ImportJob::look_up_library`], with the first files): setting an import up is the
|
||||
/// session's thread, and a table of a library of millions of photos takes seconds. With it,
|
||||
/// whether a photo in Recently Deleted is passed over (an import that replaces such photos,
|
||||
/// as the options said when the import was set up).
|
||||
library: Option<(lightcraft_catalog::Catalog, bool)>,
|
||||
/// path → (photo, a Local browse record?)
|
||||
by_path: HashMap<String, (PhotoId, bool)>,
|
||||
/// content hash → the photo that has it (`None`: a file earlier in this import)
|
||||
@@ -842,28 +888,16 @@ impl ImportJob {
|
||||
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
|
||||
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
|
||||
}
|
||||
let mut by_path = HashMap::new();
|
||||
let mut by_hash = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
// a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
|
||||
if p.deleted && opts.on_deleted == OnDeleted::Fresh {
|
||||
continue;
|
||||
}
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.clone(), (p.id, p.local));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), Some(p.id));
|
||||
}
|
||||
}
|
||||
let fresh = opts.on_deleted == OnDeleted::Fresh;
|
||||
Ok(ImportJob {
|
||||
seq: opts.rename_start.max(1),
|
||||
opts,
|
||||
mode,
|
||||
lib_dir,
|
||||
copy_root,
|
||||
by_path,
|
||||
by_hash,
|
||||
library: Some((s.catalog.clone(), fresh)),
|
||||
by_path: HashMap::new(),
|
||||
by_hash: HashMap::new(),
|
||||
probe: s.media.file_probe.clone(),
|
||||
file_bytes: s.media.file_bytes.clone(),
|
||||
cache: std::mem::take(&mut s.import_probes),
|
||||
@@ -898,9 +932,28 @@ impl ImportJob {
|
||||
&self.now
|
||||
}
|
||||
|
||||
/// What the library held when the import was set up, by path and by content: made once, with
|
||||
/// the first files (on the thread that prepares them), from the library as it was then.
|
||||
fn look_up_library(&mut self) {
|
||||
let Some((library, fresh)) = self.library.take() else { return };
|
||||
for p in library.photos() {
|
||||
// a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
|
||||
if p.deleted && fresh {
|
||||
continue;
|
||||
}
|
||||
if let Source::File { path } = &p.source {
|
||||
self.by_path.insert(path.clone(), (p.id, p.local));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
self.by_hash.insert(h.clone(), Some(p.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
|
||||
pub fn prepare_files(&mut self, files: Vec<String>, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
self.look_up_library();
|
||||
let opts = self.opts.clone();
|
||||
let mode = self.mode;
|
||||
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
|
||||
@@ -1479,6 +1532,7 @@ impl Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
preview_cut: None,
|
||||
};
|
||||
let embedded = match (&self.media.preview_loader, c.kind) {
|
||||
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
|
||||
@@ -1494,6 +1548,10 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "import/tests_known.rs"]
|
||||
mod tests_known;
|
||||
|
||||
#[cfg(test)]
|
||||
mod prepared_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! What a scan or an import knows of the library: worked out for the files it meets, and not on
|
||||
//! the session's thread. A library can hold millions of photos; looking at one folder must not
|
||||
//! cost a table of them all before the first file is read.
|
||||
use super::*;
|
||||
use lightcraft_catalog::{Catalog, Op};
|
||||
|
||||
/// A library of `folders` folders of three photos each, the third a copy (by content) of a
|
||||
/// photo in the folder before.
|
||||
fn library(folders: usize) -> Catalog {
|
||||
let mut c = Catalog::new();
|
||||
for folder in 0..folders {
|
||||
for (n, name) in ["a.jpg", "b.jpg", "c.jpg"].into_iter().enumerate() {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder:03}/{name}") }, name, "JPEG", 6, 4, "2026-01-01");
|
||||
p.content_hash = Some(format!("{:032x}", if n == 2 { folder.saturating_sub(1) * 3 } else { folder * 3 + n }));
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Given a library of many folders, when a scan meets the files of one of them (and one the
|
||||
/// library does not have), then what it knows of the library is those files' photos, each as the
|
||||
/// library has it, and nothing of the other folders.
|
||||
#[test]
|
||||
fn a_scan_knows_the_library_photos_of_the_files_it_found() {
|
||||
let c = library(100);
|
||||
let files: Vec<String> = ["/pics/007/a.jpg", "/pics/007/b.jpg", "/pics/007/new.jpg", "/pics/007//c.jpg"].map(String::from).to_vec();
|
||||
let known = library_files(&c, &files);
|
||||
assert_eq!(known.len(), 3, "the three the library has (a path is compared as a path, not as text)");
|
||||
let (id, as_found) = known.get(Path::new("/pics/007/b.jpg")).expect("b");
|
||||
let p = c.photo(*id).unwrap();
|
||||
assert_eq!((as_found.path.as_str(), as_found.existing, as_found.duplicate.as_deref()), ("/pics/007/b.jpg", Some(id.0), Some("path")));
|
||||
assert_eq!((as_found.format.as_str(), as_found.width, as_found.height, as_found.file_size), (p.format.as_str(), p.width, p.height, p.file_size));
|
||||
assert!(library_files(&c, &[]).is_empty());
|
||||
// two photos of one file (a virtual copy): the later one is the one named, as a table of the
|
||||
// whole library named it
|
||||
let mut c = c;
|
||||
let copy = c.alloc_photo_id();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(Photo::new(copy, Source::File { path: "/pics/007/a.jpg".into() }, "a.jpg", "JPEG", 6, 4, "2026-01-02")) })
|
||||
.unwrap();
|
||||
assert_eq!(library_files(&c, &files).get(Path::new("/pics/007/a.jpg")).map(|(id, _)| *id), Some(copy));
|
||||
}
|
||||
|
||||
/// Given a library and a folder on disk that holds a file the library has by its path, one it
|
||||
/// has by its content under another name, and a new one, when the folder is scanned (the whole
|
||||
/// scan, as the import review runs it), then each is found for what it is.
|
||||
#[test]
|
||||
fn a_scan_finds_what_the_library_has_by_path_and_by_content() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-known-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
|
||||
for name in ["here.jpg", "copy.jpg", "new.jpg"] {
|
||||
std::fs::write(dir.join(name), name.as_bytes()).unwrap();
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.catalog = library(20);
|
||||
let here = s.catalog.alloc_photo_id();
|
||||
s.catalog
|
||||
.apply(Op::AddPhoto { photo: Box::new(Photo::new(here, Source::File { path: at("here.jpg") }, "here.jpg", "JPEG", 6, 4, "2026-01-01")) })
|
||||
.unwrap();
|
||||
// (the probe: every file is a picture whose content is its name; `copy.jpg` has the content of a library photo)
|
||||
let had = s.catalog.photos().find(|p| p.file_name == "b.jpg").map(|p| (p.id, p.content_hash.clone().unwrap())).unwrap();
|
||||
let shared = had.1.clone();
|
||||
s.media.file_probe = Some(std::sync::Arc::new(move |path: &str| {
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let content_hash = Some(if name == "copy.jpg" { shared.clone() } else { format!("hash-of-{name}") });
|
||||
Ok(ProbeInfo { format: "JPEG".into(), width: 6, height: 4, file_size: 8, content_hash, ..Default::default() })
|
||||
}));
|
||||
let found = scan(&mut s, &[dir.to_string_lossy().to_string()]);
|
||||
let of = |name: &str| found.iter().find(|c| c.name == name).map(|c| (c.duplicate.as_deref(), c.existing)).unwrap();
|
||||
assert_eq!(of("here.jpg"), (Some("path"), Some(here.0)));
|
||||
assert_eq!(of("copy.jpg"), (Some("content"), Some(had.0.0)));
|
||||
assert_eq!(of("new.jpg"), (None, None));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Given the hashes of the files a scan probed, when the library is asked who has them, then it
|
||||
/// answers for those hashes only; and where two photos have one content, the same one of them
|
||||
/// as a table of the whole library would name (the later one).
|
||||
#[test]
|
||||
fn a_scan_knows_who_has_the_contents_it_probed() {
|
||||
let c = library(100);
|
||||
// (the content of /pics/006/a.jpg, which /pics/007/c.jpg has too; and one nobody has)
|
||||
let shared = format!("{:032x}", 6 * 3);
|
||||
let nobody = format!("{:032x}", 999_999);
|
||||
let known = library_hashes(&c, [shared.as_str(), nobody.as_str()].into_iter());
|
||||
assert_eq!(known.len(), 1);
|
||||
let whole: HashMap<String, PhotoId> = c.photos().filter_map(|p| p.content_hash.clone().map(|h| (h, p.id))).collect();
|
||||
assert_eq!(known.get(&shared), whole.get(&shared));
|
||||
assert!(matches!(&c.photo(known[&shared]).unwrap().source, Source::File { path } if path == "/pics/007/c.jpg"));
|
||||
}
|
||||
|
||||
/// Given a session with a library, when an import is set up, then no table of the library is
|
||||
/// made on the spot (that is the session's thread); it is made when the first files are
|
||||
/// prepared, and then finds a file the library has by its path and one it has by its content.
|
||||
#[test]
|
||||
fn an_import_looks_the_library_up_when_it_prepares_files() {
|
||||
let mut s = Session::new();
|
||||
s.catalog = library(50);
|
||||
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
|
||||
assert!(job.by_path.is_empty() && job.by_hash.is_empty() && job.library.is_some(), "nothing is looked up yet");
|
||||
let out = job.prepare_files(vec!["/pics/007/a.jpg".into()], &std::sync::atomic::AtomicBool::new(false));
|
||||
assert!(job.library.is_none(), "looked up once");
|
||||
assert_eq!((job.by_path.len(), job.by_hash.len()), (150, 100));
|
||||
assert!(matches!(out.items.as_slice(), [PreparedItem::Duplicate { reason: "path", existing: Some(_), .. }]), "found by its path");
|
||||
// what the options said when the import was set up decides, whatever is made of them after
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
s.catalog.apply(Op::SetDeleted { id, deleted: true }).unwrap();
|
||||
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, on_deleted: OnDeleted::Fresh, ..ImportOptions::default() }).unwrap();
|
||||
job.opts.on_deleted = OnDeleted::Skip;
|
||||
job.prepare_files(Vec::new(), &std::sync::atomic::AtomicBool::new(false));
|
||||
assert_eq!(job.by_path.len(), 149, "the trashed photo was passed over, as set up");
|
||||
}
|
||||
|
||||
/// Given a photo in Recently Deleted and an import that replaces such photos with a fresh
|
||||
/// import, when the library is looked up, then that photo is no duplicate (as before the lookup
|
||||
/// moved to the first files).
|
||||
#[test]
|
||||
fn a_trashed_photo_is_not_looked_up_when_imports_replace_it() {
|
||||
let mut s = Session::new();
|
||||
s.catalog = library(2);
|
||||
// (a photo whose content is its own: nobody else in this library has it)
|
||||
let id = s.catalog.photos().find(|p| p.file_name == "b.jpg").unwrap().id;
|
||||
s.catalog.apply(Op::SetDeleted { id, deleted: true }).unwrap();
|
||||
for (on_deleted, expected) in [(OnDeleted::Fresh, 5), (OnDeleted::Skip, 6)] {
|
||||
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, on_deleted, ..ImportOptions::default() }).unwrap();
|
||||
job.prepare_files(Vec::new(), &std::sync::atomic::AtomicBool::new(false));
|
||||
assert_eq!(job.by_path.len(), expected, "{on_deleted:?}");
|
||||
let its_content = s.catalog.photo(id).and_then(|p| p.content_hash.clone()).unwrap();
|
||||
assert_eq!(job.by_hash.contains_key(&its_content), on_deleted == OnDeleted::Skip, "{on_deleted:?}");
|
||||
}
|
||||
}
|
||||
@@ -223,11 +223,12 @@ pub(crate) fn rollback(p: &Placed) {
|
||||
}
|
||||
|
||||
/// Remove a placed photo's source (its catalog record is committed and saved): only when the
|
||||
/// destination is still there with the same size. Then its sidecars, unless another photo still
|
||||
/// destination is still there with the same bytes. Then its sidecars, unless another photo still
|
||||
/// uses a shared one. `Err` = the source was kept (why); `Ok` lists sidecars kept (path, why).
|
||||
pub(crate) fn finish(p: &Placed) -> Result<Vec<(String, String)>, String> {
|
||||
// compare the bytes now: either file may have changed since the copy was verified
|
||||
let same = match (fs::metadata(&p.src), fs::metadata(&p.dst)) {
|
||||
(Ok(s), Ok(d)) => s.is_file() && d.is_file() && s.len() == d.len(),
|
||||
(Ok(s), Ok(d)) => s.is_file() && d.is_file() && same_bytes(&p.src, &p.dst).unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
|
||||
@@ -29,8 +29,10 @@ 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;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod faces_index;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -68,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;
|
||||
@@ -98,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),
|
||||
}
|
||||
@@ -1001,6 +1011,9 @@ mod tests_face_models;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests_face_recognize;
|
||||
#[cfg(test)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests_face_tiles;
|
||||
#[cfg(test)]
|
||||
mod tests_filesystem_history;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
|
||||
@@ -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,14 @@ 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());
|
||||
}
|
||||
let mut data =
|
||||
lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done).map_err(error)?;
|
||||
// (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(|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 +266,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 +289,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 +442,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();
|
||||
|
||||
@@ -395,8 +395,10 @@ fn validate_header_fields(header: &[u8], page_size: usize) -> Result<usize, Stri
|
||||
if usable < 480 {
|
||||
return Err("SQLite usable page size is below 480 bytes".into());
|
||||
}
|
||||
// 0 = no schema yet (file format §1.3.10): a catalog whose tables are all still in its WAL
|
||||
// has that in the main file, and the WAL's own page 1 is checked again with the overlay
|
||||
let schema_format = be_u32(header, 44)?;
|
||||
if !(1..=4).contains(&schema_format) {
|
||||
if !(0..=4).contains(&schema_format) {
|
||||
return Err("unsupported SQLite schema format".into());
|
||||
}
|
||||
Ok(reserved)
|
||||
@@ -941,6 +943,34 @@ mod tests {
|
||||
assert_eq!(table_alias, Some(0));
|
||||
}
|
||||
|
||||
/// A database file with no schema yet has schema format 0 in its header (file format
|
||||
/// §1.3.10): a catalog created in WAL mode whose tables are all still in its `-wal` file (one
|
||||
/// Lightroom has open and hasn't checkpointed) was refused as "unsupported SQLite schema
|
||||
/// format" before the WAL was looked at. It opens, with no tables; other formats stay refused.
|
||||
#[test]
|
||||
fn schema_format_zero_is_an_empty_database() {
|
||||
let header = |format: u32| {
|
||||
let mut main = vec![0u8; 512];
|
||||
main[..16].copy_from_slice(b"SQLite format 3\0");
|
||||
main[16..18].copy_from_slice(&512u16.to_be_bytes());
|
||||
main[18] = 2;
|
||||
main[19] = 2;
|
||||
main[21] = 64;
|
||||
main[22] = 32;
|
||||
main[23] = 32;
|
||||
main[28..32].copy_from_slice(&1u32.to_be_bytes());
|
||||
main[44..48].copy_from_slice(&format.to_be_bytes());
|
||||
main[56..60].copy_from_slice(&1u32.to_be_bytes());
|
||||
main[100] = 0x0d;
|
||||
main[105..107].copy_from_slice(&512u16.to_be_bytes());
|
||||
main
|
||||
};
|
||||
let db = Database::open_with_wal(header(0), &[]).unwrap();
|
||||
assert!(db.live_tables().unwrap().is_empty());
|
||||
assert!(Database::open_with_wal(header(4), &[]).is_ok());
|
||||
assert_eq!(Database::open_with_wal(header(5), &[]).err().as_deref(), Some("unsupported SQLite schema format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_unrelated_schema_entries_are_skipped() {
|
||||
let mut main = vec![0u8; 2 * 512];
|
||||
|
||||
@@ -67,6 +67,17 @@ pub const RENDER_CACHE_VERSION: u64 = 62;
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
||||
/// Face tiles are rendered at a multiple of this many pixels ([`crate::Session::face_job`]): the
|
||||
/// tile's size follows the thumbnail slider and the screen's scale, and without steps every size
|
||||
/// in between would render (and cache) every face again.
|
||||
pub const FACE_EDGE_STEP: usize = 16;
|
||||
|
||||
/// The edge a face tile asked to be `edge` pixels across is rendered at: the next
|
||||
/// [`FACE_EDGE_STEP`], 16..=512.
|
||||
pub fn face_edge(edge: usize) -> usize {
|
||||
edge.clamp(16, SourceLevel::Thumb.max_edge()).div_ceil(FACE_EDGE_STEP).saturating_mul(FACE_EDGE_STEP).min(SourceLevel::Thumb.max_edge())
|
||||
}
|
||||
|
||||
pub fn thumb_bucket(long_edge: usize) -> usize {
|
||||
THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512)
|
||||
}
|
||||
@@ -741,6 +752,9 @@ pub struct RenderJob {
|
||||
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
|
||||
/// Render for this monitor profile ([`Self::with_display`]).
|
||||
pub display: Option<Arc<crate::display::Display>>,
|
||||
/// A face tile that can be cut from the raw's embedded preview, tried before `source`
|
||||
/// ([`crate::Session::face_job`]; `None` for every other job).
|
||||
pub preview_cut: Option<crate::face_tiles::PreviewCut>,
|
||||
}
|
||||
|
||||
pub struct RenderResult {
|
||||
@@ -820,6 +834,7 @@ impl RenderJob {
|
||||
self.settings = Arc::new(s);
|
||||
self.cache = None;
|
||||
self.view_cache = None;
|
||||
self.preview_cut = None;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -887,6 +902,26 @@ impl RenderJob {
|
||||
display: None,
|
||||
};
|
||||
}
|
||||
// a face tile of an unedited raw: cut from the camera's embedded preview when it is big enough
|
||||
// (the sensor data is never read for it), else rendered from `source` below
|
||||
if let Some(image) = self.preview_cut.as_ref().and_then(|c| c.cut()) {
|
||||
if let Some((cache, key)) = &self.cache {
|
||||
cache.put_at(self.cache_generation, *key, Arc::new(image.clone()));
|
||||
}
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
return RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Ok(Rendered { image, histogram, deep: None }),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
};
|
||||
}
|
||||
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
|
||||
// The app is closing (issue #620): nobody is left to show this, and quitting waits for the
|
||||
// jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU.
|
||||
@@ -1192,6 +1227,7 @@ impl crate::Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
preview_cut: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1243,13 +1279,19 @@ impl crate::Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
preview_cut: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the
|
||||
/// photo's own look with its crop replaced by a square around the face (room for hair and chin),
|
||||
/// `edge` pixels across (≤ 512). The source level is the smallest that keeps the face sharp, up
|
||||
/// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content.
|
||||
/// at least `edge` pixels across (rounded up to [`FACE_EDGE_STEP`], ≤ 512, so a tile size
|
||||
/// between two steps reuses the cached one). An unedited raw's tile is cut from the camera's
|
||||
/// embedded preview when that holds the square at [`crate::face_tiles::SOFT_LIMIT`] of the
|
||||
/// size asked or more ([`crate::face_tiles`]); else, and for every other photo, the source
|
||||
/// level is the smallest that keeps the face sharp, up to the preview. Cached (memory and the
|
||||
/// library's thumbnail cache on disk) like [`Self::variant_job`], and `key` likewise follows
|
||||
/// the content: a later launch shows the tile without reading the original.
|
||||
pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option<RenderJob> {
|
||||
let p = self.catalog.photo(id)?.clone();
|
||||
// `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the
|
||||
@@ -1267,12 +1309,13 @@ impl crate::Session {
|
||||
let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h };
|
||||
let mut settings = (*p.develop).clone();
|
||||
settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() };
|
||||
let edge = edge.clamp(16, SourceLevel::Thumb.max_edge());
|
||||
let edge = face_edge(edge);
|
||||
// pixels along the long edge that leave `edge` across the crop
|
||||
let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64);
|
||||
let level = SourceLevel::for_size(needed as usize);
|
||||
let source = self.media.source_ref(&p, level);
|
||||
let ck = Self::variant_key(&p, &settings, edge);
|
||||
let preview_cut = self.face_preview_cut(&p, rect, edge, needed as usize);
|
||||
Some(RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
@@ -1289,9 +1332,26 @@ impl crate::Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
preview_cut,
|
||||
})
|
||||
}
|
||||
|
||||
/// How a face tile of `p` can come from its embedded preview ([`crate::face_tiles`]): only an
|
||||
/// unedited raw (whose grid thumbnail is that preview too) not known to be offline, upright as
|
||||
/// imported, with a preview loader installed. `rect` is the square around the face, `edge` the
|
||||
/// tile, `needed` the source's long edge the develop render would ask for.
|
||||
fn face_preview_cut(&self, p: &Photo, rect: lightcraft_geom::Rect, edge: usize, needed: usize) -> Option<crate::face_tiles::PreviewCut> {
|
||||
let (path, loader) = self.embedded_of(p)?;
|
||||
if p.develop.orientation != lightcraft_geom::Orientation::Normal || self.media.availability.is_offline(&path) || p.width == 0 || p.height == 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let want = needed.max(edge).max(1);
|
||||
let min = (want as f64 * crate::face_tiles::SOFT_LIMIT).ceil() as usize;
|
||||
let aspect = f64::from(p.width) / f64::from(p.height);
|
||||
Some(crate::face_tiles::PreviewCut { path, loader, want, min, rect, aspect, edge })
|
||||
}
|
||||
|
||||
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
|
||||
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
|
||||
Hasher128::new()
|
||||
@@ -1427,8 +1487,10 @@ impl crate::Session {
|
||||
|
||||
/// A first grid thumbnail for an unedited raw: the cached render for `job` (the real
|
||||
/// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries
|
||||
/// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview. `None` for
|
||||
/// other photos (render them directly).
|
||||
/// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview, else `job`
|
||||
/// itself: the grid queues no render behind an unedited raw's quick thumbnail
|
||||
/// ([`Self::embedded_thumb_is_final`]), so a preview that can't be read must not leave the
|
||||
/// cell empty (issue #1448). `None` for other photos (render them directly).
|
||||
pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option<QuickJob> {
|
||||
let p = self.catalog.photo(job.photo)?.clone();
|
||||
let (path, l) = self.embedded_of(&p)?;
|
||||
@@ -1439,7 +1501,7 @@ impl crate::Session {
|
||||
key: job.key,
|
||||
cached: job.cache.clone().into_iter().collect(),
|
||||
embedded: Some((path, l, edge)),
|
||||
small: None,
|
||||
small: Some(Box::new(job.clone())),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn default_budget() -> usize {
|
||||
}
|
||||
|
||||
/// Physical memory (bytes), when the platform tells us without native calls.
|
||||
fn total_ram() -> Option<usize> {
|
||||
pub(crate) fn total_ram() -> Option<usize> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let s = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
|
||||
@@ -130,11 +130,14 @@ impl MergeJob {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let sub = |a: f32, b: f32| move |f: f32, s: &str| progress(a + (b - a) * f, s);
|
||||
// the reference frame's lens corrections (HDR); panoramas carry none
|
||||
let mut lens = Vec::new();
|
||||
let (image, color, orientation, metadata, baseline, samples, crop, mut info, ghost) = match &self.kind {
|
||||
MergeKind::Hdr { hdr } => {
|
||||
let frames = self.load(false, progress, (0.0, 0.3))?;
|
||||
let p = sub(0.3, 0.9);
|
||||
let r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?;
|
||||
let mut r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?;
|
||||
lens = std::mem::take(&mut r.lens);
|
||||
let info = json!({
|
||||
"reference": self.sources[r.reference].0.0,
|
||||
"ev": r.ev, "evExif": r.ev_exif,
|
||||
@@ -183,6 +186,7 @@ impl MergeJob {
|
||||
orientation: r.orientation,
|
||||
metadata: r.metadata,
|
||||
baseline_exposure: r.baseline_exposure,
|
||||
lens: Vec::new(),
|
||||
};
|
||||
f.orient();
|
||||
merged.push(f);
|
||||
@@ -214,7 +218,7 @@ impl MergeJob {
|
||||
metadata.caption = Some(format!("Merged from {}", names.join(", ")));
|
||||
}
|
||||
metadata.software = Some("LightCraft Photo Merge".into());
|
||||
let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples).map_err(err)?;
|
||||
let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples, &lens).map_err(err)?;
|
||||
info["width"] = json!(image.width);
|
||||
info["height"] = json!(image.height);
|
||||
let preview = match self.preview {
|
||||
|
||||
@@ -28,28 +28,45 @@ pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mp
|
||||
|
||||
// ------------------------------------------------------------------------------------- zip
|
||||
|
||||
/// The most a preset zip entry may hold unpacked (a "DNG preset" photo is the largest kind, tens
|
||||
/// of MB): its stated size is the file's word, so a small archive could otherwise ask for 8 GB.
|
||||
pub const MAX_ZIP_ENTRY: usize = 256 << 20;
|
||||
/// The most a whole preset zip may hold unpacked.
|
||||
pub const MAX_ZIP_TOTAL: usize = 1 << 30;
|
||||
|
||||
/// The files of a zip archive: (path inside the archive, bytes). Stored and deflated entries;
|
||||
/// directories, macOS resource forks and hidden files are skipped.
|
||||
/// directories, macOS resource forks and hidden files are skipped. An entry that unpacks past
|
||||
/// [`MAX_ZIP_ENTRY`], or an archive past [`MAX_ZIP_TOTAL`], is an error (a zip bomb would make
|
||||
/// the allocation fail, which ends the app).
|
||||
pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let u16_at = |o: usize| bytes.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
||||
let u32_at = |o: usize| bytes.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
|
||||
read_zip_limited(bytes, MAX_ZIP_ENTRY, MAX_ZIP_TOTAL)
|
||||
}
|
||||
|
||||
fn read_zip_limited(bytes: &[u8], max_entry: usize, max_total: usize) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let u16_at = |o: usize| bytes.get(o..o.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
||||
let u32_at = |o: usize| bytes.get(o..o.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
|
||||
// offsets from the file, added up without overflowing (a 32-bit build reads them too)
|
||||
let add = |a: usize, b: usize| a.checked_add(b).ok_or("bad zip");
|
||||
// end of central directory: the last "PK\x05\x06" within the trailing 64 KiB + 22 bytes
|
||||
let tail = bytes.len().saturating_sub(65_557);
|
||||
let eocd = (tail..bytes.len().saturating_sub(21)).rev().find(|&i| bytes[i..].starts_with(b"PK\x05\x06")).ok_or("not a zip file")?;
|
||||
let count = u16_at(eocd + 10).ok_or("bad zip")?;
|
||||
let mut at = u32_at(eocd + 16).ok_or("bad zip")?;
|
||||
let mut out = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for _ in 0..count {
|
||||
if !bytes.get(at..).is_some_and(|b| b.starts_with(b"PK\x01\x02")) {
|
||||
return Err("bad zip central directory".into());
|
||||
}
|
||||
let method = u16_at(at + 10).ok_or("bad zip")?;
|
||||
let csize = u32_at(at + 20).ok_or("bad zip")?;
|
||||
let usize_ = u32_at(at + 24).ok_or("bad zip")?;
|
||||
let (nlen, xlen, clen) = (u16_at(at + 28).ok_or("bad zip")?, u16_at(at + 30).ok_or("bad zip")?, u16_at(at + 32).ok_or("bad zip")?);
|
||||
let local = u32_at(at + 42).ok_or("bad zip")?;
|
||||
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + nlen).ok_or("bad zip")?).replace('\\', "/");
|
||||
at += 46 + nlen + xlen + clen;
|
||||
let method = u16_at(add(at, 10)?).ok_or("bad zip")?;
|
||||
let csize = u32_at(add(at, 20)?).ok_or("bad zip")?;
|
||||
let usize_ = u32_at(add(at, 24)?).ok_or("bad zip")?;
|
||||
let (nlen, xlen, clen) =
|
||||
(u16_at(add(at, 28)?).ok_or("bad zip")?, u16_at(add(at, 30)?).ok_or("bad zip")?, u16_at(add(at, 32)?).ok_or("bad zip")?);
|
||||
let local = u32_at(add(at, 42)?).ok_or("bad zip")?;
|
||||
let name_at = add(at, 46)?;
|
||||
let name = String::from_utf8_lossy(bytes.get(name_at..add(name_at, nlen)?).ok_or("bad zip")?).replace('\\', "/");
|
||||
at = add(add(add(name_at, nlen)?, xlen)?, clen)?;
|
||||
let skip = name.ends_with('/') || name.split('/').any(|p| p.starts_with('.') || p == "__MACOSX");
|
||||
if skip {
|
||||
continue;
|
||||
@@ -57,13 +74,20 @@ pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
if !bytes.get(local..).is_some_and(|b| b.starts_with(b"PK\x03\x04")) {
|
||||
return Err(format!("{name}: bad local header"));
|
||||
}
|
||||
let data_at = local + 30 + u16_at(local + 26).ok_or("bad zip")? + u16_at(local + 28).ok_or("bad zip")?;
|
||||
let data = bytes.get(data_at..data_at + csize).ok_or_else(|| format!("{name}: truncated"))?;
|
||||
let data_at = add(add(add(local, 30)?, u16_at(add(local, 26)?).ok_or("bad zip")?)?, u16_at(add(local, 28)?).ok_or("bad zip")?)?;
|
||||
let data = bytes.get(data_at..add(data_at, csize)?).ok_or_else(|| format!("{name}: truncated"))?;
|
||||
let left = max_total.saturating_sub(total);
|
||||
let limit = max_entry.min(left);
|
||||
let too_big = || format!("{name}: unpacks to more than {} MB, too much for a preset", limit >> 20);
|
||||
let content = match method {
|
||||
0 if data.len() > limit => return Err(too_big()),
|
||||
0 => data.to_vec(),
|
||||
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1) * 2 + 1024).map_err(|e| format!("{name}: {e:?}"))?,
|
||||
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1).saturating_mul(2).saturating_add(1024).min(limit)).map_err(
|
||||
|e| if matches!(e.status, miniz_oxide::inflate::TINFLStatus::HasMoreOutput) { too_big() } else { format!("{name}: {e:?}") },
|
||||
)?,
|
||||
m => return Err(format!("{name}: unsupported compression method {m}")),
|
||||
};
|
||||
total = total.saturating_add(content.len());
|
||||
out.push((name, content));
|
||||
}
|
||||
Ok(out)
|
||||
@@ -695,6 +719,33 @@ text]], z = ZSTR "loc" }"#,
|
||||
assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_bombs_are_refused() {
|
||||
// an entry unpacking past the limit (3 MB of zeros pack into a few kB): refused, named
|
||||
let zeros = vec![0u8; 3 << 20];
|
||||
let z = zip(&[("Pack/huge.xmp", &zeros)], true);
|
||||
assert!(z.len() < 64 << 10, "a small archive: {} bytes", z.len());
|
||||
let e = read_zip_limited(&z, 1 << 20, 1 << 30).unwrap_err();
|
||||
assert!(e.contains("Pack/huge.xmp") && e.contains("more than 1 MB"), "{e}");
|
||||
// the whole archive counts too
|
||||
let two = zip(&[("a.xmp", &zeros[..1 << 20]), ("b.xmp", &zeros[..1 << 20])], true);
|
||||
assert!(read_zip_limited(&two, 2 << 20, (3 << 20) / 2).is_err());
|
||||
assert_eq!(read_zip_limited(&two, 2 << 20, 3 << 20).unwrap().len(), 2);
|
||||
// and stored entries
|
||||
let stored = zip(&[("s.xmp", &zeros[..2 << 20])], false);
|
||||
assert!(read_zip_limited(&stored, 1 << 20, 1 << 30).is_err());
|
||||
// the size an entry states (here 4 GB) no longer sets how much may be unpacked
|
||||
let mut lying = zip(&[("a.xmp", b"<x:xmpmeta/>")], true);
|
||||
let cd = lying.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
|
||||
lying[cd + 24..cd + 28].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert_eq!(read_zip(&lying).unwrap()[0].1, b"<x:xmpmeta/>");
|
||||
// offsets near the top of a u32 are an error, not an overflow
|
||||
let mut far = zip(&[("a.xmp", b"x")], false);
|
||||
let cd = far.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
|
||||
far[cd + 42..cd + 46].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(read_zip(&far).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_bundles_with_folders_as_groups() {
|
||||
for deflate in [false, true] {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -450,6 +450,33 @@ fn stem_key(original: &str) -> String {
|
||||
sidecar_path(original, SidecarNaming::Stem).to_string_lossy().to_lowercase()
|
||||
}
|
||||
|
||||
/// Where a plain path is cut to make its [`stem_key`]: at the dot of its extension, which is
|
||||
/// what `with_extension` replaces. A plain path is ASCII and ends in its file's name with an
|
||||
/// extension, which is what a library's paths are; `None` for any other shape.
|
||||
fn plain_cut(original: &str) -> Option<usize> {
|
||||
let path = Path::new(original);
|
||||
let (name, stem, ext) = (path.file_name()?.to_str()?, path.file_stem()?.to_str()?, path.extension()?.to_str()?);
|
||||
// (the path ends in its file's name, as it stands: no `/.` or separator after it; and the
|
||||
// name has a stem and an extension of its own, which `..jpg` and `a.` have not)
|
||||
let plain = original.is_ascii() && original.ends_with(name) && !ext.is_empty() && !stem.is_empty() && stem != "." && stem != "..";
|
||||
let cut = original.len().checked_sub(ext.len().saturating_add(1))?;
|
||||
(plain && original.as_bytes().get(cut) == Some(&b'.')).then_some(cut)
|
||||
}
|
||||
|
||||
/// [`stem_key`] written into `key`, which is reused: nothing is allocated for a plain path
|
||||
/// ([`plain_cut`]). Any other shape goes the long way.
|
||||
fn stem_key_into(key: &mut String, original: &str) {
|
||||
key.clear();
|
||||
match plain_cut(original) {
|
||||
Some(cut) => {
|
||||
key.push_str(original.get(..cut).unwrap_or_default());
|
||||
key.make_ascii_lowercase();
|
||||
key.push_str(".xmp");
|
||||
}
|
||||
None => key.push_str(&stem_key(original)),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_path(p: &Photo) -> Option<&str> {
|
||||
match &p.source {
|
||||
Source::File { path } => Some(path),
|
||||
@@ -490,8 +517,11 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
|
||||
pub(crate) struct StemOwners(HashMap<String, PhotoId>);
|
||||
|
||||
impl StemOwners {
|
||||
/// The owners of every shared stem in the library, each file's key made the long way: what
|
||||
/// [`StemOwners::among`] must agree with for the photos it is asked about (this is how the
|
||||
/// owners were worked out before, for every write).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
|
||||
// per stem: (raw first, then file name, then id) of the best candidate; and how many
|
||||
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
let Some(path) = file_path(p) else { continue };
|
||||
@@ -505,6 +535,39 @@ impl StemOwners {
|
||||
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
|
||||
}
|
||||
|
||||
/// The owners of the stems photos `ids` map to: all that naming those photos needs. The
|
||||
/// library is passed over once, and only files of those stems are looked at closer, so a
|
||||
/// sidecar written for one photo does not cost a table of the whole library.
|
||||
pub(crate) fn among(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> StemOwners {
|
||||
let wanted: std::collections::HashSet<String> =
|
||||
ids.iter().filter_map(|id| cat.photo(*id)).filter_map(|p| file_path(p)).map(stem_key).collect();
|
||||
if wanted.is_empty() {
|
||||
return StemOwners(HashMap::new());
|
||||
}
|
||||
Self::where_wanted(cat, &wanted)
|
||||
}
|
||||
|
||||
/// The owners of the shared stems in `wanted`.
|
||||
fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet<String>) -> StemOwners {
|
||||
// per stem: (raw first, then file name, then id) of the best candidate; and how many
|
||||
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
|
||||
let mut key = String::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
let Some(path) = file_path(p) else { continue };
|
||||
stem_key_into(&mut key, path);
|
||||
if !wanted.contains(key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
|
||||
let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0));
|
||||
e.2 += 1;
|
||||
if rank < e.0 {
|
||||
(e.0, e.1) = (rank, p.id);
|
||||
}
|
||||
}
|
||||
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
|
||||
}
|
||||
|
||||
/// `naming` for photo `p`, except that a stem sidecar another file owns becomes Full.
|
||||
fn naming(&self, p: &Photo, naming: SidecarNaming) -> SidecarNaming {
|
||||
match (naming, file_path(p)) {
|
||||
@@ -514,33 +577,39 @@ impl StemOwners {
|
||||
}
|
||||
}
|
||||
|
||||
/// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming`
|
||||
/// ([`Session::sidecar_namings`], for a thread that has the library and not the session).
|
||||
pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec<SidecarNaming> {
|
||||
let owners = (naming == SidecarNaming::Stem).then(|| StemOwners::among(cat, ids));
|
||||
ids.iter()
|
||||
.map(|id| match (cat.photo(*id), &owners) {
|
||||
(Some(p), Some(o)) => o.naming(p, naming),
|
||||
_ => naming,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The sidecar naming for photo `id`: the library's, except under Stem naming when another
|
||||
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
|
||||
/// sidecar — a raw first, else the first by file name. The others use Full naming
|
||||
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
|
||||
/// [`Session::sidecar_naming`] for many photos, the catalog's sidecar owners worked out once
|
||||
/// (per photo that is a pass over the whole catalog).
|
||||
/// [`Session::sidecar_naming`] for many photos, their stems' owners worked out once (per
|
||||
/// photo that is a pass over the whole catalog).
|
||||
pub fn sidecar_namings(&self, ids: &[PhotoId]) -> Vec<SidecarNaming> {
|
||||
let owners = (self.xmp.naming == SidecarNaming::Stem).then(|| StemOwners::of(&self.catalog));
|
||||
ids.iter()
|
||||
.map(|id| match (self.catalog.photo(*id), &owners) {
|
||||
(Some(p), Some(o)) => o.naming(p, self.xmp.naming),
|
||||
_ => self.xmp.naming,
|
||||
})
|
||||
.collect()
|
||||
namings_in(&self.catalog, self.xmp.naming, ids)
|
||||
}
|
||||
|
||||
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
|
||||
match self.catalog.photo(id) {
|
||||
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming),
|
||||
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::among(&self.catalog, &[id]).naming(p, self.xmp.naming),
|
||||
_ => self.xmp.naming,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the photo's XMP sidecar, merged into an existing one (see the module docs).
|
||||
pub fn save_sidecar(&mut self, id: PhotoId) -> Result<SidecarSaved> {
|
||||
self.save_sidecar_with(id, &StemOwners::of(&self.catalog))
|
||||
self.save_sidecar_with(id, &StemOwners::among(&self.catalog, &[id]))
|
||||
}
|
||||
|
||||
pub(crate) fn save_sidecar_with(&mut self, id: PhotoId, owners: &StemOwners) -> Result<SidecarSaved> {
|
||||
@@ -642,7 +711,7 @@ impl Session {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let owners = StemOwners::of(&self.catalog);
|
||||
let owners = StemOwners::among(&self.catalog, &ids);
|
||||
for id in ids {
|
||||
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
|
||||
&& let Err(e) = self.save_sidecar_with(id, &owners)
|
||||
@@ -764,3 +833,7 @@ mod tests_repair_companions;
|
||||
#[cfg(test)]
|
||||
#[path = "sidecar/tests_repair_provenance.rs"]
|
||||
mod tests_repair_provenance;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sidecar/tests_stem_owners.rs"]
|
||||
mod tests_stem_owners;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`).
|
||||
//!
|
||||
//! A library can hold millions of photos; a sidecar that is written for one of them, or a folder
|
||||
//! that is synchronized, must not cost a table of them all.
|
||||
use super::*;
|
||||
use lightcraft_catalog::{Catalog, Op, Photo, Source};
|
||||
|
||||
/// Paths that try the key: case in folders, names and endings, no ending, a dot file, endings
|
||||
/// inside folder names, non-ASCII letters (one that lower-cases to ASCII: the Kelvin sign).
|
||||
const PATHS: [&str; 26] = [
|
||||
"/pics/trip/IMG_1.CR3",
|
||||
"/pics/trip/IMG_1.JPG",
|
||||
"/pics/trip/img_1.jpeg",
|
||||
"/Pics/Trip/IMG_1.tif",
|
||||
"/pics/trip/IMG_2.JPG",
|
||||
"/pics/trip/IMG_3.CR3",
|
||||
"/pics/trip/IMG_3.cr3.JPG",
|
||||
"/pics/trip/IMG_3.cr3",
|
||||
"/pics/other/IMG_1.JPG",
|
||||
"/pics/other/IMG_1",
|
||||
"/pics/other/.hidden",
|
||||
"/pics/other/.hidden.jpg",
|
||||
"/pics/v1.2/a.b.JPG",
|
||||
"/pics/v1.2/a.b.cr3",
|
||||
"/pics/v1.2/a",
|
||||
"/pics/straße/Größe.JPG",
|
||||
"/pics/STRASSE/GRÖSSE.cr3",
|
||||
"/pics/straße/größe.CR3",
|
||||
"/pics/kelvin/\u{212a}1.JPG",
|
||||
"/pics/kelvin/k1.cr3",
|
||||
"/pics/ΟΔΟΣ/ΟΔΟΣ.JPG",
|
||||
"/pics/οδος/οδος.CR3",
|
||||
// (names that are all dots before their ending: the ending is no extension to replace)
|
||||
"/pics/dots/..cr3",
|
||||
"/pics/dots/..jpg",
|
||||
"/pics/dots/a./.",
|
||||
"/pics/dots/a.jpg",
|
||||
];
|
||||
|
||||
fn library() -> (Catalog, Vec<PhotoId>) {
|
||||
let mut c = Catalog::new();
|
||||
let mut ids = Vec::new();
|
||||
for path in PATHS {
|
||||
let id = c.alloc_photo_id();
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
let mut p = Photo::new(id, Source::File { path: path.to_string() }, name, "X", 6, 4, "2026-01-01");
|
||||
if path.to_lowercase().ends_with("cr3") {
|
||||
p.kind = MediaKind::Raw;
|
||||
}
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
// a virtual copy of the first (it owns nothing) and a photo that is no file
|
||||
let copy = c.alloc_photo_id();
|
||||
let mut p = Photo::new(copy, Source::File { path: PATHS[0].to_string() }, "IMG_1.CR3", "X", 6, 4, "2026-01-01");
|
||||
p.copy_of = Some(ids[0]);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(copy);
|
||||
let demo = c.alloc_photo_id();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(Photo::new(demo, Source::Demo { scene: 1 }, "demo", "X", 6, 4, "2026-01-01")) }).unwrap();
|
||||
ids.push(demo);
|
||||
(c, ids)
|
||||
}
|
||||
|
||||
fn namings(owners: &StemOwners, c: &Catalog, ids: &[PhotoId]) -> Vec<SidecarNaming> {
|
||||
ids.iter().map(|id| owners.naming(c.photo(*id).unwrap(), SidecarNaming::Stem)).collect()
|
||||
}
|
||||
|
||||
/// Given a library where files share stems in every way a name can, when the owners are worked
|
||||
/// out for some of its photos only, then each of those photos is named exactly as the table of
|
||||
/// the whole library names it: whichever photos are asked about, alone or together.
|
||||
#[test]
|
||||
fn owners_among_some_photos_name_them_as_the_whole_library_does() {
|
||||
let (c, ids) = library();
|
||||
let whole = StemOwners::of(&c);
|
||||
let expected = namings(&whole, &c, &ids);
|
||||
assert!(expected.contains(&SidecarNaming::Full) && expected.contains(&SidecarNaming::Stem), "both cases are there: {expected:?}");
|
||||
// all at once
|
||||
assert_eq!(namings(&StemOwners::among(&c, &ids), &c, &ids), expected);
|
||||
// one at a time, and every pair
|
||||
for (i, a) in ids.iter().enumerate() {
|
||||
assert_eq!(namings(&StemOwners::among(&c, &[*a]), &c, &[*a]), [expected[i]], "{:?}", PATHS.get(i));
|
||||
for (j, b) in ids.iter().enumerate() {
|
||||
assert_eq!(namings(&StemOwners::among(&c, &[*a, *b]), &c, &[*a, *b]), [expected[i], expected[j]]);
|
||||
}
|
||||
}
|
||||
// an id that is not in the library is passed over
|
||||
assert_eq!(StemOwners::among(&c, &[PhotoId(9_999)]).0.len(), 0);
|
||||
}
|
||||
|
||||
/// Given a library of many folders, when the owners are worked out for the photos of one, then
|
||||
/// the table holds that folder's shared stems and nothing of the others: what it costs follows
|
||||
/// the photos asked about, not the library.
|
||||
#[test]
|
||||
fn owners_among_some_photos_hold_nothing_of_the_rest() {
|
||||
let mut c = Catalog::new();
|
||||
let mut first = Vec::new();
|
||||
for folder in 0..200 {
|
||||
for name in ["IMG_1.CR3", "IMG_1.JPG", "IMG_2.JPG"] {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder:03}/{name}") }, name, "X", 6, 4, "2026-01-01");
|
||||
if name.ends_with("CR3") {
|
||||
p.kind = MediaKind::Raw;
|
||||
}
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
if folder == 0 {
|
||||
first.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(StemOwners::of(&c).0.len(), 200, "one shared stem a folder");
|
||||
let some = StemOwners::among(&c, &first);
|
||||
assert_eq!(some.0.len(), 1, "the one shared stem of the folder asked about");
|
||||
assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]);
|
||||
}
|
||||
|
||||
/// The key written into a buffer (no allocation for a plain path) is the key: for every path
|
||||
/// above, for shapes a path from a file can have, and for every string of up to six of the
|
||||
/// characters that decide a key (a letter in each case, a dot, both separators, a letter that is
|
||||
/// not ASCII).
|
||||
#[test]
|
||||
fn the_key_in_a_buffer_is_the_key() {
|
||||
let odd = [
|
||||
"",
|
||||
"/",
|
||||
"a",
|
||||
"a.",
|
||||
".a",
|
||||
"..a",
|
||||
"...a",
|
||||
"a.b",
|
||||
"a/b.c/",
|
||||
"a/b.c//",
|
||||
"a/.b",
|
||||
"a/..b",
|
||||
"a/b.",
|
||||
"a/b..c",
|
||||
"a./.",
|
||||
"a\\b.c",
|
||||
"C:\\x\\y.JPG",
|
||||
"a/b.c/.",
|
||||
"a/b.c/..",
|
||||
"x.JPG ",
|
||||
"É.JPG",
|
||||
];
|
||||
let mut buf = String::new();
|
||||
for path in PATHS.iter().chain(&odd) {
|
||||
stem_key_into(&mut buf, path);
|
||||
assert_eq!(buf, stem_key(path), "{path:?}");
|
||||
}
|
||||
let letters = ['a', 'B', '.', '/', '\\', 'é'];
|
||||
let mut tried = 0usize;
|
||||
let mut todo: Vec<String> = vec![String::new()];
|
||||
while let Some(path) = todo.pop() {
|
||||
stem_key_into(&mut buf, &path);
|
||||
assert_eq!(buf, stem_key(&path), "{path:?}");
|
||||
tried += 1;
|
||||
if path.chars().count() < 6 {
|
||||
todo.extend(letters.iter().map(|c| format!("{path}{c}")));
|
||||
}
|
||||
}
|
||||
assert_eq!(tried, (0..=6).map(|n| 6usize.pow(n)).sum::<usize>());
|
||||
}
|
||||
@@ -74,10 +74,14 @@ impl SyncProgress {
|
||||
pub struct SyncInput {
|
||||
folder: String,
|
||||
scan: ScanInput,
|
||||
/// The library's photos in the folder, each with how its sidecar is named.
|
||||
photos: Vec<(Arc<Photo>, SidecarNaming)>,
|
||||
/// For the colour label names a sidecar may use.
|
||||
labels: Catalog,
|
||||
/// The library's photos in the folder.
|
||||
ids: Vec<PhotoId>,
|
||||
/// How the library names sidecars; how each photo's is named is worked out by the scan (a
|
||||
/// pass over the library, which the session's thread does not wait for).
|
||||
naming: SidecarNaming,
|
||||
/// The library as it is now (the one the scan looks files up in): for those photos, their
|
||||
/// sidecars' names and the colour label names a sidecar may use.
|
||||
labels: Arc<Catalog>,
|
||||
}
|
||||
|
||||
/// What changed in a folder since it came into the library.
|
||||
@@ -176,10 +180,9 @@ impl SyncInput {
|
||||
if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) {
|
||||
return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it")));
|
||||
}
|
||||
let namings = s.sidecar_namings(&ids);
|
||||
let photos = ids.iter().zip(namings).filter_map(|(id, naming)| s.catalog.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
|
||||
let (scan, _) = ScanInput::new(s, &[path.to_string()]);
|
||||
Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() })
|
||||
let labels = scan.library();
|
||||
Ok(SyncInput { folder: path.to_string(), scan, ids, naming: s.xmp.naming, labels })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,11 +193,16 @@ impl SyncInput {
|
||||
/// library's photos, which run on several threads too. Stops early when `progress.files.cancel`
|
||||
/// is set.
|
||||
pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
|
||||
let SyncInput { folder, scan, photos, labels } = input;
|
||||
let seen: Vec<Arc<Photo>> = photos.iter().map(|(p, _)| Arc::clone(p)).collect();
|
||||
let SyncInput { folder, scan, ids, naming, labels } = input;
|
||||
let seen: Vec<Arc<Photo>> = ids.iter().filter_map(|id| labels.photo(*id)).map(Arc::clone).collect();
|
||||
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
|
||||
return FolderChanges { path: folder, seen, offline: true, ..Default::default() };
|
||||
}
|
||||
// (how each photo's sidecar is named: a pass over the library, made here and not while the
|
||||
// session waits, and not at all for a folder that is not there)
|
||||
let namings = crate::sidecar::namings_in(&labels, naming, &ids);
|
||||
let photos: Vec<(Arc<Photo>, SidecarNaming)> =
|
||||
ids.iter().zip(namings).filter_map(|(id, naming)| labels.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
|
||||
// (no threads on the web: one after the other there)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
|
||||
|
||||
@@ -402,6 +402,48 @@ fn paste_selected_settings_pastes_only_chosen_copied_groups() {
|
||||
assert_eq!(s.develop_of(ids[1]).unwrap().color.vibrance, 30.0);
|
||||
}
|
||||
|
||||
/// A crop of no size (or a hair wide), as `crop.set` or a sidecar can set it, made the render's
|
||||
/// samplers read at infinite coordinates and its blurs ask for sigmas of millions of pixels: with a
|
||||
/// healing spot and masks on, render workers panicked (an i32 square in the blur, a bilinear
|
||||
/// neighbour past isize::MAX). Such a photo renders, quickly.
|
||||
#[test]
|
||||
fn degenerate_crops_render_with_spots_and_masks() {
|
||||
for rect in [[0.5, 0.5, 0.5, 0.5], [0.0, 0.0, 1e-9, 1.0]] {
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("spot.add", &json!({"points": [[0.5, 0.5]], "size": 0.2, "mode": "heal"})).unwrap();
|
||||
s.execute("mask.add", &json!({"kind": "radial", "rx": 0.3, "ry": 0.3})).unwrap();
|
||||
s.execute("mask.add", &json!({"kind": "linear"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 80})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.dehaze", "value": 50})).unwrap();
|
||||
s.execute("crop.set", &json!({"rect": rect, "angle": 30})).unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
let r = s.render_now(id, 700, 450).unwrap();
|
||||
assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}");
|
||||
assert!(t.elapsed().as_secs_f64() < 60.0, "{rect:?}: {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
/// `mask.move` with a delta near `i64::MAX` overflowed `index + delta` (a panic wherever overflow
|
||||
/// checks are on); extreme deltas and indices now move the mask to the bottom or the top.
|
||||
#[test]
|
||||
fn mask_move_with_extreme_deltas() {
|
||||
let mut s = demo();
|
||||
for kind in ["linear", "radial", "brush"] {
|
||||
s.execute("mask.add", &json!({"kind": kind})).unwrap();
|
||||
}
|
||||
let ids = |s: &Session| active_dev(s).masks.iter().map(|m| m.id).collect::<Vec<_>>();
|
||||
let [a, b, c] = ids(&s)[..] else { panic!("{:?}", ids(&s)) };
|
||||
s.execute("mask.move", &json!({"id": b, "delta": i64::MAX})).unwrap();
|
||||
assert_eq!(ids(&s), [a, c, b], "to the bottom");
|
||||
s.execute("mask.move", &json!({"id": b, "delta": i64::MIN})).unwrap();
|
||||
assert_eq!(ids(&s), [b, a, c], "to the top");
|
||||
s.execute("mask.move", &json!({"id": c, "to": i64::MIN})).unwrap();
|
||||
assert_eq!(ids(&s), [c, b, a]);
|
||||
s.execute("mask.move", &json!({"id": c, "to": i64::MAX})).unwrap();
|
||||
assert_eq!(ids(&s), [b, a, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_reorder_and_duplicate_and_invert() {
|
||||
let mut s = demo();
|
||||
@@ -1178,3 +1220,269 @@ fn hdr_edits_render_with_their_hdr_histogram() {
|
||||
assert!(r.histogram.above_sdr() > 0.0);
|
||||
assert!(r.deep.is_none(), "the preview itself stays the 8-bit SDR rendition");
|
||||
}
|
||||
|
||||
/// Found by fuzzing catalog snapshots: an album filed under one id but naming its own folder's
|
||||
/// listed itself inside itself, and `albums.list` recursed until the stack overflowed (an abort:
|
||||
/// no guard catches it). Loading repairs such a catalog (`Catalog::repair`); one that skipped
|
||||
/// that still lists, the loop cut off.
|
||||
#[test]
|
||||
fn albums_list_survives_a_catalog_that_loops() {
|
||||
let mut s = Session::with_demo();
|
||||
let f = s.execute("album.create", &json!({"name": "F", "folder": true})).unwrap()["id"].as_u64().unwrap();
|
||||
let a = s.execute("album.create", &json!({"name": "A", "parent": f})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut v: serde_json::Value = serde_json::from_str(&s.catalog.to_snapshot()).unwrap();
|
||||
v["albums"][a.to_string()]["id"] = f.into();
|
||||
// as read, without the repair a load does
|
||||
s.catalog = serde_json::from_value(v).unwrap();
|
||||
let r = s.execute("albums.list", &json!({})).unwrap();
|
||||
assert!(r.to_string().contains("\"F\""), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_paste_validates_groups_and_rejects_malformed_with_zero_mutations() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let photo_a = ids[0];
|
||||
let photo_b = ids[1];
|
||||
|
||||
// Photo A has exposure = 1.0, vibrance = 30.0
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30.0}})).unwrap();
|
||||
s.execute("develop.copy", &json!({"groups": ["light", "color"]})).unwrap();
|
||||
|
||||
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
let initial_b_hist_len = s.catalog.photo(photo_b).unwrap().history.len();
|
||||
|
||||
// 1. Invalid group names are rejected
|
||||
for invalid_group in ["bogusGroup", "LIGHT", "Exposure", "unknown"] {
|
||||
let err = s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": [invalid_group]})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 2. Incorrect JSON types are rejected
|
||||
for invalid_param in [
|
||||
json!("light"), // string instead of sequence
|
||||
json!(42), // number
|
||||
json!(true), // boolean
|
||||
json!({"name": "light"}), // object
|
||||
json!([123]), // array of non-string
|
||||
json!(["light", 456]), // mixed array with invalid type
|
||||
json!([true]), // array of bool
|
||||
] {
|
||||
let err = s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": invalid_param})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 3. Valid groups remain supported: pasting only "light" does not paste "color"
|
||||
s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": ["light"]})).unwrap();
|
||||
let dev_after_light = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_light.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_light.color.vibrance, initial_b_dev.color.vibrance);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 0.0);
|
||||
|
||||
// 4. Omitted groups preserves default behavior (all copied groups pasted)
|
||||
s.execute("develop.paste", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
let dev_after_omitted = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_omitted.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_omitted.color.vibrance, 30.0);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
|
||||
// 5. Explicit null behaves as omitted
|
||||
s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": null})).unwrap();
|
||||
let dev_after_null = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_null.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_null.color.vibrance, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_sync_validates_groups_and_rejects_malformed_with_zero_mutations() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let photo_a = ids[0];
|
||||
let photo_b = ids[1];
|
||||
|
||||
// Setup: Photo A has exposure = 1.0, vibrance = 30.0
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30.0}})).unwrap();
|
||||
|
||||
// Select Photo A (active) and Photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
|
||||
|
||||
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
let initial_b_hist_len = s.catalog.photo(photo_b).unwrap().history.len();
|
||||
|
||||
// 1. Invalid group names are rejected
|
||||
for invalid_group in ["bogusGroup", "LIGHT", "WhiteBalance", "invalid_name"] {
|
||||
let err = s.execute("develop.sync", &json!({"groups": [invalid_group]})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 2. Incorrect JSON types are rejected
|
||||
for invalid_param in [
|
||||
json!("light"), // string instead of sequence
|
||||
json!(100), // number
|
||||
json!(false), // boolean
|
||||
json!({"groups": "light"}), // object
|
||||
json!([999]), // array of non-string
|
||||
json!(["color", false]), // mixed array with invalid type
|
||||
json!([null]), // array with null
|
||||
] {
|
||||
let err = s.execute("develop.sync", &json!({"groups": invalid_param})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 3. Valid groups remain supported: syncing only "light" does not sync "color"
|
||||
s.execute("develop.sync", &json!({"groups": ["light"]})).unwrap();
|
||||
let dev_after_light = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_light.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_light.color.vibrance, initial_b_dev.color.vibrance);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 0.0);
|
||||
|
||||
// Reselect Photo A (active) and Photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
|
||||
|
||||
// 4. Omitted groups preserves default behavior (all default copy groups synced)
|
||||
s.execute("develop.sync", &json!({})).unwrap();
|
||||
let dev_after_omitted = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_omitted.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_omitted.color.vibrance, 30.0);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
|
||||
// Reselect Photo A (active) and Photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
|
||||
|
||||
// 5. Explicit null behaves as omitted
|
||||
s.execute("develop.sync", &json!({"groups": null})).unwrap();
|
||||
let dev_after_null = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_null.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_null.color.vibrance, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_copy_and_paste_previous_validate_groups() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let photo_a = ids[0];
|
||||
let photo_b = ids[1];
|
||||
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.5}})).unwrap();
|
||||
|
||||
// develop.copy rejects invalid group
|
||||
let copy_err = s.execute("develop.copy", &json!({"groups": ["notAGroup"]})).unwrap_err();
|
||||
assert!(matches!(copy_err, crate::EngineError::BadParams { .. }));
|
||||
|
||||
// develop.copy accepts valid groups
|
||||
s.execute("develop.copy", &json!({"groups": ["light"]})).unwrap();
|
||||
|
||||
// Switch active photo to photo_b so photo_a becomes previous_active
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
|
||||
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
|
||||
// develop.pastePrevious rejects invalid group
|
||||
let prev_err = s.execute("develop.pastePrevious", &json!({"groups": ["badGroup"]})).unwrap_err();
|
||||
assert!(matches!(prev_err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
|
||||
// develop.pastePrevious accepts valid group
|
||||
s.execute("develop.pastePrevious", &json!({"groups": ["light"]})).unwrap();
|
||||
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_validate_groups_on_create_and_update() {
|
||||
let mut s = demo();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 25}})).unwrap();
|
||||
let initial_preset_count = s.presets.len();
|
||||
|
||||
// 1. preset.create rejects invalid group names and incorrect JSON types without creating a preset
|
||||
for invalid_groups in [json!(["bogusGroup"]), json!("light"), json!(123), json!([true]), json!(["light", 99])] {
|
||||
let err = s.execute("preset.create", &json!({"name": "Test", "groups": invalid_groups})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(s.presets.len(), initial_preset_count);
|
||||
}
|
||||
|
||||
// 2. preset.create with valid groups creates a preset with only those groups
|
||||
let res = s.execute("preset.create", &json!({"name": "ValidPreset", "groups": ["light"]})).unwrap();
|
||||
let pid = res["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(s.presets.len(), initial_preset_count + 1);
|
||||
let p = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert!(p.settings.get("light").is_some());
|
||||
assert!(p.settings.get("color").is_none());
|
||||
|
||||
// 3. preset.create with omitted groups uses default copy groups
|
||||
let res_omitted = s.execute("preset.create", &json!({"name": "DefaultPreset"})).unwrap();
|
||||
let pid_omitted = res_omitted["id"].as_str().unwrap().to_string();
|
||||
let p_omitted = s.presets.iter().find(|p| p.id == pid_omitted).unwrap();
|
||||
assert!(p_omitted.settings.get("light").is_some());
|
||||
assert!(p_omitted.settings.get("color").is_some());
|
||||
|
||||
// Change develop settings
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 2.0, "color.vibrance": 50}})).unwrap();
|
||||
|
||||
let preset_before_update = s.presets.iter().find(|p| p.id == pid).unwrap().clone();
|
||||
|
||||
// 4. preset.update rejects invalid group names and incorrect JSON types without modifying the preset
|
||||
for invalid_groups in [json!(["bogusGroup"]), json!("color"), json!(999), json!([false]), json!(["color", "bad"])] {
|
||||
let err = s.execute("preset.update", &json!({"id": pid, "groups": invalid_groups})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
let current = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert_eq!(*current, preset_before_update);
|
||||
}
|
||||
|
||||
// 5. preset.update with valid groups updates specified groups
|
||||
s.execute("preset.update", &json!({"id": pid, "groups": ["light", "color"]})).unwrap();
|
||||
let p_after_valid = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert_eq!(p_after_valid.settings["light"]["exposure"], 2.0);
|
||||
assert_eq!(p_after_valid.settings["color"]["vibrance"], 50.0);
|
||||
|
||||
// 6. preset.update with omitted groups updates existing preset keys
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": -1.0, "color.vibrance": -20}})).unwrap();
|
||||
s.execute("preset.update", &json!({"id": pid})).unwrap();
|
||||
let p_after_omitted = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert_eq!(p_after_omitted.settings["light"]["exposure"], -1.0);
|
||||
assert_eq!(p_after_omitted.settings["color"]["vibrance"], -20.0);
|
||||
}
|
||||
|
||||
/// A Heal or Remove spot whose size (from a sidecar, which isn't held to the slider's range) is far past the photo
|
||||
/// made the render's radius infinite: its heal blur and the source it reads went with it, overflowing the blur's box
|
||||
/// radii and the bilinear sampler (issue #1519). Such spots render, quickly, as a spot over the whole frame.
|
||||
#[test]
|
||||
fn oversized_spots_render() {
|
||||
use lightcraft_develop::{Spot, SpotMode};
|
||||
for size in [10.0, 1e30, f64::MAX, f64::INFINITY, f64::NAN] {
|
||||
for mode in [SpotMode::Heal, SpotMode::Remove, SpotMode::Clone] {
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
dd.spots.push(Spot { mode, points: vec![lightcraft_geom::Point::new(0.5, 0.5)], size, ..Default::default() });
|
||||
s.set_develop(id, dd, "Remove").unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
let r = s.render_now(id, 300, 200).unwrap();
|
||||
assert!(r.image.width >= 1 && r.image.height >= 1, "{size} {mode:?}");
|
||||
assert!(t.elapsed().as_secs_f64() < 60.0, "{size} {mode:?}: {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Face tiles from the embedded preview, kept in the thumbnail cache (issue #1440).
|
||||
//!
|
||||
//! The fixture is a TIFF-family raw with two embedded JPEG previews (a small flat red one and a
|
||||
//! larger picture) and a sensor strip, as #1020's planned-read tests build them. Counting wrappers
|
||||
//! around the session's file loader (the path that reads the whole original and develops the raw)
|
||||
//! and its preview loader say which source a face tile came from.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
use lightcraft_geom::Rect;
|
||||
use lightcraft_raster::Rgba8;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
const W: u32 = 600;
|
||||
const H: u32 = 400;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-face-tiles-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The big preview's pixel at (x, y): smooth ramps and a soft disc, so a resampled square of it
|
||||
/// compares well between two resamplers.
|
||||
fn big_pixel(x: u32, y: u32) -> [u8; 3] {
|
||||
let (dx, dy) = (x as f64 - 300.0, y as f64 - 200.0);
|
||||
let disc = (-(dx * dx + dy * dy) / (2.0 * 40.0 * 40.0)).exp();
|
||||
[(40.0 + 150.0 * x as f64 / W as f64) as u8, (60.0 + 120.0 * y as f64 / H as f64) as u8, (50.0 + 160.0 * disc) as u8]
|
||||
}
|
||||
|
||||
fn jpeg(w: u32, h: u32, px: impl Fn(u32, u32) -> [u8; 3]) -> Vec<u8> {
|
||||
let rgb: Vec<u8> = (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| px(x, y)).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
encode_jpeg(&img, 95, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap()
|
||||
}
|
||||
|
||||
/// A raw (DNG-tagged) whose IFD0 is a small flat red preview, IFD1 the big picture and IFD2 a CFA
|
||||
/// "sensor" strip of `tail` bytes. The sensor data can't be developed, so the import describes the
|
||||
/// photo by its largest preview (600 × 400) and the develop path renders from it, after reading
|
||||
/// the whole file.
|
||||
fn raw_with_two_previews(tail: usize) -> Vec<u8> {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let preview = |w: u32, h: u32, bytes: Vec<u8>, first: bool| {
|
||||
let mut ifd = IfdBuilder::new();
|
||||
if first {
|
||||
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
|
||||
}
|
||||
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![7]));
|
||||
ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![bytes] });
|
||||
ifd
|
||||
};
|
||||
let small = preview(120, 80, jpeg(120, 80, |_, _| [200, 40, 40]), true);
|
||||
let big = preview(W, H, jpeg(W, H, big_pixel), false);
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
|
||||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000]));
|
||||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] });
|
||||
TiffWriter::default().write(&[small, big, raw]).unwrap()
|
||||
}
|
||||
|
||||
/// How often each loader ran.
|
||||
#[derive(Clone, Default)]
|
||||
struct Loads {
|
||||
/// The original read whole and developed (for a raw: its sensor data decoded).
|
||||
files: Arc<AtomicUsize>,
|
||||
/// The embedded preview read (planned read: header and preview only).
|
||||
previews: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Loads {
|
||||
fn get(&self) -> (usize, usize) {
|
||||
(self.files.load(Ordering::SeqCst), self.previews.load(Ordering::SeqCst))
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the session's loads from now on.
|
||||
fn count_loads(s: &mut Session) -> Loads {
|
||||
let loads = Loads::default();
|
||||
let file = s.media.file_loader.clone().unwrap();
|
||||
let n = loads.files.clone();
|
||||
s.media.file_loader = Some(Arc::new(move |p: &str, e: usize| {
|
||||
n.fetch_add(1, Ordering::SeqCst);
|
||||
file(p, e)
|
||||
}));
|
||||
let preview = s.media.preview_loader.clone().unwrap();
|
||||
let n = loads.previews.clone();
|
||||
s.media.preview_loader = Some(Arc::new(move |p: &str, e: usize| {
|
||||
n.fetch_add(1, Ordering::SeqCst);
|
||||
preview(p, e)
|
||||
}));
|
||||
loads
|
||||
}
|
||||
|
||||
/// A session on the library in `dir` (created on first use).
|
||||
fn open(dir: &Path) -> Session {
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(dir.join("library"), false).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A library holding the fixture: (dir, session, the photo, the file).
|
||||
fn setup(tag: &str) -> (PathBuf, Session, PhotoId, PathBuf) {
|
||||
let dir = temp_dir(tag);
|
||||
let file = dir.join("faces.dng");
|
||||
std::fs::write(&file, raw_with_two_previews(400_000)).unwrap();
|
||||
let mut s = open(&dir);
|
||||
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
|
||||
let p = s.catalog.photos().next().unwrap();
|
||||
assert_eq!((p.kind, p.width, p.height), (lightcraft_catalog::MediaKind::Raw, W, H), "described by its largest preview");
|
||||
let id = p.id;
|
||||
(dir, s, id, file)
|
||||
}
|
||||
|
||||
/// A face of a tenth of the frame, centred: a 64 px tile needs a 377 px source, which the 600 px
|
||||
/// preview has.
|
||||
const FACE: Rect = Rect { x0: 0.45, y0: 0.45, x1: 0.55, y1: 0.55 };
|
||||
/// A face of 2 %: a 64 px tile would need a 1883 px source; the preview is under ¾ of that.
|
||||
const TINY_FACE: Rect = Rect { x0: 0.49, y0: 0.49, x1: 0.51, y1: 0.51 };
|
||||
|
||||
fn tile(s: &mut Session, id: PhotoId, face: Rect, edge: usize) -> Result<Rgba8, String> {
|
||||
s.face_job(id, face, edge).unwrap().run().rendered.map(|r| r.image)
|
||||
}
|
||||
|
||||
fn mean_abs_diff(a: &Rgba8, b: &Rgba8) -> f64 {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
let sum: u64 = a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| u64::from(p[c].abs_diff(q[c])))).sum();
|
||||
sum as f64 / (a.data.len() * 3) as f64
|
||||
}
|
||||
|
||||
/// An unedited raw's face tile is cut from its largest embedded preview: the original is never read
|
||||
/// whole nor its raw developed, and the tile matches the develop path's at tile size.
|
||||
#[test]
|
||||
fn an_unedited_raws_face_comes_from_its_largest_preview() {
|
||||
let (dir, mut s, id, file) = setup("preview");
|
||||
// (the preview loader's planned read: the header and the preview, not the sensor strip)
|
||||
let (_, _, read) = crate::files::preview_window(&file.to_string_lossy(), usize::MAX).unwrap().unwrap();
|
||||
assert!(read.fraction() < 0.5, "{read:?}");
|
||||
let loads = count_loads(&mut s);
|
||||
let from_preview = tile(&mut s, id, FACE, 64).unwrap();
|
||||
assert_eq!((from_preview.width, from_preview.height), (64, 64));
|
||||
assert_eq!(loads.get(), (0, 1), "one preview read, no whole-file read or raw decode");
|
||||
// the big preview, not the small flat red one
|
||||
let centre = from_preview.data[32 * 64 + 32];
|
||||
assert!(centre[2] > 150 && centre[0] < 150, "the face's centre is the big preview's disc: {centre:?}");
|
||||
|
||||
// the develop path (no preview loader: what every face used before), from the memory cache's reach
|
||||
s.media.preview_loader = None;
|
||||
let mut job = s.face_job(id, FACE, 64).unwrap();
|
||||
job.cache = None;
|
||||
let developed = job.run().rendered.unwrap().image;
|
||||
assert_eq!(loads.files.load(Ordering::SeqCst), 1, "the develop path reads the original");
|
||||
let d = mean_abs_diff(&from_preview, &developed);
|
||||
eprintln!("mean difference preview/develop: {d:.3}");
|
||||
assert!(d < 4.0, "the preview's tile looks like the developed one: mean difference {d:.2} of 255");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A preview under the soft limit (¾ of the pixels the tile needs across the face) leaves the tile to
|
||||
/// the develop path; so does an edited photo, whose look the camera's JPEG doesn't have.
|
||||
#[test]
|
||||
fn a_preview_too_small_or_an_edited_photo_falls_back_to_the_develop_path() {
|
||||
let (dir, mut s, id, _) = setup("fallback");
|
||||
let loads = count_loads(&mut s);
|
||||
let img = tile(&mut s, id, TINY_FACE, 64).unwrap();
|
||||
assert_eq!((img.width, img.height), (64, 64));
|
||||
assert_eq!(loads.get(), (1, 1), "the preview was tried, then the original developed");
|
||||
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||||
let edited = tile(&mut s, id, FACE, 64).unwrap();
|
||||
assert_eq!(loads.get(), (2, 1), "an edited photo's face is developed; its preview isn't read");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
drop(edited);
|
||||
}
|
||||
|
||||
/// Rendered tiles are kept in the library's thumbnail cache: a new session (the next launch) shows
|
||||
/// them without reading the original, from the preview or the develop path alike; and a size
|
||||
/// between two steps reuses the tile of its step.
|
||||
#[test]
|
||||
fn face_tiles_are_kept_on_disk_for_the_next_launch() {
|
||||
let (dir, mut s, id, _) = setup("disk");
|
||||
let first = tile(&mut s, id, FACE, 64).unwrap();
|
||||
let _ = tile(&mut s, id, TINY_FACE, 64).unwrap();
|
||||
drop(s);
|
||||
|
||||
let mut s = open(&dir);
|
||||
let loads = count_loads(&mut s);
|
||||
let again = tile(&mut s, id, FACE, 64).unwrap();
|
||||
let tiny = tile(&mut s, id, TINY_FACE, 64).unwrap();
|
||||
assert_eq!(loads.get(), (0, 0), "both tiles come from the disk cache: the original is not touched");
|
||||
assert_eq!((again.width, tiny.width), (64, 64));
|
||||
assert!(mean_abs_diff(&first, &again) < 3.0, "the cached tile is the rendered one (as a JPEG)");
|
||||
// 60 px asks for the 64 px step: the same tile, no new render
|
||||
assert_eq!(s.face_job(id, FACE, 60).unwrap().key, s.face_job(id, FACE, 64).unwrap().key);
|
||||
let _ = tile(&mut s, id, FACE, 60).unwrap();
|
||||
assert_eq!(loads.get(), (0, 0));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// What can go wrong never panics: a preview loader that fails or answers nonsense, a damaged
|
||||
/// preview, face boxes at the edge or degenerate, an offline original, a damaged cache entry.
|
||||
#[test]
|
||||
fn face_tiles_never_crash() {
|
||||
let (dir, mut s, id, file) = setup("crash");
|
||||
|
||||
// a damaged cache entry: dropped, and the tile rendered again (from the preview)
|
||||
let job = s.face_job(id, FACE, 64).unwrap();
|
||||
let key = job.cache.as_ref().unwrap().1.to_string();
|
||||
assert!(job.run().rendered.is_ok());
|
||||
let thumbs = s.library.as_ref().unwrap().thumbs_dir();
|
||||
drop(s);
|
||||
let entry = thumbs.join(&key[..2]).join(format!("{key}.jpg"));
|
||||
assert!(entry.exists(), "the tile was written to the thumbnail cache");
|
||||
std::fs::write(&entry, b"not a jpeg").unwrap();
|
||||
let mut s = open(&dir);
|
||||
let loads = count_loads(&mut s);
|
||||
assert_eq!(tile(&mut s, id, FACE, 64).unwrap().width, 64);
|
||||
assert_eq!(loads.get(), (0, 1), "rendered again from the preview");
|
||||
|
||||
// face boxes at the corners, oversized, empty, not numbers: a tile or an error, never a panic
|
||||
for face in [
|
||||
Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.05 },
|
||||
Rect { x0: 0.95, y0: 0.95, x1: 1.0, y1: 1.0 },
|
||||
Rect { x0: -5.0, y0: -5.0, x1: 5.0, y1: 5.0 },
|
||||
Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
|
||||
Rect { x0: f64::NAN, y0: 0.5, x1: 0.6, y1: f64::INFINITY },
|
||||
Rect { x0: f64::NEG_INFINITY, y0: f64::NAN, x1: f64::NAN, y1: f64::NAN },
|
||||
] {
|
||||
for edge in [0, 1, 64, usize::MAX] {
|
||||
let img = tile(&mut s, id, face, edge).unwrap_or_else(|e| panic!("{face:?} at {edge}: {e}"));
|
||||
assert_eq!(img.width, img.height, "{face:?} at {edge}");
|
||||
}
|
||||
}
|
||||
|
||||
// a preview loader that answers nonsense or nothing: the develop path renders the tile
|
||||
let id2 = id;
|
||||
for answer in [None, Some(Rgba8 { width: W as usize, height: H as usize, data: Vec::new() }), Some(Rgba8::new(1, 1))] {
|
||||
s.media.preview_loader = Some(Arc::new(move |_: &str, _: usize| answer.clone()));
|
||||
let mut job = s.face_job(id2, FACE, 64).unwrap();
|
||||
job.cache = None;
|
||||
assert_eq!(job.run().rendered.unwrap().image.width, 64);
|
||||
}
|
||||
|
||||
// a damaged preview in the file: no tile from it, and no panic either way
|
||||
s.media.preview_loader = Some(crate::files::fs_preview_loader());
|
||||
let mut bytes = std::fs::read(&file).unwrap();
|
||||
let n = bytes.len();
|
||||
for b in bytes.iter_mut().take(n - 400_000).skip(1000) {
|
||||
*b = 0x5a;
|
||||
}
|
||||
std::fs::write(&file, &bytes).unwrap();
|
||||
let mut job = s.face_job(id, FACE, 64).unwrap();
|
||||
job.cache = None;
|
||||
let _ = job.run();
|
||||
|
||||
// the original gone (an offline drive): an error, no panic
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
let mut job = s.face_job(id, FACE, 64).unwrap();
|
||||
job.cache = None;
|
||||
assert!(job.run().rendered.is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -503,3 +503,30 @@ fn copies_run_side_by_side_with_the_same_outcome() {
|
||||
assert_eq!(first.file_name, "IMG_1.png");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_keeps_the_original_when_the_copy_changed_to_the_same_length() {
|
||||
use crate::import_move::{finish, place};
|
||||
let base = temp_dir("finish-recheck");
|
||||
let (src, dest) = (base.join("card/IMG_1.jpg"), base.join("dest"));
|
||||
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
|
||||
std::fs::write(&src, b"original bytes").unwrap();
|
||||
inject(Fault::NoLink);
|
||||
let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
|
||||
// unchanged copy: the original goes
|
||||
assert!(finish(&placed).is_ok());
|
||||
assert!(!src.exists());
|
||||
// a copy replaced by other bytes of the same length: the original stays
|
||||
std::fs::write(&src, b"original bytes").unwrap();
|
||||
let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
|
||||
std::fs::write(&placed.dst, b"different byte").unwrap();
|
||||
assert!(finish(&placed).is_err());
|
||||
assert_eq!(std::fs::read(&src).unwrap(), b"original bytes");
|
||||
// an original edited meanwhile to other bytes of the same length: it stays too
|
||||
let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
|
||||
std::fs::write(&src, b"edited bytes!").unwrap();
|
||||
assert!(finish(&placed).is_err());
|
||||
assert_eq!(std::fs::read(&src).unwrap(), b"edited bytes!");
|
||||
inject(Fault::None);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,57 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// An HDR merge keeps the reference frame's lens corrections. The sources carry an off-centre `WarpRectilinear` and
|
||||
/// `FixVignetteRadial` for their active area and have a default crop; the merged DNG must give "Enable Profile
|
||||
/// Corrections" the same correction as the reference photo, upright and for a vertical bracket (whose DNG keeps the
|
||||
/// pixels unturned).
|
||||
#[test]
|
||||
fn hdr_merge_keeps_the_reference_frames_lens_corrections() {
|
||||
use lightcraft_geom::{Orientation, Point};
|
||||
use lightcraft_pipeline::optics::{embedded_vignette_gain, embedded_warp};
|
||||
use lightcraft_raw::Opcode;
|
||||
for orientation in [Orientation::Normal, Orientation::Rotate90] {
|
||||
let dir = temp_dir(&format!("hdr-lens-{orientation:?}"));
|
||||
let mut paths = Vec::new();
|
||||
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() {
|
||||
let mut raw = lightcraft_raw::decode(&b).unwrap();
|
||||
raw.crop = lightcraft_raw::Rect::new(10, 6, 452, 304);
|
||||
raw.orientation = orientation;
|
||||
raw.metadata.orientation = Some(orientation);
|
||||
raw.opcodes.list3 = vec![
|
||||
Opcode::WarpRectilinear { planes: vec![[0.96, 0.05, -0.012, 0.002, 0.001, -0.0005]], center: [0.48, 0.53] },
|
||||
Opcode::FixVignetteRadial { k: [0.35, -0.05, 0.01, 0.0, 0.0], center: [0.48, 0.53] },
|
||||
];
|
||||
let p = dir.join(format!("IMG_{i}.dng"));
|
||||
std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap();
|
||||
paths.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let ids = import(&mut s, paths);
|
||||
let r = s.execute("merge.hdr", &json!({"ids": ids})).unwrap();
|
||||
assert_eq!(r["reference"].as_u64(), Some(ids[1]), "{r}");
|
||||
let merged = s.catalog.photo(lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap())).unwrap().clone();
|
||||
let reference = s.catalog.photo(lightcraft_catalog::PhotoId(ids[1])).unwrap().clone();
|
||||
let expect = if orientation == Orientation::Normal { (452, 304) } else { (304, 452) };
|
||||
assert_eq!(((merged.width, merged.height), (reference.width, reference.height)), (expect, expect));
|
||||
let a = reference.embedded_lens.expect("the source's correction");
|
||||
let b = merged.embedded_lens.expect("the merged DNG carries the reference frame's correction");
|
||||
let (wa, wb) = (a.warp.unwrap(), b.warp.unwrap());
|
||||
let (va, vb) = (a.vignette.unwrap(), b.vignette.unwrap());
|
||||
let (w, h) = (expect.0 as f64, expect.1 as f64);
|
||||
for (x, y) in [(0.0, 0.0), (w, h), (w, 0.0), (0.0, h), (w * 0.3, h * 0.7), (w * 0.5, h * 0.5)] {
|
||||
let p = Point::new(x, y);
|
||||
for ch in 0..3 {
|
||||
let (pa, pb) = (embedded_warp(&wa, p, ch, w, h), embedded_warp(&wb, p, ch, w, h));
|
||||
assert!((pa.x - pb.x).abs() < 1e-6 && (pa.y - pb.y).abs() < 1e-6, "{orientation:?} {p:?}: {pa:?} vs {pb:?}");
|
||||
}
|
||||
let (ga, gb) = (embedded_vignette_gain(&va, p, w, h), embedded_vignette_gain(&vb, p, w, h));
|
||||
assert!((ga - gb).abs() < 1e-9, "{orientation:?} {p:?}: {ga} vs {gb}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Raws without colour matrices of their own (CR2, NEF, ARW…) merge into a DNG with the colour they open with,
|
||||
/// not with no colour at all (issue #965: an HDR merge of CR2s rendered green and dark).
|
||||
#[test]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user