//! HTTP surface of Enhance (#192). ImageApi relays everything, previews //! included: the apps never talk to ComfyUI. //! //! - `GET /enhance/templates` //! - `POST /enhance` //! - `GET /enhance/prompts?templates=&limit=` //! - `GET /enhance/jobs?path=` //! - `GET /enhance/jobs/{id}` //! - `GET /enhance/jobs/{id}/preview/{n}` //! - `POST /enhance/jobs/{id}/approve` //! - `POST /enhance/jobs/{id}/discard` use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; use actix_web::{HttpResponse, Responder, get, post, web}; use log::warn; use serde::{Deserialize, Serialize}; use serde_json::json; use super::config::PromptMode; use super::service::{EnhanceError, EnhanceService, SourceFile}; use crate::data::Claims; use crate::database::ExifDao; use crate::database::enhance_dao::{EnhanceJobRow, STATUS_APPROVED, STATUS_READY}; use crate::error::ApiError; use crate::libraries::{self, Library}; use crate::state::AppState; use crate::utils::normalize_path; use crate::versions::handlers::build_response; use actix_web::ResponseError; type ExifData = web::Data>>; /// Enhance errors in the shared format (#202). The codes predate it and stay /// as they are: clients may already branch on them. fn error_response(e: &EnhanceError) -> HttpResponse { use actix_web::http::StatusCode; let (status, code) = match e { EnhanceError::Disabled => (StatusCode::SERVICE_UNAVAILABLE, "disabled"), EnhanceError::Unavailable => (StatusCode::SERVICE_UNAVAILABLE, "unavailable"), EnhanceError::UnknownTemplate(_) => (StatusCode::NOT_FOUND, "unknown_template"), EnhanceError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"), EnhanceError::Rejected(_) => (StatusCode::UNPROCESSABLE_ENTITY, "rejected"), EnhanceError::Expired => (StatusCode::GONE, "expired"), EnhanceError::NotFound => (StatusCode::NOT_FOUND, "not_found"), EnhanceError::Unplaceable(_) => (StatusCode::BAD_GATEWAY, "unplaceable"), EnhanceError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream"), EnhanceError::Internal(_) => { return ApiError::internal(e).error_response(); } }; ApiError::new(status, code, e.message()).error_response() } fn bad(msg: &str) -> HttpResponse { error_response(&EnhanceError::BadRequest(msg.to_string())) } // ---- views -------------------------------------------------------------- #[derive(Debug, Serialize)] struct PendingView { /// Index for `preview/{n}` and `approve { n }`. n: usize, /// Path of the preview on this API. preview: String, approved: bool, } #[derive(Debug, Serialize)] struct SavedView { n: usize, path: Option, library_id: Option, } #[derive(Debug, Serialize)] pub struct JobView { id: String, status: String, #[serde(skip_serializing_if = "Option::is_none")] queue_position: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option, path: String, library_id: i32, template: String, #[serde(skip_serializing_if = "Option::is_none")] prompt: Option, #[serde(skip_serializing_if = "Option::is_none")] reference_path: Option, created_at: i64, updated_at: i64, /// Milliseconds ComfyUI ran the job; null until it has finished. run_ms: Option, pending: Vec, saved: Vec, } pub fn job_view(job: &EnhanceJobRow) -> JobView { let saved = EnhanceService::saved_of(job); // An expired job's staged files are gone; don't offer them. let pending = if job.status == STATUS_READY || job.status == STATUS_APPROVED { EnhanceService::pending_of(job) .iter() .enumerate() .map(|(n, _)| PendingView { n, preview: format!("/enhance/jobs/{}/preview/{n}", job.id), approved: saved.iter().any(|s| s.n == n), }) .collect() } else { Vec::new() }; let queued = matches!(job.status.as_str(), "queued" | "running"); JobView { id: job.id.clone(), status: job.status.clone(), queue_position: job.queue_position.filter(|_| queued), error: job.error.clone(), path: job.rel_path.clone(), library_id: job.library_id, template: job.template.clone(), prompt: job.prompt.clone(), reference_path: job.reference_path.clone(), created_at: job.created_at, updated_at: job.updated_at, run_ms: job.run_ms, pending, saved: saved .into_iter() .map(|s| SavedView { n: s.n, path: s.rel_path, library_id: s.library_id, }) .collect(), } } // ---- file resolution ---------------------------------------------------- fn safe_rel(rel: &str) -> bool { !rel.is_empty() && Path::new(rel) .components() .all(|c| matches!(c, Component::Normal(_))) } /// An indexed, hashed file in a library. fn resolve_file( app_state: &AppState, exif_dao: &Mutex>, path: &str, library: Option<&str>, ) -> Result { let library_id = match libraries::resolve_library_param_state(app_state, library) { Ok(l) => l.map(|l| l.id), Err(msg) => return Err(bad(&msg)), }; let rel = normalize_path(path); if !safe_rel(&rel) { return Err(bad("invalid path")); } let mut rows = exif_dao .lock() .expect("Unable to lock ExifDao") .get_exif_batch(library_id, std::slice::from_ref(&rel)) .map_err(|_| error_response(&EnhanceError::Internal("lookup failed".into())))?; rows.sort_by_key(|r| r.id); let Some(row) = rows.iter().find(|r| r.content_hash.is_some()) else { return Err(error_response(&EnhanceError::NotFound)); }; let Some(lib) = app_state.library_by_id(row.library_id) else { return Err(error_response(&EnhanceError::NotFound)); }; let abs = Path::new(&lib.root_path).join(&rel); if !abs.is_file() { return Err(error_response(&EnhanceError::NotFound)); } Ok(SourceFile { library_id: row.library_id, rel_path: rel, abs, hash: row.content_hash.clone().unwrap_or_default(), }) } /// Library and relative path of an absolute file ComfyUI wrote. The job's own /// library wins when roots overlap. fn place_in_libraries(libs: &[Library], prefer: i32, abs: &Path) -> Option<(i32, String)> { let mut ordered: Vec<&Library> = libs.iter().collect(); ordered.sort_by_key(|l| l.id != prefer); for lib in ordered { if let Ok(rest) = abs.strip_prefix(&lib.root_path) { let rel = rest.to_str()?.replace('\\', "/"); if safe_rel(&rel) { return Some((lib.id, rel)); } } } None } // ---- templates ---------------------------------------------------------- #[derive(Serialize)] struct TemplateView { name: String, label: String, description: String, accepts_prompt: bool, prompt_mode: PromptMode, /// Replace-mode templates need non-empty text (empty is a 400). prompt_required: bool, accepts_reference: bool, default: bool, /// 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")] pub async fn get_templates(_: Claims, svc: web::Data) -> impl Responder { let available = svc.available().await; let reason = if !svc.is_configured() { Some(super::service::MSG_DISABLED) } else if !available { Some(super::service::MSG_UNAVAILABLE) } else { None }; let default = svc.default_template().map(str::to_string); let templates: Vec = svc .templates() .iter() .map(|t| TemplateView { name: t.cfg.name.clone(), label: t.cfg.label.clone(), description: t.cfg.description.clone(), accepts_prompt: t.accepts_prompt(), prompt_mode: t.cfg.prompt_mode, prompt_required: t.prompt_required(), accepts_reference: t.accepts_reference(), 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!({ "available": available, "default": default, "warn_above_megapixels": svc.config.warn_above_megapixels, "templates": templates, }); if let Some(r) = reason { body["reason"] = json!(r); } HttpResponse::Ok().json(body) } // ---- jobs --------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct LibraryQuery { pub library: Option, } #[derive(Debug, Deserialize)] pub struct SubmitBody { pub path: String, pub template: String, #[serde(default)] pub prompt: Option, #[serde(default)] pub reference_path: Option, #[serde(default)] pub library: Option, } #[post("/enhance")] #[tracing::instrument(name = "enhance_submit", skip_all)] pub async fn submit( _: Claims, query: web::Query, body: web::Json, svc: web::Data, app_state: web::Data, exif_dao: ExifData, ) -> impl Responder { if !svc.is_configured() { return error_response(&EnhanceError::Disabled); } let library = body.library.as_deref().or(query.library.as_deref()); let photo = match resolve_file(&app_state, &exif_dao, &body.path, library) { Ok(f) => f, Err(r) => return r, }; let reference = match body .reference_path .as_deref() .filter(|p| !p.trim().is_empty()) { Some(p) => { // The reference lives in the photo's own library. let lib = app_state .library_by_id(photo.library_id) .map(|l| l.name.clone()); match resolve_file(&app_state, &exif_dao, p, lib.as_deref()) { Ok(f) => Some(f), Err(r) => return r, } } None => None, }; match svc .submit( &photo, &body.template, body.prompt.as_deref(), reference.as_ref(), ) .await { Ok(job) => HttpResponse::Ok().json(job_view(&job)), Err(e) => error_response(&e), } } #[get("/enhance/jobs/{id}")] pub async fn get_job( _: Claims, id: web::Path, svc: web::Data, ) -> impl Responder { match svc.get_job(&id) { Ok(job) => HttpResponse::Ok().json(job_view(&job)), Err(e) => error_response(&e), } } #[derive(Debug, Deserialize)] pub struct JobsQuery { /// A photo (original or version): that photo's jobs. Omitted: every /// photo's open jobs, for the jobs manager. pub path: Option, pub library: Option, /// Comma-separated statuses to keep (e.g. `queued,running,ready`); /// omitted = all. Applies to both forms. pub status: Option, } /// With `path`: a photo's recent jobs, newest first, so the viewer can pick up /// an enhance in progress after reopening. Without: the jobs manager's list, /// every photo's queued / running / unavailable jobs plus ready / failed / /// expired ones from the last few days. Discarded and approved-and-done jobs /// are not listed. DB only. #[get("/enhance/jobs")] pub async fn list_jobs( _: Claims, q: web::Query, svc: web::Data, app_state: web::Data, exif_dao: ExifData, ) -> impl Responder { let jobs = match q.path.as_deref() { Some(path) => { let file = match resolve_file(&app_state, &exif_dao, path, q.library.as_deref()) { Ok(f) => f, Err(r) => return r, }; svc.jobs_for_hash(&file.hash) } None => svc.open_jobs(), }; let wanted: Option> = q.status.as_deref().map(|s| { s.split(',') .map(str::trim) .filter(|s| !s.is_empty()) .collect() }); match jobs { Ok(jobs) => { let views: Vec = jobs .iter() .filter(|j| { wanted .as_ref() .is_none_or(|w| w.contains(&j.status.as_str())) }) .map(job_view) .collect(); HttpResponse::Ok().json(json!({ "jobs": views })) } Err(e) => error_response(&e), } } #[derive(Debug, Deserialize)] pub struct PromptsQuery { /// Comma-separated template names; omitted = all templates. pub templates: Option, pub limit: Option, } #[derive(Debug, Serialize)] struct PromptView { prompt: String, template: String, last_used: i64, uses: i64, } /// Prompts used before, newest first, so the app can offer them again for a /// series of similar photos. DB only: not library-scoped, and answered even /// when ComfyUI is down or Enhance is off (then simply from old rows). #[get("/enhance/prompts")] pub async fn recent_prompts( _: Claims, q: web::Query, svc: web::Data, ) -> impl Responder { let names: Option> = q.templates.as_deref().map(|t| { t.split(',') .map(str::trim) .filter(|n| !n.is_empty()) .map(str::to_string) .collect() }); // `templates=` with nothing in it means "no filter", like omitting it. let names = names.filter(|n| !n.is_empty()); let limit = q.limit.unwrap_or(10).clamp(1, 50); match svc.recent_prompts(names.as_deref(), limit) { Ok(rows) => { let prompts: Vec = rows .into_iter() .map(|r| PromptView { prompt: r.prompt, template: r.template, last_used: r.last_used, uses: r.uses, }) .collect(); HttpResponse::Ok().json(json!({ "prompts": prompts })) } Err(e) => error_response(&e), } } #[get("/enhance/jobs/{id}/preview/{n}")] pub async fn preview( _: Claims, path: web::Path<(String, usize)>, svc: web::Data, ) -> impl Responder { let (id, n) = path.into_inner(); let job = match svc.get_job(&id) { Ok(j) => j, Err(e) => return error_response(&e), }; match svc.preview(&job, n).await { Ok((content_type, bytes)) => HttpResponse::Ok() .content_type(content_type) .insert_header(("Cache-Control", "private, max-age=3600")) .body(bytes), Err(e) => error_response(&e), } } #[post("/enhance/jobs/{id}/discard")] pub async fn discard( _: Claims, id: web::Path, svc: web::Data, ) -> impl Responder { match svc.discard(&id) { Ok(job) => HttpResponse::Ok().json(job_view(&job)), Err(e) => error_response(&e), } } #[derive(Debug, Deserialize)] pub struct ApproveBody { #[serde(default)] pub n: usize, } /// Ask ComfyUI to write result `n` into the library, ingest that file now, /// and answer with the version (an `/image/versions` `versions[]` entry). #[post("/enhance/jobs/{id}/approve")] #[tracing::instrument(name = "enhance_approve", skip_all)] pub async fn approve( _: Claims, id: web::Path, body: Option>, svc: web::Data, app_state: web::Data, exif_dao: ExifData, ) -> impl Responder { let n = body.map(|b| b.n).unwrap_or(0); let job = match svc.get_job(&id) { Ok(j) => j, Err(e) => return error_response(&e), }; let libs = app_state.libraries.clone(); let prefer = job.library_id; let place = move |abs: &Path| place_in_libraries(&libs, prefer, abs); let (job, saved) = match svc.approve(&id, n, &place).await { Ok(v) => v, Err(e) => return error_response(&e), }; let (Some(library_id), Some(rel)) = (saved.library_id, saved.rel_path.clone()) else { return error_response(&EnhanceError::Unplaceable( "the saved file isn't in a library".into(), )); }; let Some(library) = app_state.library_by_id(library_id).cloned() else { return error_response(&EnhanceError::Unplaceable("unknown library".into())); }; let abs = PathBuf::from(&library.root_path).join(&rel); // The file was written by another machine over a share; give a lagging // mount a moment to show it. for _ in 0..25 { if abs.is_file() { break; } tokio::time::sleep(Duration::from_millis(200)).await; } if !abs.is_file() { return error_response(&EnhanceError::Unplaceable(format!( "ComfyUI saved {rel} but it isn't visible to ImageApi yet; it will appear after the next scan" ))); } let version_dao = app_state.version_dao.clone(); let exif_arc: Arc>> = exif_dao.clone().into_inner(); let (lib2, rel2, abs2) = (library.clone(), rel.clone(), abs.clone()); let ingested = web::block(move || ingest_saved(&lib2, &rel2, &abs2, &exif_arc, &version_dao)).await; let hash = match ingested { Ok(Some(h)) => h, _ => { warn!("enhance: ingest of {rel} failed"); return HttpResponse::Accepted().json(json!({ "linked": false, "path": rel, "library_id": library_id, "job": job_view(&job), })); } }; // Which version row did that become? let entry = { let mut dao = app_state .version_dao .lock() .expect("Unable to lock VersionDao"); let original = match dao.get_link(&hash) { Ok(Some(link)) => link.original_hash, _ => job.original_hash.clone(), }; build_response(dao.as_mut(), &original, job.library_id) .ok() // 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), None => HttpResponse::Accepted().json(json!({ "linked": false, "path": rel, "library_id": library_id, "job": job_view(&job), })), } } /// Register the saved file (if the watcher hasn't yet) and link it as a /// version. Idempotent. Returns the file's content hash. pub fn ingest_saved( library: &Library, rel: &str, abs: &Path, exif_dao: &Arc>>, version_dao: &Arc>>, ) -> Option { let rel_s = rel.to_string(); let existing = exif_dao .lock() .expect("Unable to lock ExifDao") .get_exif_batch(Some(library.id), std::slice::from_ref(&rel_s)) .ok()?; let file = vec![(abs.to_path_buf(), rel_s.clone())]; if existing.is_empty() { crate::watcher::register_new_files(library, file.clone(), exif_dao); } let linked = crate::versions::link_new_files(library, &file, exif_dao, version_dao) + crate::versions::sweep_name_links(library, exif_dao, version_dao); if linked > 0 { crate::duplicates::invalidate_caches(); } exif_dao .lock() .expect("Unable to lock ExifDao") .get_exif_batch(Some(library.id), std::slice::from_ref(&rel_s)) .ok()? .into_iter() .find_map(|r| r.content_hash) }