diff --git a/CLAUDE.md b/CLAUDE.md index 89eac4e..4766653 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -360,7 +360,12 @@ hash — design it alongside Branch D. A **version** is a non-destructive restoration, upscale or edit of a photo that an external process (the ComfyUI node in `comfyui-imageapi-versions`) writes next -to the original, as `/aiedits/.[.].png`. ImageApi +to the original, as `/aiedits/.[.].png`. A +**derived clip** (kind `animated`, `CLIP_KINDS`) is a video made from a photo +(Enhance "Animate", `.animated[.].mp4`): linked, hidden and lifecycled +exactly like a version, but never counted as one (`photo_version_counts`, +`version_count`), never a display choice (`PUT /image/versions/display` answers 400 +`clip_not_displayable`), and listed apart, as `clips` in `GET /image/versions`. ImageApi only detects it (library roots stay read-only) and shows original + versions as **one photo**; a *global* choice (not per user) says which file is displayed. Code: `src/database/version_dao.rs` (tables, DAO, SQL predicates), @@ -376,8 +381,9 @@ for the `link_versions` binary). global choice; no row = the original, and a display file with no live row reads as the original. - **Linking at ingest** (`watcher::process_new_files`, before any derived work): - (1) a PNG's XMP `xmpMM:DerivedFrom/stRef:documentID = blake3:` (iTXt chunk - `XML:com.adobe.xmp`, read by hand, no decode); (2) the file-name convention next + (1) the file's XMP `xmpMM:DerivedFrom/stRef:documentID = blake3:`: a PNG's + iTXt chunk `XML:com.adobe.xmp`, or an MP4 / MOV's top-level `uuid` box with the XMP + UUID (both read by hand, no decode; `mdat` is seeked over); (2) the file-name convention next to an indexed original in the same library (same dir, or the parent of `aiedits/`). Name links are retried every tick (`sweep_name_links`, paged by a per-library resume cursor that wraps, so candidates that never resolve can't starve @@ -455,7 +461,9 @@ auth and stays LAN-only, so ImageApi relays previews too. `file`, `prompt_mode`, `prompt_join`, `output_megapixels`). - **Templates** are API-format workflow exports, read-only (never edit them from here). Nodes are found by `_meta.title`: `imageapi:source` (required, gets the - photo path), `imageapi:save` (required, forced to `require_approval = true`), + photo path), `imageapi:save` (required, forced to `require_approval = true`; a + `ImageApiSaveVideoVersion` there makes the template's `output` `video`, an MP4 clip, + else `image`), `imageapi:prompt` (optional; fills the first of `text` / `prompt` / `value`), `imageapi:reference` (optional). `prompt_mode = "append"`: the user's text is joined after the template's own prompt with `prompt_join` (default one space); empty text @@ -503,7 +511,7 @@ GET /enhance/templates "templates": [{"name","label","description","accepts_prompt", "prompt_mode": "append"|"replace", "prompt_required", "accepts_reference","default","output_megapixels", - "warn_above_megapixels"}]} + "warn_above_megapixels", "output": "image"|"video"}]} POST /enhance {"path","template","prompt"?,"reference_path"?,"library"?} -> job GET /enhance/jobs/{id} -> job GET /enhance/jobs?path=&library= -> {"jobs": [job, ...]} newest first, discarded hidden @@ -511,8 +519,9 @@ GET /enhance/prompts?templates=a,b&limit=10 (templates optional = all; limit 1 -> {"prompts": [{"prompt","template","last_used","uses"}]} distinct trimmed prompts, newest use first; template/last_used from the newest job; discarded + failed jobs count; DB only (no library scope, answers even when ComfyUI is down or Enhance is off) -GET /enhance/jobs/{id}/preview/{n} -> the staged PNG (relayed from ComfyUI /view) -POST /enhance/jobs/{id}/approve {"n"} -> a /image/versions `versions[]` entry +GET /enhance/jobs/{id}/preview/{n} -> the staged PNG or MP4 (relayed from ComfyUI /view, + with its content type) +POST /enhance/jobs/{id}/approve {"n"} -> a /image/versions `versions[]` (or `clips[]`) entry POST /enhance/jobs/{id}/discard -> job (hidden from the list; ComfyUI untouched) job = {"id","status","queue_position"?,"error"?,"path","library_id","template", @@ -737,7 +746,8 @@ POST /image/tags/bulk (many files, one transaction: { file_names, add: [names GET /image/versions?path=...&library=... (path of the original or any version -> { original:{path,library_id,hash}, versions:[{path, library_id,hash,kind,source,created_at,references, - width,height}], display_hash }) + width,height}], clips:[same shape, kind animated], + display_hash }) POST /enhance ... (Enhance on demand; see "Enhance on demand") PUT /image/versions/display (body { path, display_hash|null, library? }; global choice) // /photos hides versions behind their original (include_versions=true lists them) and adds diff --git a/src/database/version_dao.rs b/src/database/version_dao.rs index f7d0f42..2f8e2f0 100644 --- a/src/database/version_dao.rs +++ b/src/database/version_dao.rs @@ -20,7 +20,23 @@ use crate::database::{DbError, DbErrorKind, connect}; use crate::otel::trace_db_call; /// The `kind` values a version may carry. -pub const KINDS: &[&str] = &["restored", "upscale", "edit"]; +pub const KINDS: &[&str] = &["restored", "upscale", "edit", "animated"]; + +/// Kinds that are *derived clips* rather than versions: a video made from a +/// photo (Enhance "Animate"). Linked and hidden behind the photo like a +/// version, but never counted as one, never a display choice, and listed +/// separately (`clips` in `GET /image/versions`). +pub const CLIP_KINDS: &[&str] = &["animated"]; + +pub fn is_clip_kind(kind: &str) -> bool { + CLIP_KINDS.contains(&kind) +} + +/// `pv.kind NOT IN ()`, for queries about versions proper. +fn not_a_clip_sql(alias: &str) -> String { + let kinds: Vec = CLIP_KINDS.iter().map(|k| format!("'{k}'")).collect(); + format!("{alias}.kind NOT IN ({})", kinds.join(", ")) +} /// `date_taken_source` on a version row whose date, GPS and camera fields /// were copied from its original. @@ -731,16 +747,18 @@ impl VersionDao for SqliteVersionDao { } let mut conn = self.connection.lock().expect("Unable to get VersionDao"); - let originals = sql_query( + let originals = sql_query(format!( "SELECT o.content_hash AS original_hash, o.library_id AS library_id, \ o.rel_path AS rel_path, \ (SELECT COUNT(*) FROM photo_versions pv \ WHERE pv.original_hash = o.content_hash \ + AND {not_clip} \ AND EXISTS (SELECT 1 FROM image_exif v \ WHERE v.content_hash = pv.version_hash)) AS version_count \ FROM image_exif o \ WHERE o.content_hash IN (SELECT original_hash FROM photo_versions)", - ) + not_clip = not_a_clip_sql("pv"), + )) .load::(&mut *conn) .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; if originals.is_empty() { @@ -749,15 +767,17 @@ impl VersionDao for SqliteVersionDao { // Only a display choice that is still a version of that original // counts; anything else falls back to the original. - let displays = sql_query( + let displays = sql_query(format!( "SELECT d.original_hash AS original_hash, v.library_id AS library_id, \ v.rel_path AS rel_path \ FROM photo_version_display d \ JOIN photo_versions pv ON pv.version_hash = d.display_hash \ AND pv.original_hash = d.original_hash \ + AND {not_clip} \ JOIN image_exif v ON v.content_hash = d.display_hash \ ORDER BY v.id", - ) + not_clip = not_a_clip_sql("pv"), + )) .load::(&mut *conn) .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; let mut display_by_hash: HashMap> = HashMap::new(); diff --git a/src/enhance/handlers.rs b/src/enhance/handlers.rs index 7a342a8..6cdd276 100644 --- a/src/enhance/handlers.rs +++ b/src/enhance/handlers.rs @@ -221,6 +221,8 @@ struct TemplateView { /// Approximate output size; clients warn when the original is larger. output_megapixels: f64, warn_above_megapixels: f64, + /// `image` (a PNG version) or `video` (an MP4 clip, e.g. Animate). + output: super::template::OutputKind, } #[get("/enhance/templates")] @@ -248,6 +250,7 @@ pub async fn get_templates(_: Claims, svc: web::Data) -> impl Re default: default.as_deref() == Some(t.cfg.name.as_str()), output_megapixels: t.cfg.output_megapixels, warn_above_megapixels: svc.config.warn_above_megapixels, + output: t.output, }) .collect(); let mut body = json!({ @@ -538,7 +541,13 @@ pub async fn approve( }; build_response(dao.as_mut(), &original, job.library_id) .ok() - .and_then(|r| r.versions.into_iter().find(|v| v.hash == hash)) + // An approved clip (Animate) is listed under `clips`. + .and_then(|r| { + r.versions + .into_iter() + .chain(r.clips) + .find(|v| v.hash == hash) + }) }; match entry { Some(v) => HttpResponse::Ok().json(v), diff --git a/src/enhance/template.rs b/src/enhance/template.rs index 4e788b8..bc04bfa 100644 --- a/src/enhance/template.rs +++ b/src/enhance/template.rs @@ -19,6 +19,19 @@ pub const TITLE_REFERENCE: &str = "imageapi:reference"; /// Prompt-node inputs, in the order they are tried. const PROMPT_INPUTS: &[&str] = &["text", "prompt", "value"]; +/// What a template produces, from its `imageapi:save` node's class. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum OutputKind { + /// Save Image Version: PNG versions. + Image, + /// Save Video Version: an MP4 (e.g. a clip animated from the photo). + Video, +} + +/// The save node class that makes a template's output a video. +pub const VIDEO_SAVE_CLASS: &str = "ImageApiSaveVideoVersion"; + /// Output nodes that write into ComfyUI's output folder on every API run. const STRAY_OUTPUT_CLASSES: &[&str] = &[ "SaveImage", @@ -38,6 +51,8 @@ pub struct Template { /// `(node id, input name)` of the prompt text. prompt: Option<(String, &'static str)>, reference_id: Option, + /// Image or video, from the save node's class. + pub output: OutputKind, /// Startup warnings (stray output nodes). pub warnings: Vec, } @@ -143,6 +158,11 @@ impl Template { )); } } + let output = if workflow[&save_id]["class_type"].as_str() == Some(VIDEO_SAVE_CLASS) { + OutputKind::Video + } else { + OutputKind::Image + }; Ok(Template { cfg, workflow, @@ -150,6 +170,7 @@ impl Template { save_id, prompt, reference_id, + output, warnings, }) } @@ -414,6 +435,23 @@ mod tests { assert_ne!(input(&x.prompt, "5", "seed"), input(&y.prompt, "5", "seed")); } + #[test] + fn output_kind_follows_the_save_node() { + assert_eq!( + load(RESTORE, PromptMode::Append).unwrap().output, + OutputKind::Image + ); + let video = RESTORE.replace( + "\"ImageApiSaveImageVersion\"", + "\"ImageApiSaveVideoVersion\"", + ); + assert_ne!(video, RESTORE); + assert_eq!( + load(&video, PromptMode::Append).unwrap().output, + OutputKind::Video + ); + } + #[test] fn random_seeds_fit_seedvr2s_u32_limit() { // SeedVR2VideoUpscaler rejects seeds above 2^32 - 1 at validation. diff --git a/src/versions/handlers.rs b/src/versions/handlers.rs index 47dd692..80eee11 100644 --- a/src/versions/handlers.rs +++ b/src/versions/handlers.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::data::Claims; use crate::database::ExifDao; -use crate::database::version_dao::VersionDao; +use crate::database::version_dao::{VersionDao, is_clip_kind}; use crate::libraries; use crate::state::AppState; use crate::utils::normalize_path; @@ -66,6 +66,9 @@ pub struct VersionsResponse { /// standalone photos). pub original: Option, pub versions: Vec, + /// Derived clips: videos made from the photo (kind `animated`). Same + /// shape as `versions`, but never a display choice. + pub clips: Vec, /// The globally displayed version; `null` = the original. pub display_hash: Option, } @@ -137,6 +140,7 @@ pub fn build_response( let files = dao.list_versions(original_hash)?; let mut versions = Vec::with_capacity(files.len()); + let mut clips = Vec::new(); 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))?; @@ -151,7 +155,12 @@ pub fn build_response( } }) .collect(); - versions.push(VersionEntry { + let list = if is_clip_kind(&f.kind) { + &mut clips + } else { + &mut versions + }; + list.push(VersionEntry { path: f.rel_path, library_id: f.library_id, hash: f.version_hash, @@ -177,6 +186,7 @@ pub fn build_response( Ok(VersionsResponse { original, versions, + clips, display_hash, }) } @@ -241,6 +251,13 @@ pub async fn put_display( return ApiError::internal("internal error").into_response(); } }; + if current.clips.iter().any(|c| c.hash == hash) { + return ApiError::bad_request( + "clip_not_displayable", + "A clip made from this photo can't be shown in its place", + ) + .into_response(); + } if !current.versions.iter().any(|v| v.hash == hash) { return ApiError::bad_request( "not_a_version", diff --git a/src/versions/integration_tests.rs b/src/versions/integration_tests.rs index 8254d3e..f9230dc 100644 --- a/src/versions/integration_tests.rs +++ b/src/versions/integration_tests.rs @@ -14,7 +14,7 @@ use diesel::sql_query; use diesel::sql_types::{BigInt, Text}; use diesel::sqlite::SqliteConnection; -use super::xmp::test_png::{producer_xmp, write_png}; +use super::xmp::test_png::{mp4_bytes, producer_xmp, write_png}; use super::*; use crate::data::Claims; use crate::database::test::in_memory_db_connection; @@ -782,6 +782,49 @@ async fn display_choice_set_from_a_version_path_cleared_and_validated() { assert_eq!(awtest::call_service(&app, req).await.status(), 200); } +#[actix_rt::test] +async fn an_animated_clip_links_hides_and_is_listed_apart_from_versions() { + let fx = fx(); + let original = fx.put("2014/IMG_1.JPG", b"photo bytes"); + let rel = "2014/aiedits/IMG_1.JPG.animated.mp4"; + let clip = fx.put( + rel, + &mp4_bytes(Some(&producer_xmp(&original, "animated", &[])), 3), + ); + let files = vec![(fx.root.join(rel), rel.to_string())]; + assert_eq!( + link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao), + 1 + ); + let app = photos_app!(fx); + + // Hidden behind the photo, like a version, and not counted as one. + let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); + assert_eq!(body["photos"], serde_json::json!(["2014/IMG_1.JPG"])); + assert!(body.get("photo_version_counts").is_none(), "{body}"); + + // Listed under `clips`, from the photo's path or the clip's own. + for path in ["2014/IMG_1.JPG", rel] { + let (status, body) = get_json!(app, &format!("/image/versions?path={path}")); + assert_eq!(status, 200); + assert_eq!(body["versions"], serde_json::json!([])); + assert_eq!(body["clips"][0]["hash"], clip); + assert_eq!(body["clips"][0]["kind"], "animated"); + assert_eq!(body["clips"][0]["path"], rel); + } + + // Never the photo's display version. + let req = awtest::TestRequest::put() + .uri("/image/versions/display") + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .set_json(serde_json::json!({"path": "2014/IMG_1.JPG", "display_hash": clip})) + .to_request(); + let resp = awtest::call_service(&app, req).await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = awtest::read_body_json(resp).await; + assert_eq!(body["code"], "clip_not_displayable"); +} + #[actix_rt::test] async fn deleting_the_displayed_version_falls_back_to_the_original() { let (fx, _o, v1, _v2) = seeded(); diff --git a/src/versions/mod.rs b/src/versions/mod.rs index 4b7a048..45d30e3 100644 --- a/src/versions/mod.rs +++ b/src/versions/mod.rs @@ -6,7 +6,7 @@ //! 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 +//! the XMP `DerivedFrom` hash first (PNG, or MP4 / MOV for a clip), then the //! `.[.].` file-name convention; //! - the HTTP surface (`GET /image/versions`, `PUT /image/versions/display`). //! @@ -132,18 +132,16 @@ pub fn name_link( }) } -/// 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. +/// Resolve the XMP link of a version file on disk (PNG, or MP4 / MOV for a +/// derived clip). `None` when the file isn't one of those, 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 { - let is_png = abs_path - .extension() - .is_some_and(|e| e.eq_ignore_ascii_case("png")); - if !is_png { + if !xmp::has_readable_xmp(abs_path) { return None; } let parsed = match xmp::read_version_xmp(abs_path) { @@ -213,14 +211,14 @@ pub fn link_new_files( exif_dao: &Arc>>, version_dao: &Arc>>, ) -> usize { - let pngs: Vec<&(PathBuf, String)> = files + let candidates: Vec<&(PathBuf, String)> = files .iter() - .filter(|(p, _)| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("png"))) + .filter(|(p, _)| xmp::has_readable_xmp(p)) .collect(); - if pngs.is_empty() { + if candidates.is_empty() { return 0; } - let rels: Vec = pngs.iter().map(|(_, r)| r.clone()).collect(); + let rels: Vec = candidates.iter().map(|(_, r)| r.clone()).collect(); // Chunked: a first scan can register far more paths than SQLite's // bound-variable limit allows in one `IN (...)`. let mut rows = Vec::with_capacity(rels.len()); @@ -245,7 +243,7 @@ pub fn link_new_files( .collect(); let mut linked = 0; - for (abs, rel) in pngs { + for (abs, rel) in candidates { let Some(hash) = hashes.get(rel.as_str()) else { continue; }; diff --git a/src/versions/xmp.rs b/src/versions/xmp.rs index ce1d8c2..c01b084 100644 --- a/src/versions/xmp.rs +++ b/src/versions/xmp.rs @@ -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 { }) } -/// 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> { + 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> { - 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, 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 { + 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#"blake3:d5fa14088629f680f4f8e5815df473cc4684a97fc937da588baf72d2693dea3a019_19.JPGrestored"#; + #[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();