//! Backfill of photo versions that were ingested before linking existed //! (the `link_versions` binary). //! //! Two kinds of proposal: //! //! - **Exact** (`xmp`, `name`): the PNG carries the producer's XMP //! `DerivedFrom` hash, or its name follows `.[.].` next //! to an indexed original. The ingest path would have made the same link, //! so these apply without review. //! - **Review** (`comfy-loader`, `name-prefix`, `phash`): files with no XMP, //! matched by the ComfyUI prompt's `LoadImage` input, an Upscayl-style //! name, or the nearest pHash. Heuristic, so they only go through a TSV a //! human has read (`--reviewed`). Ambiguous or unresolvable cases are //! listed, never guessed. //! //! [`plan`] only reads (database rows handed in, library files for XMP and //! the prompt chunk); [`apply`] is the only writer and goes through //! [`VersionDao`]. use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::LazyLock; use regex::Regex; use super::xmp::{self, XMP_KEYWORD}; use super::{VERSIONS_SUBFOLDER, original_candidates, parse_version_name}; use crate::database::version_dao::{ LinkOutcome, NewVersionLink, SOURCE_BACKFILL, SOURCE_NAME, SOURCE_XMP, VersionDao, is_known_kind, }; use crate::file_types; use crate::libraries::Library; /// A nearest-pHash match farther than this (of 64 bits) is not proposed. pub const MAX_PHASH_DISTANCE: u32 = 16; /// One `image_exif` row, as far as planning needs it. #[derive(Debug, Clone)] pub struct FileRow { pub library_id: i32, pub rel_path: String, pub content_hash: String, pub phash: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Status { /// Applies without review. Exact, /// Heuristic; applies only from a reviewed TSV. Review, /// Listed, not guessed. Unresolved, } impl Status { fn as_str(self) -> &'static str { match self { Status::Exact => "exact", Status::Review => "review", Status::Unresolved => "unresolved", } } } #[derive(Debug, Clone)] pub struct Proposal { pub status: Status, /// `xmp` | `name` | `comfy-loader` | `name-prefix` | `phash` pub method: &'static str, pub library_id: i32, pub version_path: String, pub version_hash: String, /// Empty for an unresolved case, or an XMP link whose original isn't indexed. pub original_path: String, pub original_hash: String, pub kind: String, pub distance: Option, /// Hashes of reference photos (the other loader inputs). pub references: Vec, pub notes: String, } pub const TSV_HEADER: &str = "status\tmethod\tlibrary_id\tversion_path\toriginal_path\tphash_distance\tkind\tversion_hash\toriginal_hash\treferences\tnotes"; impl Proposal { pub fn to_tsv(&self) -> String { let clean = |s: &str| s.replace(['\t', '\n', '\r'], " "); [ self.status.as_str().to_string(), self.method.to_string(), self.library_id.to_string(), clean(&self.version_path), clean(&self.original_path), self.distance.map(|d| d.to_string()).unwrap_or_default(), self.kind.clone(), self.version_hash.clone(), self.original_hash.clone(), self.references.join(","), clean(&self.notes), ] .join("\t") } } /// A row of a reviewed TSV: just enough to link. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReviewedLink { pub method: String, pub library_id: i32, pub version_path: String, /// The original's path as the dry run proposed it (may be empty). pub original_path: String, pub kind: String, pub version_hash: String, pub original_hash: String, pub references: Vec, } /// Parse a reviewed TSV (the dry-run output with rows deleted or kept). /// Rows that aren't `review` status with both hashes are skipped; so are the /// header, blanks and `#` comments. Returns `(links, skipped_lines)`. pub fn parse_reviewed(tsv: &str) -> (Vec, usize) { let mut out = Vec::new(); let mut skipped = 0; for line in tsv.lines() { if line.trim().is_empty() || line.starts_with('#') || line.starts_with("status\t") { continue; } let f: Vec<&str> = line.split('\t').collect(); let usable = f.len() >= 10 && f[0] == "review" && f[2].parse::().is_ok() && !f[7].is_empty() && !f[8].is_empty(); if !usable { skipped += 1; continue; } out.push(ReviewedLink { method: f[1].to_string(), library_id: f[2].parse().unwrap(), version_path: f[3].to_string(), original_path: f[4].to_string(), kind: f[6].to_string(), version_hash: f[7].to_string(), original_hash: f[8].to_string(), references: f[9] .split(',') .filter(|s| !s.is_empty()) .map(str::to_string) .collect(), }); } (out, skipped) } fn dir_of(rel: &str) -> &str { rel.rsplit_once('/').map_or("", |(d, _)| d) } fn name_of(rel: &str) -> &str { rel.rsplit('/').next().unwrap_or(rel) } fn parent_of_dir(dir: &str) -> &str { dir.rsplit_once('/').map_or("", |(p, _)| p) } fn is_aiedits_dir(dir: &str) -> bool { name_of(dir).eq_ignore_ascii_case(VERSIONS_SUBFOLDER) } /// Names that are themselves versions (convention or Upscayl output) are /// never proposed as someone's original. fn looks_like_a_version(file_name: &str) -> bool { parse_version_name(file_name).is_some() || upscayl_stem(file_name).is_some() } fn hamming(a: i64, b: i64) -> u32 { ((a ^ b) as u64).count_ones() } static UPSCAYL_SUFFIX: LazyLock = LazyLock::new(|| { // `-4x.png`, `_upscayl_2x_ultramix_balanced.png` Regex::new(r"(?i)^(?P.+?)(?:-\d+x|_upscayl_\d+(?:\.\d+)?x_[^.]+)\.png$").unwrap() }); fn upscayl_stem(file_name: &str) -> Option { UPSCAYL_SUFFIX .captures(file_name) .map(|c| c["stem"].to_string()) } /// File names a ComfyUI prompt loads, in node order: the `image` input of /// every `LoadImage*` node. pub fn loader_names(prompt_json: &str) -> Vec { let Ok(value) = serde_json::from_str::(prompt_json) else { return Vec::new(); }; let Some(nodes) = value.as_object() else { return Vec::new(); }; let mut ids: Vec<&String> = nodes.keys().collect(); ids.sort_by_key(|k| k.parse::().unwrap_or(u64::MAX)); let mut out = Vec::new(); for id in ids { let node = &nodes[id]; let class = node["class_type"].as_str().unwrap_or(""); if !class.starts_with("LoadImage") { continue; } if let Some(raw) = node["inputs"]["image"].as_str() { let name = raw.trim_end_matches(" [input]"); let name = name.rsplit(['/', '\\']).next().unwrap_or(name); if !name.is_empty() { out.push(name.to_string()); } } } out } struct Index<'a> { by_path: HashMap<(i32, &'a str), &'a FileRow>, by_name: HashMap<(i32, &'a str), Vec<&'a FileRow>>, by_dir: HashMap<(i32, &'a str), Vec<&'a FileRow>>, } impl<'a> Index<'a> { fn new(rows: &'a [FileRow]) -> Self { let mut idx = Index { by_path: HashMap::new(), by_name: HashMap::new(), by_dir: HashMap::new(), }; for r in rows { idx.by_path.insert((r.library_id, r.rel_path.as_str()), r); idx.by_name .entry((r.library_id, name_of(&r.rel_path))) .or_default() .push(r); idx.by_dir .entry((r.library_id, dir_of(&r.rel_path))) .or_default() .push(r); } idx } } /// Plan every link the backfill can propose. `rows` are all hashed image /// rows; `linked` the version hashes that already have a link. pub fn plan(libs: &[Library], rows: &[FileRow], linked: &HashSet) -> Vec { let idx = Index::new(rows); let root_of: HashMap = libs.iter().map(|l| (l.id, l.root_path.as_str())).collect(); let mut out = Vec::new(); for row in rows { if linked.contains(&row.content_hash) { continue; } let name = name_of(&row.rel_path); let dir = dir_of(&row.rel_path); let is_png = name.to_ascii_lowercase().ends_with(".png"); let name_conv = parse_version_name(name); let upscayl = if is_png { upscayl_stem(name) } else { None }; let in_aiedits = is_aiedits_dir(dir); if !(is_png || name_conv.is_some()) { continue; } if !file_types::is_image_file(Path::new(&row.rel_path)) { continue; } // Text chunks (XMP + ComfyUI prompt) of PNGs, read once. let mut xmp_text = None; let mut prompt = None; if is_png && let Some(root) = root_of.get(&row.library_id) { let abs = Path::new(root).join(&row.rel_path); if let Ok(chunks) = xmp::read_png_text(&abs, &[XMP_KEYWORD, "prompt"]) { for (k, v) in chunks { if k == XMP_KEYWORD { xmp_text = Some(v); } else { prompt = Some(v); } } } } // 1. XMP: exact, even when the original isn't indexed yet. if let Some(x) = xmp_text.as_deref().and_then(xmp::parse_version_xmp) && x.original_hash != row.content_hash { let original = rows.iter().find(|r| r.content_hash == x.original_hash); let kind = x .kind .filter(|k| is_known_kind(k)) .or_else(|| name_conv.as_ref().map(|n| n.kind.to_string())) .unwrap_or_else(|| "edit".to_string()); out.push(Proposal { status: Status::Exact, method: "xmp", library_id: row.library_id, version_path: row.rel_path.clone(), version_hash: row.content_hash.clone(), original_path: original.map(|o| o.rel_path.clone()).unwrap_or_default(), original_hash: x.original_hash, kind, distance: distance(row, original), references: x.references, notes: if original.is_some() { String::new() } else { "original not indexed (yet)".into() }, }); continue; } // 2. Name convention next to an indexed original: exact. if let Some(n) = &name_conv { let found = original_candidates(&row.rel_path) .into_iter() .find_map(|c| { idx.by_path .get(&(row.library_id, c.as_str())) .copied() .filter(|o| o.content_hash != row.content_hash) }); if let Some(o) = found { out.push(Proposal { status: Status::Exact, method: "name", library_id: row.library_id, version_path: row.rel_path.clone(), version_hash: row.content_hash.clone(), original_path: o.rel_path.clone(), original_hash: o.content_hash.clone(), kind: n.kind.to_string(), distance: distance(row, Some(o)), references: Vec::new(), notes: String::new(), }); continue; } // A convention name with no original found falls through to the // heuristics only if it is also a ComfyUI output in aiedits/. if !in_aiedits { out.push(unresolved(row, "name", "no original with that name nearby")); continue; } } // 3. Heuristics (PNG only; reviewed before applying). if !is_png { continue; } let loaders = prompt.as_deref().map(loader_names).unwrap_or_default(); // A ComfyUI output names its inputs wherever it was saved (early test // runs went to folders like `edited/` or sat beside the original), so // the loader match isn't limited to aiedits/. It's review-only anyway. if !loaders.is_empty() { out.push(plan_comfy_loader(row, &loaders, &idx)); } else if let Some(stem) = upscayl { out.push(plan_prefix(row, &stem, &idx)); } else if in_aiedits { out.push(plan_phash(row, rows)); } } out.sort_by(|a, b| { (a.status as u8, a.library_id, &a.version_path).cmp(&( b.status as u8, b.library_id, &b.version_path, )) }); out } fn distance(version: &FileRow, original: Option<&FileRow>) -> Option { Some(hamming(version.phash?, original?.phash?)) } fn unresolved(row: &FileRow, method: &'static str, why: &str) -> Proposal { Proposal { status: Status::Unresolved, method, library_id: row.library_id, version_path: row.rel_path.clone(), version_hash: row.content_hash.clone(), original_path: String::new(), original_hash: String::new(), kind: "edit".into(), distance: None, references: Vec::new(), notes: why.to_string(), } } /// Nearest row to `row` by pHash among `candidates` (needs both hashes). fn nearest<'a>(row: &FileRow, candidates: &[&'a FileRow]) -> Option<(&'a FileRow, u32)> { let p = row.phash?; candidates .iter() .filter_map(|c| Some((*c, hamming(p, c.phash?)))) .min_by_key(|(c, d)| (*d, c.rel_path.clone())) } fn plan_comfy_loader(row: &FileRow, loaders: &[String], idx: &Index) -> Proposal { let dir = dir_of(&row.rel_path); let parent = parent_of_dir(dir); // Per loader name: the matching rows from the first tier that has any: // for an output in aiedits/ its parent then the same folder; for one // elsewhere the same folder (saved beside the original) then the parent // (e.g. `edited/`); then anywhere in the library. Version-looking files // never count as originals. let tiers = if is_aiedits_dir(dir) { [parent, dir] } else { [dir, parent] }; let resolve = |loader: &str| -> Vec<&FileRow> { let all: Vec<&FileRow> = idx .by_name .get(&(row.library_id, loader)) .map(|v| { v.iter() .copied() .filter(|r| { r.content_hash != row.content_hash && !is_aiedits_dir(dir_of(&r.rel_path)) }) .collect() }) .unwrap_or_default(); for tier in tiers { let hit: Vec<&FileRow> = all .iter() .copied() .filter(|r| dir_of(&r.rel_path) == tier) .collect(); if !hit.is_empty() { return hit; } } all }; let mut resolved: Vec<(&String, Vec<&FileRow>)> = Vec::new(); let mut missing: Vec<&String> = Vec::new(); for l in loaders { let hits = resolve(l); if hits.is_empty() { missing.push(l); } else { resolved.push((l, hits)); } } if resolved.is_empty() { let mut p = unresolved( row, "comfy-loader", &format!( "loader input(s) not found in the library: {}", loaders.join(", ") ), ); // A hint for the reviewer, not a proposal. let siblings: Vec<&FileRow> = idx .by_dir .get(&(row.library_id, parent)) .map(|v| { v.iter() .copied() .filter(|r| !is_aiedits_dir(dir_of(&r.rel_path))) .collect() }) .unwrap_or_default(); if let Some((n, d)) = nearest(row, &siblings) { p.notes .push_str(&format!("; hint: nearest pHash {} (d={d})", n.rel_path)); } return p; } // Primary: the nearest pHash across everything the loaders resolved to. let flat: Vec<(&String, &FileRow)> = resolved .iter() .flat_map(|(l, hits)| hits.iter().map(move |h| (*l, *h))) .collect(); let rows_only: Vec<&FileRow> = flat.iter().map(|(_, r)| *r).collect(); let (primary, dist) = match nearest(row, &rows_only) { Some((p, d)) => (p, Some(d)), None => (rows_only[0], None), }; let mut notes = Vec::new(); if resolved.len() > 1 { notes.push("two loader inputs: primary chosen by nearest pHash, the other recorded as a reference — check".to_string()); } if flat.len() > resolved.len() { notes.push("a loader name matched several files — check".into()); } if !missing.is_empty() { notes.push(format!( "other loader input(s) not in the library: {}", missing .iter() .map(|s| s.as_str()) .collect::>() .join(", ") )); } if dist.is_none() { notes.push("no pHash to compare".into()); } let mut references: Vec = Vec::new(); for (_, hits) in &resolved { for h in hits { if h.content_hash != primary.content_hash && !references.contains(&h.content_hash) { references.push(h.content_hash.clone()); } } } Proposal { status: Status::Review, method: "comfy-loader", library_id: row.library_id, version_path: row.rel_path.clone(), version_hash: row.content_hash.clone(), original_path: primary.rel_path.clone(), original_hash: primary.content_hash.clone(), kind: "edit".into(), distance: dist, references, notes: notes.join("; "), } } fn plan_prefix(row: &FileRow, stem: &str, idx: &Index) -> Proposal { let dir = dir_of(&row.rel_path); let matches: Vec<&FileRow> = idx .by_dir .get(&(row.library_id, dir)) .map(|v| { v.iter() .copied() .filter(|r| { r.content_hash != row.content_hash && name_of(&r.rel_path) .rsplit_once('.') .is_some_and(|(s, _)| s == stem) }) .collect() }) .unwrap_or_default(); match matches.as_slice() { [] => unresolved( row, "name-prefix", &format!("no file named {stem}.* beside it"), ), [only] => Proposal { status: Status::Review, method: "name-prefix", library_id: row.library_id, version_path: row.rel_path.clone(), version_hash: row.content_hash.clone(), original_path: only.rel_path.clone(), original_hash: only.content_hash.clone(), kind: "upscale".into(), distance: distance(row, Some(only)), references: Vec::new(), notes: String::new(), }, several => { let (best, d) = nearest(row, several).unwrap_or((several[0], u32::MAX)); Proposal { status: Status::Review, method: "name-prefix", library_id: row.library_id, version_path: row.rel_path.clone(), version_hash: row.content_hash.clone(), original_path: best.rel_path.clone(), original_hash: best.content_hash.clone(), kind: "upscale".into(), distance: (d != u32::MAX).then_some(d), references: Vec::new(), notes: format!("{} files share that name — check", several.len()), } } } } fn plan_phash(row: &FileRow, rows: &[FileRow]) -> Proposal { let dir = dir_of(&row.rel_path); let parent = parent_of_dir(dir); let candidates: Vec<&FileRow> = rows .iter() .filter(|r| { r.library_id == row.library_id && r.content_hash != row.content_hash && !is_aiedits_dir(dir_of(&r.rel_path)) && !looks_like_a_version(name_of(&r.rel_path)) && (dir_of(&r.rel_path) == parent || dir_of(&r.rel_path) == dir) }) .collect(); match nearest(row, &candidates) { Some((o, d)) if d <= MAX_PHASH_DISTANCE => Proposal { status: Status::Review, method: "phash", library_id: row.library_id, version_path: row.rel_path.clone(), version_hash: row.content_hash.clone(), original_path: o.rel_path.clone(), original_hash: o.content_hash.clone(), kind: "edit".into(), distance: Some(d), references: Vec::new(), notes: "matched by nearest pHash only".into(), }, Some((o, d)) => unresolved( row, "phash", &format!("nearest pHash is {} at distance {d}, too far", o.rel_path), ), None => unresolved(row, "phash", "no pHash candidates nearby"), } } /// What [`apply`] did. #[derive(Debug, Default, PartialEq, Eq)] pub struct ApplyReport { pub linked: usize, pub already_linked: usize, pub refused: usize, /// Reviewed rows whose version row no longer matches the file in the TSV. pub stale: usize, /// One line per stale reviewed row, saying what no longer matches. pub stale_details: Vec, } /// Write the links. `exact` proposals apply as they are; `reviewed` come /// from a TSV a human kept, and are checked against `rows` first so a stale /// file can't link a path that now holds different bytes. pub fn apply( dao: &mut dyn VersionDao, exact: &[Proposal], reviewed: &[ReviewedLink], rows: &[FileRow], ) -> ApplyReport { let mut report = ApplyReport::default(); let current: HashMap<(i32, &str), &str> = rows .iter() .map(|r| ((r.library_id, r.rel_path.as_str()), r.content_hash.as_str())) .collect(); let live_hashes: HashSet<&str> = rows.iter().map(|r| r.content_hash.as_str()).collect(); let mut links: Vec = Vec::new(); for p in exact.iter().filter(|p| p.status == Status::Exact) { links.push(NewVersionLink { version_hash: p.version_hash.clone(), original_hash: p.original_hash.clone(), kind: p.kind.clone(), source: if p.method == "xmp" { SOURCE_XMP } else { SOURCE_NAME } .to_string(), library_id: p.library_id, rel_path: p.version_path.clone(), references: p.references.clone(), }); } for r in reviewed { let stale = if current.get(&(r.library_id, r.version_path.as_str())) != Some(&r.version_hash.as_str()) { Some("version file now holds different bytes (or is gone)") } else if !live_hashes.contains(r.original_hash.as_str()) { Some("original_hash is not in the index any more") } else if !r.original_path.is_empty() && current .get(&(r.library_id, r.original_path.as_str())) .is_some_and(|h| *h != r.original_hash.as_str()) { Some("original path now holds different bytes than original_hash") } else { None }; if let Some(why) = stale { report.stale += 1; report.stale_details.push(format!( "{} -> {}: {why}", r.version_path, if r.original_path.is_empty() { &r.original_hash } else { &r.original_path } )); continue; } links.push(NewVersionLink { version_hash: r.version_hash.clone(), original_hash: r.original_hash.clone(), kind: if is_known_kind(&r.kind) { r.kind.clone() } else { "edit".into() }, source: SOURCE_BACKFILL.to_string(), library_id: r.library_id, rel_path: r.version_path.clone(), references: r.references.clone(), }); } for link in links { match dao.link_version(&link) { Ok(LinkOutcome::Linked) => report.linked += 1, Ok(LinkOutcome::AlreadyLinked) => report.already_linked += 1, Ok(LinkOutcome::Rejected(_)) | Err(_) => report.refused += 1, } } report } #[cfg(test)] mod tests { use super::super::xmp::test_png::{producer_xmp, write_png}; use super::*; use crate::database::SqliteVersionDao; use crate::database::test::in_memory_db_connection; use std::sync::{Arc, Mutex}; fn row(rel: &str, hash: &str, phash: Option) -> FileRow { FileRow { library_id: 1, rel_path: rel.into(), content_hash: hash.into(), phash, } } fn lib(root: &Path) -> Library { Library { id: 1, name: "main".into(), root_path: root.to_string_lossy().into(), enabled: true, excluded_dirs: vec![], } } fn prompt(loaders: &[&str]) -> String { let mut nodes = serde_json::Map::new(); nodes.insert( "1".into(), serde_json::json!({"class_type": "UNETLoader", "inputs": {"unet_name": "m"}}), ); for (i, l) in loaders.iter().enumerate() { nodes.insert( (i + 2).to_string(), serde_json::json!({"class_type": "LoadImage", "inputs": {"image": l}}), ); } serde_json::Value::Object(nodes).to_string() } fn write_comfy_png(root: &Path, rel: &str, loaders: &[&str], salt: u8) { super::super::xmp::test_png::write_png(&root.join(rel), None, Some(&prompt(loaders)), salt); } #[test] fn loader_names_reads_only_loadimage_inputs_in_node_order() { let p = prompt(&["a.jpg", "sub/b [input]"]); assert_eq!(loader_names(&p), vec!["a.jpg", "b"]); assert!(loader_names("not json").is_empty()); } #[test] fn already_ingested_xmp_files_are_proposed_as_exact_without_naming_help() { let tmp = tempfile::tempdir().unwrap(); let orig_hash = "d5".repeat(32); write_png( &tmp.path().join("2009/aiedits/019_19.JPG.restored.png"), Some(&producer_xmp(&orig_hash, "restored", &[])), None, 1, ); // An XMP file whose name gives no hint (a renamed copy). write_png( &tmp.path().join("misc/renamed.png"), Some(&producer_xmp( &orig_hash, "upscale", &["ee".repeat(32).as_str()], )), None, 2, ); let rows = vec![ row("2009/019_19.JPG", &orig_hash, None), row("2009/aiedits/019_19.JPG.restored.png", "v1", None), row("misc/renamed.png", "v2", None), ]; let plan = plan(&[lib(tmp.path())], &rows, &HashSet::new()); let by_path: HashMap<&str, &Proposal> = plan.iter().map(|p| (p.version_path.as_str(), p)).collect(); let a = by_path["2009/aiedits/019_19.JPG.restored.png"]; assert_eq!((a.status, a.method), (Status::Exact, "xmp")); assert_eq!(a.original_path, "2009/019_19.JPG"); let b = by_path["misc/renamed.png"]; assert_eq!( (b.status, b.method, b.kind.as_str()), (Status::Exact, "xmp", "upscale") ); assert_eq!(b.references.len(), 1); } #[test] fn comfy_outputs_are_resolved_by_loader_and_flagged_when_two_inputs() { let tmp = tempfile::tempdir().unwrap(); write_comfy_png(tmp.path(), "album_a/aiedits/out_00001_.png", &["a.jpg"], 1); write_comfy_png( tmp.path(), "album_a/aiedits/out_00002_.png", &["a.jpg", "b.jpg"], 2, ); write_comfy_png( tmp.path(), "album_a/aiedits/out_00003_.png", &["typo.jpg"], 3, ); let rows = vec![ row("album_a/a.jpg", "ha", Some(0b1111)), row("album_a/b.jpg", "hb", Some(-1)), row("album_a/aiedits/out_00001_.png", "v1", Some(0b1110)), row("album_a/aiedits/out_00002_.png", "v2", Some(0b1011)), row("album_a/aiedits/out_00003_.png", "v3", Some(0)), ]; let plan = plan(&[lib(tmp.path())], &rows, &HashSet::new()); let get = |p: &str| plan.iter().find(|x| x.version_path == p).unwrap(); let one = get("album_a/aiedits/out_00001_.png"); assert_eq!((one.status, one.method), (Status::Review, "comfy-loader")); assert_eq!(one.original_path, "album_a/a.jpg"); assert_eq!(one.distance, Some(1)); assert!(one.references.is_empty()); let two = get("album_a/aiedits/out_00002_.png"); assert_eq!( two.original_path, "album_a/a.jpg", "primary by nearest pHash" ); assert_eq!(two.references, vec!["hb"]); assert!(two.notes.contains("two loader inputs")); let lost = get("album_a/aiedits/out_00003_.png"); assert_eq!(lost.status, Status::Unresolved); assert!(lost.original_path.is_empty(), "listed, not guessed"); } #[test] fn comfy_outputs_outside_aiedits_resolve_from_their_own_folder_then_the_parent() { let tmp = tempfile::tempdir().unwrap(); // Saved into a sibling folder: the original is one level up. write_comfy_png(tmp.path(), "album_b/edited/out_00016_.png", &["a.jpg"], 1); // Saved beside the original; a same-named file at the root must lose. write_comfy_png(tmp.path(), "album_c/x-aiedit_00001_.png", &["x.jpg"], 2); // A ComfyUI output with no loader (txt2img) is left alone. write_comfy_png(tmp.path(), "album_c/generated_00001_.png", &[], 3); let rows = vec![ row("album_b/a.jpg", "ha", Some(0)), row("album_b/edited/out_00016_.png", "v1", Some(1)), row("x.jpg", "hroot", Some(0)), row("album_c/x.jpg", "hx", Some(0)), row("album_c/x-aiedit_00001_.png", "v2", Some(1)), row("album_c/generated_00001_.png", "g", Some(0)), ]; let plan = plan(&[lib(tmp.path())], &rows, &HashSet::new()); let get = |p: &str| plan.iter().find(|x| x.version_path == p); let edited = get("album_b/edited/out_00016_.png").unwrap(); assert_eq!( (edited.status, edited.method), (Status::Review, "comfy-loader") ); assert_eq!(edited.original_path, "album_b/a.jpg"); let beside = get("album_c/x-aiedit_00001_.png").unwrap(); assert_eq!(beside.status, Status::Review); assert_eq!( beside.original_path, "album_c/x.jpg", "same folder beats the parent" ); assert!( get("album_c/generated_00001_.png").is_none(), "no loader, not a version" ); } #[test] fn upscayl_names_and_phash_fallback() { let tmp = tempfile::tempdir().unwrap(); write_png(&tmp.path().join("p/att_1-4x.png"), None, None, 1); write_png( &tmp.path().join("p/pic_upscayl_3x_ultramix_balanced.png"), None, None, 2, ); write_png(&tmp.path().join("p/aiedits/mystery.png"), None, None, 3); let rows = vec![ row("p/att_1.jpg", "h1", Some(5)), row("p/pic.jpeg", "h2", Some(5)), row("p/other.jpg", "h3", Some(0b0111_1111_1111)), row("p/att_1-4x.png", "u1", Some(5)), row("p/pic_upscayl_3x_ultramix_balanced.png", "u2", Some(5)), row("p/aiedits/mystery.png", "m1", Some(7)), ]; let plan = plan(&[lib(tmp.path())], &rows, &HashSet::new()); let get = |p: &str| plan.iter().find(|x| x.version_path == p).unwrap(); assert_eq!(get("p/att_1-4x.png").original_path, "p/att_1.jpg"); assert_eq!(get("p/att_1-4x.png").kind, "upscale"); assert_eq!( get("p/pic_upscayl_3x_ultramix_balanced.png").original_path, "p/pic.jpeg" ); let m = get("p/aiedits/mystery.png"); assert_eq!(m.method, "phash"); assert_eq!(m.status, Status::Review); assert_eq!( m.original_path, "p/att_1.jpg", "distance 2 beats the others" ); } #[test] fn linked_rows_are_not_proposed_again() { let tmp = tempfile::tempdir().unwrap(); write_png(&tmp.path().join("a.jpg.restored.png"), None, None, 1); let rows = vec![ row("a.jpg", "o", None), row("a.jpg.restored.png", "v", None), ]; let linked = HashSet::from(["v".to_string()]); assert!(plan(&[lib(tmp.path())], &rows, &linked).is_empty()); let p = plan(&[lib(tmp.path())], &rows, &HashSet::new()); assert_eq!((p[0].status, p[0].method), (Status::Exact, "name")); } #[test] fn tsv_round_trip_and_reviewed_application() { let tmp = tempfile::tempdir().unwrap(); write_comfy_png(tmp.path(), "d/aiedits/o_00001_.png", &["a.jpg", "b.jpg"], 1); let rows = vec![ row("d/a.jpg", "ha", Some(1)), row("d/b.jpg", "hb", Some(-1)), row("d/aiedits/o_00001_.png", "v", Some(1)), row("d/x.jpg", "hx", None), row("d/x.jpg.edit.png", "vx", None), ]; write_png(&tmp.path().join("d/x.jpg.edit.png"), None, None, 9); let planned = plan(&[lib(tmp.path())], &rows, &HashSet::new()); let tsv: String = std::iter::once(TSV_HEADER.to_string()) .chain(planned.iter().map(Proposal::to_tsv)) .collect::>() .join("\n"); let (reviewed, skipped) = parse_reviewed(&tsv); assert_eq!( reviewed.len(), 1, "only the review row; the exact one isn't re-applied from the file" ); assert_eq!(skipped, 1); assert_eq!(reviewed[0].references, vec!["hb"]); let conn = Arc::new(Mutex::new(in_memory_db_connection())); let mut dao = SqliteVersionDao::from_shared(conn); // Without --reviewed: exact only. let report = apply(&mut dao, &planned, &[], &rows); assert_eq!(report.linked, 1); assert!(dao.get_link("vx").unwrap().is_some()); assert!( dao.get_link("v").unwrap().is_none(), "heuristic rows never auto-apply" ); // With the reviewed file. let report = apply(&mut dao, &planned, &reviewed, &rows); assert_eq!(report.linked, 1); assert_eq!(report.already_linked, 1); let l = dao.get_link("v").unwrap().unwrap(); assert_eq!( (l.original_hash.as_str(), l.source.as_str()), ("ha", "backfill") ); assert_eq!(dao.list_references("v").unwrap(), vec!["hb"]); // A stale TSV (the path now holds other bytes) is refused. let mut stale_rows = rows.clone(); stale_rows[2].content_hash = "changed".into(); let report = apply(&mut dao, &[], &reviewed, &stale_rows); assert_eq!(report.stale, 1); // So is one whose original changed since the dry run: its hash is // gone from the index, or its path holds other bytes. let mut gone = rows.clone(); gone[0].content_hash = "ha-edited".into(); let report = apply(&mut dao, &[], &reviewed, &gone); assert_eq!(report.stale, 1); assert_eq!(report.linked + report.already_linked, 0); assert!(report.stale_details[0].contains("d/aiedits/o_00001_.png")); let mut swapped = rows.clone(); swapped[0].content_hash = "hb".into(); // d/a.jpg now holds hb's bytes let report = apply(&mut dao, &[], &reviewed, &swapped); assert_eq!(report.stale, 1); } #[test] fn apply_drops_derived_data_of_an_already_ingested_version() { let conn = Arc::new(Mutex::new(in_memory_db_connection())); let exec = |sql: &str| { diesel::RunQueryDsl::execute(diesel::sql_query(sql), &mut *conn.lock().unwrap()) .unwrap_or_else(|e| panic!("{sql}: {e}")); }; exec( "INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash, clip_embedding, clip_model_version) VALUES (1, 'v.png', 0, 0, 'v', x'00', 'm')", ); exec( "INSERT INTO face_detections (library_id, content_hash, rel_path, bbox_x, embedding, source, status, model_version, created_at) VALUES (1, 'v', 'v.png', 0.1, x'00', 'auto', 'detected', 'm', 0)", ); exec( "INSERT INTO clip_failures (library_id, rel_path, content_hash, error, failed_at) VALUES (1, 'v.png', 'v', 'boom', 0)", ); let mut dao = SqliteVersionDao::from_shared(conn.clone()); let reviewed = vec![ReviewedLink { method: "phash".into(), library_id: 1, version_path: "v.png".into(), original_path: "o.jpg".into(), kind: "edit".into(), version_hash: "v".into(), original_hash: "o".into(), references: vec![], }]; let rows = vec![row("v.png", "v", None), row("o.jpg", "o", None)]; assert_eq!(apply(&mut dao, &[], &reviewed, &rows).linked, 1); #[derive(diesel::QueryableByName)] struct N { #[diesel(sql_type = diesel::sql_types::BigInt)] n: i64, } for sql in [ "SELECT COUNT(*) AS n FROM face_detections WHERE content_hash = 'v'", "SELECT COUNT(*) AS n FROM clip_failures WHERE content_hash = 'v'", "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = 'v' AND (clip_embedding IS NOT NULL OR clip_model_version IS NOT NULL)", ] { let n = diesel::RunQueryDsl::get_result::( diesel::sql_query(sql), &mut *conn.lock().unwrap(), ) .unwrap() .n; assert_eq!(n, 0, "{sql}"); } } }