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:
Cameron CordesandClaude Opus 5.5 committed 2026-10-30 11:28:34 -04:00
1 parent 044240037a
commit 080a75c336
24 files changed
+1614 -47

No files matched your search

+252
View File
@@ -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>,
}
+384
View File
@@ -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());
}
}
+312
View File
@@ -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(&region)?[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());
}
}