Files
ImageApi/src/versions/xmp.rs
T
Cameron CordesandClaude Opus 5.5 deb3d051ac Derived clips: link MP4 versions, list them apart, video Enhance output
Groundwork for Enhance "Animate" (#208): a video made from a photo.

- Version linking reads the XMP link from MP4 / MOV too (top-level uuid box
  with the XMP UUID, mdat seeked over), at ingest and on Enhance approve.
- New kind `animated`, a derived clip (CLIP_KINDS): hidden behind the photo
  like a version, but not counted in photo_version_counts / version_count,
  never a display choice (400 clip_not_displayable), and listed as `clips`
  in GET /image/versions. Approve returns the clip entry.
- Templates whose imageapi:save is ImageApiSaveVideoVersion report
  output "video" in /enhance/templates (no enhance.toml change). Previews
  already relay ComfyUI's content type (video/mp4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:38:56 -05:00

455 lines
18 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.
//! Video versions (MP4 / MOV, e.g. a clip animated from a photo) carry the
//! same packet in a top-level `uuid` box with the XMP UUID.
//!
//! 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,
})
}
/// The XMP UUID (Adobe XMP spec part 3): a top-level `uuid` box with this
/// id holds the packet in MP4 / MOV files.
const XMP_UUID: [u8; 16] = [
0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac,
];
/// The XMP packet of an MP4 / MOV file, if it has one. Walks the top-level
/// box headers only, seeking over `mdat`, so a large clip costs a few small
/// reads. `Ok(None)` for a file that isn't box-structured.
pub fn read_mp4_xmp(path: &Path) -> io::Result<Option<String>> {
let mut reader = BufReader::new(File::open(path)?);
let file_len = reader.get_ref().metadata()?.len();
let mut pos: u64 = 0;
while pos + 8 <= file_len {
let mut head = [0u8; 8];
reader.read_exact(&mut head)?;
let mut size = u64::from(u32::from_be_bytes([head[0], head[1], head[2], head[3]]));
let kind = [head[4], head[5], head[6], head[7]];
let mut header_len = 8u64;
if size == 1 {
let mut big = [0u8; 8];
reader.read_exact(&mut big)?;
size = u64::from_be_bytes(big);
header_len = 16;
} else if size == 0 {
size = file_len - pos; // runs to the end of the file
}
if size < header_len || pos + size > file_len {
return Ok(None); // not an MP4, or truncated: no link
}
let body = size - header_len;
if &kind == b"uuid" && body >= 16 && body - 16 <= u64::from(MAX_TEXT_CHUNK) {
let mut id = [0u8; 16];
reader.read_exact(&mut id)?;
if id == XMP_UUID {
let mut data = vec![0u8; (body - 16) as usize];
reader.read_exact(&mut data)?;
return Ok(Some(String::from_utf8_lossy(&data).into_owned()));
}
reader.seek_relative((body - 16) as i64)?;
} else {
reader.seek(SeekFrom::Current(body as i64))?;
}
pos += size;
}
Ok(None)
}
/// File types a version's XMP link is read from.
pub fn has_readable_xmp(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
["png", "mp4", "mov"]
.iter()
.any(|x| e.eq_ignore_ascii_case(x))
})
}
/// Parse the version link out of a PNG, MP4 or MOV file; `None` if it has
/// none (or isn't one of those).
pub fn read_version_xmp(path: &Path) -> io::Result<Option<VersionXmp>> {
let is_png = path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("png"));
let xmp = if is_png {
read_png_xmp(path)?
} else if has_readable_xmp(path) {
read_mp4_xmp(path)?
} else {
None
};
Ok(xmp.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
}
fn mp4_box(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&((data.len() + 8) as u32).to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(data);
}
/// An MP4-shaped byte stream as the producer writes it: ftyp, moov,
/// mdat, then the optional XMP `uuid` box. Not playable; enough for the
/// box walker (unique per `salt`).
pub fn mp4_bytes(xmp: Option<&str>, salt: u8) -> Vec<u8> {
let mut out = Vec::new();
mp4_box(&mut out, b"ftyp", b"isom\0\0\x02\0");
mp4_box(&mut out, b"moov", &[1; 24]);
mp4_box(&mut out, b"mdat", &[salt; 64]);
if let Some(x) = xmp {
let mut d = super::XMP_UUID.to_vec();
d.extend_from_slice(x.as_bytes());
mp4_box(&mut out, b"uuid", &d);
}
out
}
/// 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 reads_the_link_from_an_mp4_clip() {
let dir = tempfile::tempdir().unwrap();
let clip = dir.path().join("IMG_1.JPG.animated.mp4");
let xmp = REAL_XMP.replace("restored", "animated");
std::fs::write(&clip, mp4_bytes(Some(&xmp), 1)).unwrap();
let v = read_version_xmp(&clip).unwrap().unwrap();
assert_eq!(v.original_hash, ORIG);
assert_eq!(v.kind.as_deref(), Some("animated"));
// Same bytes under .mov are read too; other extensions aren't.
let mov = dir.path().join("clip.MOV");
std::fs::write(&mov, mp4_bytes(Some(&xmp), 1)).unwrap();
assert!(read_version_xmp(&mov).unwrap().is_some());
let avi = dir.path().join("clip.avi");
std::fs::write(&avi, mp4_bytes(Some(&xmp), 1)).unwrap();
assert!(read_version_xmp(&avi).unwrap().is_none());
}
#[test]
fn mp4_without_xmp_or_not_an_mp4_has_no_link() {
let dir = tempfile::tempdir().unwrap();
let plain = dir.path().join("plain.mp4");
std::fs::write(&plain, mp4_bytes(None, 2)).unwrap();
assert_eq!(read_mp4_xmp(&plain).unwrap(), None);
let junk = dir.path().join("junk.mp4");
std::fs::write(&junk, b"definitely not boxes at all").unwrap();
assert_eq!(read_mp4_xmp(&junk).unwrap(), None);
}
#[test]
fn mp4_64bit_box_sizes_are_walked() {
let dir = tempfile::tempdir().unwrap();
let mut data = Vec::new();
data.extend_from_slice(&16u32.to_be_bytes());
data.extend_from_slice(b"ftypisom\0\0\x02\0");
// mdat with a 64-bit size field
data.extend_from_slice(&1u32.to_be_bytes());
data.extend_from_slice(b"mdat");
data.extend_from_slice(&(16u64 + 8).to_be_bytes());
data.extend_from_slice(&[7; 8]);
let mut d = XMP_UUID.to_vec();
d.extend_from_slice(REAL_XMP.as_bytes());
data.extend_from_slice(&((d.len() + 8) as u32).to_be_bytes());
data.extend_from_slice(b"uuid");
data.extend_from_slice(&d);
let f = dir.path().join("big.mp4");
std::fs::write(&f, data).unwrap();
assert_eq!(read_mp4_xmp(&f).unwrap().as_deref(), Some(REAL_XMP));
}
#[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());
}
}