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>
This commit is contained in:
1 parent
31c1b14d61
commit
deb3d051ac
8 files changed
+308
-31
No files matched your search
+144
-2
@@ -1,6 +1,8 @@
|
||||
//! 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.
|
||||
@@ -161,9 +163,77 @@ pub fn parse_version_xmp(xmp: &str) -> Option<VersionXmp> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the version link out of a PNG file; `None` if it has none.
|
||||
/// 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>> {
|
||||
Ok(read_png_xmp(path)?.and_then(|x| parse_version_xmp(&x)))
|
||||
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)]
|
||||
@@ -177,6 +247,28 @@ pub(crate) mod test_png {
|
||||
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).
|
||||
@@ -247,6 +339,56 @@ mod tests {
|
||||
/// 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();
|
||||
|
||||
Reference in new issue
Block a user