diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs
--- a/crates/catalog/src/journal.rs
+++ b/crates/catalog/src/journal.rs
@@ -52,6 +52,7 @@
 //! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
 //! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` |
 //! | 9 | preview-only recheck | `Photo.preview_checked`, `Op::SetContent.preview_checked` |
+//! | 10 | capture-time manual edit | `Photo.captured_manual`, `Op::SetCaptured.manual` |
 //!
 //! Rules:
 //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -76,7 +77,7 @@ pub const LOG: &str = "catalog.log";
 const FORMAT: &str = "lightcraft-catalog";
 /// The catalog format this build writes (and the newest it reads). See the module docs →
 /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
-pub const VERSION: u32 = 9;
+pub const VERSION: u32 = 10;
 
 /// When [`Journal::wants_snapshot`] says it's time to compact the log.
 #[derive(Clone, Copy, Debug)]
@@ -365,6 +366,11 @@ impl Journal {
                 for r in &report.repaired {
                     log::warn!("{SNAPSHOT}: {r}");
                 }
+                // Catalogs created before format v10 didn't track whether capture times were file-derived
+                // or manual user edits. Mark snapshot capture times as manual so reloads preserve them.
+                if h.version < 10 {
+                    catalog.mark_captured_manual_for_upgrade();
+                }
                 (catalog, s.seq, Some(h.version))
             }
             None => (Catalog::new(), 0, None),
@@ -481,12 +487,11 @@ impl Journal {
                 log::warn!("catalog: can't write the new library's snapshot: {e}");
             }
         } else if report.damaged.is_none() && snapshot_version != Some(VERSION) {
-            report.upgraded_from = Some(snapshot_version.unwrap_or(0));
+            let from = snapshot_version.unwrap_or(0);
+            report.upgraded_from = Some(from);
             match j.snapshot(&catalog) {
                 Ok(()) => {
-                    if let Some(v) = report.upgraded_from {
-                        log::info!("catalog: upgraded from format v{v} to v{VERSION}");
-                    }
+                    log::info!("catalog: upgraded from format v{from} to v{VERSION}");
                 }
                 // nothing lost: the old files are intact and still load; retried on next open
                 Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"),
diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs
--- a/crates/catalog/src/lib.rs
+++ b/crates/catalog/src/lib.rs
@@ -72,6 +72,11 @@ pub type Result<T> = std::result::Result<T, CatalogError>;
 /// Maximum History entries kept per photo.
 pub const HISTORY_LIMIT: usize = 200;
 
+/// Pre-v10 `SetCaptured` records lacked the `manual` field and were only emitted for manual user edits.
+const fn default_pre_v10_manual() -> bool {
+    true
+}
+
 /// Every catalog mutation.
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 #[serde(tag = "op", rename_all = "camelCase")]
@@ -181,6 +186,8 @@ pub enum Op {
     SetCaptured {
         id: PhotoId,
         captured: Option<String>,
+        #[serde(default = "default_pre_v10_manual")]
+        manual: bool,
     },
     /// Assisted culling scores.
     SetAnalysis {
@@ -899,14 +906,16 @@ impl Catalog {
                 let p = self.photo_mut(id)?;
                 Op::SetAnalysis { id, analysis: std::mem::replace(&mut p.analysis, analysis) }
             }
-            Op::SetCaptured { id, captured } => {
+            Op::SetCaptured { id, captured, manual } => {
                 if let Some(c) = &captured
                     && stacks::iso_seconds(c).is_none()
                 {
                     return Err(CatalogError::Invalid(format!("not a date: {c}")));
                 }
                 let p = self.photo_mut(id)?;
-                Op::SetCaptured { id, captured: std::mem::replace(&mut p.captured, captured) }
+                let old_captured = std::mem::replace(&mut p.captured, captured);
+                let old_manual = std::mem::replace(&mut p.captured_manual, manual);
+                Op::SetCaptured { id, captured: old_captured, manual: old_manual }
             }
             Op::Relink { id, file_name, source, format } => {
                 let p = self.photo_mut(id)?;
@@ -1049,6 +1058,18 @@ impl Catalog {
         Ok(c)
     }
 
+    /// Catalogs created before format v10 didn't track whether capture times were file-derived
+    /// or manual user edits. Mark existing capture times as manual on upgrade so reloads preserve
+    /// their stored values. Local browsed photos are not part of the library and keep their
+    /// file-derived provenance.
+    pub fn mark_captured_manual_for_upgrade(&mut self) {
+        for p in self.photos.values_mut() {
+            if !p.local && p.captured.is_some() {
+                Arc::make_mut(p).captured_manual = true;
+            }
+        }
+    }
+
     /// Make a catalog read from a snapshot hold together where a damaged or hand-edited file left
     /// it inconsistent, so nothing that relies on it can fail (code indexes stacks by the ids it
     /// finds in them, and walks folders up to the top): every photo, album and stack carries the
@@ -1063,6 +1084,10 @@ impl Catalog {
                 changed.push(format!("photo {} was filed as {}", p.id.0, id.0));
                 Arc::make_mut(p).id = *id;
             }
+            if p.local && p.captured_manual {
+                changed.push(format!("local photo {} cleared spurious captured_manual", p.id.0));
+                Arc::make_mut(p).captured_manual = false;
+            }
         }
         for (id, a) in self.albums.iter_mut() {
             if a.id != *id {
diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs
--- a/crates/catalog/src/model.rs
+++ b/crates/catalog/src/model.rs
@@ -236,6 +236,10 @@ pub struct Photo {
     /// ISO 8601 local capture time.
     #[serde(default)]
     pub captured: Option<String>,
+    /// Whether capture time was intentionally edited by the user (e.g. via `photo.setCaptureTime`),
+    /// rather than read or derived from the file header/sidecar at import.
+    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+    pub captured_manual: bool,
     pub imported: String,
     #[serde(default)]
     pub meta: Meta,
@@ -371,6 +375,7 @@ impl Photo {
             height,
             file_size: 0,
             captured: None,
+            captured_manual: false,
             imported: imported.to_string(),
             meta: Meta::default(),
             rating: 0,
diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs
--- a/crates/catalog/src/tests_format_version.rs
+++ b/crates/catalog/src/tests_format_version.rs
@@ -97,6 +97,152 @@ fn v6_preview_only_photo_loads_unstamped_and_is_upgraded() {
     assert_eq!(snapshot_version(&m), u64::from(VERSION));
 }
 
+#[test]
+fn v9_library_marks_capture_times_manual_on_upgrade() {
+    let mut c = Catalog::new();
+    let id1 = c.alloc_photo_id();
+    let mut p1 = Photo::new(id1, Source::File { path: "/p/1.jpg".into() }, "1.jpg", "JPEG", 100, 100, "2026-01-01");
+    p1.captured = Some("2026-01-01T12:00:00".into());
+    p1.captured_manual = false;
+    c.apply(Op::AddPhoto { photo: Box::new(p1) }).unwrap();
+
+    let id2 = c.alloc_photo_id();
+    let p2 = Photo::new(id2, Source::File { path: "/p/2.jpg".into() }, "2.jpg", "JPEG", 100, 100, "2026-01-01");
+    c.apply(Op::AddPhoto { photo: Box::new(p2) }).unwrap();
+
+    let id3 = c.alloc_photo_id();
+    let mut p3 = Photo::new(id3, Source::File { path: "/p/3.jpg".into() }, "3.jpg", "JPEG", 100, 100, "2026-01-01");
+    p3.captured = Some("2026-01-01T12:00:00".into());
+    p3.local = true;
+    p3.captured_manual = false;
+    c.apply(Op::AddPhoto { photo: Box::new(p3) }).unwrap();
+
+    let snap = c.to_snapshot();
+    let m = MemStore::new();
+    m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":9,\"seq\":3,\"catalog\":{snap}}}\n").into_bytes());
+
+    let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
+    assert_eq!(r.upgraded_from, Some(9));
+    assert_eq!(snapshot_version(&m), u64::from(VERSION));
+    assert!(loaded.photo(id1).unwrap().captured_manual);
+    assert!(!loaded.photo(id2).unwrap().captured_manual);
+    assert!(!loaded.photo(id3).unwrap().captured_manual, "local browsed photo must not be marked manual on upgrade");
+
+    // Reopening the upgraded v10 catalog leaves values unchanged
+    let (_, reopened, r2) = Journal::open(Box::new(m)).unwrap();
+    assert_eq!(r2.upgraded_from, None);
+    assert!(reopened.photo(id1).unwrap().captured_manual);
+    assert!(!reopened.photo(id2).unwrap().captured_manual);
+    assert!(!reopened.photo(id3).unwrap().captured_manual);
+}
+
+#[test]
+fn v10_catalog_repair_clears_spurious_captured_manual_on_local_photos() {
+    let mut c = Catalog::new();
+    let id = c.alloc_photo_id();
+    let mut p = Photo::new(id, Source::File { path: "/p/local.jpg".into() }, "local.jpg", "JPEG", 100, 100, "2026-01-01");
+    p.captured = Some("2026-01-01T12:00:00".into());
+    p.local = true;
+    p.captured_manual = true;
+    c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+
+    let snap = c.to_snapshot();
+    let m = MemStore::new();
+    m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":10,\"seq\":1,\"catalog\":{snap}}}\n").into_bytes());
+
+    let (_, loaded, r) = Journal::open(Box::new(m)).unwrap();
+    assert!(!loaded.photo(id).unwrap().captured_manual);
+    assert!(r.repaired.iter().any(|msg| msg.contains("cleared spurious captured_manual")));
+}
+
+#[test]
+fn v10_library_preserves_captured_manual_values() {
+    let mut c = Catalog::new();
+    let id1 = c.alloc_photo_id();
+    let mut p1 = Photo::new(id1, Source::File { path: "/p/1.jpg".into() }, "1.jpg", "JPEG", 100, 100, "2026-01-01");
+    p1.captured = Some("2026-01-01T12:00:00".into());
+    p1.captured_manual = false;
+    c.apply(Op::AddPhoto { photo: Box::new(p1) }).unwrap();
+
+    let id2 = c.alloc_photo_id();
+    let mut p2 = Photo::new(id2, Source::File { path: "/p/2.jpg".into() }, "2.jpg", "JPEG", 100, 100, "2026-01-01");
+    p2.captured = Some("2026-01-01T13:00:00".into());
+    p2.captured_manual = true;
+    c.apply(Op::AddPhoto { photo: Box::new(p2) }).unwrap();
+
+    let snap = c.to_snapshot();
+    let m = MemStore::new();
+    m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":10,\"seq\":2,\"catalog\":{snap}}}\n").into_bytes());
+
+    let (_, loaded, r) = Journal::open(Box::new(m)).unwrap();
+    assert_eq!(r.upgraded_from, None);
+    assert!(!loaded.photo(id1).unwrap().captured_manual);
+    assert!(loaded.photo(id2).unwrap().captured_manual);
+}
+
+#[test]
+fn v9_library_replaying_v10_records_after_failed_snapshot_preserves_explicit_manual_values() {
+    let mut c = Catalog::new();
+    let id1 = c.alloc_photo_id();
+    let mut p1 = Photo::new(id1, Source::File { path: "/p/1.jpg".into() }, "1.jpg", "JPEG", 100, 100, "2026-01-01");
+    p1.captured = Some("2026-01-01T12:00:00".into());
+    c.apply(Op::AddPhoto { photo: Box::new(p1) }).unwrap();
+
+    let id2 = c.alloc_photo_id();
+    let mut p2 = Photo::new(id2, Source::File { path: "/p/2.jpg".into() }, "2.jpg", "JPEG", 100, 100, "2026-01-01");
+    p2.captured = Some("2026-01-01T12:00:00".into());
+    c.apply(Op::AddPhoto { photo: Box::new(p2) }).unwrap();
+
+    let id3 = c.alloc_photo_id();
+    let mut p3 = Photo::new(id3, Source::File { path: "/p/3.jpg".into() }, "3.jpg", "JPEG", 100, 100, "2026-01-01");
+    p3.captured = Some("2026-01-01T12:00:00".into());
+    c.apply(Op::AddPhoto { photo: Box::new(p3) }).unwrap();
+
+    let snap = c.to_snapshot();
+    let m = MemStore::new();
+    m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":9,\"seq\":3,\"catalog\":{snap}}}\n").into_bytes());
+
+    let op4 = Op::SetCaptured { id: id1, captured: Some("2026-01-01T14:00:00".into()), manual: false };
+    let op5 = Op::SetCaptured { id: id2, captured: Some("2026-01-01T15:00:00".into()), manual: true };
+    let log = format!("{}\n{}\n", encode_record(4, &op4), encode_record(5, &op5));
+    m.set(LOG, log.into_bytes());
+
+    let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
+    assert_eq!(r.upgraded_from, Some(9));
+    assert_eq!(snapshot_version(&m), u64::from(VERSION));
+
+    assert!(!loaded.photo(id1).unwrap().captured_manual);
+    assert_eq!(loaded.photo(id1).unwrap().captured.as_deref(), Some("2026-01-01T14:00:00"));
+
+    assert!(loaded.photo(id2).unwrap().captured_manual);
+    assert_eq!(loaded.photo(id2).unwrap().captured.as_deref(), Some("2026-01-01T15:00:00"));
+
+    assert!(loaded.photo(id3).unwrap().captured_manual);
+    assert_eq!(loaded.photo(id3).unwrap().captured.as_deref(), Some("2026-01-01T12:00:00"));
+}
+
+#[test]
+fn pre_v10_set_captured_record_in_log_deserializes_as_manual_true() {
+    let mut c = Catalog::new();
+    let id = c.alloc_photo_id();
+    let p = Photo::new(id, Source::File { path: "/p/1.jpg".into() }, "1.jpg", "JPEG", 100, 100, "2026-01-01");
+    c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+
+    let snap = c.to_snapshot();
+    let m = MemStore::new();
+    m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":9,\"seq\":1,\"catalog\":{snap}}}\n").into_bytes());
+
+    // Pre-v10 SetCaptured log record (without the "manual" field):
+    let body = format!(r#"{{"op":"setCaptured","id":{},"captured":"2026-01-01T16:00:00"}}"#, id.0);
+    let line = format!("{{\"seq\":2,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes()));
+    m.set(LOG, line.into_bytes());
+
+    let (_, loaded, r) = Journal::open(Box::new(m)).unwrap();
+    assert_eq!(r.upgraded_from, Some(9));
+    assert_eq!(loaded.photo(id).unwrap().captured.as_deref(), Some("2026-01-01T16:00:00"));
+    assert!(loaded.photo(id).unwrap().captured_manual, "pre-v10 SetCaptured record without manual field must deserialize as manual: true");
+}
+
 #[test]
 fn versionless_log_only_library_is_upgraded() {
     let mut c = Catalog::new();
@@ -238,8 +384,8 @@ fn op_variants_are_versioned() {
             Op::SetHistoryCursor { .. } => 8,
         }
     }
-    /// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`.
-    const NEWEST_FIELDS: u32 = 9;
+    /// v10: `Photo.captured_manual` and `Op::SetCaptured.manual`.
+    const NEWEST_FIELDS: u32 = 10;
     let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None });
     assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version");
 }
diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs
--- a/crates/catalog/src/tests_journal.rs
+++ b/crates/catalog/src/tests_journal.rs
@@ -91,7 +91,11 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
         9 if b.is_multiple_of(2) => {
             Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } }
         }
-        9 => Op::SetCaptured { id: pid, captured: (!b.is_multiple_of(3)).then(|| format!("2026-0{}-1{}T10:{:02}:00", 1 + b % 9, b % 10, a % 60)) },
+        9 => Op::SetCaptured {
+            id: pid,
+            captured: (!b.is_multiple_of(3)).then(|| format!("2026-0{}-1{}T10:{:02}:00", 1 + b % 9, b % 10, a % 60)),
+            manual: false,
+        },
         15 => match c.photo(pid).cloned() {
             // a virtual copy: same source, own id, copy_of/copy_name set
             Some(src) => {
diff --git a/crates/catalog/src/tests_local.rs b/crates/catalog/src/tests_local.rs
--- a/crates/catalog/src/tests_local.rs
+++ b/crates/catalog/src/tests_local.rs
@@ -116,7 +116,7 @@ fn any_user_change_keeps_a_record() {
         ("gps", set(|p| p.meta.gps = Some((1.0, 2.0)))),
         ("copyright", set(|p| p.meta.copyright = "© me".into())),
         ("creator edit", set(|p| p.meta.creator = "Me".into())),
-        ("capture time", op(|id| Op::SetCaptured { id, captured: Some("2020-01-01T00:00:00".into()) })),
+        ("capture time", op(|id| Op::SetCaptured { id, captured: Some("2020-01-01T00:00:00".into()), manual: false })),
         ("rename", set(|p| p.file_name = "renamed.JPG".into())),
         ("culling", op(|id| Op::SetAnalysis { id, analysis: Some(Analysis::default()) })),
         ("deleted", op(|id| Op::SetDeleted { id, deleted: true })),
diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs
--- a/crates/engine/src/cmd/browse.rs
+++ b/crates/engine/src/cmd/browse.rs
@@ -45,6 +45,23 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
     // looking at a folder never brings its deleted photos back (importing the files does)
     let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() };
     let report = import_with(s, &files, &opts)?;
+    // Reload local photos in this folder that have changed on disk:
+    let changed_ids: Vec<PhotoId> = s
+        .catalog
+        .photos()
+        .filter(|p| p.local && !p.deleted && p.copy_of.is_none())
+        .filter_map(|p| match &p.source {
+            Source::File { path } if in_folder(path, &dir_s, subfolders) => {
+                let meta = std::fs::metadata(path).ok()?;
+                if meta.len() != p.file_size { Some(p.id) } else { None }
+            }
+            _ => None,
+        })
+        .collect();
+    if !changed_ids.is_empty() {
+        let ids: Vec<Value> = changed_ids.iter().map(|id| json!(id.0)).collect();
+        let _ = s.execute("photo.reload", &json!({"ids": ids}));
+    }
     let forgotten = forget_gone(s, &dir_s, subfolders, &files);
     stamp_browsed(s, &dir_s, &files);
     s.browse = Some(Browse { path: dir_s.clone(), subfolders });
diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs
--- a/crates/engine/src/cmd/convert.rs
+++ b/crates/engine/src/cmd/convert.rs
@@ -248,12 +248,24 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
     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() });
+    if let Some(op) = captured_op(id, ph, info) {
+        ops.push(op);
     }
     ops
 }
 
+/// The op that gives a photo the capture time its file reads now (`info`, a fresh probe), or none when
+/// the catalog already has it, the file reads none, or the photo's capture time was intentionally edited.
+/// File-derived values (embedded EXIF or import fallback) and local browsed photos are refreshed when the
+/// file changes on disk; manual edits made via `photo.setCaptureTime` on library photos are preserved.
+pub(crate) fn captured_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Option<Op> {
+    let cap = info.captured.as_deref()?;
+    if (ph.captured_manual && !ph.local) || (ph.captured.as_deref() == Some(cap) && !ph.captured_manual) {
+        return None;
+    }
+    Some(Op::SetCaptured { id, captured: Some(cap.to_string()), manual: false })
+}
+
 /// 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
@@ -303,8 +315,12 @@ pub(crate) fn reload_ops(
     }
     // virtual copies share the file
     for c in cat.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();
+        let theirs: Vec<Op> = content_op(c.id, c, info.clone())
+            .into_iter()
+            .chain(captured_op(c.id, c, info))
+            .chain(lens_ops(c.id, c, info))
+            .chain(as_shot_wb_op(c.id, c, info))
+            .collect();
         if !theirs.is_empty() {
             ops.extend(theirs);
             changed.push(c.id);
@@ -430,6 +446,171 @@ mod tests {
         assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
     }
 
+    /// Reload from Disk updates capture time when the file carries a metadata capture time that
+    /// differs from the catalog record (e.g. metadata tags were added or edited externally after
+    /// an initial fallback to file modification time).
+    #[test]
+    fn reload_updates_stale_capture_time_from_probe() {
+        let mut ph = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");
+        ph.captured = Some("2026-10-21T01:00:06".into());
+        let info = crate::media::ProbeInfo { captured: Some("2026-10-20T17:05:32+02:00".into()), ..Default::default() };
+        let ops = fill_missing_meta(PhotoId(1), &ph, &info);
+        assert_eq!(ops.len(), 1);
+        assert_eq!(ops[0], Op::SetCaptured { id: PhotoId(1), captured: Some("2026-10-20T17:05:32+02:00".into()), manual: false });
+    }
+
+    /// A photo imported with an embedded capture time updates when the file's embedded capture time
+    /// is changed externally (e.g. by ExifTool or an external editor), emitting Op::SetCaptured.
+    #[test]
+    fn reload_updates_externally_edited_embedded_capture_time() {
+        let mut ph = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");
+        // Imported with an embedded EXIF capture time (not manual)
+        ph.captured = Some("2020-01-01T10:00:00".into());
+        ph.captured_manual = false;
+        // File on disk now carries an updated embedded capture time
+        let info = crate::media::ProbeInfo {
+            captured: Some("2020-01-01T11:00:00".into()),
+            file_modified: Some("2026-10-27T08:00:00".into()),
+            ..Default::default()
+        };
+        let ops = fill_missing_meta(PhotoId(1), &ph, &info);
+        assert_eq!(ops.len(), 1);
+        assert_eq!(ops[0], Op::SetCaptured { id: PhotoId(1), captured: Some("2020-01-01T11:00:00".into()), manual: false });
+    }
+
+    #[test]
+    fn reload_ops_updates_virtual_copies_capture_time() {
+        let mut cat = lightcraft_catalog::Catalog::new();
+        let mut master = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");
+        master.captured = Some("2026-10-21T01:00:06".into());
+        cat.apply(Op::AddPhoto { photo: Box::new(master.clone()) }).unwrap();
+
+        let mut copy = master.clone();
+        copy.id = PhotoId(2);
+        copy.copy_of = Some(PhotoId(1));
+        cat.apply(Op::AddPhoto { photo: Box::new(copy) }).unwrap();
+
+        let info = crate::media::ProbeInfo {
+            captured: Some("2026-10-20T17:05:32+02:00".into()),
+            width: 1920,
+            height: 1080,
+            file_size: master.file_size,
+            format: "JPEG".into(),
+            ..Default::default()
+        };
+
+        let mut ops = Vec::new();
+        let mut changed = Vec::new();
+        reload_ops(&cat, PhotoId(1), &info, &mut ops, &mut changed);
+
+        assert!(ops.contains(&Op::SetCaptured { id: PhotoId(1), captured: Some("2026-10-20T17:05:32+02:00".into()), manual: false }));
+        assert!(ops.contains(&Op::SetCaptured { id: PhotoId(2), captured: Some("2026-10-20T17:05:32+02:00".into()), manual: false }));
+        assert_eq!(changed, vec![PhotoId(1), PhotoId(2)]);
+    }
+
+    /// A manually edited capture time (`photo.setCaptureTime`) survives reload and is not overwritten
+    /// by probe metadata, even if the probe reads a different capture time from the file.
+    #[test]
+    fn reload_preserves_manually_edited_capture_time() {
+        let mut ph = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");
+        ph.captured = Some("2024-05-01T12:00:00".into()); // manually edited
+        ph.captured_manual = true;
+        let info = crate::media::ProbeInfo {
+            captured: Some("2020-01-01T10:00:00".into()),
+            file_modified: Some("2026-10-21T01:00:06".into()),
+            ..Default::default()
+        };
+        let ops = fill_missing_meta(PhotoId(1), &ph, &info);
+        assert!(ops.is_empty(), "manually edited capture time must survive reload");
+    }
+
+    #[test]
+    fn reload_ops_preserves_manually_edited_capture_time_on_virtual_copy() {
+        let mut cat = lightcraft_catalog::Catalog::new();
+        let mut master = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");
+        master.captured = Some("2026-10-21T01:00:06".into()); // fallback
+        master.captured_manual = false;
+        cat.apply(Op::AddPhoto { photo: Box::new(master.clone()) }).unwrap();
+
+        let mut copy = master.clone();
+        copy.id = PhotoId(2);
+        copy.copy_of = Some(PhotoId(1));
+        copy.captured = Some("2024-05-01T12:00:00".into()); // intentional edit on copy
+        copy.captured_manual = true;
+        cat.apply(Op::AddPhoto { photo: Box::new(copy) }).unwrap();
+
+        let info = crate::media::ProbeInfo {
+            captured: Some("2020-01-01T10:00:00".into()),
+            file_modified: Some("2026-10-21T01:00:06".into()),
+            width: 1920,
+            height: 1080,
+            file_size: master.file_size,
+            format: "JPEG".into(),
+            ..Default::default()
+        };
+
+        let mut ops = Vec::new();
+        let mut changed = Vec::new();
+        reload_ops(&cat, PhotoId(1), &info, &mut ops, &mut changed);
+
+        // Master had fallback, so it gets updated
+        assert!(ops.contains(&Op::SetCaptured { id: PhotoId(1), captured: Some("2020-01-01T10:00:00".into()), manual: false }));
+        // Copy was manually edited, so it must NOT receive Op::SetCaptured
+        assert!(!ops.contains(&Op::SetCaptured { id: PhotoId(2), captured: Some("2020-01-01T10:00:00".into()), manual: false }));
+    }
+
+    #[test]
+    fn photo_set_capture_time_survives_reload() {
+        let mut s = crate::Session::new();
+        let mut ph = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");
+        ph.captured = Some("2026-10-21T01:00:06".into()); // initial fallback
+        s.catalog.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap();
+        s.selection = crate::Selection::single(PhotoId(1));
+
+        // 1. Manually edit capture time via command
+        s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [1], "time": "2024-05-01T12:00:00"})).unwrap();
+        assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().captured.as_deref(), Some("2024-05-01T12:00:00"));
+        assert!(s.catalog.photo(PhotoId(1)).unwrap().captured_manual);
+
+        // 2. Perform reload logic with probe returning a different time from file
+        let info = crate::media::ProbeInfo {
+            captured: Some("2020-01-01T10:00:00".into()),
+            file_modified: Some("2026-10-21T01:00:06".into()),
+            width: 1920,
+            height: 1080,
+            format: "JPEG".into(),
+            ..Default::default()
+        };
+        let mut ops = Vec::new();
+        let mut changed = Vec::new();
+        reload_ops(&s.catalog, PhotoId(1), &info, &mut ops, &mut changed);
+        assert!(ops.is_empty(), "reload must not emit Op::SetCaptured for manually edited photo");
+        assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().captured.as_deref(), Some("2024-05-01T12:00:00"));
+    }
+
+    #[test]
+    fn local_photo_reloads_capture_time_from_disk() {
+        let mut s = crate::Session::new();
+        let mut ph =
+            Photo::new(PhotoId(1), Source::File { path: "/photos/local.jpg".into() }, "local.jpg", "JPEG", 1920, 1080, "2026-10-27T02:00:04");
+        ph.captured = Some("2026-10-27T02:00:04".into());
+        ph.local = true;
+        ph.captured_manual = true; // e.g. spuriously set by older upgrade
+        s.catalog.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap();
+
+        let info = crate::media::ProbeInfo {
+            captured: Some("2026-10-26T19:53:01+01:00".into()),
+            width: 1920,
+            height: 1080,
+            format: "JPEG".into(),
+            ..Default::default()
+        };
+        let mut ops = Vec::new();
+        let mut changed = Vec::new();
+        reload_ops(&s.catalog, PhotoId(1), &info, &mut ops, &mut changed);
+        assert_eq!(ops, vec![Op::SetCaptured { id: PhotoId(1), captured: Some("2026-10-26T19:53:01+01:00".into()), manual: false }]);
+    }
+
     /// Reload from Disk gives a photo imported before focus points were read its camera focus point,
     /// and never replaces one it has.
     #[test]
diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs
--- a/crates/engine/src/cmd/manage.rs
+++ b/crates/engine/src/cmd/manage.rs
@@ -248,8 +248,10 @@ pub fn specs() -> Vec<CommandSpec> {
                         continue;
                     };
                     out.push(json!(new));
-                    if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) {
-                        ops.push(Op::SetCaptured { id: *id, captured: Some(new) });
+                    if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str())
+                        || s.catalog.photo(*id).is_some_and(|p| !p.captured_manual)
+                    {
+                        ops.push(Op::SetCaptured { id: *id, captured: Some(new), manual: true });
                     }
                 }
                 if out_of_range > 0 {
diff --git a/crates/engine/src/import/scan.rs b/crates/engine/src/import/scan.rs
--- a/crates/engine/src/import/scan.rs
+++ b/crates/engine/src/import/scan.rs
@@ -27,13 +27,14 @@ pub(super) fn library_files(library: &lightcraft_catalog::Catalog, files: &[Stri
         if let Source::File { path } = &p.source
             && found.contains(Path::new(path))
         {
+            let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(p.file_size);
             let c = ImportCandidate {
                 path: path.clone(),
                 format: p.format.clone(),
                 kind: p.kind,
                 width: p.width,
                 height: p.height,
-                file_size: p.file_size,
+                file_size,
                 captured: p.captured.clone(),
                 duplicate: Some("path".into()),
                 existing: Some(p.id.0),
diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs
--- a/crates/engine/src/lightroom_catalog.rs
+++ b/crates/engine/src/lightroom_catalog.rs
@@ -1003,17 +1003,18 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co
         if copy_masters.contains(&src.source_id) {
             planned.insert(photo_id, p.clone());
         }
-        if s.catalog.photo(p.id).is_some() {
+        if let Some(existing) = s.catalog.photo(p.id) {
             if was_local && promoted.insert(p.id) {
                 imported += 1;
                 ops.push(Op::SetLocal { id: p.id, local: false });
             }
+            let manual = existing.captured == p.captured && existing.captured_manual;
             ops.extend([
                 Op::SetRating { id: p.id, rating: p.rating },
                 Op::SetFlag { id: p.id, flag: p.flag },
                 Op::SetLabel { id: p.id, label: p.label },
                 Op::SetMeta { id: p.id, meta: Box::new(p.meta.clone()) },
-                Op::SetCaptured { id: p.id, captured: p.captured.clone() },
+                Op::SetCaptured { id: p.id, captured: p.captured.clone(), manual },
             ]);
             ops.push(Op::SetDevelop { id: p.id, settings: p.develop.clone(), label: "Lightroom catalog".into(), edited: p.edited.clone() });
         } else {
diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs
--- a/crates/engine/src/lightroom_catalog/tests.rs
+++ b/crates/engine/src/lightroom_catalog/tests.rs
@@ -428,7 +428,7 @@ fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() {
     s.close_library().unwrap();
     s.open_library(&dir, false).unwrap();
     let id = PhotoId(first["mapping"]["1"].as_u64().unwrap());
-    s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap();
+    s.commit("Changed time", Op::SetCaptured { id, captured: None, manual: true }).unwrap();
     let second = apply(&mut s, data, true).unwrap();
     assert_eq!(first["mapping"], second["mapping"]);
     assert_eq!(first["imported"], 2);
@@ -438,6 +438,17 @@ fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() {
     assert_eq!(s.catalog.len(), 2);
     assert_eq!(s.catalog.albums().count(), 2);
     assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56"));
+    assert!(!s.catalog.photo(id).unwrap().captured_manual, "changed capture time clears manual provenance");
+
+    // When reimporting with a matching capture time, an existing manual provenance is preserved
+    s.commit("Manual edit matching time", Op::SetCaptured { id, captured: Some("2024-01-16T12:34:56".into()), manual: true }).unwrap();
+    assert!(s.catalog.photo(id).unwrap().captured_manual);
+    let mut data_matching = sample();
+    data_matching.collections.reverse();
+    apply(&mut s, data_matching, true).unwrap();
+    assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56"));
+    assert!(s.catalog.photo(id).unwrap().captured_manual, "matching capture time preserves manual provenance");
+
     s.close_library().unwrap();
     std::fs::remove_dir_all(dir).unwrap();
 }
diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs
--- a/crates/engine/src/sidecar.rs
+++ b/crates/engine/src/sidecar.rs
@@ -854,7 +854,7 @@ impl Session {
             Op::SetMeta { id, meta: Box::new(q.meta.clone()) },
         ];
         if q.captured != p.captured {
-            ops.push(Op::SetCaptured { id, captured: q.captured.clone() });
+            ops.push(Op::SetCaptured { id, captured: q.captured.clone(), manual: false });
         }
         if develop_changed {
             ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
diff --git a/crates/engine/src/tests/import.rs b/crates/engine/src/tests/import.rs
--- a/crates/engine/src/tests/import.rs
+++ b/crates/engine/src/tests/import.rs
@@ -989,10 +989,10 @@ fn assisted_culling_groups_bursts_and_scores_focus() {
     let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
     for (i, n) in ["a_soft.png", "b_sharp.png", "c_softer.png"].iter().enumerate() {
         let id = by_name(&s, n);
-        s.commit("t", lightcraft_catalog::Op::SetCaptured { id, captured: Some(format!("2026-05-01T10:00:0{i}")) }).unwrap();
+        s.commit("t", lightcraft_catalog::Op::SetCaptured { id, captured: Some(format!("2026-05-01T10:00:0{i}")), manual: false }).unwrap();
     }
     let other = by_name(&s, "d_other.png");
-    s.commit("t", lightcraft_catalog::Op::SetCaptured { id: other, captured: Some("2026-05-01T10:00:04".into()) }).unwrap();
+    s.commit("t", lightcraft_catalog::Op::SetCaptured { id: other, captured: Some("2026-05-01T10:00:04".into()), manual: false }).unwrap();
     let r = s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": 0.0, "pickBest": true})).unwrap();
     assert_eq!(r["groups"], 1, "{r}");
     let a = |s: &Session, n: &str| s.catalog.photo(by_name(s, n)).unwrap().analysis.unwrap();
@@ -1060,8 +1060,8 @@ fn assisted_culling_groups_bursts_across_month_end() {
         ("2026-11-30T23:59:59", "2026-12-01T00:00:00"),
         ("2026-12-31T23:59:59", "2027-01-01T00:00:00"),
     ] {
-        s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()) }).unwrap();
-        s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()) }).unwrap();
+        s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()), manual: false }).unwrap();
+        s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()), manual: false }).unwrap();
         let r = s.execute("photo.analyze", &json!({"ids": ids})).unwrap();
         assert_eq!(r["groups"], 1, "shots 1 s apart ({before} / {after}) must form a burst: {r}");
     }
@@ -1095,8 +1095,8 @@ fn assisted_culling_burst_limits_are_adjustable() {
     let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
     let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
     let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png"));
-    s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some("2026-05-01T10:00:00".into()) }).unwrap();
-    s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some("2026-05-01T10:00:15".into()) }).unwrap();
+    s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some("2026-05-01T10:00:00".into()), manual: false }).unwrap();
+    s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some("2026-05-01T10:00:15".into()), manual: false }).unwrap();
     // 15 s apart: past the default 10 s, inside a 20 s gap
     assert_eq!(s.execute("photo.analyze", &json!({"ids": ids})).unwrap()["groups"], 0);
     assert_eq!(s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20})).unwrap()["groups"], 1);
diff --git a/crates/engine/src/tests/libops.rs b/crates/engine/src/tests/libops.rs
--- a/crates/engine/src/tests/libops.rs
+++ b/crates/engine/src/tests/libops.rs
@@ -421,7 +421,7 @@ fn tracklog_auto_tag() {
     s.open_library(dir.join("lib"), true).unwrap();
     let ids: Vec<u64> = s.visible_cloned().iter().take(4).map(|p| p.0).collect();
     let set = |s: &mut Session, id: u64, t: &str| {
-        s.commit("t", Op::SetCaptured { id: PhotoId(id), captured: Some(t.into()) }).unwrap();
+        s.commit("t", Op::SetCaptured { id: PhotoId(id), captured: Some(t.into()), manual: false }).unwrap();
     };
     set(&mut s, ids[0], "2026-05-01T12:00:30"); // camera clock on +02:00, no zone recorded
     set(&mut s, ids[1], "2026-05-01T12:01:00+02:00"); // zone recorded: exactly on the 2nd point
diff --git a/crates/engine/src/tests/library.rs b/crates/engine/src/tests/library.rs
--- a/crates/engine/src/tests/library.rs
+++ b/crates/engine/src/tests/library.rs
@@ -546,7 +546,7 @@ fn capture_time_shift_counts_photos_whose_time_does_not_read() {
     let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
     let (good, bad) = (ids[0], ids[1]);
     for (id, t) in [(good, "2026-01-01T10:00:00"), (bad, "2026-01-01T10:00:00 and more")] {
-        s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap();
+        s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()), manual: false }).unwrap();
     }
     let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [good.0, bad.0], "hours": 1})).unwrap();
     assert_eq!(r["unreadable"], 1, "the unreadable photo is counted: {r}");
@@ -574,7 +574,7 @@ fn capture_time_set_refuses_an_active_photo_whose_time_does_not_read() {
     let (bad, other) = (ids[0], ids[1]);
     let broken = "2026-01-01T10:00:00 and more";
     for (id, t) in [(bad, broken), (other, "2026-01-01T12:00:00")] {
-        s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap();
+        s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()), manual: false }).unwrap();
     }
     s.execute("library.select", &serde_json::json!({"ids": [bad.0, other.0], "active": bad.0})).unwrap();
     assert_eq!(s.active(), Some(bad));
@@ -586,7 +586,7 @@ fn capture_time_set_refuses_an_active_photo_whose_time_does_not_read() {
     let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0], "time": "2026-01-01T11:00:00"})).unwrap();
     assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(1), Some(0)), "{r}");
     assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "alone, it gets the time");
-    s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id: bad, captured: Some(broken.into()) }).unwrap();
+    s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id: bad, captured: Some(broken.into()), manual: false }).unwrap();
     let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0, other.0], "time": "2026-01-01T11:00:00", "each": true})).unwrap();
     assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(2), Some(0)), "{r}");
     assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "each repairs it");
diff --git a/crates/engine/src/tests/view_follow.rs b/crates/engine/src/tests/view_follow.rs
--- a/crates/engine/src/tests/view_follow.rs
+++ b/crates/engine/src/tests/view_follow.rs
@@ -65,8 +65,10 @@ fn edit(s: &mut Session, dice: &mut Dice, ids: &mut Vec<PhotoId>, album: u64) {
     // (ops a command would refuse here, or on a photo that is gone, are edits that were not made: also a case)
     match dice.roll(9) {
         0 => {
-            let _ =
-                s.commit("Capture time", Op::SetCaptured { id: one, captured: Some(format!("2025-{:02}-{:02}T08:00:00", 1 + n % 12, 1 + n % 28)) });
+            let _ = s.commit(
+                "Capture time",
+                Op::SetCaptured { id: one, captured: Some(format!("2025-{:02}-{:02}T08:00:00", 1 + n % 12, 1 + n % 28)), manual: false },
+            );
             return;
         }
         1 => {
diff --git a/crates/engine/src/tests/xmp.rs b/crates/engine/src/tests/xmp.rs
--- a/crates/engine/src/tests/xmp.rs
+++ b/crates/engine/src/tests/xmp.rs
@@ -1327,7 +1327,7 @@ fn sidecar_capture_time_fills_in_when_the_file_has_none() {
 
     // Read Metadata from File fills in a missing capture time too
     let id = sample.id;
-    s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: None }).unwrap();
+    s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: None, manual: false }).unwrap();
     std::fs::copy(src.join("sample.jpg.xmp"), format!("{path}.xmp")).unwrap();
     s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
     s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs
--- a/crates/ui-egui/src/dialogs/import/scan.rs
+++ b/crates/ui-egui/src/dialogs/import/scan.rs
@@ -186,6 +186,26 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
             let listed: Vec<String> = out.candidates.iter().map(|c| c.path.clone()).collect();
             app.session.forget_gone_from(&b.path, b.subfolders, &listed);
         }
+        // Reload local photos in this folder whose file on disk has changed size:
+        let mut stale: std::collections::BTreeSet<lightcraft_catalog::PhotoId> = std::collections::BTreeSet::new();
+        for c in out.candidates.iter().filter(|c| c.duplicate == Some("path".into())) {
+            let Some(existing_id) = c.existing else { continue };
+            let Some(mut ph) = app.session.catalog.photo(lightcraft_catalog::PhotoId(existing_id)) else { continue };
+            if let Some(master_id) = ph.copy_of {
+                let Some(master) = app.session.catalog.photo(master_id) else { continue };
+                ph = master;
+            }
+            if !ph.local || ph.deleted {
+                continue;
+            }
+            if c.file_size != ph.file_size {
+                stale.insert(ph.id);
+            }
+        }
+        if !stale.is_empty() {
+            let ids: Vec<serde_json::Value> = stale.iter().map(|id| serde_json::json!(id.0)).collect();
+            let _ = app.run("photo.reload", serde_json::json!({"ids": ids}));
+        }
         // what the library doesn't know yet joins the Local view
         let queue: Vec<String> =
             out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect();
diff --git a/crates/ui-egui/src/tests/capture_time.rs b/crates/ui-egui/src/tests/capture_time.rs
--- a/crates/ui-egui/src/tests/capture_time.rs
+++ b/crates/ui-egui/src/tests/capture_time.rs
@@ -14,7 +14,7 @@ const T: Duration = Duration::from_secs(20);
 const SETTLE: Duration = Duration::from_secs(60);
 
 fn set_captured(h: &mut Headless, id: lightcraft_catalog::PhotoId, t: &str) {
-    h.app.session.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap();
+    h.app.session.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()), manual: false }).unwrap();
 }
 
 fn painted(h: &Headless, part: &str) -> bool {
diff --git a/crates/ui-egui/src/tests/grid.rs b/crates/ui-egui/src/tests/grid.rs
--- a/crates/ui-egui/src/tests/grid.rs
+++ b/crates/ui-egui/src/tests/grid.rs
@@ -293,7 +293,7 @@ fn a_batch_of_new_photos_or_an_edit_to_one_is_read_photo_by_photo() {
     assert_as_made_afresh(&mut h, "after the batch");
     // a photo taken on another day: it moves there, and only it is read
     let read = h.app.caches.grid_stats.photos_read;
-    h.app.session.commit("Capture time", Op::SetCaptured { id: PhotoId(500), captured: Some("2027-02-01T08:00:00".into()) }).unwrap();
+    h.app.session.commit("Capture time", Op::SetCaptured { id: PhotoId(500), captured: Some("2027-02-01T08:00:00".into()), manual: false }).unwrap();
     h.step();
     assert_eq!(h.app.caches.grid_stats.photos_read - read, 1, "only the photo edited is read");
     assert!(widget(&h, "group:2027-02-01").is_some(), "its new day heads the grid");
@@ -331,10 +331,10 @@ fn a_grid_followed_through_edits_shows_what_a_grid_made_afresh_shows() {
                     let _ = h.app.session.commit("Delete", Op::SetDeleted { id: PhotoId(id), deleted: true });
                 }
                 2 => {
-                    let _ = h
-                        .app
-                        .session
-                        .commit("Capture time", Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2026-{:02}-03T10:00:00", 1 + n % 12)) });
+                    let _ = h.app.session.commit(
+                        "Capture time",
+                        Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2026-{:02}-03T10:00:00", 1 + n % 12)), manual: false },
+                    );
                 }
                 3 => {
                     h.app.session.execute("library.select", &json!({"ids": [id]})).unwrap();
diff --git a/crates/ui-egui/src/tests/tallies.rs b/crates/ui-egui/src/tests/tallies.rs
--- a/crates/ui-egui/src/tests/tallies.rs
+++ b/crates/ui-egui/src/tests/tallies.rs
@@ -79,10 +79,10 @@ fn a_tally_kept_through_an_edit_is_the_tally_made_from_every_photo() {
                     let source = Source::File { path: format!("/elsewhere/{}/{name}", n % 3) };
                     Some(a.session.commit("Locate", Op::Relink { id: PhotoId(id), file_name: name, source, format: None }))
                 }
-                1 => Some(
-                    a.session
-                        .commit("Capture time", Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2024-{:02}-03T10:00:00", 1 + n % 12)) }),
-                ),
+                1 => Some(a.session.commit(
+                    "Capture time",
+                    Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2024-{:02}-03T10:00:00", 1 + n % 12)), manual: false },
+                )),
                 2 if n.is_multiple_of(3) => Some(a.session.commit("Remove", Op::RemovePhoto { id: PhotoId(id) })),
                 2 if n % 3 == 1 => {
                     let new = a.session.catalog.alloc_photo_id();
