M9.7: virtual copies — new catalog entries sharing the original file

Photo.copy_of / copy_name. photo.virtualCopy (Cmd+') clones the photo with
independent settings and history, names it "Copy N" (numbered per master),
adds it to the original's albums and stacks it (expanded) right after the
original; one undo step for several copies (Session::merge_undo). Copies
never write XMP sidecars. "Copy N" badge in the grid, Info panel line,
copy:yes search token. Also: Info panel stars laid out in a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 09:58:36 -04:00
parent 5434c5d172
commit 0571d8e438
11 changed files with 179 additions and 5 deletions

View File

@ -174,6 +174,13 @@ pub struct Photo {
/// "Enable Profile Corrections" is on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
/// A virtual copy: the photo it was copied from (it shares that photo's file but has its own
/// settings, metadata and history).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_of: Option<PhotoId>,
/// Virtual copies' name ("Copy 1").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_name: Option<String>,
}
impl Photo {
@ -202,6 +209,8 @@ impl Photo {
as_shot_wb: None,
content_hash: None,
embedded_lens: None,
copy_of: None,
copy_name: None,
}
}
pub fn is_edited(&self) -> bool {

View File

@ -97,6 +97,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
"edited" => (val == "true" || val == "yes") == p.is_edited(),
"date" => p.date().starts_with(val),
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
"name" | "file" => p.file_name.to_lowercase().contains(val),
_ => false,
};

View File

@ -26,7 +26,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
text: if b.is_multiple_of(5) { format!("{}", b % 10) } else { String::new() },
..Default::default()
};
match kind % 15 {
match kind % 16 {
0 | 1 => {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, &format!("{}.jpg", id.0), "JPEG", 60, 40, "2026-01-01");
@ -67,6 +67,18 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
}
13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) },
14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }),
15 => match c.photo(pid).cloned() {
// a virtual copy: same source, own id, copy_of/copy_name set
Some(src) => {
let id = c.alloc_photo_id();
let mut v = (*src).clone();
v.id = id;
v.copy_of = Some(pid);
v.copy_name = Some(format!("Copy {b}"));
Op::AddPhoto { photo: Box::new(v) }
}
None => Op::Batch { ops: vec![] },
},
_ => c.delete_permanently_ops(pid),
}
}

View File

@ -1,7 +1,7 @@
//! Organizing commands: stacks (group, ungroup, set top, expand/collapse, auto-stack by capture
//! time).
//! time) and virtual copies.
use lightcraft_catalog::{Op, PhotoId, StackId};
use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection};
@ -58,8 +58,73 @@ fn set_collapsed(s: &mut Session, stacks: Vec<StackId>, collapsed: bool, label:
Ok(json!({"changed": n}))
}
/// "Copy 3" → 3.
fn copy_number(name: Option<&str>) -> u32 {
name.and_then(|n| n.strip_prefix("Copy ")).and_then(|n| n.trim().parse().ok()).unwrap_or(0)
}
/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and
/// metadata, added to the same albums and stacked right after it (the stack is expanded so the
/// copy is visible).
fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) };
let master = orig.copy_of.unwrap_or(src);
let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0);
let id = s.catalog.alloc_photo_id();
let mut c = (*orig).clone();
c.id = id;
c.copy_of = Some(master);
c.copy_name = Some(format!("Copy {n}"));
c.versions.clear();
c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }];
c.deleted = false;
let mut ops = vec![Op::AddPhoto { photo: Box::new(c) }];
for a in s.catalog.albums().filter(|a| !a.is_smart() && a.photos.contains(&src)) {
let mut photos = a.photos.clone();
let at = photos.iter().position(|p| *p == src).map_or(photos.len(), |i| i + 1);
photos.insert(at, id);
ops.push(Op::SetAlbumPhotos { id: a.id, photos });
}
match s.catalog.stack_of(src).cloned() {
Some(st) => {
let mut photos = st.photos.clone();
let at = st.position(src).map_or(photos.len(), |i| i + 1);
photos.insert(at, id);
ops.push(Op::SetStack { id: st.id, photos, collapsed: false });
}
None => {
let sid = s.catalog.alloc_stack_id();
ops.push(Op::AddStack { stack: Stack { id: sid, photos: vec![src, id], collapsed: false } });
}
}
s.commit("Create Virtual Copy", Op::Batch { ops })?;
Ok(Some(id))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"photo.virtualCopy",
"Create Virtual Copy",
["Photo"],
Some("Cmd+'"),
"{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"",
has_selection,
|s, p| {
let targets = s.targets(p);
let mut made = Vec::new();
for t in targets {
if let Some(id) = virtual_copy(s, t)? {
made.push(id);
}
}
s.merge_undo(made.len(), "Create Virtual Copy");
if let Some(last) = made.last() {
s.selection = Selection { ids: made.clone(), active: Some(*last) };
}
Ok(json!({"ids": made.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
),
cmd!(
"stack.group",
"Group into Stack",

View File

@ -24,6 +24,8 @@ pub fn photo_summary(p: &Photo) -> Value {
"keywords": p.meta.keywords,
"camera": p.meta.camera,
"deleted": p.deleted,
"copyOf": p.copy_of.map(|c| c.0),
"copyName": p.copy_name,
})
}

View File

@ -182,6 +182,17 @@ impl Session {
Ok(())
}
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
/// depends on the state the previous one left).
pub fn merge_undo(&mut self, n: usize, label: &str) {
if n < 2 || n > self.undo.len() {
return;
}
let tail = self.undo.split_off(self.undo.len() - n);
let ops = tail.into_iter().rev().map(|e| e.op).collect();
self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops } });
}
/// Apply without recording undo (interactive previews).
fn apply_silent(&mut self, op: Op) -> Result<()> {
self.catalog.apply(op)?;

View File

@ -241,6 +241,9 @@ impl Session {
/// Write the photo's XMP sidecar; returns its path.
pub fn save_sidecar(&self, id: PhotoId) -> Result<PathBuf> {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
if p.copy_of.is_some() {
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
}
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
let path = sidecar_path(orig, self.xmp.naming);
write_atomic(&path, sidecar_packet(p).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
@ -272,7 +275,7 @@ impl Session {
let mut ids = Vec::new();
ops.iter().for_each(|o| op_photos(o, &mut ids));
for id in ids {
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some())
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(id)
{
log::warn!("auto-write XMP: {e}");

View File

@ -79,6 +79,52 @@ fn smart_album_from_smart_album_view_keeps_its_rules() {
assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id)));
}
#[test]
fn virtual_copies_are_independent_and_persist() {
let dir = temp_dir("vc");
let mut s = open(&dir);
let src = s.visible_cloned()[1];
let album = s.execute("album.create", &json!({"name": "Keep", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
s.execute("album.addPhotos", &json!({"id": album, "ids": [src.0]})).unwrap();
s.execute("library.select", &json!({"ids": [src.0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap();
let undo_before = s.undo.len();
let r = s.execute("photo.virtualCopy", &json!({})).unwrap();
assert_eq!(s.undo.len(), undo_before + 1);
let c1 = lightcraft_catalog::PhotoId(r["ids"][0].as_u64().unwrap());
let r2 = s.execute("photo.virtualCopy", &json!({"ids": [src.0]})).unwrap();
let c2 = lightcraft_catalog::PhotoId(r2["ids"][0].as_u64().unwrap());
let (p, a, b) = (s.catalog.photo(src).unwrap().clone(), s.catalog.photo(c1).unwrap().clone(), s.catalog.photo(c2).unwrap().clone());
assert_eq!((a.copy_of, a.copy_name.as_deref()), (Some(src), Some("Copy 1")));
assert_eq!(b.copy_name.as_deref(), Some("Copy 2"));
assert_eq!(a.source, p.source, "shares the original file");
assert_eq!(a.develop.light.exposure, 0.5, "starts from the current settings");
// a copy of a copy names itself after the master
let r3 = s.execute("photo.virtualCopy", &json!({"ids": [c1.0]})).unwrap();
let c3 = s.catalog.photo(lightcraft_catalog::PhotoId(r3["ids"][0].as_u64().unwrap())).unwrap().clone();
assert_eq!((c3.copy_of, c3.copy_name.as_deref()), (Some(src), Some("Copy 3")));
// independent settings
s.execute("library.select", &json!({"ids": [c1.0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap();
assert_eq!(s.catalog.photo(src).unwrap().develop.light.exposure, 0.5);
assert_eq!(s.catalog.photo(c1).unwrap().develop.light.exposure, -1.0);
// same album, stacked (expanded) with the original, visible in the grid
assert!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.starts_with(&[src, c2, c1, c3.id]));
assert_eq!(s.catalog.stack_of(c1).unwrap().photos, vec![src, c2, c1, c3.id]);
assert!(s.visible_cloned().contains(&c2));
let found = s.execute("catalog.query", &json!({"filter": {"text": "copy:yes"}})).unwrap();
assert_eq!(found["total"], 3);
// undo removes a copy completely
s.execute("edit.undo", &json!({})).unwrap(); // exposure
s.execute("edit.undo", &json!({})).unwrap(); // third copy
assert!(s.catalog.photo(c3.id).is_none());
let expect = s.catalog.to_snapshot();
drop(s);
let s2 = open(&dir);
assert_eq!(s2.catalog.to_snapshot(), expect);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn stacks_group_collapse_and_replay() {
let dir = temp_dir("stacks");

View File

@ -198,6 +198,23 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
paint(p, Rect::from_min_size(pos2(bar.right() - 20.0, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::Sliders, t.text_label);
}
}
if let Some(name) = &photo.copy_name {
// virtual copy: a folded-corner tag at the top right
let g = p.layout_no_wrap(name.clone(), t.semibold(10.5), Color32::WHITE);
let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) };
let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0));
p.rect_filled(br, 10.0, Color32::from_black_alpha(165));
let c = pos2(br.left() + 13.0, br.center().y);
let s = 5.0;
p.add(egui::Shape::convex_polygon(
vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)],
Color32::TRANSPARENT,
Stroke::new(1.2, Color32::WHITE),
));
p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE));
p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE));
p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE);
}
if photo.flag == Flag::Reject {
p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110));
}
@ -280,6 +297,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
}
});
if ui.button("Create Virtual Copy").clicked() {
let _ = app.run("photo.virtualCopy", json!({}));
}
ui.menu_button("Stack", |ui| {
let stacked = app.session.catalog.stack_of(id).is_some();
let several = app.session.selection.ids.len() > 1;

View File

@ -243,8 +243,12 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
padded(ui, |ui| {
ui.label(egui::RichText::new(&p.file_name).font(t.semibold(14.0)).color(t.text));
ui.label(egui::RichText::new(format!("{} × {} · {}", p.width, p.height, p.format)).color(t.text_dim));
if let Some(name) = &p.copy_name {
let of = p.copy_of.and_then(|m| app.session.catalog.photo(m)).map(|m| m.file_name.clone()).unwrap_or_else(|| "a removed photo".into());
ui.label(egui::RichText::new(format!("Virtual copy “{name}” of {of}")).color(t.text_label));
}
ui.add_space(8.0);
if let Some(r) = crate::widgets::stars(ui, "info", p.rating, 20.0) {
if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner {
let _ = app.run("photo.rate", json!({"rating": r}));
}
});

View File

@ -27,6 +27,7 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
"-" => Some(Key::Minus),
"[" => Some(Key::OpenBracket),
"]" => Some(Key::CloseBracket),
"'" => Some(Key::Quote),
_ => None,
})
}