Skip to main content

mold_server/
routes.rs

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// ── ApiError — structured JSON error response ────────────────────────────────
25
26#[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// Re-export for tests — the canonical implementation lives in queue.rs.
100#[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    // Stateful routes (need AppState) are added first, then .with_state() converts
138    // Router<AppState> → Router<()>. Stateless routes (OpenAPI, docs) are merged after.
139    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
167// ── Model readiness ──────────────────────────────────────────────────────────
168
169fn 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    // Use milliseconds for server-side filenames to avoid overwrites when
202    // concurrent requests finish in the same second.
203    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
217// ── Shared pre-queue validation ───────────────────────────────────────────────
218
219/// Validate a generate request and resolve server-side defaults.
220///
221/// Performs the identical pre-queue checks used by both `generate` and
222/// `generate_stream`: applies the default metadata setting, validates the
223/// request, checks model availability, and resolves the output directory.
224async 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    // Expand prompt if requested (before validation, so the expanded prompt gets validated)
231    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// ── /api/generate ─────────────────────────────────────────────────────────────
257
258#[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)]
270// The server always produces 1 image per request; batch looping (--batch N)
271// is handled client-side by the CLI, which sends N requests with incrementing seeds.
272async 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    // Submit to generation queue
293    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    // Wait for the queue worker to process the job
304    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            // For video responses, return the actual video data (not the thumbnail)
332            // and send video metadata in headers so the client can reconstruct VideoData.
333            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
389/// Apply prompt expansion if `expand: true` is set on a generate request.
390async 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    // Resolve model family for prompt style
402    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 lock before blocking
418    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
435/// Create the appropriate expander for server-side use.
436fn 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// ── /api/expand ──────────────────────────────────────────────────────────────
467
468#[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
508// ── /api/upscale ────────────────────────────────────────────────────────────
509
510async 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    // Auto-pull upscaler model if not downloaded
521    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            // Reuse cached engine if same model
560            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
581// ── /api/upscale/stream (SSE) ──────────────────────────────────────────────
582
583async 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    // Check if model needs pulling before spawning the SSE stream
594    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    // Validate the model exists in the manifest if we need to pull
604    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        // Auto-pull the upscaler model if not downloaded
618        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        // Read weights path after potential pull
697        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            // Install progress callback for tile-by-tile progress
748            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// ── /api/generate/stream (SSE) ───────────────────────────────────────────────
797
798#[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    // Create SSE channel
823    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
824
825    // Send dimension warning before queuing so the client sees it early
826    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    // Send initial queue position to the client
843    let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued { position }));
844
845    // Hold `tx` alive in a background task until the job completes, so the SSE
846    // stream never closes prematurely even if the queue worker hasn't received
847    // the job yet.
848    tokio::spawn(async move {
849        let _ = result_rx.await;
850        drop(tx); // closes the SSE stream
851    });
852
853    // Build SSE stream from the channel receiver.
854    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// ── /api/models ───────────────────────────────────────────────────────────────
865
866#[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// ── /api/models/load ──────────────────────────────────────────────────────────
879
880#[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// ── /api/models/pull ──────────────────────────────────────────────────────────
908
909#[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        // Legacy: blocking pull with plain text response
932        return pull_model_blocking(state, body.model)
933            .await
934            .map(PullResponse::Text);
935    }
936
937    // SSE streaming pull
938    let model = body.model.clone();
939
940    // Validate model exists in manifest before starting SSE
941    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
1046/// Legacy blocking pull — returns plain text.
1047async 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
1056/// Response type that can be either SSE stream or plain text.
1057enum 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// ── /api/models/unload ────────────────────────────────────────────────────────
1072
1073#[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// ── /api/status ───────────────────────────────────────────────────────────────
1086
1087#[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// ── /health ───────────────────────────────────────────────────────────────────
1137
1138#[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
1150// ── /api/capabilities ────────────────────────────────────────────────────────
1151
1152/// Report the feature toggles a client needs to render correctly (hide the
1153/// delete button when delete isn't allowed, etc.). No auth required — this
1154/// is a read-only introspection endpoint.
1155async 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// ── /api/shutdown ─────────────────────────────────────────────────────────────
1164
1165/// Trigger graceful server shutdown.
1166///
1167/// When API key auth is enabled, the auth middleware protects this endpoint.
1168/// When auth is disabled, only requests from loopback addresses (127.0.0.1, ::1)
1169/// are accepted to prevent remote shutdown.
1170#[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    // When auth is disabled (no AuthState extension or AuthState is None),
1181    // restrict shutdown to loopback addresses only.
1182    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
1208// ── /api/gallery ──────────────────────────────────────────────────────────────
1209
1210/// List gallery images from the server's output directory.
1211/// Returns metadata from PNG `mold:parameters` chunks, sorted newest-first.
1212async 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
1233/// Serve a gallery file by filename.
1234///
1235/// Supports HTTP `Range` requests so `<video>` elements can scrub MP4
1236/// outputs without downloading the whole clip up front. Partial responses
1237/// stream straight from disk via `tokio_util::io::ReaderStream` — nothing
1238/// buffers the full file in server RAM, which matters once a gallery
1239/// contains multi-GB LTX-2 outputs. Non-range requests still return the
1240/// whole file (streamed) with `Accept-Ranges: bytes` so the client knows
1241/// it can seek on subsequent requests.
1242async 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    // Sanitize: prevent directory traversal
1255    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            // A `Range` header we can't satisfy ⇒ 416 per RFC 9110 §15.6.2.
1290            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    // Full response: stream the file rather than buffer it in RAM.
1299    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
1311/// Parse a `Range: bytes=start-end` header into a concrete (start, end)
1312/// byte range inclusive on both ends. Returns `None` for unsatisfiable or
1313/// malformed ranges — the caller translates that into a 416 response.
1314///
1315/// Only the single-range form is supported (multipart ranges are vanishingly
1316/// rare in practice and substantially more complex to implement correctly;
1317/// browsers for `<video>` always send single ranges).
1318fn 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        // Suffix range: `bytes=-N` means "the last N bytes".
1333        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
1357/// Emit a `206 Partial Content` response streaming `[start, end]` inclusive
1358/// from the already-open file handle. `take(len)` bounds the reader so the
1359/// body terminates exactly at `end + 1` instead of reading the tail.
1360async 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        // Partial content is less cacheable at intermediaries than a plain
1384        // 200; keep a short TTL so the client's own cache still helps.
1385        .header(header::CACHE_CONTROL, "public, max-age=300")
1386        .body(body)
1387        .unwrap())
1388}
1389
1390/// Pick an HTTP Content-Type for a gallery filename. Covers every format
1391/// `OutputFormat` can emit plus a safe default.
1392fn 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
1411/// Delete a gallery image and its server-side thumbnail.
1412///
1413/// Opt-in: the endpoint returns `403 Forbidden` unless
1414/// `MOLD_GALLERY_ALLOW_DELETE=1` is set on the server. Destructive writes
1415/// should not be reachable by default — operators explicitly allow them,
1416/// ideally in combination with the existing API-key middleware.
1417async 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    // Also remove server-side thumbnail (both legacy no-suffix and current
1449    // `.png`-suffixed cache layouts).
1450    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
1457/// Whether the destructive `DELETE /api/gallery/image/:filename` route
1458/// is enabled. Off by default — operators opt in with
1459/// `MOLD_GALLERY_ALLOW_DELETE=1` (accepts `1` / `true` / `yes`, any case).
1460fn 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
1466/// Serve a thumbnail for a gallery image. Generated on-demand and cached
1467/// at ~/.mold/cache/thumbnails/ on the server side.
1468async 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    // Thumbnail cache path: always `.png` regardless of the source extension,
1496    // so mp4 / gif / apng / webp / jpg all coexist cleanly in the same cache
1497    // dir and `image.save()` doesn't pick the wrong format from the path.
1498    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        // Generate thumbnail on-demand. Videos go through openh264 for a real
1505        // first-frame extract; everything else decodes via the `image` crate.
1506        // If either path fails, we fall back to serving the source bytes
1507        // directly — browsers are more lenient about partial / checksum-
1508        // mismatched images than either decoder, and the SPA would rather
1509        // show something than a 500.
1510        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            // For videos, the browser can't render the raw mp4 as an <img>
1529            // either, so serving the source doesn't help — fall back to the
1530            // SVG play-icon placeholder instead.
1531            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
1575/// Server-side thumbnail cache directory.
1576fn 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
1583/// Generate a 256x256 max thumbnail from source image. The result is always
1584/// written as a PNG regardless of the source format, so callers should pass
1585/// a `.png`-suffixed `dest` to keep the on-disk cache unambiguous.
1586fn 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
1599/// Extract the first frame of an MP4 as a PNG thumbnail and downscale to
1600/// 256px max via the `image` crate. Uses the openh264 pipeline that
1601/// `mold_inference::ltx2::media` already ships for video probes.
1602///
1603/// The full-frame PNG is written to a sibling temp path first, then decoded
1604/// and resized — this keeps `mold_inference`'s existing helper surface stable
1605/// while still producing a compact thumbnail.
1606fn 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    // Decode the first frame to a temporary full-resolution PNG, then
1614    // thumbnail-resize via the `image` crate. We stage through a temp file
1615    // rather than through memory to reuse `extract_thumbnail`'s existing
1616    // I/O-based API.
1617    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
1629/// Pre-generate thumbnails for all gallery images on server startup.
1630pub 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
1687/// Scan a directory for gallery outputs (images + videos).
1688///
1689/// Picks up every format `OutputFormat` can emit: png / jpg / jpeg / gif /
1690/// apng / webp / mp4. For files with no embedded `mold:parameters` chunk
1691/// (notably gif / webp / mp4), we synthesize a stub `OutputMetadata` from
1692/// the filename so the UI can still display them alongside annotated items.
1693///
1694/// Invalid files are filtered out at scan time rather than surfaced as
1695/// broken tiles in the UI. "Invalid" here means any of:
1696/// - below a format-specific size floor (tiny stubs left by abandoned
1697///   writes, aborted generations, or test harnesses)
1698/// - no decodable image header (raster formats)
1699/// - no `ftyp` box at the start of the file (mp4)
1700///
1701/// This is a header-only validation, not a full pixel decode, so a file
1702/// that passes the check can still be corrupt mid-stream (e.g. broken
1703/// IDAT CRC). Those fall through to the thumbnail endpoint which serves
1704/// the raw bytes as a last resort.
1705fn 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        // Size floor: anything below this is guaranteed not a real output.
1740        if size_bytes < min_valid_size(format) {
1741            continue;
1742        }
1743
1744        // Header-level validation (fast, O(1) bytes per file).
1745        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        // Solid-black detection. Only inspect small files where a solid-color
1754        // image is plausible (real renderings at any meaningful resolution
1755        // weigh tens of KB or more). For those, we decode and sample a 16×16
1756        // thumbnail so a failed / empty generation doesn't pollute the feed.
1757        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        // Try embedded metadata first — PNG text chunks (also covers APNG
1769        // since APNG files are valid PNGs) and JPEG COM markers.
1770        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                // Synthesize. If the file is a raster whose header decodes,
1780                // use its real dimensions so the UI can render the card at
1781                // the correct aspect ratio even without mold metadata.
1782                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
1807/// Minimum on-disk size (in bytes) below which a file is treated as a
1808/// corrupt / aborted output and hidden from the gallery listing. The
1809/// thresholds are well below any real mold-generated output but above any
1810/// parseable-but-empty stub — a 1×1 pixel PNG is ~67 bytes, a real 512×512
1811/// PNG is at least tens of KB.
1812fn min_valid_size(format: mold_core::OutputFormat) -> u64 {
1813    match format {
1814        // Raster images: any real mold output is multi-KB. The 256-byte
1815        // floor comfortably filters truncated PNG stubs (signature + IHDR
1816        // only, ~45 bytes) and similar degenerate cases without touching
1817        // legitimate tiny gifs (e.g. a few hundred bytes for a 1-frame GIF).
1818        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        // An mp4 with a single frame and no audio is still many KB.
1824        mold_core::OutputFormat::Mp4 => 4096,
1825    }
1826}
1827
1828/// Fast "does this decode as an image?" check. Returns the image's
1829/// pixel dimensions (width, height) on success. Only reads the header —
1830/// typically under 1 KB — so it's safe to call for every file on every
1831/// `/api/gallery` request.
1832fn 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
1841/// Heuristic detector for "solid black" (or near-black) raster images —
1842/// typically the artefact of an aborted / NaN-poisoned generation that
1843/// wrote an all-zero image tensor. We only even consider images below a
1844/// format-specific suspect size (any real content at meaningful resolution
1845/// compresses to tens of KB at minimum; a solid-color PNG fits in a few
1846/// hundred bytes), then decode and sample a 16×16 thumbnail to check
1847/// whether any pixel's max channel exceeds a small threshold.
1848fn 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    // Allow any single channel up to this intensity before we conclude the
1855    // file is "real" content. 16 out of 255 is ~6%: enough to accept dark
1856    // images that aren't literal black, but tight enough to reject the
1857    // artefacts we actually want to filter.
1858    const CHANNEL_CEILING: u8 = 16;
1859
1860    let suspect_threshold: u64 = match format {
1861        // PNG / APNG: zlib-compressed raw pixels; 8 KB is comfortably above
1862        // any solid-color encoding at 1k-ish resolution.
1863        mold_core::OutputFormat::Png | mold_core::OutputFormat::Apng => 8 * 1024,
1864        // JPEG compresses solid color to a few hundred bytes; generous ceiling.
1865        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
1890/// Check for the ISO BMFF `ftyp` box at offset 4 of the file. A real mp4
1891/// always starts with a top-level `ftyp` box; files that fail this check
1892/// are typically truncated writes or wrong-extension text files.
1893fn 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
1905/// Build a best-effort `OutputMetadata` from a filename like
1906/// `mold-<model>-<unix>[-<idx>].<ext>`. Fields we can't recover (seed, steps,
1907/// guidance, resolution, prompt) are left at zero / empty so the UI can
1908/// render them as "unknown". The client reads `metadata_synthetic=true`
1909/// from the enclosing `GalleryImage` to treat these as placeholders.
1910fn 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            // Trim trailing `-<unix>` and optional `-<idx>` suffixes by
1920            // walking back across numeric segments.
1921            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
1958/// Read OutputMetadata from a PNG file's text chunks.
1959fn 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
1984/// Read OutputMetadata from a JPEG file's COM marker.
1985fn 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            // Standalone markers (no length field): SOI, EOI, RST0-7, TEM
1996            0xD8 | 0x01 => {
1997                i += 2;
1998            }
1999            0xD9 => break, // EOI — end of image
2000            0xD0..=0xD7 => {
2001                i += 2; // RST markers
2002            }
2003            // COM marker — check for mold:parameters
2004            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            // All other markers have a 2-byte length field
2023            _ => {
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
2038// ── /api/openapi.json ─────────────────────────────────────────────────────────
2039
2040async fn openapi_json() -> impl IntoResponse {
2041    Json(ApiDoc::openapi())
2042}
2043
2044// ── /api/docs ─────────────────────────────────────────────────────────────────
2045
2046async 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
2064// ── GPU info ──────────────────────────────────────────────────────────────────
2065
2066fn 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        // An error whose Display starts immediately with a backtrace-like line
2152        let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
2153        let msg = clean_error_message(&err);
2154        // Should fall back to root_cause since all lines look like backtrace
2155        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], // PNG magic bytes
2171            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], // JPEG magic
2204            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        // Saving to a path that can't be created should not panic
2232        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        // /dev/null/impossible can't be created as a directory
2240        save_image_to_dir(
2241            std::path::Path::new("/dev/null/impossible"),
2242            &img,
2243            "test",
2244            1,
2245        );
2246        // Test passes if no panic occurred
2247    }
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        // Trailing unix timestamp should be stripped; model tag preserved.
2314        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        // Batch suffix (trailing `-<idx>`) also stripped along with timestamp.
2320        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        // Non-mold filename falls back to "unknown".
2325        let meta = synthesize_metadata_from_filename("unrelated.png", 0);
2326        assert_eq!(meta.model, "unknown");
2327    }
2328
2329    // ── Gallery validation ───────────────────────────────────────────────
2330
2331    /// Create a scratch directory unique to this test and delete it on drop.
2332    /// Using `std::env::temp_dir()` rather than pulling in a `tempfile`
2333    /// dev-dep for three tests' worth of fixtures.
2334    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    /// Encode a noisy PNG in-memory via the `image` crate. The checkerboard
2353    /// pattern resists zlib compression so the encoded bytes exceed the
2354    /// gallery size floor — a solid-color PNG of the same dimensions would
2355    /// compress to ~80 bytes and be filtered out by `min_valid_size`.
2356    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        // Raster formats: high enough to catch every truncated / stub file
2371        // we've seen in dev harnesses (largest known bad fixture was ~200 B).
2372        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        // But not so high we filter out legitimate tiny GIFs.
2377        assert!(min_valid_size(mold_core::OutputFormat::Gif) <= 512);
2378        // MP4: no real rendering is < 1 KB; 4 KB is a comfortable floor.
2379        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        // Real-ish mp4 header: `\0\0\0\x20 ftypisom ...`
2387        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        // Wrong magic — random text with an mp4 extension.
2396        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        // Too short (fewer than 12 bytes) — can't contain an ftyp box.
2401        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        // Missing entirely.
2406        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        // Truncated: PNG signature only, no IHDR.
2417        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        // Non-image bytes entirely.
2422        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        // A valid PNG large enough to exceed the 256-byte raster size floor.
2433        std::fs::write(dir.join("mold-model-1000.png"), make_png_bytes(32, 32)).unwrap();
2434
2435        // Truncated raster that passes size floor but has no valid header.
2436        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        // Tiny raster under the size floor (sub-IHDR).
2441        std::fs::write(
2442            dir.join("mold-tiny-3000.png"),
2443            b"\x89PNG\r\n\x1a\n", // 8 bytes: signature only
2444        )
2445        .unwrap();
2446
2447        // Valid-enough mp4 (ftyp at offset 4) — should survive.
2448        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        // Pad above the 4096-byte mp4 size floor so it isn't filtered on
2453        // size alone — the scan still checks ftyp either way.
2454        mp4.resize(8192, 0);
2455        std::fs::write(dir.join("mold-ltx-4000.mp4"), &mp4).unwrap();
2456
2457        // Mp4 extension but no ftyp.
2458        let bad_mp4 = vec![0u8; 8192];
2459        std::fs::write(dir.join("mold-no-ftyp-5000.mp4"), &bad_mp4).unwrap();
2460
2461        // Unsupported extension — ignored entirely.
2462        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        // A 256×256 solid-black PNG — definitely below the suspect-size
2495        // threshold (compresses to a few hundred bytes) and every pixel is
2496        // below the channel ceiling.
2497        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        // A normal noisy PNG with the same dimensions — should survive.
2505        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        // Files above the per-format suspect size are trusted without a full
2522        // decode (the check is purely a cheap heuristic to catch NaN /
2523        // abort-flavored dev outputs). Verify we bail out on size alone.
2524        let td = TempDir::new("bigblack");
2525        let big_path = td.path().join("big.png");
2526        // Write arbitrary bytes — we never decode because of the size guard.
2527        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        // `bytes=0-499` — first 500 bytes
2538        assert_eq!(parse_byte_range("bytes=0-499", 2000), Some((0, 499)));
2539        // open-ended `bytes=100-` — from byte 100 to EOF
2540        assert_eq!(parse_byte_range("bytes=100-", 2000), Some((100, 1999)));
2541        // suffix `bytes=-500` — last 500 bytes
2542        assert_eq!(parse_byte_range("bytes=-500", 2000), Some((1500, 1999)));
2543        // end past EOF — clamped to last byte
2544        assert_eq!(parse_byte_range("bytes=0-9999", 2000), Some((0, 1999)));
2545        // whole file
2546        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        // start past EOF
2554        assert_eq!(parse_byte_range("bytes=2000-", 1000), None);
2555        // end before start
2556        assert_eq!(parse_byte_range("bytes=500-100", 1000), None);
2557        // multi-range not supported
2558        assert_eq!(parse_byte_range("bytes=0-10,20-30", 1000), None);
2559        // suffix of 0 bytes is meaningless
2560        assert_eq!(parse_byte_range("bytes=-0", 1000), None);
2561        // empty file can't satisfy any range
2562        assert_eq!(parse_byte_range("bytes=0-10", 0), None);
2563        // wrong unit prefix
2564        assert_eq!(parse_byte_range("items=0-10", 1000), None);
2565    }
2566
2567    #[test]
2568    fn gallery_delete_toggle_reads_env_var() {
2569        // Use a plausible-but-unique key so we don't clobber a caller's env.
2570        let key = "MOLD_GALLERY_ALLOW_DELETE";
2571        // Safety: env mutation is test-global; we restore the pre-test value
2572        // below regardless of assertion outcomes.
2573        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        // Restore.
2597        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        // Files without an embedded mold:parameters chunk still get their
2607        // actual width/height filled in from the header decode — useful for
2608        // the SPA's aspect-ratio-preserving layout.
2609        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}