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>
299 lines
9.6 KiB
Rust
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>,
|
|
}
|