1use axum::{
2 extract::{Request, State},
3 http::{header, HeaderMap, HeaderValue, StatusCode},
4 response::{
5 sse::{Event as SseEvent, KeepAlive, Sse},
6 IntoResponse,
7 },
8 routing::{delete, get, post},
9 Json, Router,
10};
11use base64::Engine as _;
12use mold_core::{
13 types::GpuSelection, ActiveGenerationStatus, GpuInfo, GpuWorkerState, ModelInfoExtended,
14 ResourceSnapshot, ServerStatus, SseErrorEvent, SseProgressEvent,
15};
16use serde::{Deserialize, Serialize};
17use std::cmp::Reverse;
18use std::convert::Infallible;
19use std::sync::atomic::Ordering;
20use tokio_stream::StreamExt as _;
21use utoipa::OpenApi;
22
23use crate::model_manager;
24use crate::state::{AppState, GenerationJob, SseMessage, SubmitError};
25
26fn submit_error_to_api(e: SubmitError) -> ApiError {
27 match e {
28 SubmitError::Full { pending, capacity } => {
29 ApiError::queue_full(format!("generation queue is full ({pending}/{capacity})"))
30 }
31 SubmitError::Shutdown => ApiError::internal("generation queue shut down"),
32 }
33}
34
35#[derive(Debug, Serialize)]
38pub struct ApiError {
39 pub error: String,
40 pub code: String,
41 #[serde(skip)]
42 status: StatusCode,
43}
44
45impl ApiError {
46 pub fn validation(msg: impl Into<String>) -> Self {
47 Self {
48 error: msg.into(),
49 code: "VALIDATION_ERROR".to_string(),
50 status: StatusCode::UNPROCESSABLE_ENTITY,
51 }
52 }
53
54 pub fn not_found(msg: impl Into<String>) -> Self {
55 Self {
56 error: msg.into(),
57 code: "MODEL_NOT_FOUND".to_string(),
58 status: StatusCode::NOT_FOUND,
59 }
60 }
61
62 pub fn unknown_model(msg: impl Into<String>) -> Self {
63 Self {
64 error: msg.into(),
65 code: "UNKNOWN_MODEL".to_string(),
66 status: StatusCode::BAD_REQUEST,
67 }
68 }
69
70 pub fn inference(msg: impl Into<String>) -> Self {
71 Self {
72 error: msg.into(),
73 code: "INFERENCE_ERROR".to_string(),
74 status: StatusCode::INTERNAL_SERVER_ERROR,
75 }
76 }
77
78 pub fn internal(msg: impl Into<String>) -> Self {
79 Self {
80 error: msg.into(),
81 code: "INTERNAL_ERROR".to_string(),
82 status: StatusCode::INTERNAL_SERVER_ERROR,
83 }
84 }
85
86 pub fn internal_with_status(msg: impl Into<String>, status: StatusCode) -> Self {
87 Self {
88 error: msg.into(),
89 code: "INTERNAL_ERROR".to_string(),
90 status,
91 }
92 }
93
94 pub fn insufficient_memory(msg: impl Into<String>) -> Self {
95 Self {
96 error: msg.into(),
97 code: "INSUFFICIENT_MEMORY".to_string(),
98 status: StatusCode::SERVICE_UNAVAILABLE,
99 }
100 }
101
102 pub fn forbidden(msg: impl Into<String>) -> Self {
103 Self {
104 error: msg.into(),
105 code: "FORBIDDEN".to_string(),
106 status: StatusCode::FORBIDDEN,
107 }
108 }
109
110 pub fn queue_full(msg: impl Into<String>) -> Self {
111 Self {
112 error: msg.into(),
113 code: "QUEUE_FULL".to_string(),
114 status: StatusCode::SERVICE_UNAVAILABLE,
115 }
116 }
117}
118
119impl IntoResponse for ApiError {
120 fn into_response(self) -> axum::response::Response {
121 let status = self.status;
122 if self.code == "QUEUE_FULL" {
124 let mut headers = HeaderMap::new();
125 headers.insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
126 return (status, headers, Json(self)).into_response();
127 }
128 (status, Json(self)).into_response()
129 }
130}
131
132#[cfg(test)]
134use crate::queue::clean_error_message;
135
136#[derive(OpenApi)]
137#[openapi(
138 paths(
139 generate,
140 generate_stream,
141 expand_prompt,
142 list_models,
143 load_model,
144 pull_model_endpoint,
145 unload_model,
146 server_status,
147 health,
148 capabilities_chain_limits,
149 crate::routes_chain::generate_chain,
150 crate::routes_chain::generate_chain_stream,
151 ),
152 components(schemas(
153 mold_core::GenerateRequest,
154 mold_core::GenerateResponse,
155 mold_core::ExpandRequest,
156 mold_core::ExpandResponse,
157 mold_core::ImageData,
158 mold_core::OutputFormat,
159 mold_core::ModelInfo,
160 mold_core::ServerStatus,
161 mold_core::ActiveGenerationStatus,
162 mold_core::GpuInfo,
163 mold_core::SseProgressEvent,
164 mold_core::SseCompleteEvent,
165 mold_core::SseErrorEvent,
166 mold_core::ChainRequest,
167 mold_core::ChainResponse,
168 mold_core::ChainStage,
169 mold_core::ChainProgressEvent,
170 mold_core::SseChainCompleteEvent,
171 ModelInfoExtended,
172 LoadModelBody,
173 UnloadRequest,
174 crate::chain_limits::ChainLimits,
175 )),
176 tags(
177 (name = "generation", description = "Image generation"),
178 (name = "models", description = "Model management"),
179 (name = "server", description = "Server status and health"),
180 ),
181 info(
182 title = "mold",
183 description = "Local AI image generation server — FLUX, SD3.5, SD1.5, SDXL, Z-Image, Flux.2, Qwen-Image",
184 version = env!("CARGO_PKG_VERSION"),
185 )
186)]
187pub struct ApiDoc;
188
189pub fn create_router(state: AppState) -> Router {
190 Router::new()
193 .route("/api/generate", post(generate))
194 .route("/api/generate/stream", post(generate_stream))
195 .route(
196 "/api/generate/chain",
197 post(crate::routes_chain::generate_chain),
198 )
199 .route(
200 "/api/generate/chain/stream",
201 post(crate::routes_chain::generate_chain_stream),
202 )
203 .route("/api/expand", post(expand_prompt))
204 .route("/api/models", get(list_models))
205 .route("/api/models/load", post(load_model))
206 .route("/api/models/pull", post(pull_model_endpoint))
207 .route("/api/models/unload", delete(unload_model))
208 .route("/api/gallery", get(list_gallery))
209 .route(
210 "/api/gallery/image/:filename",
211 get(get_gallery_image).delete(delete_gallery_image),
212 )
213 .route(
214 "/api/gallery/thumbnail/:filename",
215 get(get_gallery_thumbnail),
216 )
217 .route("/api/gallery/preview/:filename", get(get_gallery_preview))
218 .route("/api/downloads", get(list_downloads).post(create_download))
220 .route("/api/downloads/:id", delete(delete_download))
221 .route("/api/downloads/stream", get(stream_downloads))
222 .route(
224 "/api/catalog/families",
225 get(crate::catalog_api::list_families),
226 )
227 .route(
228 "/api/catalog/search",
229 get(crate::catalog_api::live_search_catalog),
230 )
231 .route(
232 "/api/catalog/installed",
233 get(crate::catalog_api::list_installed_catalog),
234 )
235 .route(
236 "/api/catalog/*id",
237 get(crate::catalog_api::get_catalog_entry)
238 .post(crate::catalog_api::post_catalog_dispatch),
239 )
240 .route("/api/upscale", post(upscale))
241 .route("/api/upscale/stream", post(upscale_stream))
242 .route("/api/resources", get(get_resources))
243 .route("/api/resources/stream", get(get_resources_stream))
244 .route("/api/status", get(server_status))
245 .route("/api/queue", get(list_queue))
246 .route("/api/capabilities", get(server_capabilities))
247 .route(
248 "/api/capabilities/chain-limits",
249 get(capabilities_chain_limits),
250 )
251 .route("/api/shutdown", post(shutdown_server))
252 .route(
254 "/api/config/model/:name/placement",
255 axum::routing::put(put_model_placement).delete(delete_model_placement),
256 )
257 .route("/health", get(health))
258 .with_state(state)
259 .route("/api/openapi.json", get(openapi_json))
260 .route("/api/docs", get(scalar_docs))
261}
262
263fn sse_message_to_event(msg: SseMessage) -> SseEvent {
266 fn serialize_event<T: Serialize>(event_name: &str, payload: &T) -> SseEvent {
267 match serde_json::to_string(payload) {
268 Ok(data) => SseEvent::default().event(event_name).data(data),
269 Err(err) => SseEvent::default().event("error").data(
270 serde_json::json!({
271 "message": format!("failed to serialize SSE payload: {err}")
272 })
273 .to_string(),
274 ),
275 }
276 }
277
278 match msg {
279 SseMessage::Progress(payload) => serialize_event("progress", &payload),
280 SseMessage::Complete(payload) => serialize_event("complete", &payload),
281 SseMessage::UpscaleComplete(payload) => serialize_event("complete", &payload),
282 SseMessage::Error(payload) => serialize_event("error", &payload),
283 }
284}
285
286#[cfg(test)]
287fn save_image_to_dir(
288 dir: &std::path::Path,
289 img: &mold_core::ImageData,
290 model: &str,
291 batch_size: u32,
292) {
293 if let Err(e) = std::fs::create_dir_all(dir) {
294 tracing::warn!("failed to create output dir {}: {e}", dir.display());
295 return;
296 }
297 let timestamp_ms = std::time::SystemTime::now()
300 .duration_since(std::time::UNIX_EPOCH)
301 .unwrap_or_default()
302 .as_millis() as u64;
303 let ext = img.format.to_string();
304 let filename =
305 mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
306 let path = dir.join(&filename);
307 match std::fs::write(&path, &img.data) {
308 Ok(()) => tracing::info!("saved image to {}", path.display()),
309 Err(e) => tracing::warn!("failed to save image to {}: {e}", path.display()),
310 }
311}
312
313async fn prepare_generation(
321 state: &AppState,
322 request: &mut mold_core::GenerateRequest,
323) -> Result<(Option<std::path::PathBuf>, Option<String>), ApiError> {
324 apply_default_metadata_setting(state, request).await;
329
330 let preferred_gpu = validate_multi_gpu_placement(state, request.placement.as_ref())?;
331
332 maybe_expand_prompt(state, request, preferred_gpu).await?;
334
335 if let Err(e) = model_manager::install_catalog_model(state, &request.model).await {
345 return Err(model_manager::install_error_to_api_error(&e));
346 }
347 let family_hint = model_manager::catalog_family_for(state, &request.model).await;
348
349 let resolved_family = model_manager::family_for_model(state, &request.model).await;
353 request.normalise_output_format(resolved_family.as_deref());
357
358 if let Err(e) = validate_generate_request(request, family_hint.as_deref()) {
359 return Err(ApiError::validation(e));
360 }
361
362 let _ = model_manager::check_model_available(state, &request.model).await?;
363
364 let (output_dir, dim_warning) = {
365 let config = state.config.read().await;
366 let output_dir = if config.is_output_disabled() {
367 None
368 } else {
369 Some(config.effective_output_dir())
370 };
371 let family = config.resolved_model_config(&request.model).family;
372 let dim_warning = family
373 .as_deref()
374 .and_then(|f| mold_core::dimension_warning(request.width, request.height, f));
375 (output_dir, dim_warning)
376 };
377
378 Ok((output_dir, dim_warning))
379}
380
381fn active_gpu_selection(state: &AppState) -> GpuSelection {
382 let ordinals: Vec<usize> = state
383 .gpu_pool
384 .workers
385 .iter()
386 .map(|w| w.gpu.ordinal)
387 .collect();
388 if ordinals.is_empty() {
389 GpuSelection::All
390 } else {
391 GpuSelection::Specific(ordinals)
392 }
393}
394
395fn validate_multi_gpu_placement(
396 state: &AppState,
397 placement: Option<&mold_core::types::DevicePlacement>,
398) -> Result<Option<usize>, ApiError> {
399 state
400 .gpu_pool
401 .resolve_explicit_placement_gpu(placement)
402 .map_err(ApiError::validation)
403}
404
405fn select_aux_worker(
406 state: &AppState,
407) -> Result<std::sync::Arc<crate::gpu_pool::GpuWorker>, ApiError> {
408 let mut workers: Vec<_> = state
409 .gpu_pool
410 .workers
411 .iter()
412 .filter(|w| !w.is_degraded())
413 .cloned()
414 .collect();
415 workers.sort_by_key(|w| {
416 (
417 w.in_flight.load(Ordering::SeqCst),
418 Reverse(w.gpu.total_vram_bytes),
419 )
420 });
421 workers
422 .into_iter()
423 .next()
424 .ok_or_else(|| ApiError::internal("no GPU worker available for auxiliary workload"))
425}
426
427fn clear_global_upscaler_cache(state: &AppState) {
428 if let Ok(mut cache) = state.upscaler_cache.try_lock() {
429 if cache.is_some() {
430 *cache = None;
431 tracing::info!("upscaler cache cleared");
432 }
433 }
434}
435
436#[utoipa::path(
439 post,
440 path = "/api/generate",
441 tag = "generation",
442 request_body = mold_core::GenerateRequest,
443 responses(
444 (status = 200, description = "Generated image bytes", content_type = "image/png"),
445 (status = 404, description = "Model not downloaded"),
446 (status = 422, description = "Invalid request parameters"),
447 (status = 500, description = "Inference error"),
448 (status = 503, description = "Generation queue full"),
449 )
450)]
451async fn generate(
454 State(state): State<AppState>,
455 Json(mut req): Json<mold_core::GenerateRequest>,
456) -> Result<impl IntoResponse, ApiError> {
457 let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
458
459 tracing::info!(
460 model = %req.model,
461 prompt = %req.prompt,
462 width = req.width,
463 height = req.height,
464 steps = req.steps,
465 guidance = req.guidance,
466 seed = ?req.seed,
467 format = %req.resolved_output_format(),
468 lora = ?req.lora.as_ref().map(|l| &l.path),
469 lora_scale = ?req.lora.as_ref().map(|l| l.scale),
470 loras = ?req.loras.as_ref().map(|v| v.iter().map(|l| &l.path).collect::<Vec<_>>()),
471 "generate request"
472 );
473
474 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
476 let job_id = uuid::Uuid::new_v4().to_string();
481 state.job_registry.register(&job_id, &req.model);
482 let job = GenerationJob {
483 id: job_id.clone(),
484 request: req,
485 progress_tx: None,
486 result_tx,
487 output_dir,
488 };
489
490 let _position = state
491 .queue
492 .submit(job, state.queue_capacity)
493 .await
494 .inspect_err(|_| state.job_registry.remove(&job_id))
495 .map_err(submit_error_to_api)?;
496
497 let result = result_rx
499 .await
500 .map_err(|_| ApiError::internal("generation queue worker dropped the job"))?;
501
502 match result {
503 Ok(job_result) => {
504 let img = job_result.image;
505 let response = job_result.response;
506 let content_type = HeaderValue::from_static(img.format.content_type());
507 let mut headers = HeaderMap::new();
508 headers.insert(header::CONTENT_TYPE, content_type);
509 headers.insert(
510 "x-mold-seed-used",
511 HeaderValue::from_str(&response.seed_used.to_string()).map_err(|e| {
512 ApiError::internal(format!("failed to serialize seed header: {e}"))
513 })?,
514 );
515 if let Some(ordinal) = response.gpu {
516 headers.insert(
517 "x-mold-gpu",
518 HeaderValue::from_str(&ordinal.to_string()).map_err(|e| {
519 ApiError::internal(format!("failed to serialize gpu header: {e}"))
520 })?,
521 );
522 }
523 if let Some(warning) = dim_warning {
524 match HeaderValue::from_str(&warning.replace('\n', " ")) {
525 Ok(val) => {
526 headers.insert("x-mold-dimension-warning", val);
527 }
528 Err(e) => {
529 tracing::warn!("dimension warning could not be encoded as header: {e}");
530 }
531 }
532 }
533 let output_data = if let Some(ref video) = response.video {
536 let ct = HeaderValue::from_static(video.format.content_type());
537 headers.insert(header::CONTENT_TYPE, ct);
538 if let Ok(v) = HeaderValue::from_str(&video.frames.to_string()) {
539 headers.insert("x-mold-video-frames", v);
540 }
541 if let Ok(v) = HeaderValue::from_str(&video.fps.to_string()) {
542 headers.insert("x-mold-video-fps", v);
543 }
544 if let Ok(v) = HeaderValue::from_str(&video.width.to_string()) {
545 headers.insert("x-mold-video-width", v);
546 }
547 if let Ok(v) = HeaderValue::from_str(&video.height.to_string()) {
548 headers.insert("x-mold-video-height", v);
549 }
550 if video.has_audio {
551 headers.insert("x-mold-video-has-audio", HeaderValue::from_static("1"));
552 }
553 if let Some(dur) = video.duration_ms {
554 if let Ok(v) = HeaderValue::from_str(&dur.to_string()) {
555 headers.insert("x-mold-video-duration-ms", v);
556 }
557 }
558 if let Some(sr) = video.audio_sample_rate {
559 if let Ok(v) = HeaderValue::from_str(&sr.to_string()) {
560 headers.insert("x-mold-video-audio-sample-rate", v);
561 }
562 }
563 if let Some(ch) = video.audio_channels {
564 if let Ok(v) = HeaderValue::from_str(&ch.to_string()) {
565 headers.insert("x-mold-video-audio-channels", v);
566 }
567 }
568 video.data.clone()
569 } else {
570 img.data
571 };
572 Ok((headers, output_data))
573 }
574 Err(err_msg) => {
575 if err_msg.contains("queue is full") {
579 Err(ApiError::queue_full(err_msg))
580 } else {
581 Err(ApiError::inference(err_msg))
582 }
583 }
584 }
585}
586
587fn validate_generate_request(
588 req: &mold_core::GenerateRequest,
589 family_hint: Option<&str>,
590) -> Result<(), String> {
591 mold_core::validate_generate_request_with_family(req, family_hint)
592}
593
594async fn apply_default_metadata_setting(state: &AppState, req: &mut mold_core::GenerateRequest) {
595 if req.embed_metadata.is_some() {
596 return;
597 }
598
599 let config = state.config.read().await;
600 req.embed_metadata = Some(config.effective_embed_metadata(None));
601}
602
603async fn maybe_expand_prompt(
605 state: &AppState,
606 req: &mut mold_core::GenerateRequest,
607 preferred_gpu: Option<usize>,
608) -> Result<(), ApiError> {
609 if req.expand != Some(true) {
610 return Ok(());
611 }
612
613 let config = state.config.read().await;
614 let config_snapshot = config.clone();
615 let expand_settings = config.expand.clone().with_env_overrides();
616
617 let model_family = config
619 .resolved_model_config(&req.model)
620 .family
621 .or_else(|| mold_core::manifest::find_manifest(&req.model).map(|m| m.family.clone()))
622 .unwrap_or_else(|| {
623 tracing::warn!(
624 model = %req.model,
625 "could not resolve model family for prompt expansion, defaulting to \"flux\""
626 );
627 "flux".to_string()
628 });
629
630 let expand_config = expand_settings.to_expand_config(&model_family, 1);
631 let original_prompt = req.prompt.clone();
632
633 drop(config);
635
636 let expander = create_server_expander(
637 &config_snapshot,
638 &expand_settings,
639 active_gpu_selection(state),
640 preferred_gpu,
641 )?;
642 let result =
643 tokio::task::spawn_blocking(move || expander.expand(&original_prompt, &expand_config))
644 .await
645 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
646 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
647
648 if let Some(expanded) = result.expanded.first() {
649 req.original_prompt = Some(req.prompt.clone());
650 req.prompt = expanded.clone();
651 }
652
653 Ok(())
654}
655
656fn create_server_expander(
658 _config: &mold_core::Config,
659 settings: &mold_core::ExpandSettings,
660 _gpu_selection: GpuSelection,
661 _preferred_gpu: Option<usize>,
662) -> Result<Box<dyn mold_core::PromptExpander>, ApiError> {
663 if let Some(api_expander) = settings.create_api_expander() {
664 return Ok(Box::new(api_expander));
665 }
666
667 #[cfg(feature = "expand")]
668 {
669 if let Some(local) =
670 mold_inference::expand::LocalExpander::from_config(_config, Some(&settings.model))
671 {
672 return Ok(Box::new(
673 local
674 .with_gpu_selection(_gpu_selection)
675 .with_preferred_gpu(_preferred_gpu),
676 ));
677 }
678 return Err(ApiError::validation(
679 "local expand model not found — run: mold pull qwen3-expand".to_string(),
680 ));
681 }
682
683 #[cfg(not(feature = "expand"))]
684 {
685 Err(ApiError::validation(
686 "local prompt expansion not available — built without expand feature. \
687 Configure an API backend in [expand] settings."
688 .to_string(),
689 ))
690 }
691}
692
693#[utoipa::path(
696 post,
697 path = "/api/expand",
698 tag = "generation",
699 request_body = mold_core::ExpandRequest,
700 responses(
701 (status = 200, description = "Expanded prompt(s)", body = mold_core::ExpandResponse),
702 (status = 422, description = "Invalid request parameters"),
703 (status = 500, description = "Expansion failed"),
704 )
705)]
706async fn expand_prompt(
707 State(state): State<AppState>,
708 Json(req): Json<mold_core::ExpandRequest>,
709) -> Result<Json<mold_core::ExpandResponse>, ApiError> {
710 if req.variations == 0 || req.variations > mold_core::expand::MAX_VARIATIONS {
711 return Err(ApiError::validation(format!(
712 "variations must be between 1 and {}",
713 mold_core::expand::MAX_VARIATIONS,
714 )));
715 }
716
717 let config = state.config.read().await;
718 let expand_settings = config.expand.clone().with_env_overrides();
719 let expand_config = expand_settings.to_expand_config(&req.model_family, req.variations);
720 let prompt = req.prompt.clone();
721 let config_snapshot = config.clone();
722 drop(config);
723
724 let expander = create_server_expander(
725 &config_snapshot,
726 &expand_settings,
727 active_gpu_selection(&state),
728 None,
729 )?;
730 let result = tokio::task::spawn_blocking(move || expander.expand(&prompt, &expand_config))
731 .await
732 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
733 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
734
735 Ok(Json(mold_core::ExpandResponse {
736 original: req.prompt,
737 expanded: result.expanded,
738 }))
739}
740
741async fn upscale(
744 State(state): State<AppState>,
745 Json(req): Json<mold_core::UpscaleRequest>,
746) -> Result<Json<mold_core::UpscaleResponse>, ApiError> {
747 if let Err(msg) = mold_core::validate_upscale_request(&req) {
748 return Err(ApiError::validation(msg));
749 }
750
751 let model_name = mold_core::manifest::resolve_model_name(&req.model);
752
753 let needs_pull = {
755 let config = state.config.read().await;
756 config
757 .models
758 .get(&model_name)
759 .and_then(|c| c.transformer.as_ref())
760 .is_none()
761 };
762 if needs_pull {
763 if mold_core::manifest::find_manifest(&model_name).is_none() {
764 return Err(ApiError::not_found(format!(
765 "unknown upscaler model '{}'. Run 'mold list' to see available models.",
766 model_name
767 )));
768 }
769 model_manager::pull_model(&state, &model_name, None).await?;
770 }
771
772 let config = state.config.read().await;
773 let weights_path = config
774 .models
775 .get(&model_name)
776 .and_then(|c| c.transformer.as_ref())
777 .ok_or_else(|| {
778 ApiError::not_found(format!(
779 "upscaler model '{}' not configured after pull",
780 model_name
781 ))
782 })?;
783 let weights_path = std::path::PathBuf::from(weights_path);
784 let model_name_owned = model_name.clone();
785 drop(config);
786
787 let resp = if state.gpu_pool.worker_count() > 0 {
788 let worker = select_aux_worker(&state)?;
789 worker.in_flight.fetch_add(1, Ordering::SeqCst);
790 let worker_clone = worker.clone();
791 let result =
792 tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
793 struct ThreadGpuGuard;
794 impl Drop for ThreadGpuGuard {
795 fn drop(&mut self) {
796 mold_inference::device::clear_thread_gpu_ordinal();
797 }
798 }
799
800 mold_inference::device::init_thread_gpu_ordinal(worker_clone.gpu.ordinal);
801 let _thread_gpu = ThreadGpuGuard;
802 let _load_lock = worker_clone.model_load_lock.lock().unwrap();
803 let mut engine = mold_inference::create_upscale_engine(
804 model_name_owned,
805 weights_path,
806 mold_inference::LoadStrategy::Eager,
807 worker_clone.gpu.ordinal,
808 )?;
809 engine.upscale(&req)
810 })
811 .await
812 .map_err(|e| ApiError::internal(format!("upscale task panicked: {e}")));
813 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
814 result?.map_err(|e| ApiError::internal(format!("upscale failed: {e}")))?
815 } else {
816 let upscaler_cache = state.upscaler_cache.clone();
817 tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
818 let mut cache = upscaler_cache.lock().unwrap_or_else(|e| e.into_inner());
819
820 let needs_new = cache
822 .as_ref()
823 .is_none_or(|e| e.model_name() != model_name_owned);
824 if needs_new {
825 let new_engine = mold_inference::create_upscale_engine(
826 model_name_owned,
827 weights_path,
828 mold_inference::LoadStrategy::Eager,
829 0,
830 )?;
831 *cache = Some(new_engine);
832 }
833
834 cache.as_mut().unwrap().upscale(&req)
835 })
836 .await
837 .map_err(|e| ApiError::internal(format!("upscale task panicked: {e}")))?
838 .map_err(|e| ApiError::internal(format!("upscale failed: {e}")))?
839 };
840
841 Ok(Json(resp))
842}
843
844async fn upscale_stream(
847 State(state): State<AppState>,
848 Json(req): Json<mold_core::UpscaleRequest>,
849) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
850 if let Err(msg) = mold_core::validate_upscale_request(&req) {
851 return Err(ApiError::validation(msg));
852 }
853
854 let model_name = mold_core::manifest::resolve_model_name(&req.model);
855
856 let needs_pull = {
858 let config = state.config.read().await;
859 config
860 .models
861 .get(&model_name)
862 .and_then(|c| c.transformer.as_ref())
863 .is_none()
864 };
865
866 if needs_pull && mold_core::manifest::find_manifest(&model_name).is_none() {
868 return Err(ApiError::not_found(format!(
869 "unknown upscaler model '{}'. Run 'mold list' to see available models.",
870 model_name
871 )));
872 }
873
874 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
875 let model_name_owned = model_name.clone();
876 let state_clone = state.clone();
877 let upscaler_cache = state.upscaler_cache.clone();
878
879 tokio::spawn(async move {
880 if needs_pull {
882 let progress_tx = tx.clone();
883 let callback =
884 std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
885 let sse_event = match event {
886 mold_core::download::DownloadProgressEvent::Status { message } => {
887 SseProgressEvent::Info { message }
888 }
889 mold_core::download::DownloadProgressEvent::FileStart {
890 filename,
891 file_index,
892 total_files,
893 size_bytes,
894 batch_bytes_downloaded,
895 batch_bytes_total,
896 batch_elapsed_ms,
897 } => SseProgressEvent::DownloadProgress {
898 filename,
899 file_index,
900 total_files,
901 bytes_downloaded: 0,
902 bytes_total: size_bytes,
903 batch_bytes_downloaded,
904 batch_bytes_total,
905 batch_elapsed_ms,
906 },
907 mold_core::download::DownloadProgressEvent::FileProgress {
908 filename,
909 file_index,
910 bytes_downloaded,
911 bytes_total,
912 batch_bytes_downloaded,
913 batch_bytes_total,
914 batch_elapsed_ms,
915 } => SseProgressEvent::DownloadProgress {
916 filename,
917 file_index,
918 total_files: 0,
919 bytes_downloaded,
920 bytes_total,
921 batch_bytes_downloaded,
922 batch_bytes_total,
923 batch_elapsed_ms,
924 },
925 mold_core::download::DownloadProgressEvent::FileDone {
926 filename,
927 file_index,
928 total_files,
929 batch_bytes_downloaded,
930 batch_bytes_total,
931 batch_elapsed_ms,
932 } => SseProgressEvent::DownloadDone {
933 filename,
934 file_index,
935 total_files,
936 batch_bytes_downloaded,
937 batch_bytes_total,
938 batch_elapsed_ms,
939 },
940 };
941 let _ = progress_tx.send(SseMessage::Progress(sse_event));
942 });
943
944 match model_manager::pull_model(&state_clone, &model_name_owned, Some(callback)).await {
945 Ok(_) => {
946 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
947 model: model_name_owned.clone(),
948 }));
949 }
950 Err(e) => {
951 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
952 message: format!("failed to pull upscaler model: {}", e.error),
953 }));
954 return;
955 }
956 }
957 }
958
959 let weights_path = {
961 let config = state_clone.config.read().await;
962 config
963 .models
964 .get(&model_name_owned)
965 .and_then(|c| c.transformer.as_ref())
966 .map(std::path::PathBuf::from)
967 };
968
969 let Some(weights_path) = weights_path else {
970 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
971 message: format!(
972 "upscaler model '{}' not configured after pull",
973 model_name_owned
974 ),
975 }));
976 return;
977 };
978
979 let result = if state_clone.gpu_pool.worker_count() > 0 {
980 match select_aux_worker(&state_clone) {
981 Ok(worker) => {
982 worker.in_flight.fetch_add(1, Ordering::SeqCst);
983 let worker_clone = worker.clone();
984 let tx_for_worker = tx.clone();
985 let model_name_for_worker = model_name_owned.clone();
986 let weights_path_for_worker = weights_path.clone();
987 let req_for_worker = req.clone();
988 let result = tokio::task::spawn_blocking(move || {
989 struct ThreadGpuGuard;
990 impl Drop for ThreadGpuGuard {
991 fn drop(&mut self) {
992 mold_inference::device::clear_thread_gpu_ordinal();
993 }
994 }
995
996 mold_inference::device::init_thread_gpu_ordinal(worker_clone.gpu.ordinal);
997 let _thread_gpu = ThreadGpuGuard;
998 let _load_lock = worker_clone.model_load_lock.lock().unwrap();
999 let _ = tx_for_worker.send(SseMessage::Progress(
1000 mold_core::SseProgressEvent::StageStart {
1001 name: format!(
1002 "Loading upscaler model on GPU {}",
1003 worker_clone.gpu.ordinal
1004 ),
1005 },
1006 ));
1007 let mut engine = match mold_inference::create_upscale_engine(
1008 model_name_for_worker,
1009 weights_path_for_worker,
1010 mold_inference::LoadStrategy::Eager,
1011 worker_clone.gpu.ordinal,
1012 ) {
1013 Ok(engine) => engine,
1014 Err(e) => {
1015 let _ = tx_for_worker.send(SseMessage::Error(
1016 mold_core::SseErrorEvent {
1017 message: format!("failed to load upscaler: {e}"),
1018 },
1019 ));
1020 return;
1021 }
1022 };
1023
1024 let tx_progress = tx_for_worker.clone();
1025 engine.set_on_progress(Box::new(move |event| {
1026 let sse_event: mold_core::SseProgressEvent = event.into();
1027 let _ = tx_progress.send(SseMessage::Progress(sse_event));
1028 }));
1029
1030 match engine.upscale(&req_for_worker) {
1031 Ok(resp) => {
1032 let image_b64 = base64::engine::general_purpose::STANDARD
1033 .encode(&resp.image.data);
1034 let _ = tx_for_worker.send(SseMessage::UpscaleComplete(
1035 mold_core::SseUpscaleCompleteEvent {
1036 image: image_b64,
1037 format: resp.image.format,
1038 model: resp.model,
1039 scale_factor: resp.scale_factor,
1040 original_width: resp.original_width,
1041 original_height: resp.original_height,
1042 upscale_time_ms: resp.upscale_time_ms,
1043 },
1044 ));
1045 }
1046 Err(e) => {
1047 let _ = tx_for_worker.send(SseMessage::Error(
1048 mold_core::SseErrorEvent {
1049 message: format!("upscale failed: {e}"),
1050 },
1051 ));
1052 }
1053 }
1054
1055 engine.clear_on_progress();
1056 })
1057 .await;
1058 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1059 result
1060 }
1061 Err(e) => {
1062 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
1063 message: e.error,
1064 }));
1065 return;
1066 }
1067 }
1068 } else {
1069 let model_name_for_cache = model_name_owned.clone();
1070 let weights_path_for_cache = weights_path.clone();
1071 let req_for_cache = req.clone();
1072 tokio::task::spawn_blocking(move || {
1073 let mut cache = upscaler_cache.lock().unwrap();
1074
1075 let needs_new = cache
1076 .as_ref()
1077 .is_none_or(|e| e.model_name() != model_name_for_cache);
1078 if needs_new {
1079 let _ = tx.send(SseMessage::Progress(
1080 mold_core::SseProgressEvent::StageStart {
1081 name: "Loading upscaler model".to_string(),
1082 },
1083 ));
1084 match mold_inference::create_upscale_engine(
1085 model_name_for_cache,
1086 weights_path_for_cache,
1087 mold_inference::LoadStrategy::Eager,
1088 0,
1089 ) {
1090 Ok(new_engine) => {
1091 *cache = Some(new_engine);
1092 }
1093 Err(e) => {
1094 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
1095 message: format!("failed to load upscaler: {e}"),
1096 }));
1097 return;
1098 }
1099 }
1100 }
1101
1102 let engine = cache.as_mut().unwrap();
1103 let tx_progress = tx.clone();
1104 engine.set_on_progress(Box::new(move |event| {
1105 let sse_event: mold_core::SseProgressEvent = event.into();
1106 let _ = tx_progress.send(SseMessage::Progress(sse_event));
1107 }));
1108
1109 match engine.upscale(&req_for_cache) {
1110 Ok(resp) => {
1111 let image_b64 =
1112 base64::engine::general_purpose::STANDARD.encode(&resp.image.data);
1113 let _ = tx.send(SseMessage::UpscaleComplete(
1114 mold_core::SseUpscaleCompleteEvent {
1115 image: image_b64,
1116 format: resp.image.format,
1117 model: resp.model,
1118 scale_factor: resp.scale_factor,
1119 original_width: resp.original_width,
1120 original_height: resp.original_height,
1121 upscale_time_ms: resp.upscale_time_ms,
1122 },
1123 ));
1124 }
1125 Err(e) => {
1126 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
1127 message: format!("upscale failed: {e}"),
1128 }));
1129 }
1130 }
1131
1132 engine.clear_on_progress();
1133 })
1134 .await
1135 };
1136
1137 if let Err(e) = result {
1138 tracing::error!("upscale task panicked: {e}");
1139 }
1140 });
1141
1142 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
1143 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
1144
1145 Ok(Sse::new(stream).keep_alive(
1146 KeepAlive::new()
1147 .interval(std::time::Duration::from_secs(15))
1148 .text("ping"),
1149 ))
1150}
1151
1152#[utoipa::path(
1155 post,
1156 path = "/api/generate/stream",
1157 tag = "generation",
1158 request_body = mold_core::GenerateRequest,
1159 responses(
1160 (status = 200, description = "SSE event stream with progress and result"),
1161 (status = 404, description = "Model not downloaded"),
1162 (status = 422, description = "Invalid request parameters"),
1163 (status = 500, description = "Inference error"),
1164 (status = 503, description = "Generation queue full"),
1165 )
1166)]
1167async fn generate_stream(
1168 State(state): State<AppState>,
1169 Json(mut req): Json<mold_core::GenerateRequest>,
1170) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
1171 let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
1172
1173 tracing::info!(
1174 model = %req.model,
1175 prompt = %req.prompt,
1176 "generate/stream request"
1177 );
1178
1179 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
1181
1182 if let Some(warning) = dim_warning {
1184 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Info {
1185 message: warning,
1186 }));
1187 }
1188
1189 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
1190 let job_id = uuid::Uuid::new_v4().to_string();
1193 state.job_registry.register(&job_id, &req.model);
1194 let job = GenerationJob {
1195 id: job_id.clone(),
1196 request: req,
1197 progress_tx: Some(tx.clone()),
1198 result_tx,
1199 output_dir,
1200 };
1201
1202 let position = state
1203 .queue
1204 .submit(job, state.queue_capacity)
1205 .await
1206 .inspect_err(|_| state.job_registry.remove(&job_id))
1207 .map_err(submit_error_to_api)?;
1208
1209 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued {
1212 position,
1213 id: job_id,
1214 }));
1215
1216 tokio::spawn(async move {
1220 let _ = result_rx.await;
1221 drop(tx); });
1223
1224 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
1226 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
1227
1228 Ok(Sse::new(stream).keep_alive(
1229 KeepAlive::new()
1230 .interval(std::time::Duration::from_secs(15))
1231 .text("ping"),
1232 ))
1233}
1234
1235#[utoipa::path(
1238 get,
1239 path = "/api/models",
1240 tag = "models",
1241 responses(
1242 (status = 200, description = "List of available models", body = Vec<ModelInfoExtended>),
1243 )
1244)]
1245async fn list_models(State(state): State<AppState>) -> Json<Vec<ModelInfoExtended>> {
1246 Json(model_manager::list_models(&state).await)
1247}
1248
1249#[derive(Debug, Deserialize, utoipa::ToSchema)]
1252pub struct LoadModelBody {
1253 #[schema(example = "flux-schnell:q8")]
1254 pub model: String,
1255 #[serde(default, skip_serializing_if = "Option::is_none")]
1258 pub gpu: Option<usize>,
1259}
1260
1261#[utoipa::path(
1262 post,
1263 path = "/api/models/load",
1264 tag = "models",
1265 request_body = LoadModelBody,
1266 responses(
1267 (status = 200, description = "Model loaded successfully"),
1268 (status = 404, description = "Model not downloaded"),
1269 (status = 400, description = "Unknown model"),
1270 (status = 500, description = "Failed to load model"),
1271 )
1272)]
1273async fn load_model(
1274 State(state): State<AppState>,
1275 Json(body): Json<LoadModelBody>,
1276) -> Result<impl IntoResponse, ApiError> {
1277 if state.gpu_pool.worker_count() > 0 {
1279 let worker = match body.gpu {
1280 Some(ordinal) => state
1281 .gpu_pool
1282 .workers
1283 .iter()
1284 .find(|w| w.gpu.ordinal == ordinal)
1285 .cloned()
1286 .ok_or_else(|| {
1287 ApiError::not_found(format!("no GPU worker with ordinal {ordinal}"))
1288 })?,
1289 None => {
1290 let est = crate::queue::estimate_model_vram(&body.model);
1291 state
1292 .gpu_pool
1293 .select_worker(&body.model, est)
1294 .ok_or_else(|| {
1295 ApiError::internal(format!(
1296 "no GPU available to load model '{}'",
1297 body.model
1298 ))
1299 })?
1300 }
1301 };
1302 let config_snapshot = state.config.read().await.clone();
1303 let model_name = body.model.clone();
1304 let worker_clone = worker.clone();
1305 tokio::task::spawn_blocking(move || {
1306 crate::gpu_worker::load_blocking(&worker_clone, &model_name, &config_snapshot)
1307 })
1308 .await
1309 .map_err(|e| ApiError::internal(format!("model load task failed: {e}")))?
1310 .map_err(|e| ApiError::internal(format!("model load error: {e}")))?;
1311
1312 tracing::info!(
1313 model = %body.model,
1314 gpu = worker.gpu.ordinal,
1315 "model loaded via API"
1316 );
1317 return Ok(StatusCode::OK);
1318 }
1319
1320 model_manager::ensure_model_ready(&state, &body.model, None, None).await?;
1323 tracing::info!(model = %body.model, gpu = ?body.gpu, "model loaded via API (legacy)");
1324 Ok(StatusCode::OK)
1325}
1326
1327#[utoipa::path(
1330 post,
1331 path = "/api/models/pull",
1332 tag = "models",
1333 request_body = LoadModelBody,
1334 responses(
1335 (status = 200, description = "Model pulled (SSE stream or plain text)"),
1336 (status = 400, description = "Unknown model"),
1337 (status = 500, description = "Download failed"),
1338 )
1339)]
1340async fn pull_model_endpoint(
1341 State(state): State<AppState>,
1342 headers: HeaderMap,
1343 Json(body): Json<LoadModelBody>,
1344) -> Result<impl IntoResponse, ApiError> {
1345 let wants_sse = headers
1346 .get(header::ACCEPT)
1347 .and_then(|v| v.to_str().ok())
1348 .is_some_and(|v| v.contains("text/event-stream"));
1349
1350 let (job_id, _position) = match state.downloads.enqueue(body.model.clone()).await {
1352 Ok((id, pos, _)) => (id, pos),
1353 Err(crate::downloads::EnqueueError::UnknownModel(_)) => {
1354 return Err(ApiError::unknown_model(format!(
1355 "unknown model '{}'. Run 'mold list' to see available models.",
1356 body.model
1357 )));
1358 }
1359 Err(crate::downloads::EnqueueError::LockPoisoned) => {
1360 return Err(ApiError::internal("download queue state is corrupt"));
1361 }
1362 };
1363
1364 if !wants_sse {
1365 let mut rx = state.downloads.subscribe();
1367 loop {
1368 match rx.recv().await {
1369 Ok(mold_core::types::DownloadEvent::JobDone { id, model }) if id == job_id => {
1370 return Ok(PullResponse::Text(format!(
1371 "model '{model}' pulled successfully"
1372 )));
1373 }
1374 Ok(mold_core::types::DownloadEvent::JobFailed { id, error }) if id == job_id => {
1375 return Err(ApiError::internal(format!(
1376 "failed to pull model '{}': {error}",
1377 body.model
1378 )));
1379 }
1380 Ok(mold_core::types::DownloadEvent::JobCancelled { id }) if id == job_id => {
1381 return Err(ApiError::internal(format!(
1382 "pull of '{}' was cancelled",
1383 body.model
1384 )));
1385 }
1386 Ok(_) => continue,
1387 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
1388 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1389 return Err(ApiError::internal("download queue channel closed"));
1390 }
1391 }
1392 }
1393 }
1394
1395 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
1398 let mut events = state.downloads.subscribe();
1399 let model_for_cb = body.model.clone();
1400 tokio::spawn(async move {
1401 loop {
1402 match events.recv().await {
1403 Ok(mold_core::types::DownloadEvent::Started {
1404 id,
1405 files_total,
1406 bytes_total,
1407 }) if id == job_id => {
1408 let _ = tx.send(SseMessage::Progress(SseProgressEvent::DownloadProgress {
1409 filename: String::new(),
1410 file_index: 0,
1411 total_files: files_total,
1412 bytes_downloaded: 0,
1413 bytes_total,
1414 batch_bytes_downloaded: 0,
1415 batch_bytes_total: bytes_total,
1416 batch_elapsed_ms: 0,
1417 }));
1418 }
1419 Ok(mold_core::types::DownloadEvent::Progress {
1420 id,
1421 files_done,
1422 bytes_done,
1423 current_file,
1424 }) if id == job_id => {
1425 let _ = tx.send(SseMessage::Progress(SseProgressEvent::DownloadProgress {
1426 filename: current_file.unwrap_or_default(),
1427 file_index: files_done,
1428 total_files: 0,
1429 bytes_downloaded: bytes_done,
1430 bytes_total: 0,
1431 batch_bytes_downloaded: bytes_done,
1432 batch_bytes_total: 0,
1433 batch_elapsed_ms: 0,
1434 }));
1435 }
1436 Ok(mold_core::types::DownloadEvent::JobDone { id, .. }) if id == job_id => {
1437 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
1438 model: model_for_cb.clone(),
1439 }));
1440 break;
1441 }
1442 Ok(mold_core::types::DownloadEvent::JobFailed { id, error }) if id == job_id => {
1443 let _ = tx.send(SseMessage::Error(SseErrorEvent { message: error }));
1444 break;
1445 }
1446 Ok(mold_core::types::DownloadEvent::JobCancelled { id }) if id == job_id => {
1447 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1448 message: "pull cancelled".into(),
1449 }));
1450 break;
1451 }
1452 Ok(_) => continue,
1453 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
1454 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1455 }
1456 }
1457 });
1458
1459 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
1460 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
1461
1462 Ok(PullResponse::Sse(
1463 Sse::new(stream)
1464 .keep_alive(
1465 KeepAlive::new()
1466 .interval(std::time::Duration::from_secs(15))
1467 .text("ping"),
1468 )
1469 .into_response(),
1470 ))
1471}
1472
1473enum PullResponse {
1475 Sse(axum::response::Response),
1476 Text(String),
1477}
1478
1479impl IntoResponse for PullResponse {
1480 fn into_response(self) -> axum::response::Response {
1481 match self {
1482 PullResponse::Sse(resp) => resp,
1483 PullResponse::Text(text) => text.into_response(),
1484 }
1485 }
1486}
1487
1488#[derive(Debug, Default, Deserialize, utoipa::ToSchema)]
1493pub struct UnloadRequest {
1494 #[serde(default, skip_serializing_if = "Option::is_none")]
1496 pub model: Option<String>,
1497 #[serde(default, skip_serializing_if = "Option::is_none")]
1499 pub gpu: Option<usize>,
1500}
1501
1502#[utoipa::path(
1503 delete,
1504 path = "/api/models/unload",
1505 tag = "models",
1506 request_body(content = Option<UnloadRequest>, content_type = "application/json"),
1507 responses(
1508 (status = 200, description = "Model unloaded or no model was loaded", body = String),
1509 )
1510)]
1511async fn unload_model(
1512 State(state): State<AppState>,
1513 body: Option<Json<UnloadRequest>>,
1514) -> Result<impl IntoResponse, ApiError> {
1515 let req = body.map(|b| b.0).unwrap_or_default();
1516 tracing::debug!(model = ?req.model, gpu = ?req.gpu, "unload request");
1517 clear_global_upscaler_cache(&state);
1518
1519 if state.gpu_pool.worker_count() > 0 {
1521 let targets: Vec<_> = match (req.gpu, req.model.as_deref()) {
1523 (Some(ordinal), _) => state
1524 .gpu_pool
1525 .workers
1526 .iter()
1527 .filter(|w| w.gpu.ordinal == ordinal)
1528 .cloned()
1529 .collect(),
1530 (None, Some(model)) => state
1531 .gpu_pool
1532 .workers
1533 .iter()
1534 .filter(|w| {
1535 let cache = w.model_cache.lock().unwrap();
1536 cache
1537 .get(model)
1538 .map(|e| e.residency == crate::model_cache::ModelResidency::Gpu)
1539 .unwrap_or(false)
1540 })
1541 .cloned()
1542 .collect(),
1543 (None, None) => state.gpu_pool.workers.clone(),
1544 };
1545
1546 if targets.is_empty() {
1547 return Ok((StatusCode::OK, "no model loaded".to_string()));
1548 }
1549
1550 let mut unloaded_pairs: Vec<(usize, String)> = Vec::new();
1551 for worker in targets {
1552 let worker_clone = worker.clone();
1553 let result = tokio::task::spawn_blocking(move || {
1554 crate::gpu_worker::unload_blocking(&worker_clone)
1555 })
1556 .await
1557 .map_err(|e| ApiError::internal(format!("unload task failed: {e}")))?;
1558 if let Some(name) = result {
1559 unloaded_pairs.push((worker.gpu.ordinal, name));
1560 }
1561 }
1562
1563 let msg = if unloaded_pairs.is_empty() {
1564 "no model loaded".to_string()
1565 } else {
1566 let joined: Vec<String> = unloaded_pairs
1567 .iter()
1568 .map(|(o, m)| format!("gpu{o}:{m}"))
1569 .collect();
1570 format!("unloaded {}", joined.join(", "))
1571 };
1572 return Ok((StatusCode::OK, msg));
1573 }
1574
1575 Ok((StatusCode::OK, model_manager::unload_model(&state).await))
1577}
1578
1579#[utoipa::path(
1582 get,
1583 path = "/api/status",
1584 tag = "server",
1585 responses(
1586 (status = 200, description = "Server status", body = ServerStatus),
1587 )
1588)]
1589async fn server_status(State(state): State<AppState>) -> Json<ServerStatus> {
1590 let gpu_statuses = state.gpu_pool.gpu_status();
1592 let has_gpus = !gpu_statuses.is_empty();
1593
1594 let gpu_models_loaded: Vec<String> = gpu_statuses
1596 .iter()
1597 .filter_map(|g| g.loaded_model.clone())
1598 .collect();
1599 let gpu_busy = gpu_statuses
1600 .iter()
1601 .any(|g| g.state == GpuWorkerState::Generating);
1602
1603 let multi_gpu_current_gen = if has_gpus {
1606 state.gpu_pool.workers.iter().find_map(|w| {
1607 let gen = w.active_generation.read().ok()?;
1608 gen.as_ref().map(|g| ActiveGenerationStatus {
1609 model: g.model.clone(),
1610 prompt_sha256: g.prompt_sha256.clone(),
1611 started_at_unix_ms: g.started_at_unix_ms,
1612 elapsed_ms: g.started_at.elapsed().as_millis() as u64,
1613 })
1614 })
1615 } else {
1616 None
1617 };
1618
1619 let (models_loaded, busy, current_generation) = if has_gpus {
1621 (gpu_models_loaded, gpu_busy, multi_gpu_current_gen)
1622 } else {
1623 let snapshot = state.model_cache.lock().await.snapshot();
1624 let models = match (snapshot.model_name, snapshot.is_loaded) {
1625 (Some(model_name), true) => vec![model_name],
1626 _ => vec![],
1627 };
1628 let gen = state
1629 .active_generation
1630 .read()
1631 .unwrap_or_else(|e| e.into_inner())
1632 .as_ref()
1633 .map(|active| ActiveGenerationStatus {
1634 model: active.model.clone(),
1635 prompt_sha256: active.prompt_sha256.clone(),
1636 started_at_unix_ms: active.started_at_unix_ms,
1637 elapsed_ms: active.started_at.elapsed().as_millis() as u64,
1638 });
1639 let is_busy = gen.is_some();
1640 (models, is_busy, gen)
1641 };
1642
1643 Json(ServerStatus {
1644 version: env!("CARGO_PKG_VERSION").to_string(),
1645 git_sha: if mold_core::build_info::GIT_SHA == "unknown" {
1646 None
1647 } else {
1648 Some(mold_core::build_info::GIT_SHA.to_string())
1649 },
1650 build_date: if mold_core::build_info::BUILD_DATE == "unknown" {
1651 None
1652 } else {
1653 Some(mold_core::build_info::BUILD_DATE.to_string())
1654 },
1655 models_loaded,
1656 busy,
1657 current_generation,
1658 gpu_info: query_gpu_info(),
1659 uptime_secs: state.start_time.elapsed().as_secs(),
1660 hostname: hostname::get().ok().and_then(|h| h.into_string().ok()),
1661 memory_status: mold_inference::device::memory_status_string(),
1662 gpus: if has_gpus { Some(gpu_statuses) } else { None },
1663 queue_depth: Some(state.queue.pending()),
1664 queue_capacity: Some(state.queue_capacity),
1665 })
1666}
1667
1668#[utoipa::path(
1671 get,
1672 path = "/health",
1673 tag = "server",
1674 responses(
1675 (status = 200, description = "Server is healthy"),
1676 )
1677)]
1678async fn health() -> impl IntoResponse {
1679 StatusCode::OK
1680}
1681
1682#[utoipa::path(
1689 get,
1690 path = "/api/queue",
1691 tag = "queue",
1692 responses(
1693 (status = 200, description = "Queue snapshot", body = crate::job_registry::QueueListing),
1694 )
1695)]
1696async fn list_queue(State(state): State<AppState>) -> Json<crate::job_registry::QueueListing> {
1697 Json(state.job_registry.snapshot())
1698}
1699
1700async fn server_capabilities() -> Json<mold_core::ServerCapabilities> {
1706 let catalog_available = std::env::var("MOLD_CATALOG_DISABLE")
1707 .map(|v| v != "1" && !v.eq_ignore_ascii_case("true"))
1708 .unwrap_or(true);
1709
1710 Json(mold_core::ServerCapabilities {
1711 gallery: mold_core::GalleryCapabilities { can_delete: true },
1712 catalog: mold_core::CatalogCapabilities {
1713 available: catalog_available,
1714 families: mold_catalog::families::ALL_FAMILIES
1715 .iter()
1716 .map(|f| f.as_str().to_string())
1717 .collect::<Vec<_>>(),
1718 },
1719 })
1720}
1721
1722#[utoipa::path(
1725 get,
1726 path = "/api/capabilities/chain-limits",
1727 tag = "server",
1728 params(
1729 ("model" = String, Query, description = "Model name (e.g. ltx-2-19b-distilled:fp8)")
1730 ),
1731 responses(
1732 (status = 200, description = "Chain limits for the requested model",
1733 body = crate::chain_limits::ChainLimits),
1734 (status = 400, description = "Missing required 'model' query parameter"),
1735 (status = 404, description = "Unknown or unsupported model"),
1736 )
1737)]
1738async fn capabilities_chain_limits(
1739 axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
1740) -> axum::response::Response {
1741 let raw_model = match params.get("model") {
1742 Some(m) => m.clone(),
1743 None => {
1744 return (
1745 StatusCode::BAD_REQUEST,
1746 "missing required 'model' query parameter\n",
1747 )
1748 .into_response();
1749 }
1750 };
1751
1752 let resolved = mold_core::manifest::resolve_model_name(&raw_model);
1753 let Some(manifest) = mold_core::manifest::find_manifest(&resolved) else {
1754 return (StatusCode::NOT_FOUND, "unknown model\n").into_response();
1755 };
1756 let family = manifest.family.clone();
1757
1758 if crate::chain_limits::family_cap(&family).is_none() {
1759 return (StatusCode::NOT_FOUND, "model is not chain-capable\n").into_response();
1760 }
1761
1762 let quant = resolved
1763 .split_once(':')
1764 .map(|(_, tag)| tag.to_string())
1765 .unwrap_or_default();
1766
1767 let limits = crate::chain_limits::compute_limits(&resolved, &family, &quant, 0);
1769 Json(limits).into_response()
1770}
1771
1772#[utoipa::path(
1780 post,
1781 path = "/api/shutdown",
1782 tag = "server",
1783 responses(
1784 (status = 200, description = "Shutdown initiated"),
1785 (status = 403, description = "Forbidden — remote shutdown requires API key auth"),
1786 )
1787)]
1788async fn shutdown_server(State(state): State<AppState>, request: Request) -> impl IntoResponse {
1789 let auth_enabled = request
1792 .extensions()
1793 .get::<crate::auth::AuthState>()
1794 .is_some_and(|s| s.is_some());
1795
1796 if !auth_enabled {
1797 let is_loopback = request
1798 .extensions()
1799 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
1800 .map(|ci| ci.0.ip().is_loopback())
1801 .unwrap_or(false);
1802 if !is_loopback {
1803 return (
1804 StatusCode::FORBIDDEN,
1805 "shutdown requires API key auth or localhost access\n",
1806 );
1807 }
1808 }
1809
1810 tracing::info!("shutdown requested via API");
1811 if let Some(tx) = state.shutdown_tx.lock().await.take() {
1812 let _ = tx.send(());
1813 }
1814 (StatusCode::OK, "shutdown initiated\n")
1815}
1816
1817async fn list_gallery(
1827 State(state): State<AppState>,
1828) -> Result<Json<Vec<mold_core::GalleryImage>>, ApiError> {
1829 let config = state.config.read().await;
1830 if config.is_output_disabled() {
1831 return Ok(Json(Vec::new()));
1832 }
1833 let output_dir = config.effective_output_dir();
1834 drop(config);
1835
1836 if !output_dir.is_dir() {
1837 return Ok(Json(Vec::new()));
1838 }
1839
1840 if state.metadata_db.is_some() {
1841 let db_arc = state.metadata_db.clone();
1842 let dir = output_dir.clone();
1843 let listed = tokio::task::spawn_blocking(move || {
1844 db_arc
1845 .as_ref()
1846 .as_ref()
1847 .map(|db| db.list(Some(&dir)))
1848 .transpose()
1849 })
1850 .await
1851 .map_err(|e| ApiError::internal(format!("gallery DB query failed: {e}")))?
1852 .map_err(|e| ApiError::internal(format!("gallery DB query failed: {e:#}")))?;
1853 if let Some(rows) = listed {
1854 if !rows.is_empty() {
1855 let images = rows.iter().map(|r| r.to_gallery_image()).collect();
1856 return Ok(Json(images));
1857 }
1858 }
1859 }
1860
1861 let images = tokio::task::spawn_blocking(move || scan_gallery_dir(&output_dir))
1862 .await
1863 .map_err(|e| ApiError::internal(format!("gallery scan failed: {e}")))?;
1864
1865 Ok(Json(images))
1866}
1867
1868async fn get_gallery_image(
1878 State(state): State<AppState>,
1879 headers: HeaderMap,
1880 axum::extract::Path(filename): axum::extract::Path<String>,
1881) -> Result<axum::response::Response, ApiError> {
1882 let config = state.config.read().await;
1883 if config.is_output_disabled() {
1884 return Err(ApiError::not_found("image output is disabled"));
1885 }
1886 let output_dir = config.effective_output_dir();
1887 drop(config);
1888
1889 let clean_name = std::path::Path::new(&filename)
1891 .file_name()
1892 .map(|f| f.to_string_lossy().to_string())
1893 .unwrap_or_default();
1894
1895 if clean_name.is_empty() || clean_name != filename {
1896 return Err(ApiError::validation("invalid filename"));
1897 }
1898
1899 let path = output_dir.join(&clean_name);
1900 let meta = match tokio::fs::metadata(&path).await {
1901 Ok(m) if m.is_file() => m,
1902 _ => {
1903 return Err(ApiError::not_found(format!(
1904 "image not found: {clean_name}"
1905 )));
1906 }
1907 };
1908 let total_len = meta.len();
1909 let content_type = content_type_for_filename(&clean_name);
1910
1911 let range_header = headers
1912 .get(header::RANGE)
1913 .and_then(|v| v.to_str().ok())
1914 .map(|s| s.to_string());
1915
1916 let file = tokio::fs::File::open(&path)
1917 .await
1918 .map_err(|e| ApiError::internal(format!("failed to open file: {e}")))?;
1919
1920 if let Some(raw) = range_header {
1921 if let Some((start, end)) = parse_byte_range(&raw, total_len) {
1922 return serve_range(file, start, end, total_len, content_type).await;
1923 } else {
1924 return Ok(axum::response::Response::builder()
1926 .status(StatusCode::RANGE_NOT_SATISFIABLE)
1927 .header(header::CONTENT_RANGE, format!("bytes */{total_len}"))
1928 .body(axum::body::Body::empty())
1929 .unwrap());
1930 }
1931 }
1932
1933 let stream = tokio_util::io::ReaderStream::new(file);
1935 let body = axum::body::Body::from_stream(stream);
1936 Ok(axum::response::Response::builder()
1937 .status(StatusCode::OK)
1938 .header(header::CONTENT_TYPE, content_type)
1939 .header(header::ACCEPT_RANGES, "bytes")
1940 .header(header::CONTENT_LENGTH, total_len)
1941 .header(header::CACHE_CONTROL, "public, max-age=3600")
1942 .body(body)
1943 .unwrap())
1944}
1945
1946fn parse_byte_range(header: &str, total_len: u64) -> Option<(u64, u64)> {
1954 let spec = header.strip_prefix("bytes=")?;
1955 if spec.contains(',') {
1956 return None;
1957 }
1958 let (start_s, end_s) = spec.split_once('-')?;
1959 let start_s = start_s.trim();
1960 let end_s = end_s.trim();
1961
1962 if total_len == 0 {
1963 return None;
1964 }
1965
1966 if start_s.is_empty() {
1967 let suffix: u64 = end_s.parse().ok()?;
1969 if suffix == 0 {
1970 return None;
1971 }
1972 let start = total_len.saturating_sub(suffix);
1973 return Some((start, total_len - 1));
1974 }
1975
1976 let start: u64 = start_s.parse().ok()?;
1977 if start >= total_len {
1978 return None;
1979 }
1980 let end: u64 = if end_s.is_empty() {
1981 total_len - 1
1982 } else {
1983 end_s.parse().ok()?
1984 };
1985 let end = end.min(total_len - 1);
1986 if end < start {
1987 return None;
1988 }
1989 Some((start, end))
1990}
1991
1992async fn serve_range(
1996 mut file: tokio::fs::File,
1997 start: u64,
1998 end: u64,
1999 total_len: u64,
2000 content_type: &'static str,
2001) -> Result<axum::response::Response, ApiError> {
2002 use tokio::io::{AsyncReadExt, AsyncSeekExt};
2003 file.seek(std::io::SeekFrom::Start(start))
2004 .await
2005 .map_err(|e| ApiError::internal(format!("seek failed: {e}")))?;
2006 let len = end - start + 1;
2007 let stream = tokio_util::io::ReaderStream::new(file.take(len));
2008 let body = axum::body::Body::from_stream(stream);
2009 Ok(axum::response::Response::builder()
2010 .status(StatusCode::PARTIAL_CONTENT)
2011 .header(header::CONTENT_TYPE, content_type)
2012 .header(header::ACCEPT_RANGES, "bytes")
2013 .header(header::CONTENT_LENGTH, len)
2014 .header(
2015 header::CONTENT_RANGE,
2016 format!("bytes {start}-{end}/{total_len}"),
2017 )
2018 .header(header::CACHE_CONTROL, "public, max-age=300")
2021 .body(body)
2022 .unwrap())
2023}
2024
2025fn content_type_for_filename(name: &str) -> &'static str {
2028 let lower = name.to_ascii_lowercase();
2029 if lower.ends_with(".png") {
2030 "image/png"
2031 } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
2032 "image/jpeg"
2033 } else if lower.ends_with(".gif") {
2034 "image/gif"
2035 } else if lower.ends_with(".webp") {
2036 "image/webp"
2037 } else if lower.ends_with(".apng") {
2038 "image/apng"
2039 } else if lower.ends_with(".mp4") {
2040 "video/mp4"
2041 } else {
2042 "application/octet-stream"
2043 }
2044}
2045
2046async fn delete_gallery_image(
2051 State(state): State<AppState>,
2052 axum::extract::Path(filename): axum::extract::Path<String>,
2053) -> Result<impl IntoResponse, ApiError> {
2054 let config = state.config.read().await;
2055 if config.is_output_disabled() {
2056 return Err(ApiError::not_found("image output is disabled"));
2057 }
2058 let output_dir = config.effective_output_dir();
2059 drop(config);
2060
2061 let clean_name = std::path::Path::new(&filename)
2062 .file_name()
2063 .map(|f| f.to_string_lossy().to_string())
2064 .unwrap_or_default();
2065
2066 if clean_name.is_empty() || clean_name != filename {
2067 return Err(ApiError::validation("invalid filename"));
2068 }
2069
2070 let path = output_dir.join(&clean_name);
2071 if path.is_file() {
2072 std::fs::remove_file(&path)
2073 .map_err(|e| ApiError::internal(format!("failed to delete image: {e}")))?;
2074 }
2075
2076 let thumb_dir = server_thumbnail_dir();
2081 let _ = std::fs::remove_file(thumb_dir.join(&clean_name));
2082 let _ = std::fs::remove_file(thumb_dir.join(format!("{clean_name}.png")));
2083 let _ =
2084 std::fs::remove_file(server_preview_gif_dir().join(format!("{clean_name}.preview.gif")));
2085
2086 if let Some(db) = state.metadata_db.as_ref().as_ref() {
2090 match db.delete(&output_dir, &clean_name) {
2091 Ok(true) => {}
2092 Ok(false) => tracing::debug!(
2093 "delete: no metadata row for {}",
2094 output_dir.join(&clean_name).display()
2095 ),
2096 Err(e) => tracing::warn!(
2097 "metadata DB delete failed for {}: {e:#}",
2098 output_dir.join(&clean_name).display()
2099 ),
2100 }
2101 }
2102
2103 Ok(StatusCode::NO_CONTENT)
2104}
2105
2106async fn get_gallery_thumbnail(
2109 State(state): State<AppState>,
2110 axum::extract::Path(filename): axum::extract::Path<String>,
2111) -> Result<impl IntoResponse, ApiError> {
2112 let config = state.config.read().await;
2113 if config.is_output_disabled() {
2114 return Err(ApiError::not_found("image output is disabled"));
2115 }
2116 let output_dir = config.effective_output_dir();
2117 drop(config);
2118
2119 let clean_name = std::path::Path::new(&filename)
2120 .file_name()
2121 .map(|f| f.to_string_lossy().to_string())
2122 .unwrap_or_default();
2123
2124 if clean_name.is_empty() || clean_name != filename {
2125 return Err(ApiError::validation("invalid filename"));
2126 }
2127
2128 let source_path = output_dir.join(&clean_name);
2129 if !source_path.is_file() {
2130 return Err(ApiError::not_found(format!(
2131 "image not found: {clean_name}"
2132 )));
2133 }
2134
2135 let thumb_dir = server_thumbnail_dir();
2139 let thumb_path = thumb_dir.join(format!("{clean_name}.png"));
2140 let lower = clean_name.to_ascii_lowercase();
2141 let is_video = lower.ends_with(".mp4");
2142
2143 if !thumb_path.is_file() {
2144 let source = source_path.clone();
2151 let dest = thumb_path.clone();
2152 let gen_result = tokio::task::spawn_blocking(move || {
2153 if is_video {
2154 generate_video_thumbnail(&source, &dest)
2155 } else {
2156 generate_server_thumbnail(&source, &dest)
2157 }
2158 })
2159 .await
2160 .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?;
2161
2162 if let Err(err) = gen_result {
2163 tracing::warn!(
2164 file = %clean_name,
2165 error = %err,
2166 "thumbnail decode failed; falling back to source bytes"
2167 );
2168 if is_video {
2172 let mut headers = HeaderMap::new();
2173 headers.insert(
2174 header::CONTENT_TYPE,
2175 HeaderValue::from_static("image/svg+xml"),
2176 );
2177 headers.insert(
2178 header::CACHE_CONTROL,
2179 HeaderValue::from_static("public, max-age=300"),
2180 );
2181 return Ok((headers, VIDEO_PLACEHOLDER_SVG.as_bytes().to_vec()));
2182 }
2183 let raw = tokio::fs::read(&source_path)
2184 .await
2185 .map_err(|e| ApiError::internal(format!("failed to read source: {e}")))?;
2186 let mut headers = HeaderMap::new();
2187 headers.insert(
2188 header::CONTENT_TYPE,
2189 HeaderValue::from_static(content_type_for_filename(&clean_name)),
2190 );
2191 headers.insert(
2192 header::CACHE_CONTROL,
2193 HeaderValue::from_static("public, max-age=300"),
2194 );
2195 return Ok((headers, raw));
2196 }
2197 }
2198
2199 let data = tokio::fs::read(&thumb_path)
2200 .await
2201 .map_err(|e| ApiError::internal(format!("failed to read thumbnail: {e}")))?;
2202
2203 let mut headers = HeaderMap::new();
2204 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
2205 headers.insert(
2206 header::CACHE_CONTROL,
2207 HeaderValue::from_static("public, max-age=3600"),
2208 );
2209
2210 Ok((headers, data))
2211}
2212
2213const VIDEO_PLACEHOLDER_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1e293b"/><stop offset="1" stop-color="#0f172a"/></linearGradient></defs><rect width="256" height="256" fill="url(#g)"/><circle cx="128" cy="128" r="52" fill="rgba(255,255,255,0.08)"/><polygon points="112,100 112,156 160,128" fill="rgba(226,232,240,0.85)"/></svg>"##;
2214
2215async fn get_gallery_preview(
2225 State(state): State<AppState>,
2226 axum::extract::Path(filename): axum::extract::Path<String>,
2227) -> Result<axum::response::Response, ApiError> {
2228 let config = state.config.read().await;
2229 if config.is_output_disabled() {
2230 return Err(ApiError::not_found("image output is disabled"));
2231 }
2232 let output_dir = config.effective_output_dir();
2233 drop(config);
2234
2235 let clean_name = std::path::Path::new(&filename)
2238 .file_name()
2239 .map(|f| f.to_string_lossy().to_string())
2240 .unwrap_or_default();
2241 if clean_name.is_empty() || clean_name != filename {
2242 return Err(ApiError::validation("invalid filename"));
2243 }
2244
2245 let source_path = output_dir.join(&clean_name);
2252 if !tokio::fs::metadata(&source_path)
2253 .await
2254 .map(|m| m.is_file())
2255 .unwrap_or(false)
2256 {
2257 return Err(ApiError::not_found(format!(
2258 "image not found: {clean_name}"
2259 )));
2260 }
2261
2262 let preview_path = server_preview_gif_dir().join(format!("{clean_name}.preview.gif"));
2263 let meta = match tokio::fs::metadata(&preview_path).await {
2264 Ok(m) if m.is_file() => m,
2265 _ => {
2266 return Err(ApiError::not_found(format!(
2267 "preview not found: {clean_name}"
2268 )));
2269 }
2270 };
2271 let total_len = meta.len();
2272
2273 let file = tokio::fs::File::open(&preview_path)
2274 .await
2275 .map_err(|e| ApiError::internal(format!("failed to open preview: {e}")))?;
2276 let stream = tokio_util::io::ReaderStream::new(file);
2277 let body = axum::body::Body::from_stream(stream);
2278 Ok(axum::response::Response::builder()
2279 .status(StatusCode::OK)
2280 .header(header::CONTENT_TYPE, "image/gif")
2281 .header(header::CONTENT_LENGTH, total_len)
2282 .header(header::CACHE_CONTROL, "public, max-age=3600")
2283 .body(body)
2284 .unwrap())
2285}
2286
2287fn server_preview_gif_dir() -> std::path::PathBuf {
2292 mold_core::Config::mold_dir()
2293 .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
2294 .join("cache")
2295 .join("previews")
2296}
2297
2298fn server_thumbnail_dir() -> std::path::PathBuf {
2300 mold_core::Config::mold_dir()
2301 .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
2302 .join("cache")
2303 .join("thumbnails")
2304}
2305
2306fn generate_server_thumbnail(
2310 source: &std::path::Path,
2311 dest: &std::path::Path,
2312) -> anyhow::Result<()> {
2313 let img = image::open(source)?;
2314 let thumb = img.thumbnail(256, 256);
2315 if let Some(parent) = dest.parent() {
2316 std::fs::create_dir_all(parent)?;
2317 }
2318 thumb.save_with_format(dest, image::ImageFormat::Png)?;
2319 Ok(())
2320}
2321
2322fn generate_video_thumbnail(
2330 source: &std::path::Path,
2331 dest: &std::path::Path,
2332) -> anyhow::Result<()> {
2333 if let Some(parent) = dest.parent() {
2334 std::fs::create_dir_all(parent)?;
2335 }
2336 let tmp = dest.with_extension("firstframe.png");
2341 mold_inference::ltx2::media::extract_thumbnail(source, &tmp)?;
2342 let decode_result = (|| -> anyhow::Result<()> {
2343 let img = image::open(&tmp)?;
2344 let thumb = img.thumbnail(256, 256);
2345 thumb.save_with_format(dest, image::ImageFormat::Png)?;
2346 Ok(())
2347 })();
2348 let _ = std::fs::remove_file(&tmp);
2349 decode_result
2350}
2351
2352pub fn spawn_thumbnail_warmup(config: &mold_core::Config) {
2354 if !thumbnail_warmup_enabled() {
2355 tracing::info!("thumbnail warmup disabled; thumbnails will be generated on demand");
2356 return;
2357 }
2358
2359 let output_dir = config.effective_output_dir();
2360 std::thread::spawn(move || {
2361 if !output_dir.is_dir() {
2362 return;
2363 }
2364 let thumb_dir = server_thumbnail_dir();
2365 let walker = walkdir::WalkDir::new(&output_dir).max_depth(1).into_iter();
2366 for entry in walker.filter_map(|e| e.ok()) {
2367 let path = entry.path();
2368 if !path.is_file() {
2369 continue;
2370 }
2371 let ext = path
2372 .extension()
2373 .and_then(|e| e.to_str())
2374 .map(|e| e.to_lowercase());
2375 let is_raster = matches!(
2376 ext.as_deref(),
2377 Some("png" | "jpg" | "jpeg" | "gif" | "apng" | "webp")
2378 );
2379 let is_video = matches!(ext.as_deref(), Some("mp4"));
2380 if !is_raster && !is_video {
2381 continue;
2382 }
2383 let filename = path
2384 .file_name()
2385 .map(|f| f.to_string_lossy().to_string())
2386 .unwrap_or_default();
2387 let thumb_path = thumb_dir.join(format!("{filename}.png"));
2388 if thumb_path.is_file() {
2389 continue;
2390 }
2391 let result = if is_video {
2392 generate_video_thumbnail(path, &thumb_path)
2393 } else {
2394 generate_server_thumbnail(path, &thumb_path)
2395 };
2396 if let Err(e) = result {
2397 tracing::warn!("failed to generate thumbnail for {}: {e}", path.display());
2398 }
2399 }
2400 tracing::info!("thumbnail warmup complete");
2401 });
2402}
2403
2404fn thumbnail_warmup_enabled() -> bool {
2405 std::env::var("MOLD_THUMBNAIL_WARMUP")
2406 .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
2407 .unwrap_or(false)
2408}
2409
2410fn scan_gallery_dir(dir: &std::path::Path) -> Vec<mold_core::GalleryImage> {
2429 let mut images = Vec::new();
2430
2431 let walker = walkdir::WalkDir::new(dir).max_depth(1).into_iter();
2432 for entry in walker.filter_map(|e| e.ok()) {
2433 let path = entry.path();
2434 if !path.is_file() {
2435 continue;
2436 }
2437
2438 let ext = path
2439 .extension()
2440 .and_then(|e| e.to_str())
2441 .map(|e| e.to_lowercase());
2442 let format = match ext.as_deref() {
2443 Some("png") => Some(mold_core::OutputFormat::Png),
2444 Some("jpg") | Some("jpeg") => Some(mold_core::OutputFormat::Jpeg),
2445 Some("gif") => Some(mold_core::OutputFormat::Gif),
2446 Some("apng") => Some(mold_core::OutputFormat::Apng),
2447 Some("webp") => Some(mold_core::OutputFormat::Webp),
2448 Some("mp4") => Some(mold_core::OutputFormat::Mp4),
2449 _ => None,
2450 };
2451 let Some(format) = format else { continue };
2452
2453 let fs_meta = entry.metadata().ok();
2454 let timestamp = fs_meta
2455 .as_ref()
2456 .and_then(|m| m.modified().ok())
2457 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2458 .map(|d| d.as_secs())
2459 .unwrap_or(0);
2460 let size_bytes = fs_meta.as_ref().map(|m| m.len()).unwrap_or(0);
2461
2462 if size_bytes < min_valid_size(format) {
2464 continue;
2465 }
2466
2467 let header_ok = match format {
2469 mold_core::OutputFormat::Mp4 => has_ftyp_box(path),
2470 _ => image_header_dims(path).is_some(),
2471 };
2472 if !header_ok {
2473 continue;
2474 }
2475
2476 if !matches!(format, mold_core::OutputFormat::Mp4)
2481 && is_probably_solid_black(path, format, size_bytes)
2482 {
2483 continue;
2484 }
2485
2486 let filename = path
2487 .file_name()
2488 .map(|f| f.to_string_lossy().to_string())
2489 .unwrap_or_default();
2490
2491 let embedded = match ext.as_deref() {
2494 Some("png") | Some("apng") => read_png_metadata(path),
2495 Some("jpg") | Some("jpeg") => read_jpeg_metadata(path),
2496 _ => None,
2497 };
2498
2499 let (metadata, synthetic) = match embedded {
2500 Some(m) => (m, false),
2501 None => {
2502 let mut meta = synthesize_metadata_from_filename(&filename, timestamp);
2506 if !matches!(format, mold_core::OutputFormat::Mp4) {
2507 if let Some((w, h)) = image_header_dims(path) {
2508 meta.width = w;
2509 meta.height = h;
2510 }
2511 }
2512 (meta, true)
2513 }
2514 };
2515
2516 images.push(mold_core::GalleryImage {
2517 filename,
2518 metadata,
2519 timestamp,
2520 format: Some(format),
2521 size_bytes: Some(size_bytes),
2522 metadata_synthetic: synthetic,
2523 });
2524 }
2525
2526 images.sort_by_key(|img| std::cmp::Reverse(img.timestamp));
2527 images
2528}
2529
2530fn min_valid_size(format: mold_core::OutputFormat) -> u64 {
2536 match format {
2537 mold_core::OutputFormat::Png
2542 | mold_core::OutputFormat::Apng
2543 | mold_core::OutputFormat::Jpeg
2544 | mold_core::OutputFormat::Webp => 256,
2545 mold_core::OutputFormat::Gif => 128,
2546 mold_core::OutputFormat::Mp4 => 4096,
2548 }
2549}
2550
2551fn image_header_dims(path: &std::path::Path) -> Option<(u32, u32)> {
2556 image::ImageReader::open(path)
2557 .ok()?
2558 .with_guessed_format()
2559 .ok()?
2560 .into_dimensions()
2561 .ok()
2562}
2563
2564fn is_probably_solid_black(
2572 path: &std::path::Path,
2573 format: mold_core::OutputFormat,
2574 size_bytes: u64,
2575) -> bool {
2576 const SAMPLE_DIM: u32 = 16;
2577 const CHANNEL_CEILING: u8 = 16;
2582
2583 let suspect_threshold: u64 = match format {
2584 mold_core::OutputFormat::Png | mold_core::OutputFormat::Apng => 8 * 1024,
2587 mold_core::OutputFormat::Jpeg => 4 * 1024,
2589 mold_core::OutputFormat::Gif | mold_core::OutputFormat::Webp => 4 * 1024,
2590 mold_core::OutputFormat::Mp4 => return false,
2591 };
2592 if size_bytes > suspect_threshold {
2593 return false;
2594 }
2595
2596 let Ok(img) = image::open(path) else {
2597 return false;
2598 };
2599 let thumb = img.thumbnail(SAMPLE_DIM, SAMPLE_DIM).to_rgb8();
2600 let mut max_channel: u8 = 0;
2601 for pixel in thumb.pixels() {
2602 let m = pixel.0[0].max(pixel.0[1]).max(pixel.0[2]);
2603 if m > max_channel {
2604 max_channel = m;
2605 }
2606 if max_channel > CHANNEL_CEILING {
2607 return false;
2608 }
2609 }
2610 max_channel <= CHANNEL_CEILING
2611}
2612
2613fn has_ftyp_box(path: &std::path::Path) -> bool {
2617 use std::io::Read;
2618 let Ok(mut f) = std::fs::File::open(path) else {
2619 return false;
2620 };
2621 let mut buf = [0u8; 12];
2622 if f.read_exact(&mut buf).is_err() {
2623 return false;
2624 }
2625 &buf[4..8] == b"ftyp"
2626}
2627
2628fn synthesize_metadata_from_filename(filename: &str, timestamp: u64) -> mold_core::OutputMetadata {
2634 let stem = std::path::Path::new(filename)
2635 .file_stem()
2636 .and_then(|s| s.to_str())
2637 .unwrap_or("");
2638
2639 let model = stem
2640 .strip_prefix("mold-")
2641 .and_then(|rest| {
2642 let mut parts: Vec<&str> = rest.split('-').collect();
2645 while parts
2646 .last()
2647 .map(|p| p.chars().all(|c| c.is_ascii_digit()))
2648 .unwrap_or(false)
2649 && parts.len() > 1
2650 {
2651 parts.pop();
2652 }
2653 if parts.is_empty() {
2654 None
2655 } else {
2656 Some(parts.join("-"))
2657 }
2658 })
2659 .unwrap_or_else(|| "unknown".to_string());
2660
2661 mold_core::OutputMetadata {
2662 prompt: String::new(),
2663 negative_prompt: None,
2664 original_prompt: None,
2665 model,
2666 seed: 0,
2667 steps: 0,
2668 guidance: 0.0,
2669 width: 0,
2670 height: 0,
2671 strength: None,
2672 scheduler: None,
2673 lora: None,
2674 lora_scale: None,
2675 frames: None,
2676 fps: None,
2677 version: format!("synthesized@{timestamp}"),
2678 }
2679}
2680
2681fn read_png_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
2683 let file = std::fs::File::open(path).ok()?;
2684 let decoder = png::Decoder::new(std::io::BufReader::new(file));
2685 let reader = decoder.read_info().ok()?;
2686 let info = reader.info();
2687
2688 for chunk in &info.uncompressed_latin1_text {
2689 if chunk.keyword == "mold:parameters" {
2690 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&chunk.text) {
2691 return Some(meta);
2692 }
2693 }
2694 }
2695 for chunk in &info.utf8_text {
2696 if chunk.keyword == "mold:parameters" {
2697 if let Ok(text) = chunk.get_text() {
2698 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&text) {
2699 return Some(meta);
2700 }
2701 }
2702 }
2703 }
2704 None
2705}
2706
2707fn read_jpeg_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
2709 let data = std::fs::read(path).ok()?;
2710 let mut i = 0;
2711 while i + 1 < data.len() {
2712 if data[i] != 0xFF {
2713 i += 1;
2714 continue;
2715 }
2716 let marker = data[i + 1];
2717 match marker {
2718 0xD8 | 0x01 => {
2720 i += 2;
2721 }
2722 0xD9 => break, 0xD0..=0xD7 => {
2724 i += 2; }
2726 0xFE => {
2728 if i + 3 >= data.len() {
2729 break;
2730 }
2731 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2732 if len < 2 || i + 2 + len > data.len() {
2733 break;
2734 }
2735 let comment = &data[i + 4..i + 2 + len];
2736 if let Ok(text) = std::str::from_utf8(comment) {
2737 if let Some(json) = text.strip_prefix("mold:parameters ") {
2738 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(json) {
2739 return Some(meta);
2740 }
2741 }
2742 }
2743 i += 2 + len;
2744 }
2745 _ => {
2747 if i + 3 >= data.len() {
2748 break;
2749 }
2750 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2751 if len < 2 || i + 2 + len > data.len() {
2752 break;
2753 }
2754 i += 2 + len;
2755 }
2756 }
2757 }
2758 None
2759}
2760
2761async fn put_model_placement(
2764 State(state): State<AppState>,
2765 axum::extract::Path(name): axum::extract::Path<String>,
2766 Json(placement): Json<mold_core::types::DevicePlacement>,
2767) -> Result<Json<serde_json::Value>, ApiError> {
2768 validate_multi_gpu_placement(&state, Some(&placement))?;
2769 {
2770 let mut cfg = state.config.write().await;
2771 cfg.set_model_placement(&name, Some(placement.clone()));
2772 cfg.save().map_err(|e| {
2773 tracing::warn!("failed to persist placement to config.toml: {e}");
2774 ApiError::internal(format!("failed to persist placement to config.toml: {e}"))
2775 })?;
2776 }
2777 Ok(Json(serde_json::json!({
2778 "ok": true,
2779 "model": name,
2780 })))
2781}
2782
2783async fn delete_model_placement(
2784 State(state): State<AppState>,
2785 axum::extract::Path(name): axum::extract::Path<String>,
2786) -> Result<Json<serde_json::Value>, ApiError> {
2787 let mut cfg = state.config.write().await;
2788 cfg.set_model_placement(&name, None);
2789 cfg.save().map_err(|e| {
2790 tracing::warn!("failed to persist placement removal to config.toml: {e}");
2791 ApiError::internal(format!(
2792 "failed to persist placement removal to config.toml: {e}"
2793 ))
2794 })?;
2795 Ok(Json(serde_json::json!({ "ok": true })))
2796}
2797
2798async fn openapi_json() -> impl IntoResponse {
2801 Json(ApiDoc::openapi())
2802}
2803
2804async fn scalar_docs() -> impl IntoResponse {
2807 (
2808 [(header::CONTENT_TYPE, "text/html")],
2809 r#"<!DOCTYPE html>
2810<html>
2811<head>
2812 <title>mold API</title>
2813 <meta charset="utf-8" />
2814 <meta name="viewport" content="width=device-width, initial-scale=1" />
2815</head>
2816<body>
2817 <script id="api-reference" data-url="/api/openapi.json"></script>
2818 <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
2819</body>
2820</html>"#,
2821 )
2822}
2823
2824fn query_gpu_info() -> Option<GpuInfo> {
2827 let nvidia_smi = if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
2828 "/run/current-system/sw/bin/nvidia-smi"
2829 } else {
2830 "nvidia-smi"
2831 };
2832
2833 let output = std::process::Command::new(nvidia_smi)
2834 .args([
2835 "--query-gpu=name,memory.total,memory.used",
2836 "--format=csv,noheader,nounits",
2837 ])
2838 .output()
2839 .ok()?;
2840
2841 if !output.status.success() {
2842 return None;
2843 }
2844
2845 let text = String::from_utf8(output.stdout).ok()?;
2846 let line = text.lines().next()?;
2847 let parts: Vec<&str> = line.split(',').map(str::trim).collect();
2848 if parts.len() < 3 {
2849 return None;
2850 }
2851
2852 Some(GpuInfo {
2853 name: parts[0].to_string(),
2854 vram_total_mb: parts[1].parse().ok()?,
2855 vram_used_mb: parts[2].parse().ok()?,
2856 })
2857}
2858
2859#[derive(serde::Deserialize, utoipa::ToSchema)]
2862pub struct CreateDownloadBody {
2863 pub model: String,
2864}
2865
2866#[derive(serde::Serialize, utoipa::ToSchema)]
2867pub struct CreateDownloadResponse {
2868 pub id: String,
2869 pub position: usize,
2870}
2871
2872#[utoipa::path(
2873 post,
2874 path = "/api/downloads",
2875 tag = "downloads",
2876 request_body = CreateDownloadBody,
2877 responses(
2878 (status = 200, description = "Enqueued; position 0 = will start immediately", body = CreateDownloadResponse),
2879 (status = 400, description = "Unknown model"),
2880 (status = 409, description = "Already active or queued; body contains existing id", body = CreateDownloadResponse),
2881 )
2882)]
2883pub async fn create_download(
2884 State(state): State<AppState>,
2885 Json(body): Json<CreateDownloadBody>,
2886) -> axum::response::Response {
2887 use crate::downloads::{EnqueueError, EnqueueOutcome};
2888 match state.downloads.enqueue(body.model.clone()).await {
2889 Ok((id, position, EnqueueOutcome::Created)) => (
2890 StatusCode::OK,
2891 Json(CreateDownloadResponse { id, position }),
2892 )
2893 .into_response(),
2894 Ok((id, position, EnqueueOutcome::AlreadyPresent)) => (
2895 StatusCode::CONFLICT,
2896 Json(CreateDownloadResponse { id, position }),
2897 )
2898 .into_response(),
2899 Err(EnqueueError::UnknownModel(_)) => (
2900 StatusCode::BAD_REQUEST,
2901 Json(serde_json::json!({
2902 "error": format!("unknown model '{}'. Run 'mold list' to see available models.", body.model)
2903 })),
2904 )
2905 .into_response(),
2906 Err(EnqueueError::LockPoisoned) => (
2907 StatusCode::INTERNAL_SERVER_ERROR,
2908 Json(serde_json::json!({ "error": "download queue state is corrupt" })),
2909 )
2910 .into_response(),
2911 }
2912}
2913
2914#[utoipa::path(
2915 delete,
2916 path = "/api/downloads/{id}",
2917 tag = "downloads",
2918 params(("id" = String, Path, description = "Job id")),
2919 responses(
2920 (status = 204, description = "Cancelled"),
2921 (status = 404, description = "Unknown id"),
2922 )
2923)]
2924pub async fn delete_download(
2925 State(state): State<AppState>,
2926 axum::extract::Path(id): axum::extract::Path<String>,
2927) -> axum::response::Response {
2928 if state.downloads.cancel(&id).await {
2929 StatusCode::NO_CONTENT.into_response()
2930 } else {
2931 (
2932 StatusCode::NOT_FOUND,
2933 Json(serde_json::json!({ "error": format!("unknown download id '{id}'") })),
2934 )
2935 .into_response()
2936 }
2937}
2938
2939#[utoipa::path(
2940 get,
2941 path = "/api/downloads",
2942 tag = "downloads",
2943 responses((status = 200, description = "Current queue state"))
2944)]
2945pub async fn list_downloads(State(state): State<AppState>) -> axum::response::Response {
2946 Json(state.downloads.listing().await).into_response()
2947}
2948
2949#[utoipa::path(
2950 get,
2951 path = "/api/downloads/stream",
2952 tag = "downloads",
2953 responses((status = 200, description = "SSE stream of DownloadEvent JSON")),
2954)]
2955pub async fn stream_downloads(
2956 State(state): State<AppState>,
2957) -> Sse<
2958 impl futures_core::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>,
2959> {
2960 use axum::response::sse::Event;
2961 use tokio_stream::wrappers::BroadcastStream;
2962 use tokio_stream::StreamExt as _;
2963
2964 let rx = state.downloads.subscribe();
2971 let initial = state.downloads.listing().await;
2972 let snapshot_event = mold_core::types::DownloadEvent::Snapshot { listing: initial };
2973
2974 let stream = async_stream::stream! {
2975 let data = serde_json::to_string(&snapshot_event).unwrap_or_else(|_| "{}".to_string());
2976 yield Ok::<_, std::convert::Infallible>(Event::default().event("download").data(data));
2977
2978 let mut bs = BroadcastStream::new(rx);
2979 while let Some(item) = bs.next().await {
2980 match item {
2981 Ok(event) => {
2982 let data = serde_json::to_string(&event)
2983 .unwrap_or_else(|_| "{}".to_string());
2984 yield Ok(Event::default().event("download").data(data));
2985 }
2986 Err(_lagged) => continue,
2990 }
2991 }
2992 };
2993
2994 Sse::new(stream).keep_alive(
2995 KeepAlive::new()
2996 .interval(std::time::Duration::from_secs(15))
2997 .text("ping"),
2998 )
2999}
3000
3001async fn get_resources(State(state): State<AppState>) -> Result<Json<ResourceSnapshot>, ApiError> {
3007 match state.resources.latest() {
3008 Some(snap) => Ok(Json(snap)),
3009 None => Err(ApiError::internal_with_status(
3010 "resource telemetry not ready",
3011 StatusCode::SERVICE_UNAVAILABLE,
3012 )),
3013 }
3014}
3015
3016async fn get_resources_stream(
3019 State(state): State<AppState>,
3020) -> Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>> {
3021 use tokio_stream::wrappers::BroadcastStream;
3022
3023 let rx = state.resources.subscribe();
3024 let initial = state.resources.latest();
3027
3028 let stream = async_stream::stream! {
3029 if let Some(snap) = initial {
3030 yield Ok::<_, Infallible>(snapshot_to_sse(&snap));
3031 }
3032 let mut bs = BroadcastStream::new(rx);
3033 while let Some(item) = bs.next().await {
3034 match item {
3035 Ok(snap) => yield Ok(snapshot_to_sse(&snap)),
3036 Err(_lagged) => continue,
3039 }
3040 }
3041 };
3042
3043 Sse::new(stream).keep_alive(
3044 KeepAlive::new()
3045 .interval(std::time::Duration::from_secs(15))
3046 .text("ping"),
3047 )
3048}
3049
3050fn snapshot_to_sse(snap: &ResourceSnapshot) -> SseEvent {
3051 match serde_json::to_string(snap) {
3052 Ok(data) => SseEvent::default().event("snapshot").data(data),
3053 Err(e) => SseEvent::default()
3054 .event("error")
3055 .data(format!("{{\"message\":\"serialize failed: {e}\"}}")),
3056 }
3057}
3058
3059#[cfg(test)]
3060mod tests {
3061 use super::*;
3062
3063 fn env_lock() -> &'static std::sync::Mutex<()> {
3064 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3065 &ENV_LOCK
3066 }
3067
3068 #[test]
3069 fn clean_error_message_strips_backtrace() {
3070 let err = anyhow::anyhow!(
3071 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")\n\
3072 \x20 0: candle_core::error::Error::bt\n\
3073 \x20 at /home/user/.cargo/git/candle/src/error.rs:264:25\n\
3074 \x20 1: <core::result::Result<O,E> as candle_core::cuda_backend::error::WrapErr<O>>::w\n\
3075 \x20 at /home/user/.cargo/git/candle/src/cuda_backend/error.rs:60:65"
3076 );
3077 let msg = clean_error_message(&err);
3078 assert_eq!(
3079 msg,
3080 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")"
3081 );
3082 }
3083
3084 #[test]
3085 fn clean_error_message_preserves_simple_error() {
3086 let err = anyhow::anyhow!("model not found: flux-dev:q4");
3087 let msg = clean_error_message(&err);
3088 assert_eq!(msg, "model not found: flux-dev:q4");
3089 }
3090
3091 #[test]
3092 fn clean_error_message_preserves_multiline_without_backtrace() {
3093 let err = anyhow::anyhow!("validation failed\nprompt is empty\nsteps must be > 0");
3094 let msg = clean_error_message(&err);
3095 assert_eq!(msg, "validation failed\nprompt is empty\nsteps must be > 0");
3096 }
3097
3098 #[test]
3099 fn clean_error_message_strips_high_numbered_frames() {
3100 let err = anyhow::anyhow!(
3101 "some error\n\
3102 \x20 10: tokio::runtime::task::core::Core<T,S>::poll at /home/user/.cargo/tokio/src/core.rs:375\n\
3103 \x20 11: std::panicking::catch_unwind at /nix/store/rust/src/panicking.rs:544"
3104 );
3105 let msg = clean_error_message(&err);
3106 assert_eq!(msg, "some error");
3107 }
3108
3109 #[test]
3110 fn clean_error_message_empty_fallback() {
3111 let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
3113 let msg = clean_error_message(&err);
3114 assert!(!msg.is_empty());
3116 }
3117
3118 #[test]
3119 fn clean_error_message_renders_full_anyhow_chain() {
3120 let root = std::io::Error::new(std::io::ErrorKind::InvalidData, "bytes past end");
3125 let err: anyhow::Error = anyhow::Error::new(root)
3126 .context("validate single-file checkpoint at /tmp/foo.safetensors");
3127 let msg = clean_error_message(&err);
3128 assert!(
3129 msg.contains("validate single-file checkpoint") && msg.contains("bytes past end"),
3130 "expected both context layers in the rendered chain, got: {msg}",
3131 );
3132 }
3133
3134 #[test]
3135 fn save_image_to_dir_creates_directory_and_writes_file() {
3136 let dir = std::env::temp_dir().join(format!(
3137 "mold-save-test-{}",
3138 std::time::SystemTime::now()
3139 .duration_since(std::time::UNIX_EPOCH)
3140 .unwrap()
3141 .as_nanos()
3142 ));
3143 assert!(!dir.exists());
3144
3145 let img = mold_core::ImageData {
3146 data: vec![0x89, 0x50, 0x4E, 0x47], format: mold_core::OutputFormat::Png,
3148 width: 64,
3149 height: 64,
3150 index: 0,
3151 };
3152
3153 save_image_to_dir(&dir, &img, "test-model:q8", 1);
3154
3155 assert!(dir.exists(), "directory should be created");
3156 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
3157 assert_eq!(files.len(), 1, "should have exactly one file");
3158 let file = files[0].as_ref().unwrap();
3159 let filename = file.file_name().to_str().unwrap().to_string();
3160 assert!(filename.starts_with("mold-test-model-q8-"), "{filename}");
3161 assert!(filename.ends_with(".png"), "{filename}");
3162 let contents = std::fs::read(file.path()).unwrap();
3163 assert_eq!(contents, vec![0x89, 0x50, 0x4E, 0x47]);
3164
3165 std::fs::remove_dir_all(&dir).ok();
3166 }
3167
3168 #[test]
3169 fn save_image_to_dir_batch_includes_index() {
3170 let dir = std::env::temp_dir().join(format!(
3171 "mold-save-batch-{}",
3172 std::time::SystemTime::now()
3173 .duration_since(std::time::UNIX_EPOCH)
3174 .unwrap()
3175 .as_nanos()
3176 ));
3177
3178 let img = mold_core::ImageData {
3179 data: vec![0xFF, 0xD8], format: mold_core::OutputFormat::Jpeg,
3181 width: 64,
3182 height: 64,
3183 index: 2,
3184 };
3185
3186 save_image_to_dir(&dir, &img, "flux-dev", 4);
3187
3188 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
3189 assert_eq!(files.len(), 1);
3190 let filename = files[0]
3191 .as_ref()
3192 .unwrap()
3193 .file_name()
3194 .to_str()
3195 .unwrap()
3196 .to_string();
3197 assert!(
3198 filename.contains("-2.jpeg"),
3199 "batch index in name: {filename}"
3200 );
3201
3202 std::fs::remove_dir_all(&dir).ok();
3203 }
3204
3205 #[test]
3206 fn save_image_to_dir_invalid_path_logs_warning_no_panic() {
3207 let img = mold_core::ImageData {
3209 data: vec![0x00],
3210 format: mold_core::OutputFormat::Png,
3211 width: 1,
3212 height: 1,
3213 index: 0,
3214 };
3215 save_image_to_dir(
3217 std::path::Path::new("/dev/null/impossible"),
3218 &img,
3219 "test",
3220 1,
3221 );
3222 }
3224
3225 #[test]
3226 fn thumbnail_warmup_is_disabled_by_default() {
3227 let _guard = env_lock().lock().unwrap();
3228 unsafe {
3229 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
3230 }
3231 assert!(!thumbnail_warmup_enabled());
3232 }
3233
3234 #[test]
3235 fn thumbnail_warmup_accepts_truthy_env_values() {
3236 let _guard = env_lock().lock().unwrap();
3237 unsafe {
3238 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "1");
3239 }
3240 assert!(thumbnail_warmup_enabled());
3241 unsafe {
3242 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "true");
3243 }
3244 assert!(thumbnail_warmup_enabled());
3245 unsafe {
3246 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "YES");
3247 }
3248 assert!(thumbnail_warmup_enabled());
3249 unsafe {
3250 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
3251 }
3252 }
3253
3254 #[test]
3255 fn thumbnail_warmup_rejects_falsey_env_values() {
3256 let _guard = env_lock().lock().unwrap();
3257 unsafe {
3258 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "0");
3259 }
3260 assert!(!thumbnail_warmup_enabled());
3261 unsafe {
3262 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "false");
3263 }
3264 assert!(!thumbnail_warmup_enabled());
3265 unsafe {
3266 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
3267 }
3268 }
3269
3270 #[test]
3271 fn content_type_covers_every_output_format() {
3272 assert_eq!(content_type_for_filename("a.png"), "image/png");
3273 assert_eq!(content_type_for_filename("a.PNG"), "image/png");
3274 assert_eq!(content_type_for_filename("a.jpg"), "image/jpeg");
3275 assert_eq!(content_type_for_filename("a.jpeg"), "image/jpeg");
3276 assert_eq!(content_type_for_filename("a.gif"), "image/gif");
3277 assert_eq!(content_type_for_filename("a.webp"), "image/webp");
3278 assert_eq!(content_type_for_filename("a.apng"), "image/apng");
3279 assert_eq!(content_type_for_filename("a.mp4"), "video/mp4");
3280 assert_eq!(
3281 content_type_for_filename("a.unknown"),
3282 "application/octet-stream"
3283 );
3284 }
3285
3286 #[test]
3287 fn synthesized_metadata_parses_model_from_filename() {
3288 let meta = synthesize_metadata_from_filename("mold-flux-dev-q8-1710000000.mp4", 1710000000);
3289 assert_eq!(meta.model, "flux-dev-q8");
3291 assert_eq!(meta.prompt, "");
3292 assert_eq!(meta.seed, 0);
3293 assert!(meta.version.starts_with("synthesized@"));
3294
3295 let meta =
3297 synthesize_metadata_from_filename("mold-ltx-video-bf16-1710000030-2.gif", 1710000030);
3298 assert_eq!(meta.model, "ltx-video-bf16");
3299
3300 let meta = synthesize_metadata_from_filename("unrelated.png", 0);
3302 assert_eq!(meta.model, "unknown");
3303 }
3304
3305 struct TempDir(std::path::PathBuf);
3311 impl TempDir {
3312 fn new(tag: &str) -> Self {
3313 let mut p = std::env::temp_dir();
3314 p.push(format!("mold-gallery-test-{tag}-{}", uuid::Uuid::new_v4()));
3315 std::fs::create_dir_all(&p).expect("create tempdir");
3316 Self(p)
3317 }
3318 fn path(&self) -> &std::path::Path {
3319 &self.0
3320 }
3321 }
3322 impl Drop for TempDir {
3323 fn drop(&mut self) {
3324 let _ = std::fs::remove_dir_all(&self.0);
3325 }
3326 }
3327
3328 fn make_png_bytes(width: u32, height: u32) -> Vec<u8> {
3333 let img = image::RgbImage::from_fn(width, height, |x, y| {
3334 let n = (x.wrapping_mul(37) ^ y.wrapping_mul(131)) as u8;
3335 image::Rgb([n, n.wrapping_add(85), n.wrapping_sub(17)])
3336 });
3337 let mut buf = Vec::new();
3338 image::DynamicImage::ImageRgb8(img)
3339 .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
3340 .expect("encode png");
3341 buf
3342 }
3343
3344 #[test]
3345 fn min_valid_size_thresholds_are_sensible() {
3346 assert!(min_valid_size(mold_core::OutputFormat::Png) >= 128);
3349 assert!(min_valid_size(mold_core::OutputFormat::Jpeg) >= 128);
3350 assert!(min_valid_size(mold_core::OutputFormat::Apng) >= 128);
3351 assert!(min_valid_size(mold_core::OutputFormat::Webp) >= 128);
3352 assert!(min_valid_size(mold_core::OutputFormat::Gif) <= 512);
3354 assert!(min_valid_size(mold_core::OutputFormat::Mp4) >= 1024);
3356 }
3357
3358 #[test]
3359 fn has_ftyp_box_accepts_real_header_and_rejects_garbage() {
3360 let td = TempDir::new("ftyp");
3361
3362 let mut real = Vec::new();
3364 real.extend_from_slice(&[0x00, 0x00, 0x00, 0x20]);
3365 real.extend_from_slice(b"ftyp");
3366 real.extend_from_slice(b"isom\x00\x00\x02\x00isomiso2mp41");
3367 let real_path = td.path().join("real.mp4");
3368 std::fs::write(&real_path, &real).unwrap();
3369 assert!(has_ftyp_box(&real_path));
3370
3371 let fake_path = td.path().join("fake.mp4");
3373 std::fs::write(&fake_path, b"this is not an mp4 file at all").unwrap();
3374 assert!(!has_ftyp_box(&fake_path));
3375
3376 let trunc_path = td.path().join("truncated.mp4");
3378 std::fs::write(&trunc_path, b"\x00\x00\x00\x20").unwrap();
3379 assert!(!has_ftyp_box(&trunc_path));
3380
3381 assert!(!has_ftyp_box(&td.path().join("nope.mp4")));
3383 }
3384
3385 #[test]
3386 fn image_header_dims_returns_real_dimensions() {
3387 let td = TempDir::new("header");
3388 let p = td.path().join("valid.png");
3389 std::fs::write(&p, make_png_bytes(42, 24)).unwrap();
3390 assert_eq!(image_header_dims(&p), Some((42, 24)));
3391
3392 let stub = td.path().join("stub.png");
3394 std::fs::write(&stub, b"\x89PNG\r\n\x1a\n").unwrap();
3395 assert!(image_header_dims(&stub).is_none());
3396
3397 let text = td.path().join("text.png");
3399 std::fs::write(&text, b"hello world, not a png").unwrap();
3400 assert!(image_header_dims(&text).is_none());
3401 }
3402
3403 #[test]
3404 fn scan_gallery_dir_filters_invalid_and_keeps_valid() {
3405 let td = TempDir::new("scan");
3406 let dir = td.path();
3407
3408 std::fs::write(dir.join("mold-model-1000.png"), make_png_bytes(32, 32)).unwrap();
3410
3411 let mut junk = vec![0u8; 512];
3413 junk[..4].copy_from_slice(b"JUNK");
3414 std::fs::write(dir.join("mold-broken-2000.png"), &junk).unwrap();
3415
3416 std::fs::write(
3418 dir.join("mold-tiny-3000.png"),
3419 b"\x89PNG\r\n\x1a\n", )
3421 .unwrap();
3422
3423 let mut mp4 = Vec::new();
3425 mp4.extend_from_slice(&[0x00, 0x00, 0x00, 0x20]);
3426 mp4.extend_from_slice(b"ftyp");
3427 mp4.extend_from_slice(b"isom\x00\x00\x02\x00");
3428 mp4.resize(8192, 0);
3431 std::fs::write(dir.join("mold-ltx-4000.mp4"), &mp4).unwrap();
3432
3433 let bad_mp4 = vec![0u8; 8192];
3435 std::fs::write(dir.join("mold-no-ftyp-5000.mp4"), &bad_mp4).unwrap();
3436
3437 std::fs::write(dir.join("random.txt"), b"not an output").unwrap();
3439
3440 let results = scan_gallery_dir(dir);
3441 let names: Vec<&str> = results.iter().map(|i| i.filename.as_str()).collect();
3442 assert!(
3443 names.contains(&"mold-model-1000.png"),
3444 "valid PNG should survive: {names:?}"
3445 );
3446 assert!(
3447 names.contains(&"mold-ltx-4000.mp4"),
3448 "valid MP4 with ftyp should survive: {names:?}"
3449 );
3450 assert!(
3451 !names.contains(&"mold-broken-2000.png"),
3452 "PNG with no valid header should be filtered: {names:?}"
3453 );
3454 assert!(
3455 !names.contains(&"mold-tiny-3000.png"),
3456 "under-size PNG stub should be filtered: {names:?}"
3457 );
3458 assert!(
3459 !names.contains(&"mold-no-ftyp-5000.mp4"),
3460 "MP4 without ftyp should be filtered: {names:?}"
3461 );
3462 assert_eq!(names.len(), 2, "only the 2 valid fixtures remain");
3463 }
3464
3465 #[test]
3466 fn solid_black_png_is_filtered_at_scan_time() {
3467 let td = TempDir::new("black");
3468 let dir = td.path();
3469
3470 let black = image::RgbImage::from_pixel(256, 256, image::Rgb([0, 0, 0]));
3474 let mut buf = Vec::new();
3475 image::DynamicImage::ImageRgb8(black)
3476 .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
3477 .unwrap();
3478 std::fs::write(dir.join("mold-noisy-1000.png"), &buf).unwrap();
3479
3480 std::fs::write(dir.join("mold-valid-2000.png"), make_png_bytes(256, 256)).unwrap();
3482
3483 let results = scan_gallery_dir(dir);
3484 let names: Vec<&str> = results.iter().map(|i| i.filename.as_str()).collect();
3485 assert!(
3486 !names.contains(&"mold-noisy-1000.png"),
3487 "solid-black PNG should be filtered: {names:?}"
3488 );
3489 assert!(
3490 names.contains(&"mold-valid-2000.png"),
3491 "noisy PNG should survive: {names:?}"
3492 );
3493 }
3494
3495 #[test]
3496 fn probably_solid_black_ignores_large_files() {
3497 let td = TempDir::new("bigblack");
3501 let big_path = td.path().join("big.png");
3502 std::fs::write(&big_path, vec![0u8; 20 * 1024]).unwrap();
3504 assert!(!is_probably_solid_black(
3505 &big_path,
3506 mold_core::OutputFormat::Png,
3507 20 * 1024,
3508 ));
3509 }
3510
3511 #[test]
3512 fn parse_byte_range_handles_common_forms() {
3513 assert_eq!(parse_byte_range("bytes=0-499", 2000), Some((0, 499)));
3515 assert_eq!(parse_byte_range("bytes=100-", 2000), Some((100, 1999)));
3517 assert_eq!(parse_byte_range("bytes=-500", 2000), Some((1500, 1999)));
3519 assert_eq!(parse_byte_range("bytes=0-9999", 2000), Some((0, 1999)));
3521 assert_eq!(parse_byte_range("bytes=0-1999", 2000), Some((0, 1999)));
3523 }
3524
3525 #[test]
3526 fn parse_byte_range_rejects_malformed_and_unsatisfiable() {
3527 assert_eq!(parse_byte_range("bytes=", 1000), None);
3528 assert_eq!(parse_byte_range("bytes=abc-100", 1000), None);
3529 assert_eq!(parse_byte_range("bytes=2000-", 1000), None);
3531 assert_eq!(parse_byte_range("bytes=500-100", 1000), None);
3533 assert_eq!(parse_byte_range("bytes=0-10,20-30", 1000), None);
3535 assert_eq!(parse_byte_range("bytes=-0", 1000), None);
3537 assert_eq!(parse_byte_range("bytes=0-10", 0), None);
3539 assert_eq!(parse_byte_range("items=0-10", 1000), None);
3541 }
3542
3543 #[test]
3544 fn scan_populates_real_dimensions_for_synthesized_metadata() {
3545 let td = TempDir::new("dims");
3549 let dir = td.path();
3550 std::fs::write(dir.join("mold-nometa-1000.png"), make_png_bytes(128, 96)).unwrap();
3551
3552 let results = scan_gallery_dir(dir);
3553 assert_eq!(results.len(), 1);
3554 let entry = &results[0];
3555 assert!(entry.metadata_synthetic);
3556 assert_eq!(entry.metadata.width, 128);
3557 assert_eq!(entry.metadata.height, 96);
3558 }
3559}