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:
Cameron CordesandClaude Opus 5.5 committed 2026-11-01 19:38:56 -05:00
1 parent 31c1b14d61
commit deb3d051ac
8 files changed
+308 -31

No files matched your search

+19 -2
View File
@@ -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<FileRef>,
pub versions: Vec<VersionEntry>,
/// Derived clips: videos made from the photo (kind `animated`). Same
/// shape as `versions`, but never a display choice.
pub clips: Vec<VersionEntry>,
/// The globally displayed version; `null` = the original.
pub display_hash: Option<String>,
}
@@ -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",