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 ActiveGenerationStatus, GpuInfo, ModelInfoExtended, ServerStatus, SseErrorEvent,
14 SseProgressEvent,
15};
16use serde::{Deserialize, Serialize};
17use std::convert::Infallible;
18use tokio_stream::StreamExt as _;
19use utoipa::OpenApi;
20
21use crate::model_manager;
22use crate::state::{AppState, GenerationJob, SseMessage};
23
24#[derive(Debug, Serialize)]
27pub struct ApiError {
28 pub error: String,
29 pub code: String,
30 #[serde(skip)]
31 status: StatusCode,
32}
33
34impl ApiError {
35 pub fn validation(msg: impl Into<String>) -> Self {
36 Self {
37 error: msg.into(),
38 code: "VALIDATION_ERROR".to_string(),
39 status: StatusCode::UNPROCESSABLE_ENTITY,
40 }
41 }
42
43 pub fn not_found(msg: impl Into<String>) -> Self {
44 Self {
45 error: msg.into(),
46 code: "MODEL_NOT_FOUND".to_string(),
47 status: StatusCode::NOT_FOUND,
48 }
49 }
50
51 pub fn unknown_model(msg: impl Into<String>) -> Self {
52 Self {
53 error: msg.into(),
54 code: "UNKNOWN_MODEL".to_string(),
55 status: StatusCode::BAD_REQUEST,
56 }
57 }
58
59 pub fn inference(msg: impl Into<String>) -> Self {
60 Self {
61 error: msg.into(),
62 code: "INFERENCE_ERROR".to_string(),
63 status: StatusCode::INTERNAL_SERVER_ERROR,
64 }
65 }
66
67 pub fn internal(msg: impl Into<String>) -> Self {
68 Self {
69 error: msg.into(),
70 code: "INTERNAL_ERROR".to_string(),
71 status: StatusCode::INTERNAL_SERVER_ERROR,
72 }
73 }
74
75 pub fn insufficient_memory(msg: impl Into<String>) -> Self {
76 Self {
77 error: msg.into(),
78 code: "INSUFFICIENT_MEMORY".to_string(),
79 status: StatusCode::SERVICE_UNAVAILABLE,
80 }
81 }
82
83 pub fn forbidden(msg: impl Into<String>) -> Self {
84 Self {
85 error: msg.into(),
86 code: "FORBIDDEN".to_string(),
87 status: StatusCode::FORBIDDEN,
88 }
89 }
90}
91
92impl IntoResponse for ApiError {
93 fn into_response(self) -> axum::response::Response {
94 let status = self.status;
95 (status, Json(self)).into_response()
96 }
97}
98
99#[cfg(test)]
101use crate::queue::clean_error_message;
102
103#[derive(OpenApi)]
104#[openapi(
105 paths(generate, generate_stream, expand_prompt, list_models, load_model, pull_model_endpoint, unload_model, server_status, health),
106 components(schemas(
107 mold_core::GenerateRequest,
108 mold_core::GenerateResponse,
109 mold_core::ExpandRequest,
110 mold_core::ExpandResponse,
111 mold_core::ImageData,
112 mold_core::OutputFormat,
113 mold_core::ModelInfo,
114 mold_core::ServerStatus,
115 mold_core::ActiveGenerationStatus,
116 mold_core::GpuInfo,
117 mold_core::SseProgressEvent,
118 mold_core::SseCompleteEvent,
119 mold_core::SseErrorEvent,
120 ModelInfoExtended,
121 LoadModelBody,
122 )),
123 tags(
124 (name = "generation", description = "Image generation"),
125 (name = "models", description = "Model management"),
126 (name = "server", description = "Server status and health"),
127 ),
128 info(
129 title = "mold",
130 description = "Local AI image generation server — FLUX, SD3.5, SD1.5, SDXL, Z-Image, Flux.2, Qwen-Image",
131 version = env!("CARGO_PKG_VERSION"),
132 )
133)]
134pub struct ApiDoc;
135
136pub fn create_router(state: AppState) -> Router {
137 Router::new()
140 .route("/api/generate", post(generate))
141 .route("/api/generate/stream", post(generate_stream))
142 .route("/api/expand", post(expand_prompt))
143 .route("/api/models", get(list_models))
144 .route("/api/models/load", post(load_model))
145 .route("/api/models/pull", post(pull_model_endpoint))
146 .route("/api/models/unload", delete(unload_model))
147 .route("/api/gallery", get(list_gallery))
148 .route(
149 "/api/gallery/image/:filename",
150 get(get_gallery_image).delete(delete_gallery_image),
151 )
152 .route(
153 "/api/gallery/thumbnail/:filename",
154 get(get_gallery_thumbnail),
155 )
156 .route("/api/upscale", post(upscale))
157 .route("/api/upscale/stream", post(upscale_stream))
158 .route("/api/status", get(server_status))
159 .route("/api/capabilities", get(server_capabilities))
160 .route("/api/shutdown", post(shutdown_server))
161 .route("/health", get(health))
162 .with_state(state)
163 .route("/api/openapi.json", get(openapi_json))
164 .route("/api/docs", get(scalar_docs))
165}
166
167fn sse_message_to_event(msg: SseMessage) -> SseEvent {
170 fn serialize_event<T: Serialize>(event_name: &str, payload: &T) -> SseEvent {
171 match serde_json::to_string(payload) {
172 Ok(data) => SseEvent::default().event(event_name).data(data),
173 Err(err) => SseEvent::default().event("error").data(
174 serde_json::json!({
175 "message": format!("failed to serialize SSE payload: {err}")
176 })
177 .to_string(),
178 ),
179 }
180 }
181
182 match msg {
183 SseMessage::Progress(payload) => serialize_event("progress", &payload),
184 SseMessage::Complete(payload) => serialize_event("complete", &payload),
185 SseMessage::UpscaleComplete(payload) => serialize_event("complete", &payload),
186 SseMessage::Error(payload) => serialize_event("error", &payload),
187 }
188}
189
190#[cfg(test)]
191fn save_image_to_dir(
192 dir: &std::path::Path,
193 img: &mold_core::ImageData,
194 model: &str,
195 batch_size: u32,
196) {
197 if let Err(e) = std::fs::create_dir_all(dir) {
198 tracing::warn!("failed to create output dir {}: {e}", dir.display());
199 return;
200 }
201 let timestamp_ms = std::time::SystemTime::now()
204 .duration_since(std::time::UNIX_EPOCH)
205 .unwrap_or_default()
206 .as_millis() as u64;
207 let ext = img.format.to_string();
208 let filename =
209 mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
210 let path = dir.join(&filename);
211 match std::fs::write(&path, &img.data) {
212 Ok(()) => tracing::info!("saved image to {}", path.display()),
213 Err(e) => tracing::warn!("failed to save image to {}: {e}", path.display()),
214 }
215}
216
217async fn prepare_generation(
225 state: &AppState,
226 request: &mut mold_core::GenerateRequest,
227) -> Result<(Option<std::path::PathBuf>, Option<String>), ApiError> {
228 apply_default_metadata_setting(state, request).await;
229
230 maybe_expand_prompt(state, request).await?;
232
233 if let Err(e) = validate_generate_request(request) {
234 return Err(ApiError::validation(e));
235 }
236
237 let _ = model_manager::check_model_available(state, &request.model).await?;
238
239 let (output_dir, dim_warning) = {
240 let config = state.config.read().await;
241 let output_dir = if config.is_output_disabled() {
242 None
243 } else {
244 Some(config.effective_output_dir())
245 };
246 let family = config.resolved_model_config(&request.model).family;
247 let dim_warning = family
248 .as_deref()
249 .and_then(|f| mold_core::dimension_warning(request.width, request.height, f));
250 (output_dir, dim_warning)
251 };
252
253 Ok((output_dir, dim_warning))
254}
255
256#[utoipa::path(
259 post,
260 path = "/api/generate",
261 tag = "generation",
262 request_body = mold_core::GenerateRequest,
263 responses(
264 (status = 200, description = "Generated image bytes", content_type = "image/png"),
265 (status = 404, description = "Model not downloaded"),
266 (status = 422, description = "Invalid request parameters"),
267 (status = 500, description = "Inference error"),
268 )
269)]
270async fn generate(
273 State(state): State<AppState>,
274 Json(mut req): Json<mold_core::GenerateRequest>,
275) -> Result<impl IntoResponse, ApiError> {
276 let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
277
278 tracing::info!(
279 model = %req.model,
280 prompt = %req.prompt,
281 width = req.width,
282 height = req.height,
283 steps = req.steps,
284 guidance = req.guidance,
285 seed = ?req.seed,
286 format = %req.output_format,
287 lora = ?req.lora.as_ref().map(|l| &l.path),
288 lora_scale = ?req.lora.as_ref().map(|l| l.scale),
289 "generate request"
290 );
291
292 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
294 let job = GenerationJob {
295 request: req,
296 progress_tx: None,
297 result_tx,
298 output_dir,
299 };
300
301 let _position = state.queue.submit(job).await.map_err(ApiError::internal)?;
302
303 let result = result_rx
305 .await
306 .map_err(|_| ApiError::internal("generation queue worker dropped the job"))?;
307
308 match result {
309 Ok(job_result) => {
310 let img = job_result.image;
311 let response = job_result.response;
312 let content_type = HeaderValue::from_static(img.format.content_type());
313 let mut headers = HeaderMap::new();
314 headers.insert(header::CONTENT_TYPE, content_type);
315 headers.insert(
316 "x-mold-seed-used",
317 HeaderValue::from_str(&response.seed_used.to_string()).map_err(|e| {
318 ApiError::internal(format!("failed to serialize seed header: {e}"))
319 })?,
320 );
321 if let Some(warning) = dim_warning {
322 match HeaderValue::from_str(&warning.replace('\n', " ")) {
323 Ok(val) => {
324 headers.insert("x-mold-dimension-warning", val);
325 }
326 Err(e) => {
327 tracing::warn!("dimension warning could not be encoded as header: {e}");
328 }
329 }
330 }
331 let output_data = if let Some(ref video) = response.video {
334 let ct = HeaderValue::from_static(video.format.content_type());
335 headers.insert(header::CONTENT_TYPE, ct);
336 if let Ok(v) = HeaderValue::from_str(&video.frames.to_string()) {
337 headers.insert("x-mold-video-frames", v);
338 }
339 if let Ok(v) = HeaderValue::from_str(&video.fps.to_string()) {
340 headers.insert("x-mold-video-fps", v);
341 }
342 if let Ok(v) = HeaderValue::from_str(&video.width.to_string()) {
343 headers.insert("x-mold-video-width", v);
344 }
345 if let Ok(v) = HeaderValue::from_str(&video.height.to_string()) {
346 headers.insert("x-mold-video-height", v);
347 }
348 if video.has_audio {
349 headers.insert("x-mold-video-has-audio", HeaderValue::from_static("1"));
350 }
351 if let Some(dur) = video.duration_ms {
352 if let Ok(v) = HeaderValue::from_str(&dur.to_string()) {
353 headers.insert("x-mold-video-duration-ms", v);
354 }
355 }
356 if let Some(sr) = video.audio_sample_rate {
357 if let Ok(v) = HeaderValue::from_str(&sr.to_string()) {
358 headers.insert("x-mold-video-audio-sample-rate", v);
359 }
360 }
361 if let Some(ch) = video.audio_channels {
362 if let Ok(v) = HeaderValue::from_str(&ch.to_string()) {
363 headers.insert("x-mold-video-audio-channels", v);
364 }
365 }
366 video.data.clone()
367 } else {
368 img.data
369 };
370 Ok((headers, output_data))
371 }
372 Err(err_msg) => Err(ApiError::inference(err_msg)),
373 }
374}
375
376fn validate_generate_request(req: &mold_core::GenerateRequest) -> Result<(), String> {
377 mold_core::validate_generate_request(req)
378}
379
380async fn apply_default_metadata_setting(state: &AppState, req: &mut mold_core::GenerateRequest) {
381 if req.embed_metadata.is_some() {
382 return;
383 }
384
385 let config = state.config.read().await;
386 req.embed_metadata = Some(config.effective_embed_metadata(None));
387}
388
389async fn maybe_expand_prompt(
391 state: &AppState,
392 req: &mut mold_core::GenerateRequest,
393) -> Result<(), ApiError> {
394 if req.expand != Some(true) {
395 return Ok(());
396 }
397
398 let config = state.config.read().await;
399 let expand_settings = config.expand.clone().with_env_overrides();
400
401 let model_family = config
403 .resolved_model_config(&req.model)
404 .family
405 .or_else(|| mold_core::manifest::find_manifest(&req.model).map(|m| m.family.clone()))
406 .unwrap_or_else(|| {
407 tracing::warn!(
408 model = %req.model,
409 "could not resolve model family for prompt expansion, defaulting to \"flux\""
410 );
411 "flux".to_string()
412 });
413
414 let expand_config = expand_settings.to_expand_config(&model_family, 1);
415 let original_prompt = req.prompt.clone();
416
417 drop(config);
419
420 let expander = create_server_expander(&expand_settings)?;
421 let result =
422 tokio::task::spawn_blocking(move || expander.expand(&original_prompt, &expand_config))
423 .await
424 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
425 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
426
427 if let Some(expanded) = result.expanded.first() {
428 req.original_prompt = Some(req.prompt.clone());
429 req.prompt = expanded.clone();
430 }
431
432 Ok(())
433}
434
435fn create_server_expander(
437 settings: &mold_core::ExpandSettings,
438) -> Result<Box<dyn mold_core::PromptExpander>, ApiError> {
439 if let Some(api_expander) = settings.create_api_expander() {
440 return Ok(Box::new(api_expander));
441 }
442
443 #[cfg(feature = "expand")]
444 {
445 let config = mold_core::Config::load_or_default();
446 if let Some(local) =
447 mold_inference::expand::LocalExpander::from_config(&config, Some(&settings.model))
448 {
449 return Ok(Box::new(local));
450 }
451 return Err(ApiError::validation(
452 "local expand model not found — run: mold pull qwen3-expand".to_string(),
453 ));
454 }
455
456 #[cfg(not(feature = "expand"))]
457 {
458 Err(ApiError::validation(
459 "local prompt expansion not available — built without expand feature. \
460 Configure an API backend in [expand] settings."
461 .to_string(),
462 ))
463 }
464}
465
466#[utoipa::path(
469 post,
470 path = "/api/expand",
471 tag = "generation",
472 request_body = mold_core::ExpandRequest,
473 responses(
474 (status = 200, description = "Expanded prompt(s)", body = mold_core::ExpandResponse),
475 (status = 422, description = "Invalid request parameters"),
476 (status = 500, description = "Expansion failed"),
477 )
478)]
479async fn expand_prompt(
480 State(state): State<AppState>,
481 Json(req): Json<mold_core::ExpandRequest>,
482) -> Result<Json<mold_core::ExpandResponse>, ApiError> {
483 if req.variations == 0 || req.variations > mold_core::expand::MAX_VARIATIONS {
484 return Err(ApiError::validation(format!(
485 "variations must be between 1 and {}",
486 mold_core::expand::MAX_VARIATIONS,
487 )));
488 }
489
490 let config = state.config.read().await;
491 let expand_settings = config.expand.clone().with_env_overrides();
492 let expand_config = expand_settings.to_expand_config(&req.model_family, req.variations);
493 let prompt = req.prompt.clone();
494 drop(config);
495
496 let expander = create_server_expander(&expand_settings)?;
497 let result = tokio::task::spawn_blocking(move || expander.expand(&prompt, &expand_config))
498 .await
499 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
500 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
501
502 Ok(Json(mold_core::ExpandResponse {
503 original: req.prompt,
504 expanded: result.expanded,
505 }))
506}
507
508async fn upscale(
511 State(state): State<AppState>,
512 Json(req): Json<mold_core::UpscaleRequest>,
513) -> Result<Json<mold_core::UpscaleResponse>, ApiError> {
514 if let Err(msg) = mold_core::validate_upscale_request(&req) {
515 return Err(ApiError::validation(msg));
516 }
517
518 let model_name = mold_core::manifest::resolve_model_name(&req.model);
519
520 let needs_pull = {
522 let config = state.config.read().await;
523 config
524 .models
525 .get(&model_name)
526 .and_then(|c| c.transformer.as_ref())
527 .is_none()
528 };
529 if needs_pull {
530 if mold_core::manifest::find_manifest(&model_name).is_none() {
531 return Err(ApiError::not_found(format!(
532 "unknown upscaler model '{}'. Run 'mold list' to see available models.",
533 model_name
534 )));
535 }
536 model_manager::pull_model(&state, &model_name, None).await?;
537 }
538
539 let config = state.config.read().await;
540 let weights_path = config
541 .models
542 .get(&model_name)
543 .and_then(|c| c.transformer.as_ref())
544 .ok_or_else(|| {
545 ApiError::not_found(format!(
546 "upscaler model '{}' not configured after pull",
547 model_name
548 ))
549 })?;
550 let weights_path = std::path::PathBuf::from(weights_path);
551 let model_name_owned = model_name.clone();
552 drop(config);
553
554 let upscaler_cache = state.upscaler_cache.clone();
555 let resp =
556 tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
557 let mut cache = upscaler_cache.lock().unwrap_or_else(|e| e.into_inner());
558
559 let needs_new = cache
561 .as_ref()
562 .is_none_or(|e| e.model_name() != model_name_owned);
563 if needs_new {
564 let new_engine = mold_inference::create_upscale_engine(
565 model_name_owned,
566 weights_path,
567 mold_inference::LoadStrategy::Eager,
568 )?;
569 *cache = Some(new_engine);
570 }
571
572 cache.as_mut().unwrap().upscale(&req)
573 })
574 .await
575 .map_err(|e| ApiError::internal(format!("upscale task panicked: {e}")))?
576 .map_err(|e| ApiError::internal(format!("upscale failed: {e}")))?;
577
578 Ok(Json(resp))
579}
580
581async fn upscale_stream(
584 State(state): State<AppState>,
585 Json(req): Json<mold_core::UpscaleRequest>,
586) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
587 if let Err(msg) = mold_core::validate_upscale_request(&req) {
588 return Err(ApiError::validation(msg));
589 }
590
591 let model_name = mold_core::manifest::resolve_model_name(&req.model);
592
593 let needs_pull = {
595 let config = state.config.read().await;
596 config
597 .models
598 .get(&model_name)
599 .and_then(|c| c.transformer.as_ref())
600 .is_none()
601 };
602
603 if needs_pull && mold_core::manifest::find_manifest(&model_name).is_none() {
605 return Err(ApiError::not_found(format!(
606 "unknown upscaler model '{}'. Run 'mold list' to see available models.",
607 model_name
608 )));
609 }
610
611 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
612 let model_name_owned = model_name.clone();
613 let state_clone = state.clone();
614 let upscaler_cache = state.upscaler_cache.clone();
615
616 tokio::spawn(async move {
617 if needs_pull {
619 let progress_tx = tx.clone();
620 let callback =
621 std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
622 let sse_event = match event {
623 mold_core::download::DownloadProgressEvent::Status { message } => {
624 SseProgressEvent::Info { message }
625 }
626 mold_core::download::DownloadProgressEvent::FileStart {
627 filename,
628 file_index,
629 total_files,
630 size_bytes,
631 batch_bytes_downloaded,
632 batch_bytes_total,
633 batch_elapsed_ms,
634 } => SseProgressEvent::DownloadProgress {
635 filename,
636 file_index,
637 total_files,
638 bytes_downloaded: 0,
639 bytes_total: size_bytes,
640 batch_bytes_downloaded,
641 batch_bytes_total,
642 batch_elapsed_ms,
643 },
644 mold_core::download::DownloadProgressEvent::FileProgress {
645 filename,
646 file_index,
647 bytes_downloaded,
648 bytes_total,
649 batch_bytes_downloaded,
650 batch_bytes_total,
651 batch_elapsed_ms,
652 } => SseProgressEvent::DownloadProgress {
653 filename,
654 file_index,
655 total_files: 0,
656 bytes_downloaded,
657 bytes_total,
658 batch_bytes_downloaded,
659 batch_bytes_total,
660 batch_elapsed_ms,
661 },
662 mold_core::download::DownloadProgressEvent::FileDone {
663 filename,
664 file_index,
665 total_files,
666 batch_bytes_downloaded,
667 batch_bytes_total,
668 batch_elapsed_ms,
669 } => SseProgressEvent::DownloadDone {
670 filename,
671 file_index,
672 total_files,
673 batch_bytes_downloaded,
674 batch_bytes_total,
675 batch_elapsed_ms,
676 },
677 };
678 let _ = progress_tx.send(SseMessage::Progress(sse_event));
679 });
680
681 match model_manager::pull_model(&state_clone, &model_name_owned, Some(callback)).await {
682 Ok(_) => {
683 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
684 model: model_name_owned.clone(),
685 }));
686 }
687 Err(e) => {
688 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
689 message: format!("failed to pull upscaler model: {}", e.error),
690 }));
691 return;
692 }
693 }
694 }
695
696 let weights_path = {
698 let config = state_clone.config.read().await;
699 config
700 .models
701 .get(&model_name_owned)
702 .and_then(|c| c.transformer.as_ref())
703 .map(std::path::PathBuf::from)
704 };
705
706 let Some(weights_path) = weights_path else {
707 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
708 message: format!(
709 "upscaler model '{}' not configured after pull",
710 model_name_owned
711 ),
712 }));
713 return;
714 };
715
716 let result = tokio::task::spawn_blocking(move || {
717 let mut cache = upscaler_cache.lock().unwrap();
718
719 let needs_new = cache
720 .as_ref()
721 .is_none_or(|e| e.model_name() != model_name_owned);
722 if needs_new {
723 let _ = tx.send(SseMessage::Progress(
724 mold_core::SseProgressEvent::StageStart {
725 name: "Loading upscaler model".to_string(),
726 },
727 ));
728 match mold_inference::create_upscale_engine(
729 model_name_owned,
730 weights_path,
731 mold_inference::LoadStrategy::Eager,
732 ) {
733 Ok(new_engine) => {
734 *cache = Some(new_engine);
735 }
736 Err(e) => {
737 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
738 message: format!("failed to load upscaler: {e}"),
739 }));
740 return;
741 }
742 }
743 }
744
745 let engine = cache.as_mut().unwrap();
746
747 let tx_progress = tx.clone();
749 engine.set_on_progress(Box::new(move |event| {
750 let sse_event: mold_core::SseProgressEvent = event.into();
751 let _ = tx_progress.send(SseMessage::Progress(sse_event));
752 }));
753
754 match engine.upscale(&req) {
755 Ok(resp) => {
756 let image_b64 =
757 base64::engine::general_purpose::STANDARD.encode(&resp.image.data);
758 let _ = tx.send(SseMessage::UpscaleComplete(
759 mold_core::SseUpscaleCompleteEvent {
760 image: image_b64,
761 format: resp.image.format,
762 model: resp.model,
763 scale_factor: resp.scale_factor,
764 original_width: resp.original_width,
765 original_height: resp.original_height,
766 upscale_time_ms: resp.upscale_time_ms,
767 },
768 ));
769 }
770 Err(e) => {
771 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
772 message: format!("upscale failed: {e}"),
773 }));
774 }
775 }
776
777 engine.clear_on_progress();
778 })
779 .await;
780
781 if let Err(e) = result {
782 tracing::error!("upscale task panicked: {e}");
783 }
784 });
785
786 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
787 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
788
789 Ok(Sse::new(stream).keep_alive(
790 KeepAlive::new()
791 .interval(std::time::Duration::from_secs(15))
792 .text("ping"),
793 ))
794}
795
796#[utoipa::path(
799 post,
800 path = "/api/generate/stream",
801 tag = "generation",
802 request_body = mold_core::GenerateRequest,
803 responses(
804 (status = 200, description = "SSE event stream with progress and result"),
805 (status = 404, description = "Model not downloaded"),
806 (status = 422, description = "Invalid request parameters"),
807 (status = 500, description = "Inference error"),
808 )
809)]
810async fn generate_stream(
811 State(state): State<AppState>,
812 Json(mut req): Json<mold_core::GenerateRequest>,
813) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
814 let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
815
816 tracing::info!(
817 model = %req.model,
818 prompt = %req.prompt,
819 "generate/stream request"
820 );
821
822 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
824
825 if let Some(warning) = dim_warning {
827 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Info {
828 message: warning,
829 }));
830 }
831
832 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
833 let job = GenerationJob {
834 request: req,
835 progress_tx: Some(tx.clone()),
836 result_tx,
837 output_dir,
838 };
839
840 let position = state.queue.submit(job).await.map_err(ApiError::internal)?;
841
842 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued { position }));
844
845 tokio::spawn(async move {
849 let _ = result_rx.await;
850 drop(tx); });
852
853 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
855 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
856
857 Ok(Sse::new(stream).keep_alive(
858 KeepAlive::new()
859 .interval(std::time::Duration::from_secs(15))
860 .text("ping"),
861 ))
862}
863
864#[utoipa::path(
867 get,
868 path = "/api/models",
869 tag = "models",
870 responses(
871 (status = 200, description = "List of available models", body = Vec<ModelInfoExtended>),
872 )
873)]
874async fn list_models(State(state): State<AppState>) -> Json<Vec<ModelInfoExtended>> {
875 Json(model_manager::list_models(&state).await)
876}
877
878#[derive(Debug, Deserialize, utoipa::ToSchema)]
881pub struct LoadModelBody {
882 #[schema(example = "flux-schnell:q8")]
883 pub model: String,
884}
885
886#[utoipa::path(
887 post,
888 path = "/api/models/load",
889 tag = "models",
890 request_body = LoadModelBody,
891 responses(
892 (status = 200, description = "Model loaded successfully"),
893 (status = 404, description = "Model not downloaded"),
894 (status = 400, description = "Unknown model"),
895 (status = 500, description = "Failed to load model"),
896 )
897)]
898async fn load_model(
899 State(state): State<AppState>,
900 Json(body): Json<LoadModelBody>,
901) -> Result<impl IntoResponse, ApiError> {
902 model_manager::ensure_model_ready(&state, &body.model, None).await?;
903 tracing::info!(model = %body.model, "model loaded via API");
904 Ok(StatusCode::OK)
905}
906
907#[utoipa::path(
910 post,
911 path = "/api/models/pull",
912 tag = "models",
913 request_body = LoadModelBody,
914 responses(
915 (status = 200, description = "Model pulled (SSE stream or plain text)"),
916 (status = 400, description = "Unknown model"),
917 (status = 500, description = "Download failed"),
918 )
919)]
920async fn pull_model_endpoint(
921 State(state): State<AppState>,
922 headers: HeaderMap,
923 Json(body): Json<LoadModelBody>,
924) -> Result<impl IntoResponse, ApiError> {
925 let wants_sse = headers
926 .get(header::ACCEPT)
927 .and_then(|v| v.to_str().ok())
928 .is_some_and(|v| v.contains("text/event-stream"));
929
930 if !wants_sse {
931 return pull_model_blocking(state, body.model)
933 .await
934 .map(PullResponse::Text);
935 }
936
937 let model = body.model.clone();
939
940 if mold_core::manifest::find_manifest(&mold_core::manifest::resolve_model_name(&model))
942 .is_none()
943 {
944 return Err(ApiError::unknown_model(format!(
945 "unknown model '{model}'. Run 'mold list' to see available models."
946 )));
947 }
948
949 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
950
951 tokio::spawn(async move {
952 let progress_tx = tx.clone();
953 let model_for_cb = model.clone();
954 let callback =
955 std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
956 let sse_event = match event {
957 mold_core::download::DownloadProgressEvent::Status { message } => {
958 SseProgressEvent::Info { message }
959 }
960 mold_core::download::DownloadProgressEvent::FileStart {
961 filename,
962 file_index,
963 total_files,
964 size_bytes,
965 batch_bytes_downloaded,
966 batch_bytes_total,
967 batch_elapsed_ms,
968 } => SseProgressEvent::DownloadProgress {
969 filename,
970 file_index,
971 total_files,
972 bytes_downloaded: 0,
973 bytes_total: size_bytes,
974 batch_bytes_downloaded,
975 batch_bytes_total,
976 batch_elapsed_ms,
977 },
978 mold_core::download::DownloadProgressEvent::FileProgress {
979 filename,
980 file_index,
981 bytes_downloaded,
982 bytes_total,
983 batch_bytes_downloaded,
984 batch_bytes_total,
985 batch_elapsed_ms,
986 } => SseProgressEvent::DownloadProgress {
987 filename,
988 file_index,
989 total_files: 0,
990 bytes_downloaded,
991 bytes_total,
992 batch_bytes_downloaded,
993 batch_bytes_total,
994 batch_elapsed_ms,
995 },
996 mold_core::download::DownloadProgressEvent::FileDone {
997 filename,
998 file_index,
999 total_files,
1000 batch_bytes_downloaded,
1001 batch_bytes_total,
1002 batch_elapsed_ms,
1003 } => SseProgressEvent::DownloadDone {
1004 filename,
1005 file_index,
1006 total_files,
1007 batch_bytes_downloaded,
1008 batch_bytes_total,
1009 batch_elapsed_ms,
1010 },
1011 };
1012 let _ = progress_tx.send(SseMessage::Progress(sse_event));
1013 });
1014
1015 match model_manager::pull_model(&state, &model, Some(callback)).await {
1016 Ok(model_manager::PullStatus::AlreadyAvailable) => {
1017 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
1018 model: model_for_cb,
1019 }));
1020 }
1021 Ok(model_manager::PullStatus::Pulled) => {
1022 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
1023 model: model_for_cb,
1024 }));
1025 }
1026 Err(e) => {
1027 let _ = tx.send(SseMessage::Error(SseErrorEvent { message: e.error }));
1028 }
1029 }
1030 });
1031
1032 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
1033 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
1034
1035 Ok(PullResponse::Sse(
1036 Sse::new(stream)
1037 .keep_alive(
1038 KeepAlive::new()
1039 .interval(std::time::Duration::from_secs(15))
1040 .text("ping"),
1041 )
1042 .into_response(),
1043 ))
1044}
1045
1046async fn pull_model_blocking(state: AppState, model: String) -> Result<String, ApiError> {
1048 match model_manager::pull_model(&state, &model, None).await? {
1049 model_manager::PullStatus::AlreadyAvailable => {
1050 Ok(format!("model '{}' already available", model))
1051 }
1052 model_manager::PullStatus::Pulled => Ok(format!("model '{}' pulled successfully", model)),
1053 }
1054}
1055
1056enum PullResponse {
1058 Sse(axum::response::Response),
1059 Text(String),
1060}
1061
1062impl IntoResponse for PullResponse {
1063 fn into_response(self) -> axum::response::Response {
1064 match self {
1065 PullResponse::Sse(resp) => resp,
1066 PullResponse::Text(text) => text.into_response(),
1067 }
1068 }
1069}
1070
1071#[utoipa::path(
1074 delete,
1075 path = "/api/models/unload",
1076 tag = "models",
1077 responses(
1078 (status = 200, description = "Model unloaded or no model was loaded", body = String),
1079 )
1080)]
1081async fn unload_model(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
1082 Ok((StatusCode::OK, model_manager::unload_model(&state).await))
1083}
1084
1085#[utoipa::path(
1088 get,
1089 path = "/api/status",
1090 tag = "server",
1091 responses(
1092 (status = 200, description = "Server status", body = ServerStatus),
1093 )
1094)]
1095async fn server_status(State(state): State<AppState>) -> Json<ServerStatus> {
1096 let snapshot = state.engine_snapshot.read().await.clone();
1097 let models_loaded = match (snapshot.model_name, snapshot.is_loaded) {
1098 (Some(model_name), true) => vec![model_name],
1099 _ => vec![],
1100 };
1101 let current_generation = state
1102 .active_generation
1103 .read()
1104 .unwrap_or_else(|e| e.into_inner())
1105 .as_ref()
1106 .map(|active| ActiveGenerationStatus {
1107 model: active.model.clone(),
1108 prompt_sha256: active.prompt_sha256.clone(),
1109 started_at_unix_ms: active.started_at_unix_ms,
1110 elapsed_ms: active.started_at.elapsed().as_millis() as u64,
1111 });
1112 let busy = current_generation.is_some();
1113
1114 Json(ServerStatus {
1115 version: env!("CARGO_PKG_VERSION").to_string(),
1116 git_sha: if mold_core::build_info::GIT_SHA == "unknown" {
1117 None
1118 } else {
1119 Some(mold_core::build_info::GIT_SHA.to_string())
1120 },
1121 build_date: if mold_core::build_info::BUILD_DATE == "unknown" {
1122 None
1123 } else {
1124 Some(mold_core::build_info::BUILD_DATE.to_string())
1125 },
1126 models_loaded,
1127 busy,
1128 current_generation,
1129 gpu_info: query_gpu_info(),
1130 uptime_secs: state.start_time.elapsed().as_secs(),
1131 hostname: hostname::get().ok().and_then(|h| h.into_string().ok()),
1132 memory_status: mold_inference::device::memory_status_string(),
1133 })
1134}
1135
1136#[utoipa::path(
1139 get,
1140 path = "/health",
1141 tag = "server",
1142 responses(
1143 (status = 200, description = "Server is healthy"),
1144 )
1145)]
1146async fn health() -> impl IntoResponse {
1147 StatusCode::OK
1148}
1149
1150async fn server_capabilities() -> Json<mold_core::ServerCapabilities> {
1156 Json(mold_core::ServerCapabilities {
1157 gallery: mold_core::GalleryCapabilities {
1158 can_delete: gallery_delete_allowed(),
1159 },
1160 })
1161}
1162
1163#[utoipa::path(
1171 post,
1172 path = "/api/shutdown",
1173 tag = "server",
1174 responses(
1175 (status = 200, description = "Shutdown initiated"),
1176 (status = 403, description = "Forbidden — remote shutdown requires API key auth"),
1177 )
1178)]
1179async fn shutdown_server(State(state): State<AppState>, request: Request) -> impl IntoResponse {
1180 let auth_enabled = request
1183 .extensions()
1184 .get::<crate::auth::AuthState>()
1185 .is_some_and(|s| s.is_some());
1186
1187 if !auth_enabled {
1188 let is_loopback = request
1189 .extensions()
1190 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
1191 .map(|ci| ci.0.ip().is_loopback())
1192 .unwrap_or(false);
1193 if !is_loopback {
1194 return (
1195 StatusCode::FORBIDDEN,
1196 "shutdown requires API key auth or localhost access\n",
1197 );
1198 }
1199 }
1200
1201 tracing::info!("shutdown requested via API");
1202 if let Some(tx) = state.shutdown_tx.lock().await.take() {
1203 let _ = tx.send(());
1204 }
1205 (StatusCode::OK, "shutdown initiated\n")
1206}
1207
1208async fn list_gallery(
1213 State(state): State<AppState>,
1214) -> Result<Json<Vec<mold_core::GalleryImage>>, ApiError> {
1215 let config = state.config.read().await;
1216 if config.is_output_disabled() {
1217 return Ok(Json(Vec::new()));
1218 }
1219 let output_dir = config.effective_output_dir();
1220 drop(config);
1221
1222 if !output_dir.is_dir() {
1223 return Ok(Json(Vec::new()));
1224 }
1225
1226 let images = tokio::task::spawn_blocking(move || scan_gallery_dir(&output_dir))
1227 .await
1228 .map_err(|e| ApiError::internal(format!("gallery scan failed: {e}")))?;
1229
1230 Ok(Json(images))
1231}
1232
1233async fn get_gallery_image(
1243 State(state): State<AppState>,
1244 headers: HeaderMap,
1245 axum::extract::Path(filename): axum::extract::Path<String>,
1246) -> Result<axum::response::Response, ApiError> {
1247 let config = state.config.read().await;
1248 if config.is_output_disabled() {
1249 return Err(ApiError::not_found("image output is disabled"));
1250 }
1251 let output_dir = config.effective_output_dir();
1252 drop(config);
1253
1254 let clean_name = std::path::Path::new(&filename)
1256 .file_name()
1257 .map(|f| f.to_string_lossy().to_string())
1258 .unwrap_or_default();
1259
1260 if clean_name.is_empty() || clean_name != filename {
1261 return Err(ApiError::validation("invalid filename"));
1262 }
1263
1264 let path = output_dir.join(&clean_name);
1265 let meta = match tokio::fs::metadata(&path).await {
1266 Ok(m) if m.is_file() => m,
1267 _ => {
1268 return Err(ApiError::not_found(format!(
1269 "image not found: {clean_name}"
1270 )));
1271 }
1272 };
1273 let total_len = meta.len();
1274 let content_type = content_type_for_filename(&clean_name);
1275
1276 let range_header = headers
1277 .get(header::RANGE)
1278 .and_then(|v| v.to_str().ok())
1279 .map(|s| s.to_string());
1280
1281 let file = tokio::fs::File::open(&path)
1282 .await
1283 .map_err(|e| ApiError::internal(format!("failed to open file: {e}")))?;
1284
1285 if let Some(raw) = range_header {
1286 if let Some((start, end)) = parse_byte_range(&raw, total_len) {
1287 return serve_range(file, start, end, total_len, content_type).await;
1288 } else {
1289 return Ok(axum::response::Response::builder()
1291 .status(StatusCode::RANGE_NOT_SATISFIABLE)
1292 .header(header::CONTENT_RANGE, format!("bytes */{total_len}"))
1293 .body(axum::body::Body::empty())
1294 .unwrap());
1295 }
1296 }
1297
1298 let stream = tokio_util::io::ReaderStream::new(file);
1300 let body = axum::body::Body::from_stream(stream);
1301 Ok(axum::response::Response::builder()
1302 .status(StatusCode::OK)
1303 .header(header::CONTENT_TYPE, content_type)
1304 .header(header::ACCEPT_RANGES, "bytes")
1305 .header(header::CONTENT_LENGTH, total_len)
1306 .header(header::CACHE_CONTROL, "public, max-age=3600")
1307 .body(body)
1308 .unwrap())
1309}
1310
1311fn parse_byte_range(header: &str, total_len: u64) -> Option<(u64, u64)> {
1319 let spec = header.strip_prefix("bytes=")?;
1320 if spec.contains(',') {
1321 return None;
1322 }
1323 let (start_s, end_s) = spec.split_once('-')?;
1324 let start_s = start_s.trim();
1325 let end_s = end_s.trim();
1326
1327 if total_len == 0 {
1328 return None;
1329 }
1330
1331 if start_s.is_empty() {
1332 let suffix: u64 = end_s.parse().ok()?;
1334 if suffix == 0 {
1335 return None;
1336 }
1337 let start = total_len.saturating_sub(suffix);
1338 return Some((start, total_len - 1));
1339 }
1340
1341 let start: u64 = start_s.parse().ok()?;
1342 if start >= total_len {
1343 return None;
1344 }
1345 let end: u64 = if end_s.is_empty() {
1346 total_len - 1
1347 } else {
1348 end_s.parse().ok()?
1349 };
1350 let end = end.min(total_len - 1);
1351 if end < start {
1352 return None;
1353 }
1354 Some((start, end))
1355}
1356
1357async fn serve_range(
1361 mut file: tokio::fs::File,
1362 start: u64,
1363 end: u64,
1364 total_len: u64,
1365 content_type: &'static str,
1366) -> Result<axum::response::Response, ApiError> {
1367 use tokio::io::{AsyncReadExt, AsyncSeekExt};
1368 file.seek(std::io::SeekFrom::Start(start))
1369 .await
1370 .map_err(|e| ApiError::internal(format!("seek failed: {e}")))?;
1371 let len = end - start + 1;
1372 let stream = tokio_util::io::ReaderStream::new(file.take(len));
1373 let body = axum::body::Body::from_stream(stream);
1374 Ok(axum::response::Response::builder()
1375 .status(StatusCode::PARTIAL_CONTENT)
1376 .header(header::CONTENT_TYPE, content_type)
1377 .header(header::ACCEPT_RANGES, "bytes")
1378 .header(header::CONTENT_LENGTH, len)
1379 .header(
1380 header::CONTENT_RANGE,
1381 format!("bytes {start}-{end}/{total_len}"),
1382 )
1383 .header(header::CACHE_CONTROL, "public, max-age=300")
1386 .body(body)
1387 .unwrap())
1388}
1389
1390fn content_type_for_filename(name: &str) -> &'static str {
1393 let lower = name.to_ascii_lowercase();
1394 if lower.ends_with(".png") {
1395 "image/png"
1396 } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
1397 "image/jpeg"
1398 } else if lower.ends_with(".gif") {
1399 "image/gif"
1400 } else if lower.ends_with(".webp") {
1401 "image/webp"
1402 } else if lower.ends_with(".apng") {
1403 "image/apng"
1404 } else if lower.ends_with(".mp4") {
1405 "video/mp4"
1406 } else {
1407 "application/octet-stream"
1408 }
1409}
1410
1411async fn delete_gallery_image(
1418 State(state): State<AppState>,
1419 axum::extract::Path(filename): axum::extract::Path<String>,
1420) -> Result<impl IntoResponse, ApiError> {
1421 if !gallery_delete_allowed() {
1422 return Err(ApiError::forbidden(
1423 "gallery delete is disabled; set MOLD_GALLERY_ALLOW_DELETE=1 to enable",
1424 ));
1425 }
1426 let config = state.config.read().await;
1427 if config.is_output_disabled() {
1428 return Err(ApiError::not_found("image output is disabled"));
1429 }
1430 let output_dir = config.effective_output_dir();
1431 drop(config);
1432
1433 let clean_name = std::path::Path::new(&filename)
1434 .file_name()
1435 .map(|f| f.to_string_lossy().to_string())
1436 .unwrap_or_default();
1437
1438 if clean_name.is_empty() || clean_name != filename {
1439 return Err(ApiError::validation("invalid filename"));
1440 }
1441
1442 let path = output_dir.join(&clean_name);
1443 if path.is_file() {
1444 std::fs::remove_file(&path)
1445 .map_err(|e| ApiError::internal(format!("failed to delete image: {e}")))?;
1446 }
1447
1448 let thumb_dir = server_thumbnail_dir();
1451 let _ = std::fs::remove_file(thumb_dir.join(&clean_name));
1452 let _ = std::fs::remove_file(thumb_dir.join(format!("{clean_name}.png")));
1453
1454 Ok(StatusCode::NO_CONTENT)
1455}
1456
1457fn gallery_delete_allowed() -> bool {
1461 std::env::var("MOLD_GALLERY_ALLOW_DELETE")
1462 .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
1463 .unwrap_or(false)
1464}
1465
1466async fn get_gallery_thumbnail(
1469 State(state): State<AppState>,
1470 axum::extract::Path(filename): axum::extract::Path<String>,
1471) -> Result<impl IntoResponse, ApiError> {
1472 let config = state.config.read().await;
1473 if config.is_output_disabled() {
1474 return Err(ApiError::not_found("image output is disabled"));
1475 }
1476 let output_dir = config.effective_output_dir();
1477 drop(config);
1478
1479 let clean_name = std::path::Path::new(&filename)
1480 .file_name()
1481 .map(|f| f.to_string_lossy().to_string())
1482 .unwrap_or_default();
1483
1484 if clean_name.is_empty() || clean_name != filename {
1485 return Err(ApiError::validation("invalid filename"));
1486 }
1487
1488 let source_path = output_dir.join(&clean_name);
1489 if !source_path.is_file() {
1490 return Err(ApiError::not_found(format!(
1491 "image not found: {clean_name}"
1492 )));
1493 }
1494
1495 let thumb_dir = server_thumbnail_dir();
1499 let thumb_path = thumb_dir.join(format!("{clean_name}.png"));
1500 let lower = clean_name.to_ascii_lowercase();
1501 let is_video = lower.ends_with(".mp4");
1502
1503 if !thumb_path.is_file() {
1504 let source = source_path.clone();
1511 let dest = thumb_path.clone();
1512 let gen_result = tokio::task::spawn_blocking(move || {
1513 if is_video {
1514 generate_video_thumbnail(&source, &dest)
1515 } else {
1516 generate_server_thumbnail(&source, &dest)
1517 }
1518 })
1519 .await
1520 .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?;
1521
1522 if let Err(err) = gen_result {
1523 tracing::warn!(
1524 file = %clean_name,
1525 error = %err,
1526 "thumbnail decode failed; falling back to source bytes"
1527 );
1528 if is_video {
1532 let mut headers = HeaderMap::new();
1533 headers.insert(
1534 header::CONTENT_TYPE,
1535 HeaderValue::from_static("image/svg+xml"),
1536 );
1537 headers.insert(
1538 header::CACHE_CONTROL,
1539 HeaderValue::from_static("public, max-age=300"),
1540 );
1541 return Ok((headers, VIDEO_PLACEHOLDER_SVG.as_bytes().to_vec()));
1542 }
1543 let raw = tokio::fs::read(&source_path)
1544 .await
1545 .map_err(|e| ApiError::internal(format!("failed to read source: {e}")))?;
1546 let mut headers = HeaderMap::new();
1547 headers.insert(
1548 header::CONTENT_TYPE,
1549 HeaderValue::from_static(content_type_for_filename(&clean_name)),
1550 );
1551 headers.insert(
1552 header::CACHE_CONTROL,
1553 HeaderValue::from_static("public, max-age=300"),
1554 );
1555 return Ok((headers, raw));
1556 }
1557 }
1558
1559 let data = tokio::fs::read(&thumb_path)
1560 .await
1561 .map_err(|e| ApiError::internal(format!("failed to read thumbnail: {e}")))?;
1562
1563 let mut headers = HeaderMap::new();
1564 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
1565 headers.insert(
1566 header::CACHE_CONTROL,
1567 HeaderValue::from_static("public, max-age=3600"),
1568 );
1569
1570 Ok((headers, data))
1571}
1572
1573const 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>"##;
1574
1575fn server_thumbnail_dir() -> std::path::PathBuf {
1577 mold_core::Config::mold_dir()
1578 .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
1579 .join("cache")
1580 .join("thumbnails")
1581}
1582
1583fn generate_server_thumbnail(
1587 source: &std::path::Path,
1588 dest: &std::path::Path,
1589) -> anyhow::Result<()> {
1590 let img = image::open(source)?;
1591 let thumb = img.thumbnail(256, 256);
1592 if let Some(parent) = dest.parent() {
1593 std::fs::create_dir_all(parent)?;
1594 }
1595 thumb.save_with_format(dest, image::ImageFormat::Png)?;
1596 Ok(())
1597}
1598
1599fn generate_video_thumbnail(
1607 source: &std::path::Path,
1608 dest: &std::path::Path,
1609) -> anyhow::Result<()> {
1610 if let Some(parent) = dest.parent() {
1611 std::fs::create_dir_all(parent)?;
1612 }
1613 let tmp = dest.with_extension("firstframe.png");
1618 mold_inference::ltx2::media::extract_thumbnail(source, &tmp)?;
1619 let decode_result = (|| -> anyhow::Result<()> {
1620 let img = image::open(&tmp)?;
1621 let thumb = img.thumbnail(256, 256);
1622 thumb.save_with_format(dest, image::ImageFormat::Png)?;
1623 Ok(())
1624 })();
1625 let _ = std::fs::remove_file(&tmp);
1626 decode_result
1627}
1628
1629pub fn spawn_thumbnail_warmup(config: &mold_core::Config) {
1631 if !thumbnail_warmup_enabled() {
1632 tracing::info!("thumbnail warmup disabled; thumbnails will be generated on demand");
1633 return;
1634 }
1635
1636 let output_dir = config.effective_output_dir();
1637 std::thread::spawn(move || {
1638 if !output_dir.is_dir() {
1639 return;
1640 }
1641 let thumb_dir = server_thumbnail_dir();
1642 let walker = walkdir::WalkDir::new(&output_dir).max_depth(1).into_iter();
1643 for entry in walker.filter_map(|e| e.ok()) {
1644 let path = entry.path();
1645 if !path.is_file() {
1646 continue;
1647 }
1648 let ext = path
1649 .extension()
1650 .and_then(|e| e.to_str())
1651 .map(|e| e.to_lowercase());
1652 let is_raster = matches!(
1653 ext.as_deref(),
1654 Some("png" | "jpg" | "jpeg" | "gif" | "apng" | "webp")
1655 );
1656 let is_video = matches!(ext.as_deref(), Some("mp4"));
1657 if !is_raster && !is_video {
1658 continue;
1659 }
1660 let filename = path
1661 .file_name()
1662 .map(|f| f.to_string_lossy().to_string())
1663 .unwrap_or_default();
1664 let thumb_path = thumb_dir.join(format!("{filename}.png"));
1665 if thumb_path.is_file() {
1666 continue;
1667 }
1668 let result = if is_video {
1669 generate_video_thumbnail(path, &thumb_path)
1670 } else {
1671 generate_server_thumbnail(path, &thumb_path)
1672 };
1673 if let Err(e) = result {
1674 tracing::warn!("failed to generate thumbnail for {}: {e}", path.display());
1675 }
1676 }
1677 tracing::info!("thumbnail warmup complete");
1678 });
1679}
1680
1681fn thumbnail_warmup_enabled() -> bool {
1682 std::env::var("MOLD_THUMBNAIL_WARMUP")
1683 .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
1684 .unwrap_or(false)
1685}
1686
1687fn scan_gallery_dir(dir: &std::path::Path) -> Vec<mold_core::GalleryImage> {
1706 let mut images = Vec::new();
1707
1708 let walker = walkdir::WalkDir::new(dir).max_depth(1).into_iter();
1709 for entry in walker.filter_map(|e| e.ok()) {
1710 let path = entry.path();
1711 if !path.is_file() {
1712 continue;
1713 }
1714
1715 let ext = path
1716 .extension()
1717 .and_then(|e| e.to_str())
1718 .map(|e| e.to_lowercase());
1719 let format = match ext.as_deref() {
1720 Some("png") => Some(mold_core::OutputFormat::Png),
1721 Some("jpg") | Some("jpeg") => Some(mold_core::OutputFormat::Jpeg),
1722 Some("gif") => Some(mold_core::OutputFormat::Gif),
1723 Some("apng") => Some(mold_core::OutputFormat::Apng),
1724 Some("webp") => Some(mold_core::OutputFormat::Webp),
1725 Some("mp4") => Some(mold_core::OutputFormat::Mp4),
1726 _ => None,
1727 };
1728 let Some(format) = format else { continue };
1729
1730 let fs_meta = entry.metadata().ok();
1731 let timestamp = fs_meta
1732 .as_ref()
1733 .and_then(|m| m.modified().ok())
1734 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1735 .map(|d| d.as_secs())
1736 .unwrap_or(0);
1737 let size_bytes = fs_meta.as_ref().map(|m| m.len()).unwrap_or(0);
1738
1739 if size_bytes < min_valid_size(format) {
1741 continue;
1742 }
1743
1744 let header_ok = match format {
1746 mold_core::OutputFormat::Mp4 => has_ftyp_box(path),
1747 _ => image_header_dims(path).is_some(),
1748 };
1749 if !header_ok {
1750 continue;
1751 }
1752
1753 if !matches!(format, mold_core::OutputFormat::Mp4)
1758 && is_probably_solid_black(path, format, size_bytes)
1759 {
1760 continue;
1761 }
1762
1763 let filename = path
1764 .file_name()
1765 .map(|f| f.to_string_lossy().to_string())
1766 .unwrap_or_default();
1767
1768 let embedded = match ext.as_deref() {
1771 Some("png") | Some("apng") => read_png_metadata(path),
1772 Some("jpg") | Some("jpeg") => read_jpeg_metadata(path),
1773 _ => None,
1774 };
1775
1776 let (metadata, synthetic) = match embedded {
1777 Some(m) => (m, false),
1778 None => {
1779 let mut meta = synthesize_metadata_from_filename(&filename, timestamp);
1783 if !matches!(format, mold_core::OutputFormat::Mp4) {
1784 if let Some((w, h)) = image_header_dims(path) {
1785 meta.width = w;
1786 meta.height = h;
1787 }
1788 }
1789 (meta, true)
1790 }
1791 };
1792
1793 images.push(mold_core::GalleryImage {
1794 filename,
1795 metadata,
1796 timestamp,
1797 format: Some(format),
1798 size_bytes: Some(size_bytes),
1799 metadata_synthetic: synthetic,
1800 });
1801 }
1802
1803 images.sort_by_key(|img| std::cmp::Reverse(img.timestamp));
1804 images
1805}
1806
1807fn min_valid_size(format: mold_core::OutputFormat) -> u64 {
1813 match format {
1814 mold_core::OutputFormat::Png
1819 | mold_core::OutputFormat::Apng
1820 | mold_core::OutputFormat::Jpeg
1821 | mold_core::OutputFormat::Webp => 256,
1822 mold_core::OutputFormat::Gif => 128,
1823 mold_core::OutputFormat::Mp4 => 4096,
1825 }
1826}
1827
1828fn image_header_dims(path: &std::path::Path) -> Option<(u32, u32)> {
1833 image::ImageReader::open(path)
1834 .ok()?
1835 .with_guessed_format()
1836 .ok()?
1837 .into_dimensions()
1838 .ok()
1839}
1840
1841fn is_probably_solid_black(
1849 path: &std::path::Path,
1850 format: mold_core::OutputFormat,
1851 size_bytes: u64,
1852) -> bool {
1853 const SAMPLE_DIM: u32 = 16;
1854 const CHANNEL_CEILING: u8 = 16;
1859
1860 let suspect_threshold: u64 = match format {
1861 mold_core::OutputFormat::Png | mold_core::OutputFormat::Apng => 8 * 1024,
1864 mold_core::OutputFormat::Jpeg => 4 * 1024,
1866 mold_core::OutputFormat::Gif | mold_core::OutputFormat::Webp => 4 * 1024,
1867 mold_core::OutputFormat::Mp4 => return false,
1868 };
1869 if size_bytes > suspect_threshold {
1870 return false;
1871 }
1872
1873 let Ok(img) = image::open(path) else {
1874 return false;
1875 };
1876 let thumb = img.thumbnail(SAMPLE_DIM, SAMPLE_DIM).to_rgb8();
1877 let mut max_channel: u8 = 0;
1878 for pixel in thumb.pixels() {
1879 let m = pixel.0[0].max(pixel.0[1]).max(pixel.0[2]);
1880 if m > max_channel {
1881 max_channel = m;
1882 }
1883 if max_channel > CHANNEL_CEILING {
1884 return false;
1885 }
1886 }
1887 max_channel <= CHANNEL_CEILING
1888}
1889
1890fn has_ftyp_box(path: &std::path::Path) -> bool {
1894 use std::io::Read;
1895 let Ok(mut f) = std::fs::File::open(path) else {
1896 return false;
1897 };
1898 let mut buf = [0u8; 12];
1899 if f.read_exact(&mut buf).is_err() {
1900 return false;
1901 }
1902 &buf[4..8] == b"ftyp"
1903}
1904
1905fn synthesize_metadata_from_filename(filename: &str, timestamp: u64) -> mold_core::OutputMetadata {
1911 let stem = std::path::Path::new(filename)
1912 .file_stem()
1913 .and_then(|s| s.to_str())
1914 .unwrap_or("");
1915
1916 let model = stem
1917 .strip_prefix("mold-")
1918 .and_then(|rest| {
1919 let mut parts: Vec<&str> = rest.split('-').collect();
1922 while parts
1923 .last()
1924 .map(|p| p.chars().all(|c| c.is_ascii_digit()))
1925 .unwrap_or(false)
1926 && parts.len() > 1
1927 {
1928 parts.pop();
1929 }
1930 if parts.is_empty() {
1931 None
1932 } else {
1933 Some(parts.join("-"))
1934 }
1935 })
1936 .unwrap_or_else(|| "unknown".to_string());
1937
1938 mold_core::OutputMetadata {
1939 prompt: String::new(),
1940 negative_prompt: None,
1941 original_prompt: None,
1942 model,
1943 seed: 0,
1944 steps: 0,
1945 guidance: 0.0,
1946 width: 0,
1947 height: 0,
1948 strength: None,
1949 scheduler: None,
1950 lora: None,
1951 lora_scale: None,
1952 frames: None,
1953 fps: None,
1954 version: format!("synthesized@{timestamp}"),
1955 }
1956}
1957
1958fn read_png_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
1960 let file = std::fs::File::open(path).ok()?;
1961 let decoder = png::Decoder::new(std::io::BufReader::new(file));
1962 let reader = decoder.read_info().ok()?;
1963 let info = reader.info();
1964
1965 for chunk in &info.uncompressed_latin1_text {
1966 if chunk.keyword == "mold:parameters" {
1967 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&chunk.text) {
1968 return Some(meta);
1969 }
1970 }
1971 }
1972 for chunk in &info.utf8_text {
1973 if chunk.keyword == "mold:parameters" {
1974 if let Ok(text) = chunk.get_text() {
1975 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&text) {
1976 return Some(meta);
1977 }
1978 }
1979 }
1980 }
1981 None
1982}
1983
1984fn read_jpeg_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
1986 let data = std::fs::read(path).ok()?;
1987 let mut i = 0;
1988 while i + 1 < data.len() {
1989 if data[i] != 0xFF {
1990 i += 1;
1991 continue;
1992 }
1993 let marker = data[i + 1];
1994 match marker {
1995 0xD8 | 0x01 => {
1997 i += 2;
1998 }
1999 0xD9 => break, 0xD0..=0xD7 => {
2001 i += 2; }
2003 0xFE => {
2005 if i + 3 >= data.len() {
2006 break;
2007 }
2008 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2009 if len < 2 || i + 2 + len > data.len() {
2010 break;
2011 }
2012 let comment = &data[i + 4..i + 2 + len];
2013 if let Ok(text) = std::str::from_utf8(comment) {
2014 if let Some(json) = text.strip_prefix("mold:parameters ") {
2015 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(json) {
2016 return Some(meta);
2017 }
2018 }
2019 }
2020 i += 2 + len;
2021 }
2022 _ => {
2024 if i + 3 >= data.len() {
2025 break;
2026 }
2027 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2028 if len < 2 || i + 2 + len > data.len() {
2029 break;
2030 }
2031 i += 2 + len;
2032 }
2033 }
2034 }
2035 None
2036}
2037
2038async fn openapi_json() -> impl IntoResponse {
2041 Json(ApiDoc::openapi())
2042}
2043
2044async fn scalar_docs() -> impl IntoResponse {
2047 (
2048 [(header::CONTENT_TYPE, "text/html")],
2049 r#"<!DOCTYPE html>
2050<html>
2051<head>
2052 <title>mold API</title>
2053 <meta charset="utf-8" />
2054 <meta name="viewport" content="width=device-width, initial-scale=1" />
2055</head>
2056<body>
2057 <script id="api-reference" data-url="/api/openapi.json"></script>
2058 <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
2059</body>
2060</html>"#,
2061 )
2062}
2063
2064fn query_gpu_info() -> Option<GpuInfo> {
2067 let nvidia_smi = if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
2068 "/run/current-system/sw/bin/nvidia-smi"
2069 } else {
2070 "nvidia-smi"
2071 };
2072
2073 let output = std::process::Command::new(nvidia_smi)
2074 .args([
2075 "--query-gpu=name,memory.total,memory.used",
2076 "--format=csv,noheader,nounits",
2077 ])
2078 .output()
2079 .ok()?;
2080
2081 if !output.status.success() {
2082 return None;
2083 }
2084
2085 let text = String::from_utf8(output.stdout).ok()?;
2086 let line = text.lines().next()?;
2087 let parts: Vec<&str> = line.split(',').map(str::trim).collect();
2088 if parts.len() < 3 {
2089 return None;
2090 }
2091
2092 Some(GpuInfo {
2093 name: parts[0].to_string(),
2094 vram_total_mb: parts[1].parse().ok()?,
2095 vram_used_mb: parts[2].parse().ok()?,
2096 })
2097}
2098
2099#[cfg(test)]
2100mod tests {
2101 use super::*;
2102
2103 fn env_lock() -> &'static std::sync::Mutex<()> {
2104 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2105 &ENV_LOCK
2106 }
2107
2108 #[test]
2109 fn clean_error_message_strips_backtrace() {
2110 let err = anyhow::anyhow!(
2111 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")\n\
2112 \x20 0: candle_core::error::Error::bt\n\
2113 \x20 at /home/user/.cargo/git/candle/src/error.rs:264:25\n\
2114 \x20 1: <core::result::Result<O,E> as candle_core::cuda_backend::error::WrapErr<O>>::w\n\
2115 \x20 at /home/user/.cargo/git/candle/src/cuda_backend/error.rs:60:65"
2116 );
2117 let msg = clean_error_message(&err);
2118 assert_eq!(
2119 msg,
2120 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")"
2121 );
2122 }
2123
2124 #[test]
2125 fn clean_error_message_preserves_simple_error() {
2126 let err = anyhow::anyhow!("model not found: flux-dev:q4");
2127 let msg = clean_error_message(&err);
2128 assert_eq!(msg, "model not found: flux-dev:q4");
2129 }
2130
2131 #[test]
2132 fn clean_error_message_preserves_multiline_without_backtrace() {
2133 let err = anyhow::anyhow!("validation failed\nprompt is empty\nsteps must be > 0");
2134 let msg = clean_error_message(&err);
2135 assert_eq!(msg, "validation failed\nprompt is empty\nsteps must be > 0");
2136 }
2137
2138 #[test]
2139 fn clean_error_message_strips_high_numbered_frames() {
2140 let err = anyhow::anyhow!(
2141 "some error\n\
2142 \x20 10: tokio::runtime::task::core::Core<T,S>::poll at /home/user/.cargo/tokio/src/core.rs:375\n\
2143 \x20 11: std::panicking::catch_unwind at /nix/store/rust/src/panicking.rs:544"
2144 );
2145 let msg = clean_error_message(&err);
2146 assert_eq!(msg, "some error");
2147 }
2148
2149 #[test]
2150 fn clean_error_message_empty_fallback() {
2151 let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
2153 let msg = clean_error_message(&err);
2154 assert!(!msg.is_empty());
2156 }
2157
2158 #[test]
2159 fn save_image_to_dir_creates_directory_and_writes_file() {
2160 let dir = std::env::temp_dir().join(format!(
2161 "mold-save-test-{}",
2162 std::time::SystemTime::now()
2163 .duration_since(std::time::UNIX_EPOCH)
2164 .unwrap()
2165 .as_nanos()
2166 ));
2167 assert!(!dir.exists());
2168
2169 let img = mold_core::ImageData {
2170 data: vec![0x89, 0x50, 0x4E, 0x47], format: mold_core::OutputFormat::Png,
2172 width: 64,
2173 height: 64,
2174 index: 0,
2175 };
2176
2177 save_image_to_dir(&dir, &img, "test-model:q8", 1);
2178
2179 assert!(dir.exists(), "directory should be created");
2180 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
2181 assert_eq!(files.len(), 1, "should have exactly one file");
2182 let file = files[0].as_ref().unwrap();
2183 let filename = file.file_name().to_str().unwrap().to_string();
2184 assert!(filename.starts_with("mold-test-model-q8-"), "{filename}");
2185 assert!(filename.ends_with(".png"), "{filename}");
2186 let contents = std::fs::read(file.path()).unwrap();
2187 assert_eq!(contents, vec![0x89, 0x50, 0x4E, 0x47]);
2188
2189 std::fs::remove_dir_all(&dir).ok();
2190 }
2191
2192 #[test]
2193 fn save_image_to_dir_batch_includes_index() {
2194 let dir = std::env::temp_dir().join(format!(
2195 "mold-save-batch-{}",
2196 std::time::SystemTime::now()
2197 .duration_since(std::time::UNIX_EPOCH)
2198 .unwrap()
2199 .as_nanos()
2200 ));
2201
2202 let img = mold_core::ImageData {
2203 data: vec![0xFF, 0xD8], format: mold_core::OutputFormat::Jpeg,
2205 width: 64,
2206 height: 64,
2207 index: 2,
2208 };
2209
2210 save_image_to_dir(&dir, &img, "flux-dev", 4);
2211
2212 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
2213 assert_eq!(files.len(), 1);
2214 let filename = files[0]
2215 .as_ref()
2216 .unwrap()
2217 .file_name()
2218 .to_str()
2219 .unwrap()
2220 .to_string();
2221 assert!(
2222 filename.contains("-2.jpeg"),
2223 "batch index in name: {filename}"
2224 );
2225
2226 std::fs::remove_dir_all(&dir).ok();
2227 }
2228
2229 #[test]
2230 fn save_image_to_dir_invalid_path_logs_warning_no_panic() {
2231 let img = mold_core::ImageData {
2233 data: vec![0x00],
2234 format: mold_core::OutputFormat::Png,
2235 width: 1,
2236 height: 1,
2237 index: 0,
2238 };
2239 save_image_to_dir(
2241 std::path::Path::new("/dev/null/impossible"),
2242 &img,
2243 "test",
2244 1,
2245 );
2246 }
2248
2249 #[test]
2250 fn thumbnail_warmup_is_disabled_by_default() {
2251 let _guard = env_lock().lock().unwrap();
2252 unsafe {
2253 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
2254 }
2255 assert!(!thumbnail_warmup_enabled());
2256 }
2257
2258 #[test]
2259 fn thumbnail_warmup_accepts_truthy_env_values() {
2260 let _guard = env_lock().lock().unwrap();
2261 unsafe {
2262 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "1");
2263 }
2264 assert!(thumbnail_warmup_enabled());
2265 unsafe {
2266 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "true");
2267 }
2268 assert!(thumbnail_warmup_enabled());
2269 unsafe {
2270 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "YES");
2271 }
2272 assert!(thumbnail_warmup_enabled());
2273 unsafe {
2274 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
2275 }
2276 }
2277
2278 #[test]
2279 fn thumbnail_warmup_rejects_falsey_env_values() {
2280 let _guard = env_lock().lock().unwrap();
2281 unsafe {
2282 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "0");
2283 }
2284 assert!(!thumbnail_warmup_enabled());
2285 unsafe {
2286 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "false");
2287 }
2288 assert!(!thumbnail_warmup_enabled());
2289 unsafe {
2290 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
2291 }
2292 }
2293
2294 #[test]
2295 fn content_type_covers_every_output_format() {
2296 assert_eq!(content_type_for_filename("a.png"), "image/png");
2297 assert_eq!(content_type_for_filename("a.PNG"), "image/png");
2298 assert_eq!(content_type_for_filename("a.jpg"), "image/jpeg");
2299 assert_eq!(content_type_for_filename("a.jpeg"), "image/jpeg");
2300 assert_eq!(content_type_for_filename("a.gif"), "image/gif");
2301 assert_eq!(content_type_for_filename("a.webp"), "image/webp");
2302 assert_eq!(content_type_for_filename("a.apng"), "image/apng");
2303 assert_eq!(content_type_for_filename("a.mp4"), "video/mp4");
2304 assert_eq!(
2305 content_type_for_filename("a.unknown"),
2306 "application/octet-stream"
2307 );
2308 }
2309
2310 #[test]
2311 fn synthesized_metadata_parses_model_from_filename() {
2312 let meta = synthesize_metadata_from_filename("mold-flux-dev-q8-1710000000.mp4", 1710000000);
2313 assert_eq!(meta.model, "flux-dev-q8");
2315 assert_eq!(meta.prompt, "");
2316 assert_eq!(meta.seed, 0);
2317 assert!(meta.version.starts_with("synthesized@"));
2318
2319 let meta =
2321 synthesize_metadata_from_filename("mold-ltx-video-bf16-1710000030-2.gif", 1710000030);
2322 assert_eq!(meta.model, "ltx-video-bf16");
2323
2324 let meta = synthesize_metadata_from_filename("unrelated.png", 0);
2326 assert_eq!(meta.model, "unknown");
2327 }
2328
2329 struct TempDir(std::path::PathBuf);
2335 impl TempDir {
2336 fn new(tag: &str) -> Self {
2337 let mut p = std::env::temp_dir();
2338 p.push(format!("mold-gallery-test-{tag}-{}", uuid::Uuid::new_v4()));
2339 std::fs::create_dir_all(&p).expect("create tempdir");
2340 Self(p)
2341 }
2342 fn path(&self) -> &std::path::Path {
2343 &self.0
2344 }
2345 }
2346 impl Drop for TempDir {
2347 fn drop(&mut self) {
2348 let _ = std::fs::remove_dir_all(&self.0);
2349 }
2350 }
2351
2352 fn make_png_bytes(width: u32, height: u32) -> Vec<u8> {
2357 let img = image::RgbImage::from_fn(width, height, |x, y| {
2358 let n = (x.wrapping_mul(37) ^ y.wrapping_mul(131)) as u8;
2359 image::Rgb([n, n.wrapping_add(85), n.wrapping_sub(17)])
2360 });
2361 let mut buf = Vec::new();
2362 image::DynamicImage::ImageRgb8(img)
2363 .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
2364 .expect("encode png");
2365 buf
2366 }
2367
2368 #[test]
2369 fn min_valid_size_thresholds_are_sensible() {
2370 assert!(min_valid_size(mold_core::OutputFormat::Png) >= 128);
2373 assert!(min_valid_size(mold_core::OutputFormat::Jpeg) >= 128);
2374 assert!(min_valid_size(mold_core::OutputFormat::Apng) >= 128);
2375 assert!(min_valid_size(mold_core::OutputFormat::Webp) >= 128);
2376 assert!(min_valid_size(mold_core::OutputFormat::Gif) <= 512);
2378 assert!(min_valid_size(mold_core::OutputFormat::Mp4) >= 1024);
2380 }
2381
2382 #[test]
2383 fn has_ftyp_box_accepts_real_header_and_rejects_garbage() {
2384 let td = TempDir::new("ftyp");
2385
2386 let mut real = Vec::new();
2388 real.extend_from_slice(&[0x00, 0x00, 0x00, 0x20]);
2389 real.extend_from_slice(b"ftyp");
2390 real.extend_from_slice(b"isom\x00\x00\x02\x00isomiso2mp41");
2391 let real_path = td.path().join("real.mp4");
2392 std::fs::write(&real_path, &real).unwrap();
2393 assert!(has_ftyp_box(&real_path));
2394
2395 let fake_path = td.path().join("fake.mp4");
2397 std::fs::write(&fake_path, b"this is not an mp4 file at all").unwrap();
2398 assert!(!has_ftyp_box(&fake_path));
2399
2400 let trunc_path = td.path().join("truncated.mp4");
2402 std::fs::write(&trunc_path, b"\x00\x00\x00\x20").unwrap();
2403 assert!(!has_ftyp_box(&trunc_path));
2404
2405 assert!(!has_ftyp_box(&td.path().join("nope.mp4")));
2407 }
2408
2409 #[test]
2410 fn image_header_dims_returns_real_dimensions() {
2411 let td = TempDir::new("header");
2412 let p = td.path().join("valid.png");
2413 std::fs::write(&p, make_png_bytes(42, 24)).unwrap();
2414 assert_eq!(image_header_dims(&p), Some((42, 24)));
2415
2416 let stub = td.path().join("stub.png");
2418 std::fs::write(&stub, b"\x89PNG\r\n\x1a\n").unwrap();
2419 assert!(image_header_dims(&stub).is_none());
2420
2421 let text = td.path().join("text.png");
2423 std::fs::write(&text, b"hello world, not a png").unwrap();
2424 assert!(image_header_dims(&text).is_none());
2425 }
2426
2427 #[test]
2428 fn scan_gallery_dir_filters_invalid_and_keeps_valid() {
2429 let td = TempDir::new("scan");
2430 let dir = td.path();
2431
2432 std::fs::write(dir.join("mold-model-1000.png"), make_png_bytes(32, 32)).unwrap();
2434
2435 let mut junk = vec![0u8; 512];
2437 junk[..4].copy_from_slice(b"JUNK");
2438 std::fs::write(dir.join("mold-broken-2000.png"), &junk).unwrap();
2439
2440 std::fs::write(
2442 dir.join("mold-tiny-3000.png"),
2443 b"\x89PNG\r\n\x1a\n", )
2445 .unwrap();
2446
2447 let mut mp4 = Vec::new();
2449 mp4.extend_from_slice(&[0x00, 0x00, 0x00, 0x20]);
2450 mp4.extend_from_slice(b"ftyp");
2451 mp4.extend_from_slice(b"isom\x00\x00\x02\x00");
2452 mp4.resize(8192, 0);
2455 std::fs::write(dir.join("mold-ltx-4000.mp4"), &mp4).unwrap();
2456
2457 let bad_mp4 = vec![0u8; 8192];
2459 std::fs::write(dir.join("mold-no-ftyp-5000.mp4"), &bad_mp4).unwrap();
2460
2461 std::fs::write(dir.join("random.txt"), b"not an output").unwrap();
2463
2464 let results = scan_gallery_dir(dir);
2465 let names: Vec<&str> = results.iter().map(|i| i.filename.as_str()).collect();
2466 assert!(
2467 names.contains(&"mold-model-1000.png"),
2468 "valid PNG should survive: {names:?}"
2469 );
2470 assert!(
2471 names.contains(&"mold-ltx-4000.mp4"),
2472 "valid MP4 with ftyp should survive: {names:?}"
2473 );
2474 assert!(
2475 !names.contains(&"mold-broken-2000.png"),
2476 "PNG with no valid header should be filtered: {names:?}"
2477 );
2478 assert!(
2479 !names.contains(&"mold-tiny-3000.png"),
2480 "under-size PNG stub should be filtered: {names:?}"
2481 );
2482 assert!(
2483 !names.contains(&"mold-no-ftyp-5000.mp4"),
2484 "MP4 without ftyp should be filtered: {names:?}"
2485 );
2486 assert_eq!(names.len(), 2, "only the 2 valid fixtures remain");
2487 }
2488
2489 #[test]
2490 fn solid_black_png_is_filtered_at_scan_time() {
2491 let td = TempDir::new("black");
2492 let dir = td.path();
2493
2494 let black = image::RgbImage::from_pixel(256, 256, image::Rgb([0, 0, 0]));
2498 let mut buf = Vec::new();
2499 image::DynamicImage::ImageRgb8(black)
2500 .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
2501 .unwrap();
2502 std::fs::write(dir.join("mold-noisy-1000.png"), &buf).unwrap();
2503
2504 std::fs::write(dir.join("mold-valid-2000.png"), make_png_bytes(256, 256)).unwrap();
2506
2507 let results = scan_gallery_dir(dir);
2508 let names: Vec<&str> = results.iter().map(|i| i.filename.as_str()).collect();
2509 assert!(
2510 !names.contains(&"mold-noisy-1000.png"),
2511 "solid-black PNG should be filtered: {names:?}"
2512 );
2513 assert!(
2514 names.contains(&"mold-valid-2000.png"),
2515 "noisy PNG should survive: {names:?}"
2516 );
2517 }
2518
2519 #[test]
2520 fn probably_solid_black_ignores_large_files() {
2521 let td = TempDir::new("bigblack");
2525 let big_path = td.path().join("big.png");
2526 std::fs::write(&big_path, vec![0u8; 20 * 1024]).unwrap();
2528 assert!(!is_probably_solid_black(
2529 &big_path,
2530 mold_core::OutputFormat::Png,
2531 20 * 1024,
2532 ));
2533 }
2534
2535 #[test]
2536 fn parse_byte_range_handles_common_forms() {
2537 assert_eq!(parse_byte_range("bytes=0-499", 2000), Some((0, 499)));
2539 assert_eq!(parse_byte_range("bytes=100-", 2000), Some((100, 1999)));
2541 assert_eq!(parse_byte_range("bytes=-500", 2000), Some((1500, 1999)));
2543 assert_eq!(parse_byte_range("bytes=0-9999", 2000), Some((0, 1999)));
2545 assert_eq!(parse_byte_range("bytes=0-1999", 2000), Some((0, 1999)));
2547 }
2548
2549 #[test]
2550 fn parse_byte_range_rejects_malformed_and_unsatisfiable() {
2551 assert_eq!(parse_byte_range("bytes=", 1000), None);
2552 assert_eq!(parse_byte_range("bytes=abc-100", 1000), None);
2553 assert_eq!(parse_byte_range("bytes=2000-", 1000), None);
2555 assert_eq!(parse_byte_range("bytes=500-100", 1000), None);
2557 assert_eq!(parse_byte_range("bytes=0-10,20-30", 1000), None);
2559 assert_eq!(parse_byte_range("bytes=-0", 1000), None);
2561 assert_eq!(parse_byte_range("bytes=0-10", 0), None);
2563 assert_eq!(parse_byte_range("items=0-10", 1000), None);
2565 }
2566
2567 #[test]
2568 fn gallery_delete_toggle_reads_env_var() {
2569 let key = "MOLD_GALLERY_ALLOW_DELETE";
2571 let prev = std::env::var(key).ok();
2574 for val in ["1", "true", "YES"] {
2575 unsafe {
2576 std::env::set_var(key, val);
2577 }
2578 assert!(
2579 gallery_delete_allowed(),
2580 "delete should be allowed for env {val:?}"
2581 );
2582 }
2583 for val in ["0", "false", "no", ""] {
2584 unsafe {
2585 std::env::set_var(key, val);
2586 }
2587 assert!(
2588 !gallery_delete_allowed(),
2589 "delete should be blocked for env {val:?}"
2590 );
2591 }
2592 unsafe {
2593 std::env::remove_var(key);
2594 }
2595 assert!(!gallery_delete_allowed(), "default is off");
2596 if let Some(v) = prev {
2598 unsafe {
2599 std::env::set_var(key, v);
2600 }
2601 }
2602 }
2603
2604 #[test]
2605 fn scan_populates_real_dimensions_for_synthesized_metadata() {
2606 let td = TempDir::new("dims");
2610 let dir = td.path();
2611 std::fs::write(dir.join("mold-nometa-1000.png"), make_png_bytes(128, 96)).unwrap();
2612
2613 let results = scan_gallery_dir(dir);
2614 assert_eq!(results.len(), 1);
2615 let entry = &results[0];
2616 assert!(entry.metadata_synthetic);
2617 assert_eq!(entry.metadata.width, 128);
2618 assert_eq!(entry.metadata.height, 96);
2619 }
2620}