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