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

+44 -1
View File
@@ -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();