diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs
--- a/crates/engine/src/cmd.rs
+++ b/crates/engine/src/cmd.rs
@@ -13,6 +13,7 @@ mod cull;
 pub mod curves;
 pub(crate) mod denoise;
 mod develop;
+mod duplicates;
 mod edit;
 mod export;
 pub(crate) mod face_detect;
@@ -154,6 +155,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
         v.extend(lut_profiles::specs());
         v.extend(mask_effects::specs());
         v.extend(cull::specs());
+        v.extend(duplicates::specs());
         v.extend(convert::specs());
         v.extend(convert::edit_specs());
         v.extend(merge::specs());
diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs
--- a/crates/engine/src/cmd/cull.rs
+++ b/crates/engine/src/cmd/cull.rs
@@ -105,6 +105,18 @@ fn burst_params(p: &Value) -> Result<(f64, f32)> {
     Ok((num("burstGap", BURST_GAP, MAX_GAP)?, num("similarity", SAME, 1.0)? as f32))
 }
 
+/// A photo's look-alike signature (from its thumbnail-level source), cached by content.
+pub(super) fn signature_of(s: &mut Session, id: PhotoId) -> Option<[f32; 64]> {
+    let key = s.catalog.photo(id).map(|p| crate::media::content_key(p))?;
+    if let Some(v) = s.signatures.get(&key) {
+        return Some(*v);
+    }
+    let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
+    let v = lightcraft_pipeline::cull::signature(&src);
+    s.signatures.insert(key, v);
+    Some(v)
+}
+
 /// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by
 /// content), most similar first; the view is filtered to them.
 fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
@@ -112,16 +124,7 @@ fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
     let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
     let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32;
     let all: Vec<PhotoId> = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect();
-    let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> {
-        let key = s.catalog.photo(id).map(|p| crate::media::content_key(p))?;
-        if let Some(v) = s.signatures.get(&key) {
-            return Some(*v);
-        }
-        let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
-        let v = lightcraft_pipeline::cull::signature(&src);
-        s.signatures.insert(key, v);
-        Some(v)
-    };
+    let sig = signature_of;
     let me = sig(s, id).ok_or_else(|| super::bad(C, "can't read the photo"))?;
     let mut hits: Vec<(PhotoId, f32)> = Vec::new();
     for q in all {
diff --git a/crates/engine/src/cmd/duplicates.rs b/crates/engine/src/cmd/duplicates.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/cmd/duplicates.rs
@@ -0,0 +1,364 @@
+//! Duplicates (`library.findDuplicates`, `library.resolveDuplicates`): list the library's duplicate photos in groups, at
+//! two levels that need no AI model, and mark the copies the user does not keep Rejected.
+//!
+//! - **exact**: byte-identical files (the same content hash).
+//! - **near**: the same picture saved again (resized, re-encoded, converted): the 8 x 8 brightness signature Find Similar
+//!   uses, with a strict cut-off and the same shape (aspect ratio). Exact copies are in these groups too.
+//!
+//! Each group names the copy to keep (the user's own marks first, then raw, edits, pixels, focus). Nothing here deletes a
+//! photo: rejected photos stay in the library until the user deletes them (Photo > Delete Rejected Photos).
+
+use std::collections::BTreeMap;
+
+use lightcraft_catalog::{Flag, MediaKind, Op, Photo, PhotoId, Source};
+use lightcraft_pipeline::cull::similarity;
+use serde_json::{Value, json};
+
+use super::{CommandSpec, always, bad, cmd};
+use crate::{Result, Session};
+
+/// Signature similarity from which two photos are "the same picture" (Find Similar's default is 0.8; the same picture
+/// saved again measures 0.99 and more).
+const NEAR_MIN: f64 = 0.985;
+/// The least `similarity` accepted: below it look-alikes, not copies, would be grouped.
+const NEAR_FLOOR: f64 = 0.95;
+/// Aspect ratios of one picture saved again agree within this share.
+const ASPECT_TOLERANCE: f64 = 0.02;
+
+fn known_keys(p: &Value, allowed: &[&str], c: &str) -> Result<()> {
+    let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !allowed.contains(&k.as_str()))) else { return Ok(()) };
+    Err(bad(c, format!("unknown parameter `{k}`: use {}", allowed.join(", "))))
+}
+
+/// A photo a scan can look at: in the library, not a video, not a virtual copy (which is meant to share its file).
+fn usable(p: &Photo) -> bool {
+    p.in_library() && p.copy_of.is_none() && p.duration.is_none()
+}
+
+/// What byte-identical files share: their content hash (a demo photo, which has no file, its scene).
+fn exact_key(p: &Photo) -> Option<String> {
+    match (&p.content_hash, &p.source) {
+        (Some(h), _) => Some(h.clone()),
+        (None, Source::Demo { scene }) => Some(format!("demo:{scene}")),
+        (None, Source::File { .. }) => None,
+    }
+}
+
+/// Higher is better to keep: the user's own marks first (pick, stars, never a reject), then what the file holds (raw,
+/// edits, pixels), then focus.
+fn score(p: &Photo) -> f64 {
+    let pixels = f64::from(p.width) * f64::from(p.height);
+    let mut s = 0.0;
+    if p.flag == Flag::Reject {
+        s -= 1e6;
+    }
+    if p.flag == Flag::Pick {
+        s += 1e5;
+    }
+    s += f64::from(p.rating.min(5)) * 1e4;
+    if p.kind == MediaKind::Raw && p.preview_only.is_none() {
+        s += 5e3;
+    }
+    if p.edited.is_some() {
+        s += 2e3;
+    }
+    s += (pixels.max(1.0).log2() * 50.0).min(1.5e3);
+    s + f64::from(p.analysis.as_ref().map_or(0.0, |a| a.sharpness).clamp(0.0, 100.0))
+}
+
+/// Why `keep` is proposed over `other`, as ids the interface words.
+fn reasons_over(keep: &Photo, other: &Photo) -> Vec<&'static str> {
+    let mut out = Vec::new();
+    if keep.flag == Flag::Pick && other.flag != Flag::Pick {
+        out.push("picked");
+    }
+    if keep.rating > other.rating {
+        out.push("rated");
+    }
+    let raw = |p: &Photo| p.kind == MediaKind::Raw && p.preview_only.is_none();
+    if raw(keep) && !raw(other) {
+        out.push("raw");
+    }
+    if keep.edited.is_some() && other.edited.is_none() {
+        out.push("edited");
+    }
+    if u64::from(keep.width) * u64::from(keep.height) > u64::from(other.width) * u64::from(other.height) {
+        out.push("larger");
+    }
+    let sharp = |p: &Photo| p.analysis.as_ref().map_or(0.0, |a| a.sharpness);
+    if sharp(keep) > sharp(other) + 5.0 {
+        out.push("sharper");
+    }
+    out
+}
+
+/// Union-find over `n` items.
+struct Sets(Vec<usize>);
+
+impl Sets {
+    fn new(n: usize) -> Self {
+        Sets((0..n).collect())
+    }
+    fn find(&mut self, mut i: usize) -> usize {
+        while let Some(&parent) = self.0.get(i) {
+            if parent == i {
+                break;
+            }
+            let up = self.0.get(parent).copied().unwrap_or(parent);
+            if let Some(slot) = self.0.get_mut(i) {
+                *slot = up;
+            }
+            i = up;
+        }
+        i
+    }
+    fn join(&mut self, a: usize, b: usize) {
+        let (a, b) = (self.find(a), self.find(b));
+        if a != b
+            && let Some(slot) = self.0.get_mut(a.max(b))
+        {
+            *slot = a.min(b);
+        }
+    }
+}
+
+/// A set of byte-identical photos, the one to keep first, and what the near level knows of the set.
+struct Unit {
+    /// Best to keep first.
+    members: Vec<PhotoId>,
+    signature: Option<[f32; 64]>,
+    aspect: f64,
+}
+
+/// `ids` ordered best-to-keep first (ties: the lower id).
+fn ranked(s: &Session, mut ids: Vec<PhotoId>) -> Vec<PhotoId> {
+    let score_of = |id: &PhotoId| s.catalog.photo(*id).map_or(f64::MIN, |p| score(p));
+    ids.sort_by(|a, b| score_of(b).total_cmp(&score_of(a)).then(a.cmp(b)));
+    ids
+}
+
+/// The photos a scan looks at: the ones named, else every usable library photo.
+fn scope(s: &Session, p: &Value, c: &str) -> Result<Vec<PhotoId>> {
+    match super::photo_ids(p, c)? {
+        None => Ok(s.catalog.photos().filter(|q| usable(q)).map(|q| q.id).collect()),
+        Some(named) => {
+            let mut seen = std::collections::HashSet::new();
+            let mut out = Vec::new();
+            for id in named {
+                if !s.catalog.photo(id).is_some_and(|q| usable(q)) {
+                    return Err(bad(c, format!("photo {} is not a library photo a scan can look at", id.0)));
+                }
+                // a photo named twice is one photo (it would otherwise be grouped with itself)
+                if seen.insert(id) {
+                    out.push(id);
+                }
+            }
+            Ok(out)
+        }
+    }
+}
+
+fn find(s: &mut Session, p: &Value) -> Result<Value> {
+    const C: &str = "library.findDuplicates";
+    known_keys(p, &["level", "ids", "similarity"], C)?;
+    let near = match p.get("level") {
+        None | Some(Value::Null) => true,
+        Some(Value::String(l)) if l == "near" => true,
+        Some(Value::String(l)) if l == "exact" => false,
+        Some(v) => return Err(bad(C, format!("`level` must be \"exact\" or \"near\" (got {v})"))),
+    };
+    let min = match p.get("similarity") {
+        None | Some(Value::Null) => NEAR_MIN,
+        Some(v) => v
+            .as_f64()
+            .filter(|x| (NEAR_FLOOR..=1.0).contains(x))
+            .ok_or_else(|| bad(C, format!("`similarity` must be a number from {NEAR_FLOOR} to 1 (got {v})")))?,
+    };
+    let ids = scope(s, p, C)?;
+
+    // exact: the same content hash
+    let mut by_hash: BTreeMap<String, Vec<PhotoId>> = BTreeMap::new();
+    let mut alone: Vec<PhotoId> = Vec::new();
+    for &id in &ids {
+        match s.catalog.photo(id).and_then(|q| exact_key(q)) {
+            Some(key) => by_hash.entry(key).or_default().push(id),
+            None => alone.push(id),
+        }
+    }
+    let mut units: Vec<Unit> = Vec::new();
+    for members in by_hash.into_values().chain(alone.into_iter().map(|id| vec![id])) {
+        units.push(Unit { members: ranked(s, members), signature: None, aspect: 0.0 });
+    }
+    units.sort_by_key(|u| u.members.iter().map(|m| m.0).min());
+
+    // near: one signature per unit, read from its best copy; units of the same shape that look the same are one group
+    let mut unreadable: Vec<u64> = Vec::new();
+    let mut sets = Sets::new(units.len());
+    if near {
+        for u in &mut units {
+            let Some(&best) = u.members.first() else { continue };
+            let (w, h) = s.catalog.photo(best).map_or((0, 0), |q| (q.width, q.height));
+            u.aspect = if w > 0 && h > 0 { f64::from(w) / f64::from(h) } else { 0.0 };
+            if u.aspect > 0.0 {
+                u.signature = super::cull::signature_of(s, best).filter(|sig| sig.iter().any(|v| *v != 0.0));
+                if u.signature.is_none() {
+                    unreadable.extend(u.members.iter().map(|m| m.0));
+                }
+            }
+        }
+        join_look_alikes(&units, min as f32, &mut sets);
+    }
+
+    // groups: the units that ended in one set
+    let mut sets_members: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
+    for i in 0..units.len() {
+        sets_members.entry(sets.find(i)).or_default().push(i);
+    }
+    let mut groups: Vec<(bool, u64, Value)> = Vec::new();
+    for unit_ids in sets_members.into_values() {
+        let members: Vec<PhotoId> = unit_ids.iter().filter_map(|&i| units.get(i)).flat_map(|u| u.members.iter().copied()).collect();
+        if members.len() < 2 {
+            continue;
+        }
+        let all = ranked(s, members);
+        let Some(&keep) = all.first() else { continue };
+        let Some(keep_unit) = unit_ids.iter().filter_map(|&i| units.get(i)).find(|u| u.members.contains(&keep)) else { continue };
+        let exact_level = unit_ids.len() == 1;
+        let photos: Vec<Value> = all
+            .iter()
+            .filter_map(|&id| {
+                let q = s.catalog.photo(id)?;
+                let unit = unit_ids.iter().filter_map(|&i| units.get(i)).find(|u| u.members.contains(&id))?;
+                let same_file = std::ptr::eq(unit, keep_unit);
+                let alike = match (same_file, &unit.signature, &keep_unit.signature) {
+                    (true, ..) => 1.0,
+                    (false, Some(a), Some(b)) => similarity(a, b),
+                    _ => 0.0,
+                };
+                Some(json!({
+                    "id": id.0,
+                    "file": q.file_name,
+                    "width": q.width,
+                    "height": q.height,
+                    "exact": same_file,
+                    "similarity": (f64::from(alike) * 1000.0).round() / 1000.0,
+                    "reasons": if id == keep { Vec::new() } else { s.catalog.photo(keep).map(|k| reasons_over(k, q)).unwrap_or_default() },
+                }))
+            })
+            .collect();
+        let mut reasons: Vec<&str> = Vec::new();
+        for r in photos.iter().skip(1).filter_map(|p| p["reasons"].as_array()).flatten().filter_map(Value::as_str) {
+            if !reasons.contains(&r) {
+                reasons.push(r);
+            }
+        }
+        let first = all.iter().map(|i| i.0).min().unwrap_or(0);
+        groups.push((
+            !exact_level,
+            first,
+            json!({"level": if exact_level { "exact" } else { "near" }, "keep": keep.0, "reasons": reasons, "photos": photos}),
+        ));
+    }
+    // exact groups first, then the near ones, each by its lowest photo id
+    groups.sort_by_key(|g| (g.0, g.1));
+    let in_groups: usize = groups.iter().map(|g| g.2["photos"].as_array().map_or(0, Vec::len)).sum();
+    let groups: Vec<Value> = groups.into_iter().map(|g| g.2).collect();
+    Ok(
+        json!({"level": if near { "near" } else { "exact" }, "scanned": ids.len(), "groups": groups, "photosInGroups": in_groups, "unreadable": unreadable}),
+    )
+}
+
+/// Join into one set the units whose signatures reach `min` and whose shapes agree. Units are swept in order of their
+/// signature's position along one fixed direction: two signatures that are `min` alike are no further apart than
+/// `sqrt(2 - 2 min)` there, so a unit is only compared with the ones that close.
+fn join_look_alikes(units: &[Unit], min: f32, sets: &mut Sets) {
+    let weights: [f32; 64] = std::array::from_fn(|i| (i % 8) as f32 - 3.5);
+    let norm = weights.iter().map(|w| w * w).sum::<f32>().sqrt().max(1e-6);
+    let along = |sig: &[f32; 64]| sig.iter().zip(&weights).map(|(v, w)| v * w).sum::<f32>() / norm;
+    let mut order: Vec<(f32, usize)> = units.iter().enumerate().filter_map(|(i, u)| Some((along(u.signature.as_ref()?), i))).collect();
+    order.sort_by(|a, b| a.0.total_cmp(&b.0));
+    let reach = (2.0 - 2.0 * min).max(0.0).sqrt() + 1e-4;
+    for (n, &(pos, i)) in order.iter().enumerate() {
+        let Some(a) = units.get(i) else { continue };
+        for &(other, j) in order.iter().skip(n + 1) {
+            if other - pos > reach {
+                break;
+            }
+            let Some(b) = units.get(j) else { continue };
+            let (Some(sa), Some(sb)) = (&a.signature, &b.signature) else { continue };
+            let shape = (a.aspect - b.aspect).abs() <= ASPECT_TOLERANCE * a.aspect.max(b.aspect);
+            if shape && similarity(sa, sb) >= min {
+                sets.join(i, j);
+            }
+        }
+    }
+}
+
+/// Mark every photo of a group Rejected except the ones kept. One undo step; nothing is deleted.
+fn resolve(s: &mut Session, p: &Value) -> Result<Value> {
+    const C: &str = "library.resolveDuplicates";
+    known_keys(p, &["ids", "keep"], C)?;
+    let ids = super::photo_ids(p, C)?.ok_or_else(|| bad(C, "missing `ids`: the photos of the group (from library.findDuplicates)"))?;
+    let keep = match p.get("keep") {
+        None | Some(Value::Null) => return Err(bad(C, "missing `keep`: the photo ids of the group to keep (at least one)")),
+        Some(Value::Array(a)) => {
+            a.iter().map(|v| v.as_u64().map(PhotoId).ok_or_else(|| bad(C, "`keep` must be photo ids (whole numbers)"))).collect::<Result<Vec<_>>>()?
+        }
+        Some(_) => return Err(bad(C, "`keep` must be a list of photo ids")),
+    };
+    if keep.is_empty() {
+        return Err(bad(C, "keep at least one photo of the group: nothing is rejected without a photo kept"));
+    }
+    let mut group: Vec<PhotoId> = Vec::new();
+    for id in ids {
+        if !s.catalog.photo(id).is_some_and(|q| usable(q)) {
+            return Err(bad(C, format!("photo {} is not a library photo", id.0)));
+        }
+        if !group.contains(&id) {
+            group.push(id);
+        }
+    }
+    if group.len() < 2 {
+        return Err(bad(C, "a group has at least two photos"));
+    }
+    if let Some(x) = keep.iter().find(|k| !group.contains(k)) {
+        return Err(bad(C, format!("photo {} is not in `ids`", x.0)));
+    }
+    let mut ops = Vec::new();
+    let (mut rejected, mut kept) = (Vec::new(), Vec::new());
+    for id in &group {
+        if keep.contains(id) {
+            kept.push(id.0);
+        } else if s.catalog.photo(*id).is_some_and(|q| q.flag != Flag::Reject) {
+            ops.push(Op::SetFlag { id: *id, flag: Flag::Reject });
+            rejected.push(id.0);
+        }
+    }
+    if !ops.is_empty() {
+        s.commit("Mark Duplicates Rejected", Op::Batch { ops })?;
+    }
+    Ok(json!({"rejected": rejected, "kept": kept}))
+}
+
+pub fn specs() -> Vec<CommandSpec> {
+    vec![
+        cmd!(
+            query "library.findDuplicates",
+            "Find Duplicates",
+            [],
+            None,
+            "{level?: exact | near (default), ids?: photo ids (default: every library photo but videos and virtual copies), similarity?: 0.95..1 (0.985; near only)} — group the duplicate photos, no AI model: exact = byte-identical files (same content hash); near = also the same picture saved again (resized, re-encoded), by Find Similar's 8x8 brightness signature at a strict cut-off and the same shape. Slow on a big library the first time (every photo's signature is read once; they are cached) → {level, scanned, photosInGroups, unreadable: [ids], groups: [{level: exact | near, keep: the copy to keep (the user's pick, stars, a raw file, edits, more pixels, sharper), reasons: [picked | rated | raw | edited | larger | sharper], photos: [{id, file, width, height, exact: byte-identical to the keeper, similarity, reasons}]}]}",
+            always,
+            find
+        ),
+        cmd!(
+            "library.resolveDuplicates",
+            "Mark Others Rejected",
+            [],
+            None,
+            "{ids: the photos of one group, keep: [the ids to keep, at least one of them]} — mark the group's other photos Rejected (one undo step); never deletes a photo (Delete Rejected Photos does that, when the user asks) → {rejected, kept}",
+            always,
+            resolve
+        ),
+    ]
+}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -10,6 +10,7 @@ mod constrain_crop;
 mod crop_angle;
 mod crop_aspect;
 mod denoise;
+mod duplicates;
 mod export;
 mod face_models;
 #[cfg(not(target_arch = "wasm32"))]
diff --git a/crates/engine/src/tests/duplicates.rs b/crates/engine/src/tests/duplicates.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/tests/duplicates.rs
@@ -0,0 +1,213 @@
+//! Duplicates: byte-identical files, the same picture saved again, a different picture that must not group, the
+//! keep / reject action, and malformed input.
+
+use std::path::Path;
+
+use lightcraft_catalog::{Flag, Op, PhotoId, Source};
+use serde_json::{Value, json};
+
+use crate::Session;
+
+fn temp_dir(tag: &str) -> std::path::PathBuf {
+    let d = std::env::temp_dir().join(format!("lc-dupes-{tag}-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&d);
+    std::fs::create_dir_all(&d).unwrap();
+    d
+}
+
+/// A picture with some structure, drawn at any size: `seed` picks which picture (the same seed at another size is the
+/// same picture resized).
+fn picture(seed: u32, w: usize, h: usize) -> lightcraft_raster::Rgba8 {
+    let data = (0..w * h)
+        .map(|i| {
+            let (u, v) = ((i % w) as f32 / w as f32, (i / w) as f32 / h as f32);
+            let (fx, fy) = ((1 + seed % 3) as f32, (1 + seed / 3) as f32);
+            let l = 0.5 + 0.35 * (std::f32::consts::TAU * u * fx + seed as f32).sin() * (std::f32::consts::TAU * v * fy).cos();
+            let c = |k: f32| (l * k * 255.0).clamp(0.0, 255.0) as u8;
+            [c(1.0), c(0.85), c(0.6), 255]
+        })
+        .collect();
+    lightcraft_raster::Rgba8 { width: w, height: h, data }
+}
+
+fn write_png(path: &Path, seed: u32, w: usize, h: usize) {
+    let img = picture(seed, w, h);
+    let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &Default::default()).unwrap();
+    std::fs::write(path, bytes).unwrap();
+}
+
+fn write_jpeg(path: &Path, seed: u32, w: usize, h: usize, quality: u8) {
+    let img = picture(seed, w, h);
+    let bytes = lightcraft_codecs::encode_jpeg(
+        &lightcraft_codecs::EncodeImage::rgba8(&img),
+        quality,
+        lightcraft_codecs::ChromaSubsampling::S420,
+        &Default::default(),
+    )
+    .unwrap();
+    std::fs::write(path, bytes).unwrap();
+}
+
+/// A library of: `a.png` (picture 1, 96 x 64), `a-resaved.jpg` (the same picture re-encoded), `a-small.png` (resized to
+/// 48 x 32), and `b.png` (another picture, same size).
+fn library(tag: &str) -> Session {
+    let dir = temp_dir(tag);
+    write_png(&dir.join("a.png"), 1, 96, 64);
+    write_jpeg(&dir.join("a-resaved.jpg"), 1, 96, 64, 80);
+    write_png(&dir.join("a-small.png"), 1, 48, 32);
+    write_png(&dir.join("b.png"), 5, 96, 64);
+    let mut s = Session::new().with_fs();
+    let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
+    assert_eq!(r["imported"].as_array().map(Vec::len), Some(4), "{r}");
+    s
+}
+
+fn id_of(s: &Session, name: &str) -> PhotoId {
+    s.catalog.photos().find(|p| p.file_name == name).unwrap_or_else(|| panic!("no {name}")).id
+}
+
+fn ids_of(group: &Value) -> Vec<u64> {
+    let mut v: Vec<u64> = group["photos"].as_array().unwrap().iter().map(|p| p["id"].as_u64().unwrap()).collect();
+    v.sort_unstable();
+    v
+}
+
+/// A second record of the same file (the same bytes and content hash), as a library holds after an import that kept both.
+fn add_copy_of(s: &mut Session, name: &str, rating: u8) -> PhotoId {
+    let mut copy = (**s.catalog.photo(id_of(s, name)).unwrap()).clone();
+    copy.id = s.catalog.alloc_photo_id();
+    copy.file_name = format!("copy-of-{name}");
+    copy.rating = rating;
+    let id = copy.id;
+    s.catalog.apply(Op::AddPhoto { photo: Box::new(copy) }).unwrap();
+    id
+}
+
+#[test]
+fn byte_identical_files_form_an_exact_group_with_the_better_copy_to_keep() {
+    let mut s = library("exact");
+    let a = id_of(&s, "a.png");
+    let copy = add_copy_of(&mut s, "a.png", 3);
+    let r = s.execute("library.findDuplicates", &json!({"level": "exact"})).unwrap();
+    let groups = r["groups"].as_array().unwrap();
+    assert_eq!(groups.len(), 1, "{r}");
+    assert_eq!(groups[0]["level"], "exact");
+    assert_eq!(ids_of(&groups[0]), {
+        let mut v = vec![a.0, copy.0];
+        v.sort_unstable();
+        v
+    });
+    assert_eq!(groups[0]["keep"], copy.0, "the rated copy is the one to keep");
+    assert_eq!(groups[0]["reasons"], json!(["rated"]));
+    assert!(groups[0]["photos"].as_array().unwrap().iter().all(|p| p["exact"] == true && p["similarity"] == 1.0));
+}
+
+#[test]
+fn the_same_picture_saved_again_forms_a_near_group_and_a_different_picture_does_not() {
+    let mut s = library("near");
+    let (a, jpg, small, b) = (id_of(&s, "a.png"), id_of(&s, "a-resaved.jpg"), id_of(&s, "a-small.png"), id_of(&s, "b.png"));
+    // exact finds nothing: no two files are byte-identical
+    let exact = s.execute("library.findDuplicates", &json!({"level": "exact"})).unwrap();
+    assert_eq!(exact["groups"], json!([]));
+    let r = s.execute("library.findDuplicates", &json!({})).unwrap();
+    let groups = r["groups"].as_array().unwrap();
+    assert_eq!(groups.len(), 1, "{r}");
+    assert_eq!(groups[0]["level"], "near");
+    let mut want = vec![a.0, jpg.0, small.0];
+    want.sort_unstable();
+    assert_eq!(ids_of(&groups[0]), want, "the re-encoded and the resized copy are grouped; {b:?} (another picture) is not");
+    assert!(!ids_of(&groups[0]).contains(&b.0));
+    // the larger of the equal-size files is kept; the resized copy says why it is the smaller
+    assert!(groups[0]["keep"] == a.0 || groups[0]["keep"] == jpg.0, "{r}");
+    assert!(groups[0]["reasons"].as_array().unwrap().contains(&json!("larger")), "{r}");
+    let small_row = groups[0]["photos"].as_array().unwrap().iter().find(|p| p["id"] == small.0).unwrap();
+    assert_eq!(small_row["exact"], false);
+    let sim = small_row["similarity"].as_f64().unwrap();
+    assert!((0.985..=1.0).contains(&sim), "{sim}");
+    // a stricter cut-off than the copies reach keeps them apart; the allowed range is checked
+    let strict = s.execute("library.findDuplicates", &json!({"similarity": 1.0})).unwrap();
+    assert!(strict["groups"].as_array().unwrap().iter().all(|g| g["photos"].as_array().unwrap().len() < 3), "{strict}");
+}
+
+#[test]
+fn exact_copies_join_the_near_group_of_their_picture() {
+    let mut s = library("both");
+    let copy = add_copy_of(&mut s, "a-resaved.jpg", 0);
+    let r = s.execute("library.findDuplicates", &json!({})).unwrap();
+    let groups = r["groups"].as_array().unwrap();
+    assert_eq!(groups.len(), 1, "one picture, one group: {r}");
+    assert_eq!(groups[0]["photos"].as_array().unwrap().len(), 4);
+    assert!(ids_of(&groups[0]).contains(&copy.0));
+}
+
+#[test]
+fn keeping_one_rejects_the_others_in_one_undo_step_and_deletes_nothing() {
+    let mut s = library("resolve");
+    let (a, jpg, small) = (id_of(&s, "a.png"), id_of(&s, "a-resaved.jpg"), id_of(&s, "a-small.png"));
+    let total = s.catalog.photos().count();
+    let r = s.execute("library.resolveDuplicates", &json!({"ids": [a.0, jpg.0, small.0], "keep": [a.0]})).unwrap();
+    let mut rejected: Vec<u64> = r["rejected"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
+    rejected.sort_unstable();
+    let mut want = vec![jpg.0, small.0];
+    want.sort_unstable();
+    assert_eq!((rejected, r["kept"].clone()), (want, json!([a.0])));
+    let flag = |s: &Session, id: PhotoId| s.catalog.photo(id).unwrap().flag;
+    assert_eq!((flag(&s, a), flag(&s, jpg), flag(&s, small)), (Flag::None, Flag::Reject, Flag::Reject));
+    assert_eq!(s.catalog.photos().count(), total, "nothing is deleted");
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert_eq!((flag(&s, a), flag(&s, jpg), flag(&s, small)), (Flag::None, Flag::None, Flag::None), "one undo step");
+    // keeping several keeps them all
+    let r = s.execute("library.resolveDuplicates", &json!({"ids": [a.0, jpg.0, small.0], "keep": [a.0, jpg.0]})).unwrap();
+    assert_eq!(r["rejected"], json!([small.0]));
+}
+
+#[test]
+fn malformed_input_is_an_error_not_a_panic() {
+    let mut s = library("errors");
+    let (a, jpg) = (id_of(&s, "a.png"), id_of(&s, "a-resaved.jpg"));
+    let before: Vec<Flag> = s.catalog.photos().map(|p| p.flag).collect();
+    for params in [
+        json!({"level": "similar"}),
+        json!({"level": 3}),
+        json!({"similarity": 0.5}),
+        json!({"similarity": "high"}),
+        json!({"similarity": 1.5}),
+        json!({"ids": 3}),
+        json!({"ids": ["3"]}),
+        json!({"ids": [9_999_999]}),
+        json!({"colour": "red"}),
+    ] {
+        assert!(s.execute("library.findDuplicates", &params).is_err(), "{params}");
+    }
+    for params in [
+        json!({}),
+        json!({"ids": [a.0, jpg.0]}),
+        json!({"ids": [a.0, jpg.0], "keep": []}),
+        json!({"ids": [a.0, jpg.0], "keep": "all"}),
+        json!({"ids": [a.0, jpg.0], "keep": ["x"]}),
+        json!({"ids": [a.0, jpg.0], "keep": [9_999_999]}),
+        json!({"ids": [a.0, 9_999_999], "keep": [a.0]}),
+        json!({"ids": [a.0, a.0], "keep": [a.0]}),
+        json!({"ids": [a.0], "keep": [a.0]}),
+        json!({"ids": 7, "keep": [a.0]}),
+        json!({"ids": [a.0, jpg.0], "keep": [a.0], "delete": true}),
+    ] {
+        assert!(s.execute("library.resolveDuplicates", &params).is_err(), "{params}");
+    }
+    assert_eq!(s.catalog.photos().map(|p| p.flag).collect::<Vec<_>>(), before, "a refused call changes nothing");
+}
+
+#[test]
+fn a_photo_whose_file_cannot_be_read_is_reported_not_fatal() {
+    let mut s = library("unreadable");
+    let mut gone = (**s.catalog.photo(id_of(&s, "a.png")).unwrap()).clone();
+    gone.id = s.catalog.alloc_photo_id();
+    gone.file_name = "gone.png".into();
+    gone.content_hash = Some("not-a-real-hash".into());
+    gone.source = Source::File { path: "Z:/no/such/dir/gone.png".into() };
+    let gone_id = gone.id;
+    s.catalog.apply(Op::AddPhoto { photo: Box::new(gone) }).unwrap();
+    let r = s.execute("library.findDuplicates", &json!({})).unwrap();
+    assert_eq!(r["unreadable"], json!([gone_id.0]), "{r}");
+    assert_eq!(r["groups"].as_array().map(Vec::len), Some(1), "the readable copies are still grouped");
+}
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -9,6 +9,7 @@ use crate::theme::Tokens;
 
 mod confirm;
 mod cull;
+mod duplicates;
 mod export;
 mod frame;
 mod rename;
@@ -135,6 +136,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
         Dialog::FaceModel { .. } => "Add Face Model",
         Dialog::WhatsNew => "What's New",
         Dialog::Cull { .. } => "Assisted Culling",
+        Dialog::Duplicates { .. } => "Find Duplicates",
         Dialog::SmartRules { id: None, .. } => "New Smart Album",
         Dialog::SmartRules { .. } => "Edit Smart Album",
         Dialog::AutoStack { .. } => "Auto-Stack by Capture Time",
@@ -209,6 +211,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
             Dialog::LightroomCatalog { .. } => 440.0,
             Dialog::LightroomDestination { .. } => 480.0,
             Dialog::ManageEffects { .. } => 560.0,
+            Dialog::Duplicates { .. } => 760.0,
             _ => 380.0,
         })
         .show(ctx, |ui| {
@@ -274,6 +277,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
                     ui.label(egui::RichText::new(crate::i18n::tr("Lower it when a panning or moving burst splits into several groups.")).color(t.text_dim));
                     ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim));
                 }
+                Dialog::Duplicates { near, result, keep } => duplicates::body(app, ui, &t, near, result, keep),
                 Dialog::WhatsNew => {
                     egui::ScrollArea::vertical().max_height(list_height(ui, 460.0)).auto_shrink([false, true]).show(ui, |ui| {
                         for line in WHATS_NEW.lines() {
diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs
--- a/crates/ui-egui/src/dialogs/confirm.rs
+++ b/crates/ui-egui/src/dialogs/confirm.rs
@@ -164,7 +164,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
         Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})),
         Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted),
         Dialog::FaceModel { path, info, accepted } => crate::panels::faces::install(app, path, info, *accepted),
-        Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null),
+        Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew | Dialog::Duplicates { .. } => Ok(serde_json::Value::Null),
         Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => {
             app.ui.cull_burst = Some((*burst_gap, *similarity));
             app.run("photo.analyze", cull_params(*reject_below, *pick_best, *burst_gap, *similarity))
diff --git a/crates/ui-egui/src/dialogs/duplicates.rs b/crates/ui-egui/src/dialogs/duplicates.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/dialogs/duplicates.rs
@@ -0,0 +1,188 @@
+//! Photo > Find Duplicates…: choose how close a copy has to be, find the groups (`library.findDuplicates`), and for each
+//! group tick the photos to keep (the suggested one is ticked, with why) and mark the others Rejected
+//! (`library.resolveDuplicates`). Nothing is deleted here.
+
+use serde_json::Value;
+
+use super::*;
+
+/// The width of one photo's cell in a group's rows.
+const CELL: f32 = 120.0;
+
+/// The whole percent shown as "n% alike" for a copy that is not byte-identical: rounded down and never 100, so a near
+/// copy cannot read as identical next to an "Exact copy" row.
+fn alike_percent(similarity: f64) -> i32 {
+    ((similarity * 100.0).floor() as i32).clamp(0, 99)
+}
+
+/// A reason id from the engine in words.
+fn reason_words(id: &str) -> &'static str {
+    match id {
+        "picked" => "your pick",
+        "rated" => "more stars",
+        "raw" => "raw file",
+        "edited" => "edited",
+        "larger" => "more pixels",
+        "sharper" => "sharper",
+        _ => "",
+    }
+}
+
+fn ids_of(group: &Value, key: &str) -> Vec<u64> {
+    group["photos"].as_array().into_iter().flatten().filter_map(|p| p[key].as_u64()).collect()
+}
+
+/// Find the groups at the chosen level; the suggested photo of each is ticked to keep.
+fn find(app: &mut LightcraftApp, near: bool, result: &mut Option<Value>, keep: &mut Vec<u64>) -> Result<(), String> {
+    let found = app.run("library.findDuplicates", json!({"level": if near { "near" } else { "exact" }}))?;
+    *keep = found["groups"].as_array().into_iter().flatten().filter_map(|g| g["keep"].as_u64()).collect();
+    *result = Some(found);
+    Ok(())
+}
+
+/// Mark group `g`'s photos not ticked Rejected, and take the group out of the list.
+fn resolve(app: &mut LightcraftApp, g: usize, result: &mut Option<Value>, keep: &[u64]) -> Result<(), String> {
+    let group = result.as_ref().and_then(|r| r["groups"].get(g)).cloned().ok_or("that group is gone")?;
+    let ids = ids_of(&group, "id");
+    let kept: Vec<u64> = ids.iter().copied().filter(|id| keep.contains(id)).collect();
+    app.run("library.resolveDuplicates", json!({"ids": ids, "keep": kept}))?;
+    if let Some(groups) = result.as_mut().and_then(|r| r["groups"].as_array_mut()) {
+        groups.remove(g);
+    }
+    Ok(())
+}
+
+pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, near: &mut bool, result: &mut Option<Value>, keep: &mut Vec<u64>) {
+    let tr = crate::i18n::tr;
+    // never wider than the window, however many photos a group has: its rows wrap inside this width
+    ui.set_max_width((ui.ctx().content_rect().width() - 120.0).clamp(300.0, 1060.0));
+    ui.label(
+        egui::RichText::new(tr(
+            "Finds photos that are copies of each other. Nothing is deleted: you choose which copy to keep, and the others are marked Rejected.",
+        ))
+        .color(t.text_label),
+    );
+    ui.add_space(6.0);
+    for (is_near, id, label, help) in [
+        (false, "exact", "Exact copies", "The very same file, found twice in the library."),
+        (true, "near", "Same picture saved again", "Also copies that were resized or saved again, for example as a smaller JPEG."),
+    ] {
+        let r = ui.radio_value(near, is_near, tr(label));
+        crate::widgets::register(ui, format!("radio:duplicates.{id}"), r.rect);
+        ui.label(egui::RichText::new(tr(help)).color(t.text_dim));
+    }
+    ui.add_space(6.0);
+    let r = ui.button(tr("Find Duplicates"));
+    crate::widgets::register(ui, "button:findDuplicates", r.rect);
+    if r.clicked()
+        && let Err(e) = find(app, *near, result, keep)
+    {
+        app.toast_error(ui.ctx(), e);
+    }
+    let Some(found) = result.clone() else { return };
+    let groups = found["groups"].as_array().cloned().unwrap_or_default();
+    ui.add_space(8.0);
+    if groups.is_empty() {
+        ui.label(egui::RichText::new(tr("No duplicates found.")).color(t.text_label));
+        return;
+    }
+    let photos: usize = groups.iter().map(|g| g["photos"].as_array().map_or(0, Vec::len)).sum();
+    ui.label(egui::RichText::new(format!("{}: {}   {}: {}", tr("Groups"), groups.len(), tr("Photos"), photos)).color(t.text_label));
+    ui.label(egui::RichText::new(tr("Tick the photos to keep.")).color(t.text_dim));
+    let mut action: Option<usize> = None;
+    egui::ScrollArea::vertical().max_height(list_height(ui, 420.0)).auto_shrink([false, true]).show(ui, |ui| {
+        ui.set_max_width(ui.available_width());
+        for (gi, g) in groups.iter().enumerate() {
+            ui.separator();
+            let level = if g["level"] == "exact" { "Exact copies" } else { "Same picture saved again" };
+            ui.label(egui::RichText::new(tr(level)).font(t.semibold(12.5)).color(t.text));
+            let rows = g["photos"].as_array().cloned().unwrap_or_default();
+            // rows of as many photos as fit (a wrapped layout does not break between nested blocks)
+            let per_row = ((ui.available_width() / (CELL + ui.spacing().item_spacing.x)).floor() as usize).max(1);
+            let cells: Vec<_> = rows.iter().enumerate().collect();
+            for row in cells.chunks(per_row) {
+                ui.horizontal(|ui| {
+                    for &(pi, p) in row {
+                        let id = lightcraft_catalog::PhotoId(p["id"].as_u64().unwrap_or(0));
+                        ui.vertical(|ui| {
+                            ui.set_width(CELL);
+                            let size = egui::vec2(112.0, 84.0);
+                            match app.renderer.thumb(id) {
+                                Some(tex) => {
+                                    let [w, h] = tex.size;
+                                    let scale = (size.x / w.max(1) as f32).min(size.y / h.max(1) as f32);
+                                    ui.add(egui::Image::new((tex.tex.id(), egui::vec2(w as f32 * scale, h as f32 * scale))));
+                                }
+                                None => {
+                                    let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
+                                    ui.painter().rect_filled(rect, 2.0, t.inset);
+                                }
+                            }
+                            let mut on = keep.contains(&id.0);
+                            let name = p["file"].as_str().unwrap_or_default().to_string();
+                            let r = ui.checkbox(&mut on, tr("Keep")).on_hover_text(&name);
+                            crate::widgets::register(ui, format!("check:duplicates.{gi}.{pi}"), r.rect);
+                            if r.changed() {
+                                if on {
+                                    keep.push(id.0);
+                                } else {
+                                    keep.retain(|k| *k != id.0);
+                                }
+                            }
+                            ui.label(egui::RichText::new(name).small().color(t.text_label));
+                            ui.label(egui::RichText::new(format!("{} x {}", p["width"], p["height"])).small().color(t.text_dim));
+                            if g["keep"] == p["id"] {
+                                let why: Vec<&str> = g["reasons"]
+                                    .as_array()
+                                    .into_iter()
+                                    .flatten()
+                                    .filter_map(Value::as_str)
+                                    .map(reason_words)
+                                    .filter(|w| !w.is_empty())
+                                    .map(tr)
+                                    .collect();
+                                let text =
+                                    if why.is_empty() { tr("Suggested").to_string() } else { format!("{}: {}", tr("Suggested"), why.join(", ")) };
+                                ui.label(egui::RichText::new(text).small().color(t.accent));
+                            } else {
+                                let how = if p["exact"] == true {
+                                    tr("Exact copy").to_string()
+                                } else {
+                                    format!("{}% {}", alike_percent(p["similarity"].as_f64().unwrap_or(0.0)), tr("alike"))
+                                };
+                                ui.label(egui::RichText::new(how).small().color(t.text_dim));
+                            }
+                        });
+                    }
+                });
+            }
+            let any = ids_of(g, "id").iter().any(|id| keep.contains(id));
+            let r = ui
+                .add_enabled(any, egui::Button::new(tr("Mark Others Rejected")))
+                .on_disabled_hover_text(tr("Keep at least one photo of the group."));
+            crate::widgets::register(ui, format!("button:duplicates.resolve.{gi}"), r.rect);
+            if r.clicked() {
+                action = Some(gi);
+            }
+        }
+    });
+    if let Some(g) = action
+        && let Err(e) = resolve(app, g, result, keep)
+    {
+        app.toast_error(ui.ctx(), e);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::alike_percent;
+
+    #[test]
+    fn a_copy_that_is_not_exact_never_reads_100_percent() {
+        assert_eq!(alike_percent(0.998), 99);
+        assert_eq!(alike_percent(1.0), 99);
+        assert_eq!(alike_percent(0.925), 92);
+        assert_eq!(alike_percent(f64::NAN), 0);
+        assert_eq!(alike_percent(-0.5), 0);
+    }
+}
diff --git a/crates/ui-egui/src/dialogs/frame.rs b/crates/ui-egui/src/dialogs/frame.rs
--- a/crates/ui-egui/src/dialogs/frame.rs
+++ b/crates/ui-egui/src/dialogs/frame.rs
@@ -208,6 +208,7 @@ pub(super) fn only_shows(dlg: &Dialog) -> bool {
             | Dialog::SystemInfo { .. }
             | Dialog::AllMetadata { .. }
             | Dialog::WhatsNew
+            | Dialog::Duplicates { .. }
             | Dialog::ManageEffects { .. }
             | Dialog::ViewOptions
     ) || matches!(dlg, Dialog::SynchronizeFolder { counts: Some(c), .. } if c.up_to_date())
diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs
--- a/crates/ui-egui/src/menus/run.rs
+++ b/crates/ui-egui/src/menus/run.rs
@@ -1311,6 +1311,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
             app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true, burst_gap, similarity });
             Ok(Value::Null)
         }
+        "dialog.duplicates" => {
+            app.ui.dialog = Some(Dialog::Duplicates { near: true, result: None, keep: Vec::new() });
+            Ok(Value::Null)
+        }
         "app.whatsNew" => {
             app.ui.dialog = Some(Dialog::WhatsNew);
             Ok(json!({"text": crate::panels::dialogs::WHATS_NEW}))
diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs
--- a/crates/ui-egui/src/menus/ui_commands.rs
+++ b/crates/ui-egui/src/menus/ui_commands.rs
@@ -259,6 +259,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
     ("view.frameRate", "Frame Rate", None, "Help>Debug"),
     ("app.whatsNew", "What's New", None, "Help"),
     ("dialog.cull", "Assisted Culling…", None, "Photo"),
+    ("dialog.duplicates", "Find Duplicates…", None, "Photo"),
     ("app.help", "LightCraft Help", Some("F1"), "Help"),
     ("app.discord", "Join the ArtCraft Discord…", None, "Help"),
     ("app.feedback", "Send Feedback…", None, "Help"),
diff --git a/crates/ui-egui/src/state/dialog.rs b/crates/ui-egui/src/state/dialog.rs
--- a/crates/ui-egui/src/state/dialog.rs
+++ b/crates/ui-egui/src/state/dialog.rs
@@ -174,6 +174,15 @@ pub enum Dialog {
         burst_gap: f32,
         similarity: f32,
     },
+    /// Photo ▸ Find Duplicates: the level chosen (`near`: also the same picture saved again), the last
+    /// `library.findDuplicates` answer, and the photos ticked to keep.
+    Duplicates {
+        near: bool,
+        #[serde(default)]
+        result: Option<serde_json::Value>,
+        #[serde(default)]
+        keep: Vec<u64>,
+    },
     /// Help ▸ System Info: (label, value) rows.
     DenoiseModel {
         info: serde_json::Value,
diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs
--- a/crates/ui-egui/src/tests.rs
+++ b/crates/ui-egui/src/tests.rs
@@ -21,6 +21,7 @@ mod dialog_tab;
 mod dialogs;
 mod docking_acceptance;
 mod drop_import;
+mod duplicates;
 mod edit_layout;
 mod empty_library;
 mod error_kinds;
diff --git a/crates/ui-egui/src/tests/duplicates.rs b/crates/ui-egui/src/tests/duplicates.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/tests/duplicates.rs
@@ -0,0 +1,89 @@
+//! Photo > Find Duplicates…: the dialog groups a library's exact copies (no model), ticks the suggested photo of each
+//! group to keep, and its button marks the other copy Rejected without deleting anything.
+
+use std::time::Duration;
+
+use lightcraft_catalog::{Flag, Op};
+use serde_json::json;
+
+use crate::headless::Headless;
+use crate::state::Dialog;
+use crate::{LightcraftApp, Services};
+
+const T: Duration = Duration::from_secs(20);
+
+fn click(h: &mut Headless, id: &str) {
+    let r = h.request("ui.clickWidget", json!({"id": id}), T);
+    assert_eq!(r["ok"], true, "{id}: {r}");
+    h.step();
+}
+
+#[test]
+fn the_dialog_keeps_the_suggested_copy_and_rejects_the_other() {
+    let mut session = lightcraft_engine::Session::with_demo();
+    let original = session.catalog.photos().map(|p| p.id).min().unwrap();
+    // the same demo picture again, at half the size: an exact copy by content, with fewer pixels
+    let mut copy = (**session.catalog.photo(original).unwrap()).clone();
+    copy.id = session.catalog.alloc_photo_id();
+    copy.width /= 2;
+    copy.height /= 2;
+    let copy_id = copy.id;
+    session.catalog.apply(Op::AddPhoto { photo: Box::new(copy) }).unwrap();
+    let photos = session.catalog.photos().count();
+    let original_flag = session.catalog.photo(original).unwrap().flag;
+    let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
+    let mut h = Headless::new(app, [1400.0, 1000.0], 1.0);
+    h.step();
+
+    h.app.run("dialog.duplicates", json!({})).unwrap();
+    h.step();
+    h.step();
+    assert!(matches!(&h.app.ui.dialog, Some(Dialog::Duplicates { near: true, .. })));
+    click(&mut h, "radio:duplicates.exact");
+    click(&mut h, "button:findDuplicates");
+    h.step();
+    let Some(Dialog::Duplicates { result: Some(found), keep, .. }) = &h.app.ui.dialog else { panic!("the dialog shows the groups") };
+    let groups = found["groups"].as_array().unwrap();
+    assert_eq!(groups.len(), 1, "{found}");
+    assert_eq!(groups[0]["keep"], original.0, "the bigger copy is suggested");
+    assert!(keep.contains(&original.0) && !keep.contains(&copy_id.0), "the suggested photo is ticked: {keep:?}");
+
+    click(&mut h, "button:duplicates.resolve.0");
+    assert_eq!(h.app.session.catalog.photo(copy_id).unwrap().flag, Flag::Reject);
+    assert_eq!(h.app.session.catalog.photo(original).unwrap().flag, original_flag, "the kept photo is left as it was");
+    assert_eq!(h.app.session.catalog.photos().count(), photos, "nothing is deleted");
+    let Some(Dialog::Duplicates { result: Some(found), .. }) = &h.app.ui.dialog else { panic!("the dialog stays open") };
+    assert_eq!(found["groups"], json!([]), "the decided group leaves the list");
+    click(&mut h, "button:dialogOk");
+    assert!(h.app.ui.dialog.is_none(), "the dialog only shows things: its one button closes it");
+}
+
+/// Unticking every photo of a group leaves its button disabled: nothing is rejected without a photo kept.
+#[test]
+fn a_group_with_nothing_ticked_cannot_be_rejected() {
+    let mut session = lightcraft_engine::Session::with_demo();
+    let original = session.catalog.photos().map(|p| p.id).min().unwrap();
+    let mut copy = (**session.catalog.photo(original).unwrap()).clone();
+    copy.id = session.catalog.alloc_photo_id();
+    let copy_id = copy.id;
+    session.catalog.apply(Op::AddPhoto { photo: Box::new(copy) }).unwrap();
+    let flags = (session.catalog.photo(original).unwrap().flag, session.catalog.photo(copy_id).unwrap().flag);
+    let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
+    let mut h = Headless::new(app, [1400.0, 1000.0], 1.0);
+    h.step();
+    h.app.run("dialog.duplicates", json!({})).unwrap();
+    h.step();
+    h.step();
+    click(&mut h, "radio:duplicates.exact");
+    click(&mut h, "button:findDuplicates");
+    h.step();
+    if let Some(Dialog::Duplicates { keep, .. }) = &mut h.app.ui.dialog {
+        keep.clear();
+    }
+    h.step();
+    let r = h.request("ui.clickWidget", json!({"id": "button:duplicates.resolve.0"}), T);
+    h.step();
+    let _ = r;
+    let now = (h.app.session.catalog.photo(original).unwrap().flag, h.app.session.catalog.photo(copy_id).unwrap().flag);
+    assert_eq!(now, flags, "a disabled button rejects nothing");
+}
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
--- a/docs/parity-checklist.md
+++ b/docs/parity-checklist.md
@@ -1,6 +1,6 @@
 # Lightroom parity checklist
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LRC-LIB-DUPLICATES: Photo ▸ Find Duplicates… groups exact and same-picture copies without a model; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
 
 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
 have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md.
 | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) |
 | Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
 | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) |
-| Lightroom Classic extras | 41 | 25 | 21 | 8 | — | 24/26 (92%) |
-| **Total** | 421 | 65 | 57 | 35 | 196/203 (97%) | 146/161 (91%) |
+| Lightroom Classic extras | 41 | 26 | 21 | 8 | — | 24/26 (92%) |
+| **Total** | 421 | 66 | 57 | 35 | 196/203 (97%) | 146/161 (91%) |
 
-Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.5%** of 543 in-scope rows — P0 98.3% of 203 · P1 94.4% of 161 · P2 56.7% of 178.
+Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.5%** of 544 in-scope rows — P0 98.3% of 203 · P1 94.4% of 161 · P2 56.7% of 179.
 <!-- /parity:summary -->
 
 ## Top gaps and shortcut notes
@@ -717,6 +717,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
 | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm one, × to hide for the session, or ↑ / hovered-or-focused A key to confirm all currently shown non-hidden suggestions at or above that face's score, including ties, in one undo step). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people
 | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | |
 | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) |
+| LRC-LIB-DUPLICATES | Find duplicate photos | P2 | 🟡 | `cmd:library.findDuplicates`, `cmd:library.resolveDuplicates`, `cmd:dialog.duplicates`, `crates/engine/src/cmd/duplicates.rs`, `crates/ui-egui/src/dialogs/duplicates.rs` | Photo ▸ Find Duplicates…: groups the library's byte-identical files (same content hash) and, as a second level, the same picture saved again (resized, re-encoded; Find Similar's 8 x 8 brightness signature at a strict cut-off of 0.985 and the same shape), no AI model. Each group suggests the copy to keep (your pick, stars, raw, edits, pixels, focus) with why; tick what to keep and Mark Others Rejected (one undo step, nothing is deleted). 🟡: the scan runs on the UI thread (slow on a big library the first time), a different photo with the same tonal layout can still group (review before rejecting), no content-based (AI) grouping |
 | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike |
 | LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable; the DNG is decoded and compared with the raw data, written to a temp file, synced, read back and only then given a free name (never replacing a file) — a failed write leaves no DNG and the photo on its raw (issue #106) |
 | LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (a row in the activity stack, ✕ to stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) |
@@ -959,3 +960,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
 | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) |
 | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone |
 | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) |
+| 2026-10-27 | minor | LRC-LIB-DUPLICATES: Photo ▸ Find Duplicates… (`library.findDuplicates`, `library.resolveDuplicates`) groups exact copies and the same picture saved again, no AI model, and marks the copies the user does not keep Rejected; new row 🟡 |
diff --git a/docs/whats-new.md b/docs/whats-new.md
--- a/docs/whats-new.md
+++ b/docs/whats-new.md
@@ -1,5 +1,10 @@
 # What's new in LightCraft
 
+## 2026-10-27
+
+### Find Duplicates
+- Photo ▸ Find Duplicates… lists the duplicate photos of your library in groups, with no AI model to download. “Exact copies” are the very same file found twice; “Same picture saved again” also catches copies that were resized or saved again, for example as a smaller JPEG. Each group suggests the copy to keep (your pick, more stars, a raw file, edits, more pixels, sharper) and says why. Tick the photos to keep and choose Mark Others Rejected: the others are flagged Rejected in one undo step, and nothing is deleted.
+
 ## 2026-10-24
 
 ### Relative-date filters and smart albums follow the clock
