Files
ImageApi/src/versions/mod.rs
T
Cameron CordesandClaude Opus 5.5 080a75c336 feat(versions): link at ingest, hide in listings, version endpoints
XMP (PNG iTXt DerivedFrom, native chunk reader) then file-name convention
links at ingest; unlinked name candidates are retried each tick. Versions
are hidden from /photos (SQL for the recursive listing, hidden-row set for
the walk and tag branches), /memories, reels, /photos/exif, the GPS summary,
unified search and the media gauges while their original is reachable in the
view; include_versions=true lists them. /photos gains the parallel arrays
photo_display_paths / photo_version_counts, memories / search hits /
/photos/exif rows gain display_path / version_count, all skipped when empty.
GET /image/versions and PUT /image/versions/display (library optional).
Tags and favorites on a version path bind to the original; faces, CLIP and
duplicate grouping skip version hashes; insight generation redirects to the
original. Orphan GC and the back-ref refresh cover the new tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:28:34 -04:00

385 lines
13 KiB
Rust

//! Photo versions (#191): AI restorations / upscales / edits that sit near
//! their original and are shown as one photo.
//!
//! ImageApi only *detects* versions; an external process (the ComfyUI node
//! in `comfyui-imageapi-versions`) writes them into the library, so the
//! read-only rule for library roots holds. This module owns
//!
//! - the link rules at ingest ([`link_new_files`], [`sweep_name_links`]):
//! the PNG's XMP `DerivedFrom` hash first, then the
//! `<name>.<kind>[.<n>].<ext>` file-name convention;
//! - the HTTP surface (`GET /image/versions`, `PUT /image/versions/display`).
//!
//! The data model lives in [`crate::database::version_dao`]. The link is the
//! original's `content_hash`, never a path: ComfyUI writes through a UNC
//! share and ImageApi reads `/media/...`, and files get moved.
pub mod handlers;
pub mod xmp;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex};
use log::{debug, info, warn};
use regex::Regex;
use crate::database::version_dao::{
KINDS, LinkOutcome, NewVersionLink, SOURCE_NAME, SOURCE_XMP, VersionDao, is_known_kind,
};
use crate::database::{ExifDao, version_dao};
use crate::libraries::Library;
pub use handlers::{get_versions, put_display};
/// Directory the producer saves versions into, next to the original.
pub const VERSIONS_SUBFOLDER: &str = "aiedits";
/// Cap on name-convention candidates examined per library per tick.
const NAME_SWEEP_LIMIT: i64 = 500;
static VERSION_NAME: LazyLock<Regex> = LazyLock::new(|| {
let kinds = KINDS.join("|");
Regex::new(&format!(
r"(?i)^(?P<name>.+)\.(?P<kind>{kinds})(?:\.(?P<n>\d+))?\.(?P<ext>[a-z0-9]+)$"
))
.unwrap()
});
/// A file name that follows the producer's `<original name>.<kind>[.<n>].<ext>`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionName {
/// The original's full file name, extension included.
pub original_name: String,
pub kind: &'static str,
}
/// Parse a bare file name (no directory). The original's extension stays in
/// `original_name`, so `IMG_1.NEF` and `IMG_1.JPG` are told apart.
pub fn parse_version_name(file_name: &str) -> Option<VersionName> {
let caps = VERSION_NAME.captures(file_name)?;
let kind_text = caps["kind"].to_ascii_lowercase();
let kind = KINDS.iter().find(|k| **k == kind_text)?;
Some(VersionName {
original_name: caps["name"].to_string(),
kind,
})
}
/// Rel paths where the original of a name-convention file may sit, best
/// first: the same directory, then the parent of an `aiedits/` directory.
pub fn original_candidates(rel_path: &str) -> Vec<String> {
let (dir, file_name) = match rel_path.rsplit_once('/') {
Some((d, f)) => (d, f),
None => ("", rel_path),
};
let Some(parsed) = parse_version_name(file_name) else {
return Vec::new();
};
let join = |d: &str| {
if d.is_empty() {
parsed.original_name.clone()
} else {
format!("{d}/{}", parsed.original_name)
}
};
let mut out = vec![join(dir)];
let (parent, leaf) = match dir.rsplit_once('/') {
Some((p, l)) => (p, l),
None => ("", dir),
};
if leaf.eq_ignore_ascii_case(VERSIONS_SUBFOLDER) {
out.push(join(parent));
}
out
}
/// Resolve the name-convention link of `rel_path` (in `library`) against the
/// `image_exif` rows of that library. `None` when the name doesn't follow the
/// convention or no candidate original has a hash yet (the sweep retries).
pub fn name_link(
library: &Library,
rel_path: &str,
version_hash: &str,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
) -> Option<NewVersionLink> {
let file_name = rel_path.rsplit('/').next().unwrap_or(rel_path);
let kind = parse_version_name(file_name)?.kind;
let candidates = original_candidates(rel_path);
let rows = exif_dao
.lock()
.expect("Unable to lock ExifDao")
.get_exif_batch(Some(library.id), &candidates)
.ok()?;
// First candidate (in preference order) that has a hash of its own.
let original_hash = candidates.iter().find_map(|c| {
rows.iter()
.find(|r| &r.file_path == c)
.and_then(|r| r.content_hash.clone())
.filter(|h| h != version_hash)
})?;
Some(NewVersionLink {
version_hash: version_hash.to_string(),
original_hash,
kind: kind.to_string(),
source: SOURCE_NAME.to_string(),
library_id: library.id,
rel_path: rel_path.to_string(),
references: Vec::new(),
})
}
/// Resolve the XMP link of a PNG on disk. `None` when the file isn't a PNG,
/// can't be read, or carries no `DerivedFrom` blake3 id.
pub fn xmp_link(
library: &Library,
rel_path: &str,
abs_path: &Path,
version_hash: &str,
) -> Option<NewVersionLink> {
let is_png = abs_path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("png"));
if !is_png {
return None;
}
let parsed = match xmp::read_version_xmp(abs_path) {
Ok(p) => p?,
Err(e) => {
debug!(
"versions: could not read XMP of {}: {}",
abs_path.display(),
e
);
return None;
}
};
let file_name = rel_path.rsplit('/').next().unwrap_or(rel_path);
let kind = parsed
.kind
.filter(|k| is_known_kind(k))
.or_else(|| parse_version_name(file_name).map(|n| n.kind.to_string()))
.unwrap_or_else(|| "edit".to_string());
Some(NewVersionLink {
version_hash: version_hash.to_string(),
original_hash: parsed.original_hash,
kind,
source: SOURCE_XMP.to_string(),
library_id: library.id,
rel_path: rel_path.to_string(),
references: parsed.references,
})
}
/// Record a link, logging the outcome. Returns true if a new link was made.
pub fn record_link(version_dao: &Arc<Mutex<Box<dyn VersionDao>>>, link: &NewVersionLink) -> bool {
let outcome = version_dao
.lock()
.expect("Unable to lock VersionDao")
.link_version(link);
match outcome {
Ok(LinkOutcome::Linked) => {
info!(
"versions: linked {} -> original {}.. ({}, {})",
link.rel_path,
&link.original_hash[..12.min(link.original_hash.len())],
link.kind,
link.source
);
true
}
Ok(LinkOutcome::AlreadyLinked) => false,
Ok(LinkOutcome::Rejected(why)) => {
warn!("versions: refused link for {}: {}", link.rel_path, why);
false
}
Err(e) => {
warn!("versions: link failed for {}: {:?}", link.rel_path, e);
false
}
}
}
/// Link freshly ingested files by their embedded XMP. `files` are the
/// `(absolute, rel_path)` pairs the watcher just registered; rows without a
/// hash yet are skipped (an XMP-less file is the name sweep's job anyway).
/// Returns the number of new links.
pub fn link_new_files(
library: &Library,
files: &[(PathBuf, String)],
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
version_dao: &Arc<Mutex<Box<dyn VersionDao>>>,
) -> usize {
let pngs: Vec<&(PathBuf, String)> = files
.iter()
.filter(|(p, _)| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("png")))
.collect();
if pngs.is_empty() {
return 0;
}
let rels: Vec<String> = pngs.iter().map(|(_, r)| r.clone()).collect();
let rows = exif_dao
.lock()
.expect("Unable to lock ExifDao")
.get_exif_batch(Some(library.id), &rels)
.unwrap_or_default();
let mut linked = 0;
for (abs, rel) in pngs {
let Some(hash) = rows
.iter()
.find(|r| &r.file_path == rel)
.and_then(|r| r.content_hash.clone())
else {
continue;
};
if let Some(link) = xmp_link(library, rel, abs, &hash)
&& record_link(version_dao, &link)
{
linked += 1;
}
}
linked
}
/// Link name-convention files whose original is (now) known. Idempotent and
/// DB-only, so it also serves as the retry for "original not hashed yet" and
/// "original ingested after the version". Returns the number of new links.
pub fn sweep_name_links(
library: &Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
version_dao: &Arc<Mutex<Box<dyn VersionDao>>>,
) -> usize {
let candidates = match version_dao
.lock()
.expect("Unable to lock VersionDao")
.unlinked_name_candidates(library.id, NAME_SWEEP_LIMIT)
{
Ok(c) => c,
Err(e) => {
warn!(
"versions: name sweep failed for '{}': {:?}",
library.name, e
);
return 0;
}
};
let mut linked = 0;
for (rel, hash) in candidates {
if let Some(link) = name_link(library, &rel, &hash, exif_dao)
&& record_link(version_dao, &link)
{
linked += 1;
}
}
linked
}
/// Sweep names and invalidate what a new link makes stale. The watcher calls
/// this once per library per tick, after ingest.
pub fn link_pending(
library: &Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
version_dao: &Arc<Mutex<Box<dyn VersionDao>>>,
) {
if sweep_name_links(library, exif_dao, version_dao) > 0 {
crate::duplicates::invalidate_caches();
}
}
/// The version facts a per-photo record (a memory, a search hit) carries.
/// Flattened into the record; absent for a photo without versions, so
/// responses stay byte-identical for libraries that have none.
///
/// `display_path` is `null` when the original is displayed, a rel_path when a
/// version is. `/photos` carries the same facts as the parallel arrays
/// `photo_display_paths` / `photo_version_counts` instead.
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct VersionFields {
#[serde(skip_serializing_if = "Option::is_none")]
pub display_path: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_count: Option<u32>,
}
impl VersionFields {
pub fn from_summary(summary: Option<&version_dao::PhotoVersionSummary>) -> Self {
match summary {
Some(s) if s.version_count > 0 => VersionFields {
display_path: Some(s.display.as_ref().map(|(_, p)| p.clone())),
version_count: Some(s.version_count),
},
_ => VersionFields::default(),
}
}
}
/// Load the per-photo version summaries once for a response. Empty (and
/// cheap) when nothing has versions; a DB failure degrades to "no versions"
/// rather than failing the listing.
pub fn load_summaries(
version_dao: &Mutex<Box<dyn VersionDao>>,
) -> std::collections::HashMap<(i32, String), version_dao::PhotoVersionSummary> {
version_dao
.lock()
.expect("Unable to lock VersionDao")
.photo_summaries()
.unwrap_or_else(|e| {
warn!("versions: could not load summaries: {:?}", e);
Default::default()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_producer_naming_convention() {
let n = parse_version_name("scan_0042.jpg.restored.png").unwrap();
assert_eq!(n.original_name, "scan_0042.jpg");
assert_eq!(n.kind, "restored");
let n = parse_version_name("scan_0042.jpg.restored.2.png").unwrap();
assert_eq!(n.original_name, "scan_0042.jpg");
let n = parse_version_name("IMG_1.NEF.upscale.png").unwrap();
assert_eq!(n.original_name, "IMG_1.NEF");
assert_eq!(n.kind, "upscale");
assert_eq!(
parse_version_name("a.b.c.EDIT.12.PNG")
.unwrap()
.original_name,
"a.b.c"
);
}
#[test]
fn rejects_names_outside_the_convention() {
assert!(parse_version_name("holiday.png").is_none());
assert!(parse_version_name("restored.png").is_none());
assert!(parse_version_name("x.jpg.blurred.png").is_none());
assert!(parse_version_name("x.jpg.restored").is_none());
assert!(parse_version_name("x.restored.png.bak").is_none());
assert!(parse_version_name(".restored.png").is_none());
}
#[test]
fn candidates_prefer_the_same_dir_then_the_parent_of_aiedits() {
assert_eq!(
original_candidates("2004/aiedits/scan.jpg.restored.png"),
vec!["2004/aiedits/scan.jpg", "2004/scan.jpg"]
);
assert_eq!(
original_candidates("2004/scan.jpg.restored.png"),
vec!["2004/scan.jpg"]
);
assert_eq!(
original_candidates("aiedits/scan.jpg.edit.3.png"),
vec!["aiedits/scan.jpg", "scan.jpg"]
);
assert_eq!(
original_candidates("scan.jpg.upscale.png"),
vec!["scan.jpg"]
);
assert!(original_candidates("2004/plain.png").is_empty());
}
}