mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 19:17:02 -06:00
photo.editExternal takes the selection (or ids, at most 50): each photo gets its own -Edit copy stacked on its original, the copies are selected and returned in copies (path / id / original still name the first). Photo ▸ Edit In opens each copy in the editor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
523 lines
25 KiB
Rust
523 lines
25 KiB
Rust
//! Convert to DNG: a raw photo's original is re-encoded as a lossless DNG (its develop settings
|
|
//! embedded as XMP) next to it, and the photo is relinked to the DNG. The original stays on
|
|
//! disk; undo relinks the photo to it.
|
|
|
|
use std::path::Path;
|
|
|
|
use lightcraft_catalog::{MediaKind, Op, Source};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, bad, cmd, has_active, has_selection};
|
|
use crate::{Result, Session};
|
|
|
|
/// `<stem>.dng`, then `<stem>-2.dng`, `<stem>-3.dng`… next to `path`.
|
|
fn dng_names(path: &str) -> impl Iterator<Item = std::path::PathBuf> {
|
|
let p = Path::new(path);
|
|
let dir = p.parent().unwrap_or(Path::new("")).to_path_buf();
|
|
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
|
|
(1u64..).map(move |n| if n == 1 { dir.join(format!("{stem}.dng")) } else { dir.join(format!("{stem}-{n}.dng")) })
|
|
}
|
|
|
|
/// Write the DNG for raw file `path` (develop settings in `packet`) next to it under a free name;
|
|
/// returns the new path. An empty `packet` writes no XMP.
|
|
///
|
|
/// The DNG is checked before anyone relies on it (Convert to DNG relinks the photo to it, Copy as
|
|
/// DNG deletes the raw copy): the encoded file must decode to the same raw data, and it is
|
|
/// written to a temp file, synced, read back identical and only then given its name — never
|
|
/// replacing an existing file. On any failure no DNG is left behind and the raw stays in use.
|
|
pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result<String, String> {
|
|
write_dng_with(s.media.file_bytes.as_ref(), path, packet)
|
|
}
|
|
|
|
/// [`write_dng_for`] without the session (imports convert on a worker thread).
|
|
pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path: &str, packet: String) -> std::result::Result<String, String> {
|
|
let bytes = match file_bytes {
|
|
Some(r) => r(path)?,
|
|
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
|
|
};
|
|
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
|
drop(bytes);
|
|
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() })
|
|
.map_err(|e| e.to_string())?;
|
|
let back = lightcraft_raw::decode(&dng).map_err(|e| format!("{path}: the DNG written for it doesn't decode ({e}); the raw is kept"))?;
|
|
let same = match (&back.data, &raw.data) {
|
|
(lightcraft_raw::RawData::U16(a), lightcraft_raw::RawData::U16(b)) => a == b,
|
|
(lightcraft_raw::RawData::F32(a), lightcraft_raw::RawData::F32(b)) => {
|
|
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits())
|
|
}
|
|
_ => false,
|
|
};
|
|
if (back.width, back.height, back.cpp) != (raw.width, raw.height, raw.cpp) || !same {
|
|
return Err(format!("{path}: the DNG written for it doesn't hold the same raw data; the raw is kept"));
|
|
}
|
|
drop((raw, back));
|
|
let out = lightcraft_catalog::safe_file::write_new_unique(&mut dng_names(path), &dng)
|
|
.map_err(|e| format!("{path}: could not write its DNG ({e}); the raw is kept"))?;
|
|
Ok(out.to_string_lossy().to_string())
|
|
}
|
|
|
|
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "photo.convertToDng";
|
|
let ids = s.targets(p);
|
|
let mut ops = Vec::new();
|
|
let mut converted = Vec::new();
|
|
let mut skipped = Vec::new();
|
|
for id in ids {
|
|
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
|
|
let Source::File { path } = &ph.source else {
|
|
skipped.push(json!([id.0, "not a file"]));
|
|
continue;
|
|
};
|
|
if ph.kind != MediaKind::Raw || ph.format.eq_ignore_ascii_case("DNG") || ph.copy_of.is_some() {
|
|
skipped.push(json!([
|
|
id.0,
|
|
if ph.kind != MediaKind::Raw {
|
|
"not a raw photo"
|
|
} else if ph.copy_of.is_some() {
|
|
"a virtual copy"
|
|
} else {
|
|
"already a DNG"
|
|
}
|
|
]));
|
|
continue;
|
|
}
|
|
let packet = crate::sidecar::sidecar_packet(&ph, &s.catalog);
|
|
match write_dng_for(s, path, packet) {
|
|
Ok(out) => {
|
|
let file_name = Path::new(&out).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
|
// virtual copies of this photo follow it to the DNG
|
|
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
|
ops.push(Op::Relink {
|
|
id: c.id,
|
|
file_name: file_name.clone(),
|
|
source: Source::File { path: out.clone() },
|
|
format: Some("DNG".into()),
|
|
});
|
|
}
|
|
ops.push(Op::Relink { id, file_name, source: Source::File { path: out.clone() }, format: Some("DNG".into()) });
|
|
converted.push(json!({"id": id.0, "path": out, "original": path}));
|
|
}
|
|
Err(e) => skipped.push(json!([id.0, e])),
|
|
}
|
|
}
|
|
if !ops.is_empty() {
|
|
let n = converted.len();
|
|
s.commit(&format!("Convert {n} Photo{} to DNG", if n == 1 { "" } else { "s" }), Op::Batch { ops }).map_err(|e| bad(C, e.to_string()))?;
|
|
}
|
|
Ok(json!({"converted": converted, "skipped": skipped}))
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![cmd!(
|
|
"photo.convertToDng",
|
|
"Convert to DNG",
|
|
["Photo"],
|
|
None,
|
|
"{ids?} — write each raw photo as a lossless DNG next to it (settings embedded) and relink the photo to it; originals are kept → {converted: [{id, path, original}], skipped}",
|
|
has_selection,
|
|
convert
|
|
)]
|
|
}
|
|
|
|
/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit
|
|
/// TIFF (or PSD, or 8-bit) next to the original (`<name>-Edit.tif`, never overwriting), add it to
|
|
/// the library and stack it on top of the original. The host opens the file in the editor.
|
|
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "photo.editExternal";
|
|
let mut ids = s.targets(p);
|
|
ids.dedup();
|
|
if ids.is_empty() {
|
|
return Err(bad(C, "no active photo"));
|
|
}
|
|
if ids.len() > MAX_EDIT_COPIES {
|
|
return Err(bad(C, format!("{} photos selected: edit at most {MAX_EDIT_COPIES} at a time", ids.len())));
|
|
}
|
|
let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") {
|
|
"srgb" => lightcraft_pipeline::OutputSpace::Srgb,
|
|
"displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3,
|
|
"prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto,
|
|
_ => lightcraft_pipeline::OutputSpace::AdobeRgb,
|
|
};
|
|
let format = match super::str_param(p, "format").unwrap_or("tiff") {
|
|
"tiff" | "tif" => crate::export::ExportFormat::Tiff,
|
|
"psd" => crate::export::ExportFormat::Psd,
|
|
f => return Err(bad(C, format!("format {f:?}: use tiff or psd"))),
|
|
};
|
|
let bit_depth = match p.get("bitDepth").and_then(Value::as_u64).unwrap_or(16) {
|
|
8 => 8,
|
|
16 => 16,
|
|
d => return Err(bad(C, format!("bitDepth {d}: use 8 or 16"))),
|
|
};
|
|
let opts = crate::export::ExportOptions {
|
|
format,
|
|
bit_depth: Some(bit_depth),
|
|
color_space: space,
|
|
naming: "{name}-Edit".into(),
|
|
conflict: crate::export::Conflict::Unique,
|
|
..Default::default()
|
|
};
|
|
let mut copies = Vec::new();
|
|
for id in ids {
|
|
copies.push(edit_copy(s, p, id, &opts)?);
|
|
}
|
|
let new: Vec<lightcraft_catalog::PhotoId> = copies.iter().filter_map(|c| c["id"].as_u64()).map(lightcraft_catalog::PhotoId).collect();
|
|
s.selection = crate::Selection { active: new.first().copied(), ids: new };
|
|
let first = copies.first().cloned().unwrap_or(Value::Null);
|
|
Ok(json!({"path": first["path"], "id": first["id"], "original": first["original"], "copies": copies}))
|
|
}
|
|
|
|
/// Most photos one Edit In renders (each becomes a full-size file on disk and a library photo).
|
|
const MAX_EDIT_COPIES: usize = 50;
|
|
|
|
/// One photo's edit copy for [`edit_external`]: rendered with `opts` next to its original (a demo
|
|
/// photo into `dir`), added to the library and stacked on top of the original, expanded.
|
|
fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: &crate::export::ExportOptions) -> Result<Value> {
|
|
const C: &str = "photo.editExternal";
|
|
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
|
|
let dir = match &ph.source {
|
|
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
|
|
Source::Demo { .. } => match super::str_param(p, "dir") {
|
|
Some(d) => d.to_string(),
|
|
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
|
|
},
|
|
};
|
|
let mut written = Vec::new();
|
|
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
|
|
// durable: the TIFF becomes a library photo the external editor then changes
|
|
crate::export::write_file_durable(path, bytes)?;
|
|
written.push(path.to_string());
|
|
Ok(())
|
|
};
|
|
let to = crate::export::Destination { dir, exact: None };
|
|
crate::export::export_batch(s, &[id], opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
|
|
let ext = format!(".{}", opts.format.extension());
|
|
let out = written.into_iter().find(|w| w.ends_with(&ext)).ok_or_else(|| bad(C, "nothing was written"))?;
|
|
let r = s.execute("library.import", &json!({"paths": [out]}))?;
|
|
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
|
|
// stack: the edit on top of the original, expanded so both show
|
|
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
|
|
Ok(json!({"path": out, "id": new, "original": id.0}))
|
|
}
|
|
|
|
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
|
|
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
|
|
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
|
|
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
|
|
let same = info.content_hash == ph.content_hash
|
|
&& info.file_size == ph.file_size
|
|
&& (info.width, info.height) == (ph.width, ph.height)
|
|
&& info.preview_only == ph.preview_only;
|
|
(!same).then_some(Op::SetContent {
|
|
id,
|
|
width: info.width,
|
|
height: info.height,
|
|
file_size: info.file_size,
|
|
content_hash: info.content_hash,
|
|
preview_only: info.preview_only,
|
|
})
|
|
}
|
|
|
|
/// Ops that fill a photo's empty camera fields (camera, lens, exposure, GPS, capture time) from a fresh probe.
|
|
fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {
|
|
let (mut m, src) = (ph.meta.clone(), &info.meta);
|
|
let before = m.clone();
|
|
if m.camera.is_empty() {
|
|
m.camera = src.camera.clone();
|
|
}
|
|
if m.lens.is_empty() {
|
|
m.lens = src.lens.clone();
|
|
}
|
|
if m.shutter.is_empty() {
|
|
m.shutter = src.shutter.clone();
|
|
}
|
|
m.focal_mm = m.focal_mm.or(src.focal_mm);
|
|
m.aperture = m.aperture.or(src.aperture);
|
|
m.iso = m.iso.or(src.iso);
|
|
m.gps = m.gps.or(src.gps);
|
|
let mut ops = Vec::new();
|
|
if m != before {
|
|
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
|
}
|
|
if ph.captured.is_none() && info.captured.is_some() {
|
|
ops.push(Op::SetCaptured { id, captured: info.captured.clone() });
|
|
}
|
|
ops
|
|
}
|
|
|
|
/// Ops that give a photo the lens data its file carries now (`info`, a fresh probe), or none when
|
|
/// it already has them. A catalog from before the lens data was read at import has none, so such a
|
|
/// photo's correction stayed off. When the photo gains lens data and its develop settings are still
|
|
/// what import gave it, the lens correction is turned on as import would; an edited photo keeps
|
|
/// its settings.
|
|
pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {
|
|
if info.embedded_lens == ph.embedded_lens {
|
|
return Vec::new();
|
|
}
|
|
let mut ops = vec![Op::SetEmbeddedLens { id, lens: info.embedded_lens.map(Box::new) }];
|
|
// (a photo with a default-preset look keeps it: that look is its import state)
|
|
if ph.embedded_lens.is_none() && info.embedded_lens.is_some() && ph.import_look.is_none() && !ph.is_edited() && !ph.develop.optics.lens_profile {
|
|
let mut d = (*ph.develop).clone();
|
|
d.optics.lens_profile = true;
|
|
ops.push(Op::SetDevelop { id, settings: std::sync::Arc::new(d), label: "Lens Corrections".into(), edited: ph.edited.clone() });
|
|
}
|
|
ops
|
|
}
|
|
|
|
/// The op that gives a raw the as-shot white balance its file reads now (`info`, a fresh probe), or none when the
|
|
/// catalog already has it or the photo is not a raw edited in Kelvin. A raw imported before its camera's matrices
|
|
/// gave it a Kelvin scale keeps the relative scale's 6500 / 0 until a decode or Reload reads the file (issue #730).
|
|
pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Option<Op> {
|
|
let read = info.as_shot_wb.filter(|(t, tint)| t.is_finite() && tint.is_finite())?;
|
|
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
|
|
}
|
|
|
|
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
|
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
|
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = s.targets(p);
|
|
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
|
|
.iter()
|
|
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
|
|
Some(Source::File { path }) => Some((*id, path)),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
|
|
let mut ops = Vec::new();
|
|
let mut reloaded = Vec::new();
|
|
for ((id, _), info) in paths.into_iter().zip(probed) {
|
|
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
|
|
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
|
|
// nothing already set is overwritten
|
|
let meta_ops = fill_missing_meta(id, ph, &info);
|
|
let mut mine: Vec<Op> =
|
|
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect();
|
|
if !mine.is_empty() {
|
|
ops.append(&mut mine);
|
|
reloaded.push(id);
|
|
}
|
|
// virtual copies share the file
|
|
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
|
let theirs: Vec<Op> =
|
|
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect();
|
|
if !theirs.is_empty() {
|
|
ops.extend(theirs);
|
|
reloaded.push(c.id);
|
|
}
|
|
}
|
|
}
|
|
for id in &reloaded {
|
|
s.media.forget(*id);
|
|
}
|
|
if !ops.is_empty() {
|
|
s.commit("Reload Changed Files", Op::Batch { ops })?;
|
|
}
|
|
Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
|
}
|
|
|
|
/// Duplicate: copy the file next to itself (`<name>-copy`, never overwriting) and add it with the
|
|
/// same settings, metadata, rating, flag, label and albums. → {ids}
|
|
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "photo.duplicate";
|
|
let mut made = Vec::new();
|
|
for id in s.targets(p) {
|
|
let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue };
|
|
let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) };
|
|
let src = Path::new(path);
|
|
let stem = src.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
|
|
let ext = src.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
|
|
let dir = src.parent().unwrap_or(Path::new(""));
|
|
let mut dst = dir.join(format!("{stem}-copy{ext}"));
|
|
let mut n = 2;
|
|
while dst.exists() {
|
|
dst = dir.join(format!("{stem}-copy-{n}{ext}"));
|
|
n += 1;
|
|
}
|
|
std::fs::copy(src, &dst).map_err(|e| bad(C, format!("{path}: {e}")))?;
|
|
let new = s.catalog.alloc_photo_id();
|
|
let mut q = ph.clone();
|
|
q.id = new;
|
|
q.source = Source::File { path: dst.to_string_lossy().to_string() };
|
|
q.file_name = dst.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
|
// the same bytes, but its own photo: no duplicate-content clash, no shared history
|
|
q.content_hash = q.content_hash.map(|h| format!("{h}:dup{}", new.0));
|
|
q.copy_of = None;
|
|
q.copy_name = None;
|
|
q.history.clear();
|
|
q.versions.retain(|v| !v.auto);
|
|
let mut ops = vec![Op::AddPhoto { photo: Box::new(q) }];
|
|
for a in s.catalog.albums().filter(|a| !a.is_smart() && !a.folder && a.photos.contains(&id)) {
|
|
let mut photos = a.photos.clone();
|
|
photos.push(new);
|
|
ops.push(Op::SetAlbumPhotos { id: a.id, photos });
|
|
}
|
|
s.commit("Duplicate", Op::Batch { ops })?;
|
|
made.push(new);
|
|
}
|
|
s.merge_undo(made.len(), "Duplicate");
|
|
if let Some(last) = made.last() {
|
|
s.selection = crate::Selection { ids: made.clone(), active: Some(*last) };
|
|
}
|
|
Ok(json!({"ids": made.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
|
}
|
|
|
|
pub fn edit_specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
"photo.duplicate",
|
|
"Duplicate",
|
|
["Photo"],
|
|
None,
|
|
"{ids?} — copy each photo's file next to it (`-copy`) and add it with the same settings, metadata and albums (a real file, unlike a virtual copy) → {ids}",
|
|
has_selection,
|
|
duplicate
|
|
),
|
|
cmd!(
|
|
"photo.reload",
|
|
"Reload from Disk",
|
|
["Photo"],
|
|
None,
|
|
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
|
|
has_selection,
|
|
reload
|
|
),
|
|
cmd!(
|
|
"photo.editExternal",
|
|
"Edit Copy for External Editor",
|
|
[],
|
|
None,
|
|
"{ids?, colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render each selected photo (or `ids`, at most 50) with its edits as `<name>-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add each stacked on its original and select the copies → {path, id, original (the first copy), copies: [{path, id, original}]} (the app then opens them in the external editor)",
|
|
has_active,
|
|
edit_external
|
|
),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use lightcraft_catalog::{Photo, PhotoId, Source};
|
|
|
|
/// Reload fills camera fields a photo lacks (a CR3 imported before CR3 metadata was read) and keeps
|
|
/// everything already set.
|
|
#[test]
|
|
fn reload_fills_only_missing_camera_fields() {
|
|
let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.cr3".into() }, "x.cr3", "CR3", 1620, 1080, "2026-10-06T00:00:00");
|
|
ph.meta.title = "Mine".into();
|
|
ph.meta.iso = Some(800);
|
|
let mut info = crate::media::ProbeInfo { captured: Some("2026-10-04T08:16:11".into()), ..Default::default() };
|
|
info.meta.camera = "Canon EOS R6 Mark III".into();
|
|
info.meta.lens = "RF24-105mm".into();
|
|
info.meta.iso = Some(400);
|
|
info.meta.aperture = Some(6.3);
|
|
let ops = fill_missing_meta(PhotoId(1), &ph, &info);
|
|
let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") };
|
|
assert_eq!((meta.camera.as_str(), meta.lens.as_str(), meta.aperture), ("Canon EOS R6 Mark III", "RF24-105mm", Some(6.3)));
|
|
assert_eq!((meta.title.as_str(), meta.iso), ("Mine", Some(800)), "set fields are kept");
|
|
assert!(matches!(ops.get(1), Some(Op::SetCaptured { captured: Some(_), .. })));
|
|
// nothing missing → nothing to do
|
|
ph.meta = (**meta).clone();
|
|
ph.captured = Some("2026-10-04T08:16:11".into());
|
|
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
|
|
}
|
|
|
|
fn lens() -> lightcraft_develop::EmbeddedLens {
|
|
lightcraft_develop::EmbeddedLens {
|
|
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
|
|
vignette: None,
|
|
stages: Default::default(),
|
|
}
|
|
}
|
|
fn old_rw2() -> lightcraft_catalog::Photo {
|
|
// as an older build catalogued it: no lens data, correction off
|
|
lightcraft_catalog::Photo::new(
|
|
lightcraft_catalog::PhotoId(1),
|
|
Source::File { path: "/x.rw2".into() },
|
|
"x.rw2",
|
|
"RW2",
|
|
4592,
|
|
3448,
|
|
"2026-10-06T00:00:00",
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn reload_adds_lens_data_and_turns_the_correction_on_for_an_untouched_photo() {
|
|
let ph = old_rw2();
|
|
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
|
let ops = lens_ops(ph.id, &ph, &info);
|
|
assert!(matches!(ops.first(), Some(Op::SetEmbeddedLens { lens: Some(_), .. })), "{ops:?}");
|
|
let Some(Op::SetDevelop { settings, .. }) = ops.get(1) else { panic!("{ops:?}") };
|
|
assert!(settings.optics.lens_profile);
|
|
// once applied, the photo is still "unedited" and has what import would have given it
|
|
let mut c = lightcraft_catalog::Catalog::default();
|
|
c.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap();
|
|
for op in ops {
|
|
c.apply(op).unwrap();
|
|
}
|
|
let after = c.photo(lightcraft_catalog::PhotoId(1)).unwrap();
|
|
assert_eq!(after.embedded_lens, Some(lens()));
|
|
assert!(!after.is_edited());
|
|
assert_eq!(*after.develop, after.camera_defaults());
|
|
assert!(lens_ops(after.id, after, &info).is_empty(), "nothing more to do the second time");
|
|
}
|
|
|
|
#[test]
|
|
fn reload_adds_lens_data_but_keeps_an_edited_photos_settings() {
|
|
let mut ph = old_rw2();
|
|
let mut d = (*ph.develop).clone();
|
|
d.light.exposure = 0.7;
|
|
ph.develop = std::sync::Arc::new(d);
|
|
assert!(ph.is_edited());
|
|
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
|
let ops = lens_ops(ph.id, &ph, &info);
|
|
assert!(matches!(ops.as_slice(), [Op::SetEmbeddedLens { lens: Some(_), .. }]), "{ops:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn reload_leaves_a_photo_whose_user_switched_the_correction_off_alone() {
|
|
let mut ph = old_rw2();
|
|
ph.embedded_lens = Some(lens());
|
|
let mut d = ph.camera_defaults();
|
|
d.optics.lens_profile = false;
|
|
ph.develop = std::sync::Arc::new(d);
|
|
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
|
assert!(lens_ops(ph.id, &ph, &info).is_empty());
|
|
}
|
|
|
|
/// End to end on a public Panasonic raw (skipped without the corpus): a catalog from before the lens data was read
|
|
/// gets it on Reload, the render changes to the corrected one, and Undo takes it all back.
|
|
#[test]
|
|
fn corpus_reload_corrects_an_rw2_catalogued_without_lens_data() {
|
|
let root = std::env::var_os("LIGHTCRAFT_CORPUS")
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
|
|
let f = root.join("raw/rw2-panasonic-gx80.rw2");
|
|
if !f.exists() {
|
|
eprintln!("skip: {} absent", f.display());
|
|
return;
|
|
}
|
|
let mut s = Session::new().with_fs();
|
|
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
|
|
let id = s.active().unwrap();
|
|
let ph = s.catalog.photo(id).unwrap().clone();
|
|
assert!(ph.embedded_lens.is_some() && ph.develop.optics.lens_profile, "import reads it");
|
|
// back to how 0.4.0 catalogued it
|
|
let mut old = (*ph.develop).clone();
|
|
old.optics.lens_profile = false;
|
|
s.catalog.apply(Op::SetEmbeddedLens { id, lens: None }).unwrap();
|
|
s.catalog.apply(Op::SetDevelop { id, settings: std::sync::Arc::new(old), label: "x".into(), edited: None }).unwrap();
|
|
s.media.forget(id);
|
|
let before = s.render_now(id, 160, 120).unwrap().image.data;
|
|
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
|
|
assert_eq!(r["reloaded"], json!([id.0]));
|
|
let now = s.catalog.photo(id).unwrap().clone();
|
|
assert_eq!(now.embedded_lens, ph.embedded_lens);
|
|
assert!(now.develop.optics.lens_profile && !now.is_edited());
|
|
assert_ne!(s.render_now(id, 160, 120).unwrap().image.data, before, "the corrected render differs");
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
let undone = s.catalog.photo(id).unwrap();
|
|
assert!(undone.embedded_lens.is_none() && !undone.develop.optics.lens_profile, "one undo step");
|
|
}
|
|
}
|