Files
ImageApi/src/versions/xmp.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

313 lines
13 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}