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>
This commit is contained in:
1 parent
044240037a
commit
080a75c336
24 files changed
+1614
-47
No files matched your search
@@ -0,0 +1,252 @@
|
||||
//! `GET /image/versions` and `PUT /image/versions/display`.
|
||||
//!
|
||||
//! Both take the path of the original *or* of any version, and answer about
|
||||
//! the whole set. The display choice is global (not per user).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use actix_web::{HttpResponse, Responder, get, put, web};
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::data::Claims;
|
||||
use crate::database::ExifDao;
|
||||
use crate::database::version_dao::VersionDao;
|
||||
use crate::libraries;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::normalize_path;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VersionsQuery {
|
||||
pub path: String,
|
||||
pub library: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetDisplayBody {
|
||||
pub path: String,
|
||||
/// Content hash of the version to display; `null` / absent = the original.
|
||||
#[serde(default)]
|
||||
pub display_hash: Option<String>,
|
||||
pub library: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct FileRef {
|
||||
pub path: String,
|
||||
pub library_id: i32,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct ReferenceEntry {
|
||||
pub hash: String,
|
||||
/// Where the reference photo lives, if it has been ingested.
|
||||
pub path: Option<String>,
|
||||
pub library_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct VersionEntry {
|
||||
pub path: String,
|
||||
pub library_id: i32,
|
||||
pub hash: String,
|
||||
pub kind: String,
|
||||
pub source: String,
|
||||
pub created_at: i64,
|
||||
pub references: Vec<ReferenceEntry>,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct VersionsResponse {
|
||||
/// `null` when the original's file is gone (its versions then show up as
|
||||
/// standalone photos).
|
||||
pub original: Option<FileRef>,
|
||||
pub versions: Vec<VersionEntry>,
|
||||
/// The globally displayed version; `null` = the original.
|
||||
pub display_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// The photo a request path belongs to.
|
||||
struct Resolved {
|
||||
original_hash: String,
|
||||
/// Library of the row the path named; preferred when picking files.
|
||||
library_id: i32,
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
app_state: &AppState,
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
path: &str,
|
||||
library: Option<&str>,
|
||||
) -> Result<Resolved, HttpResponse> {
|
||||
let library_id = match libraries::resolve_library_param_state(app_state, library) {
|
||||
Ok(lib) => lib.map(|l| l.id),
|
||||
Err(msg) => return Err(HttpResponse::BadRequest().body(msg)),
|
||||
};
|
||||
let path = normalize_path(path);
|
||||
let mut rows = exif_dao
|
||||
.lock()
|
||||
.expect("Unable to lock ExifDao")
|
||||
.get_exif_batch(library_id, std::slice::from_ref(&path))
|
||||
.map_err(|_| HttpResponse::InternalServerError().finish())?;
|
||||
// Without `library`, a path can name rows in several libraries. Take the
|
||||
// earliest hashed one; it then resolves to its original below, so a
|
||||
// version in one library and its original in another agree.
|
||||
rows.sort_by_key(|r| r.id);
|
||||
let Some((row, hash)) = rows
|
||||
.iter()
|
||||
.find_map(|r| r.content_hash.clone().map(|h| (r, h)))
|
||||
else {
|
||||
return Err(HttpResponse::NotFound().body("no indexed file at that path"));
|
||||
};
|
||||
let link = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.get_link(&hash)
|
||||
.map_err(|_| HttpResponse::InternalServerError().finish())?;
|
||||
Ok(Resolved {
|
||||
original_hash: link.map(|l| l.original_hash).unwrap_or(hash),
|
||||
library_id: row.library_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything about the set around `original_hash`. Reads fall back to the
|
||||
/// original when the stored display choice has no live row.
|
||||
pub fn build_response(
|
||||
dao: &mut dyn VersionDao,
|
||||
original_hash: &str,
|
||||
prefer_library: i32,
|
||||
) -> Result<VersionsResponse, crate::database::DbError> {
|
||||
let original = dao
|
||||
.files_for_hashes(&[original_hash.to_string()], Some(prefer_library))?
|
||||
.remove(original_hash)
|
||||
.map(|(library_id, path)| FileRef {
|
||||
path,
|
||||
library_id,
|
||||
hash: original_hash.to_string(),
|
||||
});
|
||||
|
||||
let files = dao.list_versions(original_hash)?;
|
||||
let mut versions = Vec::with_capacity(files.len());
|
||||
for f in files {
|
||||
let reference_hashes = dao.list_references(&f.version_hash)?;
|
||||
let located = dao.files_for_hashes(&reference_hashes, Some(f.library_id))?;
|
||||
let references = reference_hashes
|
||||
.into_iter()
|
||||
.map(|hash| {
|
||||
let found = located.get(&hash);
|
||||
ReferenceEntry {
|
||||
path: found.map(|(_, p)| p.clone()),
|
||||
library_id: found.map(|(l, _)| *l),
|
||||
hash,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
versions.push(VersionEntry {
|
||||
path: f.rel_path,
|
||||
library_id: f.library_id,
|
||||
hash: f.version_hash,
|
||||
kind: f.kind,
|
||||
source: f.source,
|
||||
created_at: f.created_at,
|
||||
references,
|
||||
width: f.width,
|
||||
height: f.height,
|
||||
});
|
||||
}
|
||||
|
||||
let display_hash = dao
|
||||
.get_display(original_hash)?
|
||||
.filter(|d| versions.iter().any(|v| &v.hash == d));
|
||||
Ok(VersionsResponse {
|
||||
original,
|
||||
versions,
|
||||
display_hash,
|
||||
})
|
||||
}
|
||||
|
||||
#[get("/image/versions")]
|
||||
#[tracing::instrument(name = "get_versions", skip_all)]
|
||||
pub async fn get_versions(
|
||||
_: Claims,
|
||||
q: web::Query<VersionsQuery>,
|
||||
app_state: web::Data<AppState>,
|
||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> impl Responder {
|
||||
let resolved = match resolve(&app_state, &exif_dao, &q.path, q.library.as_deref()) {
|
||||
Ok(r) => r,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let mut dao = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao");
|
||||
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
|
||||
Ok(body) => HttpResponse::Ok().json(body),
|
||||
Err(e) => {
|
||||
warn!("get_versions failed: {:?}", e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/image/versions/display")]
|
||||
#[tracing::instrument(name = "put_version_display", skip_all)]
|
||||
pub async fn put_display(
|
||||
_: Claims,
|
||||
// The client also sends `library` as a query parameter, like its other
|
||||
// path-keyed calls; the body field wins when both are given.
|
||||
query: web::Query<LibraryQuery>,
|
||||
body: web::Json<SetDisplayBody>,
|
||||
app_state: web::Data<AppState>,
|
||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> impl Responder {
|
||||
let library = body.library.as_deref().or(query.library.as_deref());
|
||||
let resolved = match resolve(&app_state, &exif_dao, &body.path, library) {
|
||||
Ok(r) => r,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let mut dao = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao");
|
||||
|
||||
// Choosing the original is the same as clearing the choice.
|
||||
let wanted = body
|
||||
.display_hash
|
||||
.as_deref()
|
||||
.filter(|h| *h != resolved.original_hash);
|
||||
if let Some(hash) = wanted {
|
||||
let current =
|
||||
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("put_display lookup failed: {:?}", e);
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
if !current.versions.iter().any(|v| v.hash == hash) {
|
||||
return HttpResponse::BadRequest().body("display_hash is not a version of this photo");
|
||||
}
|
||||
}
|
||||
if let Err(e) = dao.set_display(&resolved.original_hash, wanted) {
|
||||
warn!("put_display failed: {:?}", e);
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
|
||||
Ok(body) => HttpResponse::Ok().json(body),
|
||||
Err(e) => {
|
||||
warn!("put_display response failed: {:?}", e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LibraryQuery {
|
||||
pub library: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Reading the version link out of a PNG: the `XML:com.adobe.xmp` iTXt
|
||||
//! chunk the ComfyUI producer writes (`comfyui-imageapi-versions`), and the
|
||||
//! ComfyUI `prompt` tEXt chunk the backfill uses for files without XMP.
|
||||
//!
|
||||
//! The chunk stream is walked by hand: no pixel decode, and the `png`
|
||||
//! crate's text API would still inflate every IDAT's neighbours for no gain.
|
||||
//! IDAT payloads are seeked over, so a multi-megabyte restoration costs a
|
||||
//! handful of small reads on a network share.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufReader, Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// iTXt keyword exiftool, Lightroom and the producer put XMP under.
|
||||
pub const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
|
||||
|
||||
/// Text chunks bigger than this are skipped rather than read: ComfyUI's
|
||||
/// `prompt` / `workflow` JSON is tens of KiB, an XMP packet around 1 KiB.
|
||||
const MAX_TEXT_CHUNK: u32 = 4 * 1024 * 1024;
|
||||
|
||||
const PNG_SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
/// What a version PNG's XMP says about its provenance.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VersionXmp {
|
||||
/// `xmpMM:DerivedFrom/stRef:documentID`: the original's `content_hash`.
|
||||
pub original_hash: String,
|
||||
/// `xmpMM:Ingredients[]/stRef:documentID`: reference photos.
|
||||
pub references: Vec<String>,
|
||||
/// `imageapi:versionKind`, as written (not yet validated).
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
/// Value of the first text chunk (`tEXt` or uncompressed `iTXt`) whose
|
||||
/// keyword is in `keywords`, for each keyword found. Compressed chunks are
|
||||
/// skipped (the producer never compresses). `Ok(vec![])` for a file that
|
||||
/// isn't a PNG.
|
||||
pub fn read_png_text(path: &Path, keywords: &[&str]) -> io::Result<Vec<(String, String)>> {
|
||||
let mut reader = BufReader::new(File::open(path)?);
|
||||
let mut sig = [0u8; 8];
|
||||
if reader.read_exact(&mut sig).is_err() || sig != PNG_SIGNATURE {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut found: Vec<(String, String)> = Vec::new();
|
||||
loop {
|
||||
let mut head = [0u8; 8];
|
||||
if reader.read_exact(&mut head).is_err() {
|
||||
break; // truncated file: use what we have
|
||||
}
|
||||
let len = u32::from_be_bytes([head[0], head[1], head[2], head[3]]);
|
||||
let kind = &head[4..8];
|
||||
let is_text = kind == b"tEXt" || kind == b"iTXt";
|
||||
if is_text && len <= MAX_TEXT_CHUNK {
|
||||
let mut data = vec![0u8; len as usize];
|
||||
if reader.read_exact(&mut data).is_err() {
|
||||
break;
|
||||
}
|
||||
let parsed = if kind == b"tEXt" {
|
||||
parse_text(&data)
|
||||
} else {
|
||||
parse_itxt(&data)
|
||||
};
|
||||
if let Some((key, value)) = parsed
|
||||
&& keywords.contains(&key.as_str())
|
||||
&& !found.iter().any(|(k, _)| *k == key)
|
||||
{
|
||||
found.push((key, value));
|
||||
if found.len() == keywords.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
reader.seek_relative(4)?; // CRC
|
||||
} else {
|
||||
reader.seek(SeekFrom::Current(i64::from(len) + 4))?;
|
||||
}
|
||||
if kind == b"IEND" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// `keyword\0text` (Latin-1).
|
||||
fn parse_text(data: &[u8]) -> Option<(String, String)> {
|
||||
let nul = data.iter().position(|b| *b == 0)?;
|
||||
let key = String::from_utf8_lossy(&data[..nul]).into_owned();
|
||||
let value: String = data[nul + 1..].iter().map(|b| *b as char).collect();
|
||||
Some((key, value))
|
||||
}
|
||||
|
||||
/// `keyword\0 compressed(1) method(1) lang\0 translated\0 text` (UTF-8);
|
||||
/// `None` for compressed chunks.
|
||||
fn parse_itxt(data: &[u8]) -> Option<(String, String)> {
|
||||
let nul = data.iter().position(|b| *b == 0)?;
|
||||
let key = String::from_utf8_lossy(&data[..nul]).into_owned();
|
||||
let rest = data.get(nul + 1..)?;
|
||||
if *rest.first()? != 0 {
|
||||
return None;
|
||||
}
|
||||
let rest = rest.get(2..)?; // flag + method
|
||||
let lang_end = rest.iter().position(|b| *b == 0)?;
|
||||
let rest = &rest[lang_end + 1..];
|
||||
let translated_end = rest.iter().position(|b| *b == 0)?;
|
||||
let text = &rest[translated_end + 1..];
|
||||
Some((key, String::from_utf8_lossy(text).into_owned()))
|
||||
}
|
||||
|
||||
/// The XMP packet of a PNG, if it has one.
|
||||
pub fn read_png_xmp(path: &Path) -> io::Result<Option<String>> {
|
||||
Ok(read_png_text(path, &[XMP_KEYWORD])?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(_, v)| v))
|
||||
}
|
||||
|
||||
static DERIVED_FROM: LazyLock<Regex> = LazyLock::new(|| {
|
||||
// Element form (`<xmpMM:DerivedFrom ...>...</xmpMM:DerivedFrom>`) or the
|
||||
// attribute form exiftool may write (`<xmpMM:DerivedFrom stRef:documentID=".."/>`).
|
||||
Regex::new(r"(?s)<xmpMM:DerivedFrom\b([^>]*?)(?:/>|>(.*?)</xmpMM:DerivedFrom>)").unwrap()
|
||||
});
|
||||
static INGREDIENTS: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?s)<xmpMM:Ingredients\b[^>]*>(.*?)</xmpMM:Ingredients>").unwrap()
|
||||
});
|
||||
static DOCUMENT_ID: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"documentID(?:\s*=\s*["']|\s*>\s*)blake3:([0-9a-fA-F]{64})"#).unwrap()
|
||||
});
|
||||
static VERSION_KIND: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"imageapi:versionKind(?:\s*=\s*["']|\s*>\s*)([A-Za-z]+)"#).unwrap()
|
||||
});
|
||||
|
||||
/// Parse the link fields out of an XMP packet. `None` when it has no
|
||||
/// `DerivedFrom` blake3 id (an ordinary photo's XMP, or someone else's).
|
||||
pub fn parse_version_xmp(xmp: &str) -> Option<VersionXmp> {
|
||||
let derived = DERIVED_FROM.captures(xmp)?;
|
||||
let region = format!(
|
||||
"{} {}",
|
||||
derived.get(1).map_or("", |m| m.as_str()),
|
||||
derived.get(2).map_or("", |m| m.as_str())
|
||||
);
|
||||
let original_hash = DOCUMENT_ID.captures(®ion)?[1].to_ascii_lowercase();
|
||||
|
||||
let mut references: Vec<String> = Vec::new();
|
||||
if let Some(ing) = INGREDIENTS.captures(xmp) {
|
||||
for cap in DOCUMENT_ID.captures_iter(&ing[1]) {
|
||||
let h = cap[1].to_ascii_lowercase();
|
||||
if !references.contains(&h) {
|
||||
references.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let kind = VERSION_KIND
|
||||
.captures(xmp)
|
||||
.map(|c| c[1].to_ascii_lowercase());
|
||||
Some(VersionXmp {
|
||||
original_hash,
|
||||
references,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the version link out of a PNG file; `None` if it has none.
|
||||
pub fn read_version_xmp(path: &Path) -> io::Result<Option<VersionXmp>> {
|
||||
Ok(read_png_xmp(path)?.and_then(|x| parse_version_xmp(&x)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_png {
|
||||
use std::io::Write;
|
||||
|
||||
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(&[0, 0, 0, 0]); // CRC: the reader never checks it
|
||||
}
|
||||
|
||||
/// A PNG-shaped byte stream: signature, IHDR, optional tEXt `prompt`,
|
||||
/// optional XMP iTXt, an IDAT, IEND. Not decodable; enough for the
|
||||
/// chunk walker (and unique per `salt`, so each file hashes differently).
|
||||
pub fn png_bytes(xmp: Option<&str>, prompt: Option<&str>, salt: u8) -> Vec<u8> {
|
||||
let mut out = super::PNG_SIGNATURE.to_vec();
|
||||
chunk(&mut out, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0]);
|
||||
if let Some(p) = prompt {
|
||||
let mut d = b"prompt\0".to_vec();
|
||||
d.extend_from_slice(p.as_bytes());
|
||||
chunk(&mut out, b"tEXt", &d);
|
||||
}
|
||||
if let Some(x) = xmp {
|
||||
let mut d = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec();
|
||||
d.extend_from_slice(x.as_bytes());
|
||||
chunk(&mut out, b"iTXt", &d);
|
||||
}
|
||||
chunk(&mut out, b"IDAT", &[salt; 32]);
|
||||
chunk(&mut out, b"IEND", &[]);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write_png(path: &std::path::Path, xmp: Option<&str>, prompt: Option<&str>, salt: u8) {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
std::fs::File::create(path)
|
||||
.unwrap()
|
||||
.write_all(&png_bytes(xmp, prompt, salt))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The packet the producer writes, built the way `versioning.build_xmp` does.
|
||||
pub fn producer_xmp(original: &str, kind: &str, references: &[&str]) -> String {
|
||||
let ingredients = if references.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let items: String = references
|
||||
.iter()
|
||||
.map(|h| {
|
||||
format!(
|
||||
"<rdf:li rdf:parseType=\"Resource\"><stRef:documentID>blake3:{h}</stRef:documentID>\
|
||||
<stRef:filePath>ref.jpg</stRef:filePath></rdf:li>"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!("<xmpMM:Ingredients><rdf:Bag>{items}</rdf:Bag></xmpMM:Ingredients>")
|
||||
};
|
||||
format!(
|
||||
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\
|
||||
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description rdf:about=\"\" \
|
||||
xmlns:xmpMM=\"http://ns.adobe.com/xap/1.0/mm/\" xmlns:stRef=\"http://ns.adobe.com/xap/1.0/sType/ResourceRef#\" \
|
||||
xmlns:imageapi=\"https://git.crkc.xyz/Apps/ImageApi/ns/versions/1.0/\">\
|
||||
<xmpMM:DerivedFrom rdf:parseType=\"Resource\"><stRef:documentID>blake3:{original}</stRef:documentID>\
|
||||
<stRef:filePath>orig.jpg</stRef:filePath></xmpMM:DerivedFrom>{ingredients}\
|
||||
<imageapi:versionKind>{kind}</imageapi:versionKind></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::test_png::*;
|
||||
use super::*;
|
||||
|
||||
const ORIG: &str = "d5fa14088629f680f4f8e5815df473cc4684a97fc937da588baf72d2693dea3a";
|
||||
const REF: &str = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
|
||||
/// The packet embedded in a real archive restoration (copied verbatim).
|
||||
const REAL_XMP: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" xmlns:imageapi="https://git.crkc.xyz/Apps/ImageApi/ns/versions/1.0/"><xmpMM:DerivedFrom rdf:parseType="Resource"><stRef:documentID>blake3:d5fa14088629f680f4f8e5815df473cc4684a97fc937da588baf72d2693dea3a</stRef:documentID><stRef:filePath>019_19.JPG</stRef:filePath></xmpMM:DerivedFrom><imageapi:versionKind>restored</imageapi:versionKind></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#;
|
||||
|
||||
#[test]
|
||||
fn parses_the_real_producer_packet() {
|
||||
let x = parse_version_xmp(REAL_XMP).unwrap();
|
||||
assert_eq!(x.original_hash, ORIG);
|
||||
assert_eq!(x.kind.as_deref(), Some("restored"));
|
||||
assert!(x.references.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_references_and_keeps_them_apart_from_the_original() {
|
||||
let x = parse_version_xmp(&producer_xmp(ORIG, "edit", &[REF])).unwrap();
|
||||
assert_eq!(x.original_hash, ORIG);
|
||||
assert_eq!(x.references, vec![REF]);
|
||||
assert_eq!(x.kind.as_deref(), Some("edit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_the_attribute_form_other_writers_use() {
|
||||
let xmp = format!(
|
||||
r#"<x:xmpmeta><rdf:RDF><rdf:Description><xmpMM:DerivedFrom stRef:documentID="blake3:{ORIG}" stRef:filePath="a.jpg"/></rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||||
);
|
||||
assert_eq!(parse_version_xmp(&xmp).unwrap().original_hash, ORIG);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_xmp_without_a_blake3_derived_from() {
|
||||
assert!(parse_version_xmp("<x:xmpmeta><dc:title>hi</dc:title></x:xmpmeta>").is_none());
|
||||
let other = "<xmpMM:DerivedFrom><stRef:documentID>xmp.did:1234</stRef:documentID></xmpMM:DerivedFrom>";
|
||||
assert!(parse_version_xmp(other).is_none());
|
||||
// An ingredient alone is not a link.
|
||||
let only_ing = format!(
|
||||
"<xmpMM:Ingredients><rdf:Bag><rdf:li><stRef:documentID>blake3:{REF}</stRef:documentID></rdf:li></rdf:Bag></xmpMM:Ingredients>"
|
||||
);
|
||||
assert!(parse_version_xmp(&only_ing).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_xmp_and_prompt_chunks_from_a_png_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("v.png");
|
||||
write_png(
|
||||
&p,
|
||||
Some(&producer_xmp(ORIG, "restored", &[])),
|
||||
Some(r#"{"1": {"class_type": "LoadImage"}}"#),
|
||||
1,
|
||||
);
|
||||
let got = read_version_xmp(&p).unwrap().unwrap();
|
||||
assert_eq!(got.original_hash, ORIG);
|
||||
let text = read_png_text(&p, &["prompt"]).unwrap();
|
||||
assert_eq!(text[0].1, r#"{"1": {"class_type": "LoadImage"}}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_png_and_non_png_have_no_link() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("plain.png");
|
||||
write_png(&p, None, None, 2);
|
||||
assert!(read_version_xmp(&p).unwrap().is_none());
|
||||
let j = dir.path().join("x.jpg");
|
||||
std::fs::write(&j, b"\xff\xd8\xff\xe0 not a png").unwrap();
|
||||
assert!(read_version_xmp(&j).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user