Files
ImageApi/src/versions/handlers.rs
T
Cameron CordesandClaude Opus 5.5 deb3d051ac 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>
2026-11-01 19:38:56 -05:00

299 lines
9.6 KiB
Rust

//! `GET /image/versions` and `PUT /image/versions/display`.
//!
//! Both take the path of the original *or* of any version, and answer about
//! the whole set. The display choice is global (not per user).
use crate::error::ApiError;
use std::sync::Mutex;
use actix_web::{HttpResponse, Responder, get, put, web};
use log::warn;
use serde::{Deserialize, Serialize};
use crate::data::Claims;
use crate::database::ExifDao;
use crate::database::version_dao::{VersionDao, is_clip_kind};
use crate::libraries;
use crate::state::AppState;
use crate::utils::normalize_path;
#[derive(Debug, Deserialize)]
pub struct VersionsQuery {
pub path: String,
pub library: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SetDisplayBody {
pub path: String,
/// Content hash of the version to display; `null` / absent = the original.
#[serde(default)]
pub display_hash: Option<String>,
pub library: Option<String>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct FileRef {
pub path: String,
pub library_id: i32,
pub hash: String,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct ReferenceEntry {
pub hash: String,
/// Where the reference photo lives, if it has been ingested.
pub path: Option<String>,
pub library_id: Option<i32>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct VersionEntry {
pub path: String,
pub library_id: i32,
pub hash: String,
pub kind: String,
pub source: String,
pub created_at: i64,
pub references: Vec<ReferenceEntry>,
pub width: Option<i32>,
pub height: Option<i32>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct VersionsResponse {
/// `null` when the original's file is gone (its versions then show up as
/// 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>,
}
/// The photo a request path belongs to.
struct Resolved {
original_hash: String,
/// Library of the row the path named; preferred when picking files.
library_id: i32,
}
fn resolve(
app_state: &AppState,
exif_dao: &Mutex<Box<dyn ExifDao>>,
path: &str,
library: Option<&str>,
) -> Result<Resolved, HttpResponse> {
let library_id = match libraries::resolve_library_param_state(app_state, library) {
Ok(lib) => lib.map(|l| l.id),
Err(msg) => return Err(libraries::unknown_library(msg).into_response()),
};
let path = normalize_path(path);
let mut rows = exif_dao
.lock()
.expect("Unable to lock ExifDao")
.get_exif_batch(library_id, std::slice::from_ref(&path))
.map_err(|_| ApiError::internal("internal error").into_response())?;
// Without `library`, a path can name rows in several libraries. Take the
// earliest hashed one; it then resolves to its original below, so a
// version in one library and its original in another agree.
rows.sort_by_key(|r| r.id);
let Some((row, hash)) = rows
.iter()
.find_map(|r| r.content_hash.clone().map(|h| (r, h)))
else {
return Err(ApiError::not_found(
"photo_not_indexed",
"This photo isn't indexed yet. Try again in a minute.",
)
.into_response());
};
let link = app_state
.version_dao
.lock()
.expect("Unable to lock VersionDao")
.get_link(&hash)
.map_err(|_| ApiError::internal("internal error").into_response())?;
Ok(Resolved {
original_hash: link.map(|l| l.original_hash).unwrap_or(hash),
library_id: row.library_id,
})
}
/// Everything about the set around `original_hash`. Reads fall back to the
/// original when the stored display choice has no live row.
pub fn build_response(
dao: &mut dyn VersionDao,
original_hash: &str,
prefer_library: i32,
) -> Result<VersionsResponse, crate::database::DbError> {
let original = dao
.files_for_hashes(&[original_hash.to_string()], Some(prefer_library))?
.remove(original_hash)
.map(|(library_id, path)| FileRef {
path,
library_id,
hash: original_hash.to_string(),
});
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))?;
let references = reference_hashes
.into_iter()
.map(|hash| {
let found = located.get(&hash);
ReferenceEntry {
path: found.map(|(_, p)| p.clone()),
library_id: found.map(|(l, _)| *l),
hash,
}
})
.collect();
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,
kind: f.kind,
source: f.source,
created_at: f.created_at,
references,
width: f.width,
height: f.height,
});
}
// Clients fetch the display file through the original's library, so a
// choice with no row there reads as the original (as `/photos` does).
let display_hash = dao.get_display(original_hash)?.filter(|d| {
versions.iter().any(|v| {
&v.hash == d
&& original
.as_ref()
.is_none_or(|o| o.library_id == v.library_id)
})
});
Ok(VersionsResponse {
original,
versions,
clips,
display_hash,
})
}
#[get("/image/versions")]
#[tracing::instrument(name = "get_versions", skip_all)]
pub async fn get_versions(
_: Claims,
q: web::Query<VersionsQuery>,
app_state: web::Data<AppState>,
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let resolved = match resolve(&app_state, &exif_dao, &q.path, q.library.as_deref()) {
Ok(r) => r,
Err(resp) => return resp,
};
let mut dao = app_state
.version_dao
.lock()
.expect("Unable to lock VersionDao");
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
Ok(body) => HttpResponse::Ok().json(body),
Err(e) => {
warn!("get_versions failed: {:?}", e);
ApiError::internal("internal error").into_response()
}
}
}
#[put("/image/versions/display")]
#[tracing::instrument(name = "put_version_display", skip_all)]
pub async fn put_display(
_: Claims,
// The client also sends `library` as a query parameter, like its other
// path-keyed calls; the body field wins when both are given.
query: web::Query<LibraryQuery>,
body: web::Json<SetDisplayBody>,
app_state: web::Data<AppState>,
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let library = body.library.as_deref().or(query.library.as_deref());
let resolved = match resolve(&app_state, &exif_dao, &body.path, library) {
Ok(r) => r,
Err(resp) => return resp,
};
let mut dao = app_state
.version_dao
.lock()
.expect("Unable to lock VersionDao");
// Choosing the original is the same as clearing the choice.
let wanted = body
.display_hash
.as_deref()
.filter(|h| *h != resolved.original_hash);
if let Some(hash) = wanted {
let current =
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
Ok(r) => r,
Err(e) => {
warn!("put_display lookup failed: {:?}", e);
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",
"That file isn't a version of this photo",
)
.into_response();
}
// The display file is fetched through the original's library.
let original_lib = current.original.as_ref().map(|o| o.library_id);
if !current
.versions
.iter()
.any(|v| v.hash == hash && original_lib.is_none_or(|l| l == v.library_id))
{
return ApiError::bad_request(
"version_in_other_library",
"That version isn't in the same library as the original",
)
.into_response();
}
}
if let Err(e) = dao.set_display(&resolved.original_hash, wanted) {
warn!("put_display failed: {:?}", e);
return ApiError::internal("internal error").into_response();
}
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
Ok(body) => HttpResponse::Ok().json(body),
Err(e) => {
warn!("put_display response failed: {:?}", e);
ApiError::internal("internal error").into_response()
}
}
}
#[derive(Debug, Deserialize)]
pub struct LibraryQuery {
pub library: Option<String>,
}