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    types::GpuSelection, ActiveGenerationStatus, GpuInfo, GpuWorkerState, ModelInfoExtended,
14    ResourceSnapshot, ServerStatus, SseErrorEvent, SseProgressEvent,
15};
16use serde::{Deserialize, Serialize};
17use std::cmp::Reverse;
18use std::convert::Infallible;
19use std::sync::atomic::Ordering;
20use tokio_stream::StreamExt as _;
21use utoipa::OpenApi;
22
23use crate::model_manager;
24use crate::state::{AppState, GenerationJob, SseMessage, SubmitError};
25
26fn submit_error_to_api(e: SubmitError) -> ApiError {
27    match e {
28        SubmitError::Full { pending, capacity } => {
29            ApiError::queue_full(format!("generation queue is full ({pending}/{capacity})"))
30        }
31        SubmitError::Shutdown => ApiError::internal("generation queue shut down"),
32    }
33}
34
35// ── ApiError — structured JSON error response ────────────────────────────────
36
37#[derive(Debug, Serialize)]
38pub struct ApiError {
39    pub error: String,
40    pub code: String,
41    #[serde(skip)]
42    status: StatusCode,
43}
44
45impl ApiError {
46    pub fn validation(msg: impl Into<String>) -> Self {
47        Self {
48            error: msg.into(),
49            code: "VALIDATION_ERROR".to_string(),
50            status: StatusCode::UNPROCESSABLE_ENTITY,
51        }
52    }
53
54    pub fn not_found(msg: impl Into<String>) -> Self {
55        Self {
56            error: msg.into(),
57            code: "MODEL_NOT_FOUND".to_string(),
58            status: StatusCode::NOT_FOUND,
59        }
60    }
61
62    pub fn unknown_model(msg: impl Into<String>) -> Self {
63        Self {
64            error: msg.into(),
65            code: "UNKNOWN_MODEL".to_string(),
66            status: StatusCode::BAD_REQUEST,
67        }
68    }
69
70    pub fn inference(msg: impl Into<String>) -> Self {
71        Self {
72            error: msg.into(),
73            code: "INFERENCE_ERROR".to_string(),
74            status: StatusCode::INTERNAL_SERVER_ERROR,
75        }
76    }
77
78    pub fn internal(msg: impl Into<String>) -> Self {
79        Self {
80            error: msg.into(),
81            code: "INTERNAL_ERROR".to_string(),
82            status: StatusCode::INTERNAL_SERVER_ERROR,
83        }
84    }
85
86    pub fn internal_with_status(msg: impl Into<String>, status: StatusCode) -> Self {
87        Self {
88            error: msg.into(),
89            code: "INTERNAL_ERROR".to_string(),
90            status,
91        }
92    }
93
94    pub fn insufficient_memory(msg: impl Into<String>) -> Self {
95        Self {
96            error: msg.into(),
97            code: "INSUFFICIENT_MEMORY".to_string(),
98            status: StatusCode::SERVICE_UNAVAILABLE,
99        }
100    }
101
102    pub fn forbidden(msg: impl Into<String>) -> Self {
103        Self {
104            error: msg.into(),
105            code: "FORBIDDEN".to_string(),
106            status: StatusCode::FORBIDDEN,
107        }
108    }
109
110    pub fn queue_full(msg: impl Into<String>) -> Self {
111        Self {
112            error: msg.into(),
113            code: "QUEUE_FULL".to_string(),
114            status: StatusCode::SERVICE_UNAVAILABLE,
115        }
116    }
117}
118
119impl IntoResponse for ApiError {
120    fn into_response(self) -> axum::response::Response {
121        let status = self.status;
122        // On queue-full (503), hint clients to retry with a short delay.
123        if self.code == "QUEUE_FULL" {
124            let mut headers = HeaderMap::new();
125            headers.insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
126            return (status, headers, Json(self)).into_response();
127        }
128        (status, Json(self)).into_response()
129    }
130}
131
132// Re-export for tests — the canonical implementation lives in queue.rs.
133#[cfg(test)]
134use crate::queue::clean_error_message;
135
136#[derive(OpenApi)]
137#[openapi(
138    paths(
139        generate,
140        generate_stream,
141        expand_prompt,
142        list_models,
143        load_model,
144        pull_model_endpoint,
145        unload_model,
146        server_status,
147        health,
148        capabilities_chain_limits,
149        crate::routes_chain::generate_chain,
150        crate::routes_chain::generate_chain_stream,
151    ),
152    components(schemas(
153        mold_core::GenerateRequest,
154        mold_core::GenerateResponse,
155        mold_core::ExpandRequest,
156        mold_core::ExpandResponse,
157        mold_core::ImageData,
158        mold_core::OutputFormat,
159        mold_core::ModelInfo,
160        mold_core::ServerStatus,
161        mold_core::ActiveGenerationStatus,
162        mold_core::GpuInfo,
163        mold_core::SseProgressEvent,
164        mold_core::SseCompleteEvent,
165        mold_core::SseErrorEvent,
166        mold_core::ChainRequest,
167        mold_core::ChainResponse,
168        mold_core::ChainStage,
169        mold_core::ChainProgressEvent,
170        mold_core::SseChainCompleteEvent,
171        ModelInfoExtended,
172        LoadModelBody,
173        UnloadRequest,
174        crate::chain_limits::ChainLimits,
175    )),
176    tags(
177        (name = "generation", description = "Image generation"),
178        (name = "models", description = "Model management"),
179        (name = "server", description = "Server status and health"),
180    ),
181    info(
182        title = "mold",
183        description = "Local AI image generation server — FLUX, SD3.5, SD1.5, SDXL, Z-Image, Flux.2, Qwen-Image",
184        version = env!("CARGO_PKG_VERSION"),
185    )
186)]
187pub struct ApiDoc;
188
189pub fn create_router(state: AppState) -> Router {
190    // Stateful routes (need AppState) are added first, then .with_state() converts
191    // Router<AppState> → Router<()>. Stateless routes (OpenAPI, docs) are merged after.
192    Router::new()
193        .route("/api/generate", post(generate))
194        .route("/api/generate/stream", post(generate_stream))
195        .route(
196            "/api/generate/chain",
197            post(crate::routes_chain::generate_chain),
198        )
199        .route(
200            "/api/generate/chain/stream",
201            post(crate::routes_chain::generate_chain_stream),
202        )
203        .route("/api/expand", post(expand_prompt))
204        .route("/api/models", get(list_models))
205        .route("/api/models/load", post(load_model))
206        .route("/api/models/pull", post(pull_model_endpoint))
207        .route("/api/models/unload", delete(unload_model))
208        .route("/api/gallery", get(list_gallery))
209        .route(
210            "/api/gallery/image/:filename",
211            get(get_gallery_image).delete(delete_gallery_image),
212        )
213        .route(
214            "/api/gallery/thumbnail/:filename",
215            get(get_gallery_thumbnail),
216        )
217        .route("/api/gallery/preview/:filename", get(get_gallery_preview))
218        // ─── Downloads UI (Agent A) ────────────────────────────────────────
219        .route("/api/downloads", get(list_downloads).post(create_download))
220        .route("/api/downloads/:id", delete(delete_download))
221        .route("/api/downloads/stream", get(stream_downloads))
222        // ─── Catalog (live HF + Civitai proxy) ──────────────────────────
223        .route(
224            "/api/catalog/families",
225            get(crate::catalog_api::list_families),
226        )
227        .route(
228            "/api/catalog/search",
229            get(crate::catalog_api::live_search_catalog),
230        )
231        .route(
232            "/api/catalog/installed",
233            get(crate::catalog_api::list_installed_catalog),
234        )
235        .route(
236            "/api/catalog/*id",
237            get(crate::catalog_api::get_catalog_entry)
238                .post(crate::catalog_api::post_catalog_dispatch),
239        )
240        .route("/api/upscale", post(upscale))
241        .route("/api/upscale/stream", post(upscale_stream))
242        .route("/api/resources", get(get_resources))
243        .route("/api/resources/stream", get(get_resources_stream))
244        .route("/api/status", get(server_status))
245        .route("/api/queue", get(list_queue))
246        .route("/api/capabilities", get(server_capabilities))
247        .route(
248            "/api/capabilities/chain-limits",
249            get(capabilities_chain_limits),
250        )
251        .route("/api/shutdown", post(shutdown_server))
252        // Agent C (model-ui-overhaul §3): placement persistence.
253        .route(
254            "/api/config/model/:name/placement",
255            axum::routing::put(put_model_placement).delete(delete_model_placement),
256        )
257        .route("/health", get(health))
258        .with_state(state)
259        .route("/api/openapi.json", get(openapi_json))
260        .route("/api/docs", get(scalar_docs))
261}
262
263// ── Model readiness ──────────────────────────────────────────────────────────
264
265fn sse_message_to_event(msg: SseMessage) -> SseEvent {
266    fn serialize_event<T: Serialize>(event_name: &str, payload: &T) -> SseEvent {
267        match serde_json::to_string(payload) {
268            Ok(data) => SseEvent::default().event(event_name).data(data),
269            Err(err) => SseEvent::default().event("error").data(
270                serde_json::json!({
271                    "message": format!("failed to serialize SSE payload: {err}")
272                })
273                .to_string(),
274            ),
275        }
276    }
277
278    match msg {
279        SseMessage::Progress(payload) => serialize_event("progress", &payload),
280        SseMessage::Complete(payload) => serialize_event("complete", &payload),
281        SseMessage::UpscaleComplete(payload) => serialize_event("complete", &payload),
282        SseMessage::Error(payload) => serialize_event("error", &payload),
283    }
284}
285
286#[cfg(test)]
287fn save_image_to_dir(
288    dir: &std::path::Path,
289    img: &mold_core::ImageData,
290    model: &str,
291    batch_size: u32,
292) {
293    if let Err(e) = std::fs::create_dir_all(dir) {
294        tracing::warn!("failed to create output dir {}: {e}", dir.display());
295        return;
296    }
297    // Use milliseconds for server-side filenames to avoid overwrites when
298    // concurrent requests finish in the same second.
299    let timestamp_ms = std::time::SystemTime::now()
300        .duration_since(std::time::UNIX_EPOCH)
301        .unwrap_or_default()
302        .as_millis() as u64;
303    let ext = img.format.to_string();
304    let filename =
305        mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
306    let path = dir.join(&filename);
307    match std::fs::write(&path, &img.data) {
308        Ok(()) => tracing::info!("saved image to {}", path.display()),
309        Err(e) => tracing::warn!("failed to save image to {}: {e}", path.display()),
310    }
311}
312
313// ── Shared pre-queue validation ───────────────────────────────────────────────
314
315/// Validate a generate request and resolve server-side defaults.
316///
317/// Performs the identical pre-queue checks used by both `generate` and
318/// `generate_stream`: applies the default metadata setting, validates the
319/// request, checks model availability, and resolves the output directory.
320async fn prepare_generation(
321    state: &AppState,
322    request: &mut mold_core::GenerateRequest,
323) -> Result<(Option<std::path::PathBuf>, Option<String>), ApiError> {
324    // NOTE: the capacity check is enforced inside `state.queue.submit(...)` so
325    // that a burst of concurrent callers can't all slip past an open check
326    // (classic TOCTOU).  The submit call in `generate`/`generate_stream` will
327    // return `SubmitError::Full`, which is mapped to `ApiError::queue_full()`.
328    apply_default_metadata_setting(state, request).await;
329
330    let preferred_gpu = validate_multi_gpu_placement(state, request.placement.as_ref())?;
331
332    // Expand prompt if requested (before validation, so the expanded prompt gets validated)
333    maybe_expand_prompt(state, request, preferred_gpu).await?;
334
335    // Catalog (`cv:*` / `hf:*`) IDs aren't in the static manifest, so the
336    // pure-mold-core family lookup returns `None` for them. Run the live
337    // single-id install first so the intent cache has the entry; then
338    // feed its family string through as a hint so audio / keyframes /
339    // pipeline gates work for installed Civitai LTX-2 checkpoints.
340    //
341    // A `Network` error here means Civitai/HF is unreachable — surface it
342    // immediately as 502 rather than letting the user fall through to
343    // a "not installed" 404 they can't act on.
344    if let Err(e) = model_manager::install_catalog_model(state, &request.model).await {
345        return Err(model_manager::install_error_to_api_error(&e));
346    }
347    let family_hint = model_manager::catalog_family_for(state, &request.model).await;
348
349    // Resolve the model family for normalisation. `family_for_model` checks the
350    // static manifest first (covers all built-in models), then falls back to the
351    // catalog DB entry (covers `cv:*` / `hf:*` models installed above).
352    let resolved_family = model_manager::family_for_model(state, &request.model).await;
353    // Fill in a family-aware output format default when the caller omitted the
354    // field. This must happen before validation so the validator sees a concrete
355    // format and can gate on it correctly.
356    request.normalise_output_format(resolved_family.as_deref());
357
358    if let Err(e) = validate_generate_request(request, family_hint.as_deref()) {
359        return Err(ApiError::validation(e));
360    }
361
362    let _ = model_manager::check_model_available(state, &request.model).await?;
363
364    let (output_dir, dim_warning) = {
365        let config = state.config.read().await;
366        let output_dir = if config.is_output_disabled() {
367            None
368        } else {
369            Some(config.effective_output_dir())
370        };
371        let family = config.resolved_model_config(&request.model).family;
372        let dim_warning = family
373            .as_deref()
374            .and_then(|f| mold_core::dimension_warning(request.width, request.height, f));
375        (output_dir, dim_warning)
376    };
377
378    Ok((output_dir, dim_warning))
379}
380
381fn active_gpu_selection(state: &AppState) -> GpuSelection {
382    let ordinals: Vec<usize> = state
383        .gpu_pool
384        .workers
385        .iter()
386        .map(|w| w.gpu.ordinal)
387        .collect();
388    if ordinals.is_empty() {
389        GpuSelection::All
390    } else {
391        GpuSelection::Specific(ordinals)
392    }
393}
394
395fn validate_multi_gpu_placement(
396    state: &AppState,
397    placement: Option<&mold_core::types::DevicePlacement>,
398) -> Result<Option<usize>, ApiError> {
399    state
400        .gpu_pool
401        .resolve_explicit_placement_gpu(placement)
402        .map_err(ApiError::validation)
403}
404
405fn select_aux_worker(
406    state: &AppState,
407) -> Result<std::sync::Arc<crate::gpu_pool::GpuWorker>, ApiError> {
408    let mut workers: Vec<_> = state
409        .gpu_pool
410        .workers
411        .iter()
412        .filter(|w| !w.is_degraded())
413        .cloned()
414        .collect();
415    workers.sort_by_key(|w| {
416        (
417            w.in_flight.load(Ordering::SeqCst),
418            Reverse(w.gpu.total_vram_bytes),
419        )
420    });
421    workers
422        .into_iter()
423        .next()
424        .ok_or_else(|| ApiError::internal("no GPU worker available for auxiliary workload"))
425}
426
427fn clear_global_upscaler_cache(state: &AppState) {
428    if let Ok(mut cache) = state.upscaler_cache.try_lock() {
429        if cache.is_some() {
430            *cache = None;
431            tracing::info!("upscaler cache cleared");
432        }
433    }
434}
435
436// ── /api/generate ─────────────────────────────────────────────────────────────
437
438#[utoipa::path(
439    post,
440    path = "/api/generate",
441    tag = "generation",
442    request_body = mold_core::GenerateRequest,
443    responses(
444        (status = 200, description = "Generated image bytes", content_type = "image/png"),
445        (status = 404, description = "Model not downloaded"),
446        (status = 422, description = "Invalid request parameters"),
447        (status = 500, description = "Inference error"),
448        (status = 503, description = "Generation queue full"),
449    )
450)]
451// The server always produces 1 image per request; batch looping (--batch N)
452// is handled client-side by the CLI, which sends N requests with incrementing seeds.
453async fn generate(
454    State(state): State<AppState>,
455    Json(mut req): Json<mold_core::GenerateRequest>,
456) -> Result<impl IntoResponse, ApiError> {
457    let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
458
459    tracing::info!(
460        model = %req.model,
461        prompt = %req.prompt,
462        width = req.width,
463        height = req.height,
464        steps = req.steps,
465        guidance = req.guidance,
466        seed = ?req.seed,
467        format = %req.resolved_output_format(),
468        lora = ?req.lora.as_ref().map(|l| &l.path),
469        lora_scale = ?req.lora.as_ref().map(|l| l.scale),
470        loras = ?req.loras.as_ref().map(|v| v.iter().map(|l| &l.path).collect::<Vec<_>>()),
471        "generate request"
472    );
473
474    // Submit to generation queue
475    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
476    // Non-streaming path still gets a registry entry so an HTTP client
477    // hanging on the response is visible via GET /api/queue alongside
478    // SSE clients. Cleanup happens unconditionally on every terminal
479    // path (drop guard in `gpu_worker::process_job`).
480    let job_id = uuid::Uuid::new_v4().to_string();
481    state.job_registry.register(&job_id, &req.model);
482    let job = GenerationJob {
483        id: job_id.clone(),
484        request: req,
485        progress_tx: None,
486        result_tx,
487        output_dir,
488    };
489
490    let _position = state
491        .queue
492        .submit(job, state.queue_capacity)
493        .await
494        .inspect_err(|_| state.job_registry.remove(&job_id))
495        .map_err(submit_error_to_api)?;
496
497    // Wait for the queue worker to process the job
498    let result = result_rx
499        .await
500        .map_err(|_| ApiError::internal("generation queue worker dropped the job"))?;
501
502    match result {
503        Ok(job_result) => {
504            let img = job_result.image;
505            let response = job_result.response;
506            let content_type = HeaderValue::from_static(img.format.content_type());
507            let mut headers = HeaderMap::new();
508            headers.insert(header::CONTENT_TYPE, content_type);
509            headers.insert(
510                "x-mold-seed-used",
511                HeaderValue::from_str(&response.seed_used.to_string()).map_err(|e| {
512                    ApiError::internal(format!("failed to serialize seed header: {e}"))
513                })?,
514            );
515            if let Some(ordinal) = response.gpu {
516                headers.insert(
517                    "x-mold-gpu",
518                    HeaderValue::from_str(&ordinal.to_string()).map_err(|e| {
519                        ApiError::internal(format!("failed to serialize gpu header: {e}"))
520                    })?,
521                );
522            }
523            if let Some(warning) = dim_warning {
524                match HeaderValue::from_str(&warning.replace('\n', " ")) {
525                    Ok(val) => {
526                        headers.insert("x-mold-dimension-warning", val);
527                    }
528                    Err(e) => {
529                        tracing::warn!("dimension warning could not be encoded as header: {e}");
530                    }
531                }
532            }
533            // For video responses, return the actual video data (not the thumbnail)
534            // and send video metadata in headers so the client can reconstruct VideoData.
535            let output_data = if let Some(ref video) = response.video {
536                let ct = HeaderValue::from_static(video.format.content_type());
537                headers.insert(header::CONTENT_TYPE, ct);
538                if let Ok(v) = HeaderValue::from_str(&video.frames.to_string()) {
539                    headers.insert("x-mold-video-frames", v);
540                }
541                if let Ok(v) = HeaderValue::from_str(&video.fps.to_string()) {
542                    headers.insert("x-mold-video-fps", v);
543                }
544                if let Ok(v) = HeaderValue::from_str(&video.width.to_string()) {
545                    headers.insert("x-mold-video-width", v);
546                }
547                if let Ok(v) = HeaderValue::from_str(&video.height.to_string()) {
548                    headers.insert("x-mold-video-height", v);
549                }
550                if video.has_audio {
551                    headers.insert("x-mold-video-has-audio", HeaderValue::from_static("1"));
552                }
553                if let Some(dur) = video.duration_ms {
554                    if let Ok(v) = HeaderValue::from_str(&dur.to_string()) {
555                        headers.insert("x-mold-video-duration-ms", v);
556                    }
557                }
558                if let Some(sr) = video.audio_sample_rate {
559                    if let Ok(v) = HeaderValue::from_str(&sr.to_string()) {
560                        headers.insert("x-mold-video-audio-sample-rate", v);
561                    }
562                }
563                if let Some(ch) = video.audio_channels {
564                    if let Ok(v) = HeaderValue::from_str(&ch.to_string()) {
565                        headers.insert("x-mold-video-audio-channels", v);
566                    }
567                }
568                video.data.clone()
569            } else {
570                img.data
571            };
572            Ok((headers, output_data))
573        }
574        Err(err_msg) => {
575            // The multi-GPU dispatcher sends a queue-full error through result_tx
576            // when a per-worker channel is saturated; surface that as a proper 503
577            // instead of the generic INFERENCE_ERROR 500.
578            if err_msg.contains("queue is full") {
579                Err(ApiError::queue_full(err_msg))
580            } else {
581                Err(ApiError::inference(err_msg))
582            }
583        }
584    }
585}
586
587fn validate_generate_request(
588    req: &mold_core::GenerateRequest,
589    family_hint: Option<&str>,
590) -> Result<(), String> {
591    mold_core::validate_generate_request_with_family(req, family_hint)
592}
593
594async fn apply_default_metadata_setting(state: &AppState, req: &mut mold_core::GenerateRequest) {
595    if req.embed_metadata.is_some() {
596        return;
597    }
598
599    let config = state.config.read().await;
600    req.embed_metadata = Some(config.effective_embed_metadata(None));
601}
602
603/// Apply prompt expansion if `expand: true` is set on a generate request.
604async fn maybe_expand_prompt(
605    state: &AppState,
606    req: &mut mold_core::GenerateRequest,
607    preferred_gpu: Option<usize>,
608) -> Result<(), ApiError> {
609    if req.expand != Some(true) {
610        return Ok(());
611    }
612
613    let config = state.config.read().await;
614    let config_snapshot = config.clone();
615    let expand_settings = config.expand.clone().with_env_overrides();
616
617    // Resolve model family for prompt style
618    let model_family = config
619        .resolved_model_config(&req.model)
620        .family
621        .or_else(|| mold_core::manifest::find_manifest(&req.model).map(|m| m.family.clone()))
622        .unwrap_or_else(|| {
623            tracing::warn!(
624                model = %req.model,
625                "could not resolve model family for prompt expansion, defaulting to \"flux\""
626            );
627            "flux".to_string()
628        });
629
630    let expand_config = expand_settings.to_expand_config(&model_family, 1);
631    let original_prompt = req.prompt.clone();
632
633    // Drop config lock before blocking
634    drop(config);
635
636    let expander = create_server_expander(
637        &config_snapshot,
638        &expand_settings,
639        active_gpu_selection(state),
640        preferred_gpu,
641    )?;
642    let result =
643        tokio::task::spawn_blocking(move || expander.expand(&original_prompt, &expand_config))
644            .await
645            .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
646            .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
647
648    if let Some(expanded) = result.expanded.first() {
649        req.original_prompt = Some(req.prompt.clone());
650        req.prompt = expanded.clone();
651    }
652
653    Ok(())
654}
655
656/// Create the appropriate expander for server-side use.
657fn create_server_expander(
658    _config: &mold_core::Config,
659    settings: &mold_core::ExpandSettings,
660    _gpu_selection: GpuSelection,
661    _preferred_gpu: Option<usize>,
662) -> Result<Box<dyn mold_core::PromptExpander>, ApiError> {
663    if let Some(api_expander) = settings.create_api_expander() {
664        return Ok(Box::new(api_expander));
665    }
666
667    #[cfg(feature = "expand")]
668    {
669        if let Some(local) =
670            mold_inference::expand::LocalExpander::from_config(_config, Some(&settings.model))
671        {
672            return Ok(Box::new(
673                local
674                    .with_gpu_selection(_gpu_selection)
675                    .with_preferred_gpu(_preferred_gpu),
676            ));
677        }
678        return Err(ApiError::validation(
679            "local expand model not found — run: mold pull qwen3-expand".to_string(),
680        ));
681    }
682
683    #[cfg(not(feature = "expand"))]
684    {
685        Err(ApiError::validation(
686            "local prompt expansion not available — built without expand feature. \
687             Configure an API backend in [expand] settings."
688                .to_string(),
689        ))
690    }
691}
692
693// ── /api/expand ──────────────────────────────────────────────────────────────
694
695#[utoipa::path(
696    post,
697    path = "/api/expand",
698    tag = "generation",
699    request_body = mold_core::ExpandRequest,
700    responses(
701        (status = 200, description = "Expanded prompt(s)", body = mold_core::ExpandResponse),
702        (status = 422, description = "Invalid request parameters"),
703        (status = 500, description = "Expansion failed"),
704    )
705)]
706async fn expand_prompt(
707    State(state): State<AppState>,
708    Json(req): Json<mold_core::ExpandRequest>,
709) -> Result<Json<mold_core::ExpandResponse>, ApiError> {
710    if req.variations == 0 || req.variations > mold_core::expand::MAX_VARIATIONS {
711        return Err(ApiError::validation(format!(
712            "variations must be between 1 and {}",
713            mold_core::expand::MAX_VARIATIONS,
714        )));
715    }
716
717    let config = state.config.read().await;
718    let expand_settings = config.expand.clone().with_env_overrides();
719    let expand_config = expand_settings.to_expand_config(&req.model_family, req.variations);
720    let prompt = req.prompt.clone();
721    let config_snapshot = config.clone();
722    drop(config);
723
724    let expander = create_server_expander(
725        &config_snapshot,
726        &expand_settings,
727        active_gpu_selection(&state),
728        None,
729    )?;
730    let result = tokio::task::spawn_blocking(move || expander.expand(&prompt, &expand_config))
731        .await
732        .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
733        .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
734
735    Ok(Json(mold_core::ExpandResponse {
736        original: req.prompt,
737        expanded: result.expanded,
738    }))
739}
740
741// ── /api/upscale ────────────────────────────────────────────────────────────
742
743async fn upscale(
744    State(state): State<AppState>,
745    Json(req): Json<mold_core::UpscaleRequest>,
746) -> Result<Json<mold_core::UpscaleResponse>, ApiError> {
747    if let Err(msg) = mold_core::validate_upscale_request(&req) {
748        return Err(ApiError::validation(msg));
749    }
750
751    let model_name = mold_core::manifest::resolve_model_name(&req.model);
752
753    // Auto-pull upscaler model if not downloaded
754    let needs_pull = {
755        let config = state.config.read().await;
756        config
757            .models
758            .get(&model_name)
759            .and_then(|c| c.transformer.as_ref())
760            .is_none()
761    };
762    if needs_pull {
763        if mold_core::manifest::find_manifest(&model_name).is_none() {
764            return Err(ApiError::not_found(format!(
765                "unknown upscaler model '{}'. Run 'mold list' to see available models.",
766                model_name
767            )));
768        }
769        model_manager::pull_model(&state, &model_name, None).await?;
770    }
771
772    let config = state.config.read().await;
773    let weights_path = config
774        .models
775        .get(&model_name)
776        .and_then(|c| c.transformer.as_ref())
777        .ok_or_else(|| {
778            ApiError::not_found(format!(
779                "upscaler model '{}' not configured after pull",
780                model_name
781            ))
782        })?;
783    let weights_path = std::path::PathBuf::from(weights_path);
784    let model_name_owned = model_name.clone();
785    drop(config);
786
787    let resp = if state.gpu_pool.worker_count() > 0 {
788        let worker = select_aux_worker(&state)?;
789        worker.in_flight.fetch_add(1, Ordering::SeqCst);
790        let worker_clone = worker.clone();
791        let result =
792            tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
793                struct ThreadGpuGuard;
794                impl Drop for ThreadGpuGuard {
795                    fn drop(&mut self) {
796                        mold_inference::device::clear_thread_gpu_ordinal();
797                    }
798                }
799
800                mold_inference::device::init_thread_gpu_ordinal(worker_clone.gpu.ordinal);
801                let _thread_gpu = ThreadGpuGuard;
802                let _load_lock = worker_clone.model_load_lock.lock().unwrap();
803                let mut engine = mold_inference::create_upscale_engine(
804                    model_name_owned,
805                    weights_path,
806                    mold_inference::LoadStrategy::Eager,
807                    worker_clone.gpu.ordinal,
808                )?;
809                engine.upscale(&req)
810            })
811            .await
812            .map_err(|e| ApiError::internal(format!("upscale task panicked: {e}")));
813        worker.in_flight.fetch_sub(1, Ordering::SeqCst);
814        result?.map_err(|e| ApiError::internal(format!("upscale failed: {e}")))?
815    } else {
816        let upscaler_cache = state.upscaler_cache.clone();
817        tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
818            let mut cache = upscaler_cache.lock().unwrap_or_else(|e| e.into_inner());
819
820            // Reuse cached engine if same model.
821            let needs_new = cache
822                .as_ref()
823                .is_none_or(|e| e.model_name() != model_name_owned);
824            if needs_new {
825                let new_engine = mold_inference::create_upscale_engine(
826                    model_name_owned,
827                    weights_path,
828                    mold_inference::LoadStrategy::Eager,
829                    0,
830                )?;
831                *cache = Some(new_engine);
832            }
833
834            cache.as_mut().unwrap().upscale(&req)
835        })
836        .await
837        .map_err(|e| ApiError::internal(format!("upscale task panicked: {e}")))?
838        .map_err(|e| ApiError::internal(format!("upscale failed: {e}")))?
839    };
840
841    Ok(Json(resp))
842}
843
844// ── /api/upscale/stream (SSE) ──────────────────────────────────────────────
845
846async fn upscale_stream(
847    State(state): State<AppState>,
848    Json(req): Json<mold_core::UpscaleRequest>,
849) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
850    if let Err(msg) = mold_core::validate_upscale_request(&req) {
851        return Err(ApiError::validation(msg));
852    }
853
854    let model_name = mold_core::manifest::resolve_model_name(&req.model);
855
856    // Check if model needs pulling before spawning the SSE stream
857    let needs_pull = {
858        let config = state.config.read().await;
859        config
860            .models
861            .get(&model_name)
862            .and_then(|c| c.transformer.as_ref())
863            .is_none()
864    };
865
866    // Validate the model exists in the manifest if we need to pull
867    if needs_pull && mold_core::manifest::find_manifest(&model_name).is_none() {
868        return Err(ApiError::not_found(format!(
869            "unknown upscaler model '{}'. Run 'mold list' to see available models.",
870            model_name
871        )));
872    }
873
874    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
875    let model_name_owned = model_name.clone();
876    let state_clone = state.clone();
877    let upscaler_cache = state.upscaler_cache.clone();
878
879    tokio::spawn(async move {
880        // Auto-pull the upscaler model if not downloaded
881        if needs_pull {
882            let progress_tx = tx.clone();
883            let callback =
884                std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
885                    let sse_event = match event {
886                        mold_core::download::DownloadProgressEvent::Status { message } => {
887                            SseProgressEvent::Info { message }
888                        }
889                        mold_core::download::DownloadProgressEvent::FileStart {
890                            filename,
891                            file_index,
892                            total_files,
893                            size_bytes,
894                            batch_bytes_downloaded,
895                            batch_bytes_total,
896                            batch_elapsed_ms,
897                        } => SseProgressEvent::DownloadProgress {
898                            filename,
899                            file_index,
900                            total_files,
901                            bytes_downloaded: 0,
902                            bytes_total: size_bytes,
903                            batch_bytes_downloaded,
904                            batch_bytes_total,
905                            batch_elapsed_ms,
906                        },
907                        mold_core::download::DownloadProgressEvent::FileProgress {
908                            filename,
909                            file_index,
910                            bytes_downloaded,
911                            bytes_total,
912                            batch_bytes_downloaded,
913                            batch_bytes_total,
914                            batch_elapsed_ms,
915                        } => SseProgressEvent::DownloadProgress {
916                            filename,
917                            file_index,
918                            total_files: 0,
919                            bytes_downloaded,
920                            bytes_total,
921                            batch_bytes_downloaded,
922                            batch_bytes_total,
923                            batch_elapsed_ms,
924                        },
925                        mold_core::download::DownloadProgressEvent::FileDone {
926                            filename,
927                            file_index,
928                            total_files,
929                            batch_bytes_downloaded,
930                            batch_bytes_total,
931                            batch_elapsed_ms,
932                        } => SseProgressEvent::DownloadDone {
933                            filename,
934                            file_index,
935                            total_files,
936                            batch_bytes_downloaded,
937                            batch_bytes_total,
938                            batch_elapsed_ms,
939                        },
940                    };
941                    let _ = progress_tx.send(SseMessage::Progress(sse_event));
942                });
943
944            match model_manager::pull_model(&state_clone, &model_name_owned, Some(callback)).await {
945                Ok(_) => {
946                    let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
947                        model: model_name_owned.clone(),
948                    }));
949                }
950                Err(e) => {
951                    let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
952                        message: format!("failed to pull upscaler model: {}", e.error),
953                    }));
954                    return;
955                }
956            }
957        }
958
959        // Read weights path after potential pull
960        let weights_path = {
961            let config = state_clone.config.read().await;
962            config
963                .models
964                .get(&model_name_owned)
965                .and_then(|c| c.transformer.as_ref())
966                .map(std::path::PathBuf::from)
967        };
968
969        let Some(weights_path) = weights_path else {
970            let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
971                message: format!(
972                    "upscaler model '{}' not configured after pull",
973                    model_name_owned
974                ),
975            }));
976            return;
977        };
978
979        let result = if state_clone.gpu_pool.worker_count() > 0 {
980            match select_aux_worker(&state_clone) {
981                Ok(worker) => {
982                    worker.in_flight.fetch_add(1, Ordering::SeqCst);
983                    let worker_clone = worker.clone();
984                    let tx_for_worker = tx.clone();
985                    let model_name_for_worker = model_name_owned.clone();
986                    let weights_path_for_worker = weights_path.clone();
987                    let req_for_worker = req.clone();
988                    let result = tokio::task::spawn_blocking(move || {
989                        struct ThreadGpuGuard;
990                        impl Drop for ThreadGpuGuard {
991                            fn drop(&mut self) {
992                                mold_inference::device::clear_thread_gpu_ordinal();
993                            }
994                        }
995
996                        mold_inference::device::init_thread_gpu_ordinal(worker_clone.gpu.ordinal);
997                        let _thread_gpu = ThreadGpuGuard;
998                        let _load_lock = worker_clone.model_load_lock.lock().unwrap();
999                        let _ = tx_for_worker.send(SseMessage::Progress(
1000                            mold_core::SseProgressEvent::StageStart {
1001                                name: format!(
1002                                    "Loading upscaler model on GPU {}",
1003                                    worker_clone.gpu.ordinal
1004                                ),
1005                            },
1006                        ));
1007                        let mut engine = match mold_inference::create_upscale_engine(
1008                            model_name_for_worker,
1009                            weights_path_for_worker,
1010                            mold_inference::LoadStrategy::Eager,
1011                            worker_clone.gpu.ordinal,
1012                        ) {
1013                            Ok(engine) => engine,
1014                            Err(e) => {
1015                                let _ = tx_for_worker.send(SseMessage::Error(
1016                                    mold_core::SseErrorEvent {
1017                                        message: format!("failed to load upscaler: {e}"),
1018                                    },
1019                                ));
1020                                return;
1021                            }
1022                        };
1023
1024                        let tx_progress = tx_for_worker.clone();
1025                        engine.set_on_progress(Box::new(move |event| {
1026                            let sse_event: mold_core::SseProgressEvent = event.into();
1027                            let _ = tx_progress.send(SseMessage::Progress(sse_event));
1028                        }));
1029
1030                        match engine.upscale(&req_for_worker) {
1031                            Ok(resp) => {
1032                                let image_b64 = base64::engine::general_purpose::STANDARD
1033                                    .encode(&resp.image.data);
1034                                let _ = tx_for_worker.send(SseMessage::UpscaleComplete(
1035                                    mold_core::SseUpscaleCompleteEvent {
1036                                        image: image_b64,
1037                                        format: resp.image.format,
1038                                        model: resp.model,
1039                                        scale_factor: resp.scale_factor,
1040                                        original_width: resp.original_width,
1041                                        original_height: resp.original_height,
1042                                        upscale_time_ms: resp.upscale_time_ms,
1043                                    },
1044                                ));
1045                            }
1046                            Err(e) => {
1047                                let _ = tx_for_worker.send(SseMessage::Error(
1048                                    mold_core::SseErrorEvent {
1049                                        message: format!("upscale failed: {e}"),
1050                                    },
1051                                ));
1052                            }
1053                        }
1054
1055                        engine.clear_on_progress();
1056                    })
1057                    .await;
1058                    worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1059                    result
1060                }
1061                Err(e) => {
1062                    let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
1063                        message: e.error,
1064                    }));
1065                    return;
1066                }
1067            }
1068        } else {
1069            let model_name_for_cache = model_name_owned.clone();
1070            let weights_path_for_cache = weights_path.clone();
1071            let req_for_cache = req.clone();
1072            tokio::task::spawn_blocking(move || {
1073                let mut cache = upscaler_cache.lock().unwrap();
1074
1075                let needs_new = cache
1076                    .as_ref()
1077                    .is_none_or(|e| e.model_name() != model_name_for_cache);
1078                if needs_new {
1079                    let _ = tx.send(SseMessage::Progress(
1080                        mold_core::SseProgressEvent::StageStart {
1081                            name: "Loading upscaler model".to_string(),
1082                        },
1083                    ));
1084                    match mold_inference::create_upscale_engine(
1085                        model_name_for_cache,
1086                        weights_path_for_cache,
1087                        mold_inference::LoadStrategy::Eager,
1088                        0,
1089                    ) {
1090                        Ok(new_engine) => {
1091                            *cache = Some(new_engine);
1092                        }
1093                        Err(e) => {
1094                            let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
1095                                message: format!("failed to load upscaler: {e}"),
1096                            }));
1097                            return;
1098                        }
1099                    }
1100                }
1101
1102                let engine = cache.as_mut().unwrap();
1103                let tx_progress = tx.clone();
1104                engine.set_on_progress(Box::new(move |event| {
1105                    let sse_event: mold_core::SseProgressEvent = event.into();
1106                    let _ = tx_progress.send(SseMessage::Progress(sse_event));
1107                }));
1108
1109                match engine.upscale(&req_for_cache) {
1110                    Ok(resp) => {
1111                        let image_b64 =
1112                            base64::engine::general_purpose::STANDARD.encode(&resp.image.data);
1113                        let _ = tx.send(SseMessage::UpscaleComplete(
1114                            mold_core::SseUpscaleCompleteEvent {
1115                                image: image_b64,
1116                                format: resp.image.format,
1117                                model: resp.model,
1118                                scale_factor: resp.scale_factor,
1119                                original_width: resp.original_width,
1120                                original_height: resp.original_height,
1121                                upscale_time_ms: resp.upscale_time_ms,
1122                            },
1123                        ));
1124                    }
1125                    Err(e) => {
1126                        let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
1127                            message: format!("upscale failed: {e}"),
1128                        }));
1129                    }
1130                }
1131
1132                engine.clear_on_progress();
1133            })
1134            .await
1135        };
1136
1137        if let Err(e) = result {
1138            tracing::error!("upscale task panicked: {e}");
1139        }
1140    });
1141
1142    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
1143        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
1144
1145    Ok(Sse::new(stream).keep_alive(
1146        KeepAlive::new()
1147            .interval(std::time::Duration::from_secs(15))
1148            .text("ping"),
1149    ))
1150}
1151
1152// ── /api/generate/stream (SSE) ───────────────────────────────────────────────
1153
1154#[utoipa::path(
1155    post,
1156    path = "/api/generate/stream",
1157    tag = "generation",
1158    request_body = mold_core::GenerateRequest,
1159    responses(
1160        (status = 200, description = "SSE event stream with progress and result"),
1161        (status = 404, description = "Model not downloaded"),
1162        (status = 422, description = "Invalid request parameters"),
1163        (status = 500, description = "Inference error"),
1164        (status = 503, description = "Generation queue full"),
1165    )
1166)]
1167async fn generate_stream(
1168    State(state): State<AppState>,
1169    Json(mut req): Json<mold_core::GenerateRequest>,
1170) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
1171    let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
1172
1173    tracing::info!(
1174        model = %req.model,
1175        prompt = %req.prompt,
1176        "generate/stream request"
1177    );
1178
1179    // Create SSE channel
1180    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
1181
1182    // Send dimension warning before queuing so the client sees it early
1183    if let Some(warning) = dim_warning {
1184        let _ = tx.send(SseMessage::Progress(SseProgressEvent::Info {
1185            message: warning,
1186        }));
1187    }
1188
1189    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
1190    // Assign a server-side ID and register before submit so the entry is
1191    // visible to /api/queue from the moment we accept the request.
1192    let job_id = uuid::Uuid::new_v4().to_string();
1193    state.job_registry.register(&job_id, &req.model);
1194    let job = GenerationJob {
1195        id: job_id.clone(),
1196        request: req,
1197        progress_tx: Some(tx.clone()),
1198        result_tx,
1199        output_dir,
1200    };
1201
1202    let position = state
1203        .queue
1204        .submit(job, state.queue_capacity)
1205        .await
1206        .inspect_err(|_| state.job_registry.remove(&job_id))
1207        .map_err(submit_error_to_api)?;
1208
1209    // First event the client sees — carries the position AND the server
1210    // ID so the SPA can later reconcile this job against /api/queue.
1211    let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued {
1212        position,
1213        id: job_id,
1214    }));
1215
1216    // Hold `tx` alive in a background task until the job completes, so the SSE
1217    // stream never closes prematurely even if the queue worker hasn't received
1218    // the job yet.
1219    tokio::spawn(async move {
1220        let _ = result_rx.await;
1221        drop(tx); // closes the SSE stream
1222    });
1223
1224    // Build SSE stream from the channel receiver.
1225    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
1226        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
1227
1228    Ok(Sse::new(stream).keep_alive(
1229        KeepAlive::new()
1230            .interval(std::time::Duration::from_secs(15))
1231            .text("ping"),
1232    ))
1233}
1234
1235// ── /api/models ───────────────────────────────────────────────────────────────
1236
1237#[utoipa::path(
1238    get,
1239    path = "/api/models",
1240    tag = "models",
1241    responses(
1242        (status = 200, description = "List of available models", body = Vec<ModelInfoExtended>),
1243    )
1244)]
1245async fn list_models(State(state): State<AppState>) -> Json<Vec<ModelInfoExtended>> {
1246    Json(model_manager::list_models(&state).await)
1247}
1248
1249// ── /api/models/load ──────────────────────────────────────────────────────────
1250
1251#[derive(Debug, Deserialize, utoipa::ToSchema)]
1252pub struct LoadModelBody {
1253    #[schema(example = "flux-schnell:q8")]
1254    pub model: String,
1255    /// Target GPU ordinal (multi-GPU only). If omitted, the server uses its
1256    /// default placement strategy.
1257    #[serde(default, skip_serializing_if = "Option::is_none")]
1258    pub gpu: Option<usize>,
1259}
1260
1261#[utoipa::path(
1262    post,
1263    path = "/api/models/load",
1264    tag = "models",
1265    request_body = LoadModelBody,
1266    responses(
1267        (status = 200, description = "Model loaded successfully"),
1268        (status = 404, description = "Model not downloaded"),
1269        (status = 400, description = "Unknown model"),
1270        (status = 500, description = "Failed to load model"),
1271    )
1272)]
1273async fn load_model(
1274    State(state): State<AppState>,
1275    Json(body): Json<LoadModelBody>,
1276) -> Result<impl IntoResponse, ApiError> {
1277    // Multi-GPU path: route through the pool.
1278    if state.gpu_pool.worker_count() > 0 {
1279        let worker = match body.gpu {
1280            Some(ordinal) => state
1281                .gpu_pool
1282                .workers
1283                .iter()
1284                .find(|w| w.gpu.ordinal == ordinal)
1285                .cloned()
1286                .ok_or_else(|| {
1287                    ApiError::not_found(format!("no GPU worker with ordinal {ordinal}"))
1288                })?,
1289            None => {
1290                let est = crate::queue::estimate_model_vram(&body.model);
1291                state
1292                    .gpu_pool
1293                    .select_worker(&body.model, est)
1294                    .ok_or_else(|| {
1295                        ApiError::internal(format!(
1296                            "no GPU available to load model '{}'",
1297                            body.model
1298                        ))
1299                    })?
1300            }
1301        };
1302        let config_snapshot = state.config.read().await.clone();
1303        let model_name = body.model.clone();
1304        let worker_clone = worker.clone();
1305        tokio::task::spawn_blocking(move || {
1306            crate::gpu_worker::load_blocking(&worker_clone, &model_name, &config_snapshot)
1307        })
1308        .await
1309        .map_err(|e| ApiError::internal(format!("model load task failed: {e}")))?
1310        .map_err(|e| ApiError::internal(format!("model load error: {e}")))?;
1311
1312        tracing::info!(
1313            model = %body.model,
1314            gpu = worker.gpu.ordinal,
1315            "model loaded via API"
1316        );
1317        return Ok(StatusCode::OK);
1318    }
1319
1320    // Legacy single-GPU path (no workers discovered). No resolution context
1321    // here — admin load uses size-only peak via the `None` hint.
1322    model_manager::ensure_model_ready(&state, &body.model, None, None).await?;
1323    tracing::info!(model = %body.model, gpu = ?body.gpu, "model loaded via API (legacy)");
1324    Ok(StatusCode::OK)
1325}
1326
1327// ── /api/models/pull ──────────────────────────────────────────────────────────
1328
1329#[utoipa::path(
1330    post,
1331    path = "/api/models/pull",
1332    tag = "models",
1333    request_body = LoadModelBody,
1334    responses(
1335        (status = 200, description = "Model pulled (SSE stream or plain text)"),
1336        (status = 400, description = "Unknown model"),
1337        (status = 500, description = "Download failed"),
1338    )
1339)]
1340async fn pull_model_endpoint(
1341    State(state): State<AppState>,
1342    headers: HeaderMap,
1343    Json(body): Json<LoadModelBody>,
1344) -> Result<impl IntoResponse, ApiError> {
1345    let wants_sse = headers
1346        .get(header::ACCEPT)
1347        .and_then(|v| v.to_str().ok())
1348        .is_some_and(|v| v.contains("text/event-stream"));
1349
1350    // Enqueue via the queue. Treat idempotent AlreadyPresent as success.
1351    let (job_id, _position) = match state.downloads.enqueue(body.model.clone()).await {
1352        Ok((id, pos, _)) => (id, pos),
1353        Err(crate::downloads::EnqueueError::UnknownModel(_)) => {
1354            return Err(ApiError::unknown_model(format!(
1355                "unknown model '{}'. Run 'mold list' to see available models.",
1356                body.model
1357            )));
1358        }
1359        Err(crate::downloads::EnqueueError::LockPoisoned) => {
1360            return Err(ApiError::internal("download queue state is corrupt"));
1361        }
1362    };
1363
1364    if !wants_sse {
1365        // Await terminal event for this job, return plain text.
1366        let mut rx = state.downloads.subscribe();
1367        loop {
1368            match rx.recv().await {
1369                Ok(mold_core::types::DownloadEvent::JobDone { id, model }) if id == job_id => {
1370                    return Ok(PullResponse::Text(format!(
1371                        "model '{model}' pulled successfully"
1372                    )));
1373                }
1374                Ok(mold_core::types::DownloadEvent::JobFailed { id, error }) if id == job_id => {
1375                    return Err(ApiError::internal(format!(
1376                        "failed to pull model '{}': {error}",
1377                        body.model
1378                    )));
1379                }
1380                Ok(mold_core::types::DownloadEvent::JobCancelled { id }) if id == job_id => {
1381                    return Err(ApiError::internal(format!(
1382                        "pull of '{}' was cancelled",
1383                        body.model
1384                    )));
1385                }
1386                Ok(_) => continue,
1387                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
1388                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1389                    return Err(ApiError::internal("download queue channel closed"));
1390                }
1391            }
1392        }
1393    }
1394
1395    // SSE: re-emit queue events shaped like the legacy SseProgressEvent::DownloadProgress
1396    // so the TUI's existing consumer continues to work unchanged.
1397    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
1398    let mut events = state.downloads.subscribe();
1399    let model_for_cb = body.model.clone();
1400    tokio::spawn(async move {
1401        loop {
1402            match events.recv().await {
1403                Ok(mold_core::types::DownloadEvent::Started {
1404                    id,
1405                    files_total,
1406                    bytes_total,
1407                }) if id == job_id => {
1408                    let _ = tx.send(SseMessage::Progress(SseProgressEvent::DownloadProgress {
1409                        filename: String::new(),
1410                        file_index: 0,
1411                        total_files: files_total,
1412                        bytes_downloaded: 0,
1413                        bytes_total,
1414                        batch_bytes_downloaded: 0,
1415                        batch_bytes_total: bytes_total,
1416                        batch_elapsed_ms: 0,
1417                    }));
1418                }
1419                Ok(mold_core::types::DownloadEvent::Progress {
1420                    id,
1421                    files_done,
1422                    bytes_done,
1423                    current_file,
1424                }) if id == job_id => {
1425                    let _ = tx.send(SseMessage::Progress(SseProgressEvent::DownloadProgress {
1426                        filename: current_file.unwrap_or_default(),
1427                        file_index: files_done,
1428                        total_files: 0,
1429                        bytes_downloaded: bytes_done,
1430                        bytes_total: 0,
1431                        batch_bytes_downloaded: bytes_done,
1432                        batch_bytes_total: 0,
1433                        batch_elapsed_ms: 0,
1434                    }));
1435                }
1436                Ok(mold_core::types::DownloadEvent::JobDone { id, .. }) if id == job_id => {
1437                    let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
1438                        model: model_for_cb.clone(),
1439                    }));
1440                    break;
1441                }
1442                Ok(mold_core::types::DownloadEvent::JobFailed { id, error }) if id == job_id => {
1443                    let _ = tx.send(SseMessage::Error(SseErrorEvent { message: error }));
1444                    break;
1445                }
1446                Ok(mold_core::types::DownloadEvent::JobCancelled { id }) if id == job_id => {
1447                    let _ = tx.send(SseMessage::Error(SseErrorEvent {
1448                        message: "pull cancelled".into(),
1449                    }));
1450                    break;
1451                }
1452                Ok(_) => continue,
1453                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
1454                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1455            }
1456        }
1457    });
1458
1459    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
1460        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
1461
1462    Ok(PullResponse::Sse(
1463        Sse::new(stream)
1464            .keep_alive(
1465                KeepAlive::new()
1466                    .interval(std::time::Duration::from_secs(15))
1467                    .text("ping"),
1468            )
1469            .into_response(),
1470    ))
1471}
1472
1473/// Response type that can be either SSE stream or plain text.
1474enum PullResponse {
1475    Sse(axum::response::Response),
1476    Text(String),
1477}
1478
1479impl IntoResponse for PullResponse {
1480    fn into_response(self) -> axum::response::Response {
1481        match self {
1482            PullResponse::Sse(resp) => resp,
1483            PullResponse::Text(text) => text.into_response(),
1484        }
1485    }
1486}
1487
1488// ── /api/models/unload ────────────────────────────────────────────────────────
1489
1490/// Optional request body for unload — clients may specify a model or GPU target.
1491/// An empty body (or no body) unloads the active model on the legacy path.
1492#[derive(Debug, Default, Deserialize, utoipa::ToSchema)]
1493pub struct UnloadRequest {
1494    /// Specific model to unload. If omitted, the active model is unloaded.
1495    #[serde(default, skip_serializing_if = "Option::is_none")]
1496    pub model: Option<String>,
1497    /// Target GPU ordinal (multi-GPU only).
1498    #[serde(default, skip_serializing_if = "Option::is_none")]
1499    pub gpu: Option<usize>,
1500}
1501
1502#[utoipa::path(
1503    delete,
1504    path = "/api/models/unload",
1505    tag = "models",
1506    request_body(content = Option<UnloadRequest>, content_type = "application/json"),
1507    responses(
1508        (status = 200, description = "Model unloaded or no model was loaded", body = String),
1509    )
1510)]
1511async fn unload_model(
1512    State(state): State<AppState>,
1513    body: Option<Json<UnloadRequest>>,
1514) -> Result<impl IntoResponse, ApiError> {
1515    let req = body.map(|b| b.0).unwrap_or_default();
1516    tracing::debug!(model = ?req.model, gpu = ?req.gpu, "unload request");
1517    clear_global_upscaler_cache(&state);
1518
1519    // Multi-GPU path: target specific GPU or model across the pool.
1520    if state.gpu_pool.worker_count() > 0 {
1521        // Select the workers to unload from.
1522        let targets: Vec<_> = match (req.gpu, req.model.as_deref()) {
1523            (Some(ordinal), _) => state
1524                .gpu_pool
1525                .workers
1526                .iter()
1527                .filter(|w| w.gpu.ordinal == ordinal)
1528                .cloned()
1529                .collect(),
1530            (None, Some(model)) => state
1531                .gpu_pool
1532                .workers
1533                .iter()
1534                .filter(|w| {
1535                    let cache = w.model_cache.lock().unwrap();
1536                    cache
1537                        .get(model)
1538                        .map(|e| e.residency == crate::model_cache::ModelResidency::Gpu)
1539                        .unwrap_or(false)
1540                })
1541                .cloned()
1542                .collect(),
1543            (None, None) => state.gpu_pool.workers.clone(),
1544        };
1545
1546        if targets.is_empty() {
1547            return Ok((StatusCode::OK, "no model loaded".to_string()));
1548        }
1549
1550        let mut unloaded_pairs: Vec<(usize, String)> = Vec::new();
1551        for worker in targets {
1552            let worker_clone = worker.clone();
1553            let result = tokio::task::spawn_blocking(move || {
1554                crate::gpu_worker::unload_blocking(&worker_clone)
1555            })
1556            .await
1557            .map_err(|e| ApiError::internal(format!("unload task failed: {e}")))?;
1558            if let Some(name) = result {
1559                unloaded_pairs.push((worker.gpu.ordinal, name));
1560            }
1561        }
1562
1563        let msg = if unloaded_pairs.is_empty() {
1564            "no model loaded".to_string()
1565        } else {
1566            let joined: Vec<String> = unloaded_pairs
1567                .iter()
1568                .map(|(o, m)| format!("gpu{o}:{m}"))
1569                .collect();
1570            format!("unloaded {}", joined.join(", "))
1571        };
1572        return Ok((StatusCode::OK, msg));
1573    }
1574
1575    // Legacy single-GPU path.
1576    Ok((StatusCode::OK, model_manager::unload_model(&state).await))
1577}
1578
1579// ── /api/status ───────────────────────────────────────────────────────────────
1580
1581#[utoipa::path(
1582    get,
1583    path = "/api/status",
1584    tag = "server",
1585    responses(
1586        (status = 200, description = "Server status", body = ServerStatus),
1587    )
1588)]
1589async fn server_status(State(state): State<AppState>) -> Json<ServerStatus> {
1590    // Aggregate GPU status from the pool.
1591    let gpu_statuses = state.gpu_pool.gpu_status();
1592    let has_gpus = !gpu_statuses.is_empty();
1593
1594    // Collect loaded models from GPU workers.
1595    let gpu_models_loaded: Vec<String> = gpu_statuses
1596        .iter()
1597        .filter_map(|g| g.loaded_model.clone())
1598        .collect();
1599    let gpu_busy = gpu_statuses
1600        .iter()
1601        .any(|g| g.state == GpuWorkerState::Generating);
1602
1603    // Pull current_generation from the first busy worker (multi-GPU) or
1604    // from the legacy snapshot.
1605    let multi_gpu_current_gen = if has_gpus {
1606        state.gpu_pool.workers.iter().find_map(|w| {
1607            let gen = w.active_generation.read().ok()?;
1608            gen.as_ref().map(|g| ActiveGenerationStatus {
1609                model: g.model.clone(),
1610                prompt_sha256: g.prompt_sha256.clone(),
1611                started_at_unix_ms: g.started_at_unix_ms,
1612                elapsed_ms: g.started_at.elapsed().as_millis() as u64,
1613            })
1614        })
1615    } else {
1616        None
1617    };
1618
1619    // Fall back to legacy single-GPU snapshot for backwards compat.
1620    let (models_loaded, busy, current_generation) = if has_gpus {
1621        (gpu_models_loaded, gpu_busy, multi_gpu_current_gen)
1622    } else {
1623        let snapshot = state.model_cache.lock().await.snapshot();
1624        let models = match (snapshot.model_name, snapshot.is_loaded) {
1625            (Some(model_name), true) => vec![model_name],
1626            _ => vec![],
1627        };
1628        let gen = state
1629            .active_generation
1630            .read()
1631            .unwrap_or_else(|e| e.into_inner())
1632            .as_ref()
1633            .map(|active| ActiveGenerationStatus {
1634                model: active.model.clone(),
1635                prompt_sha256: active.prompt_sha256.clone(),
1636                started_at_unix_ms: active.started_at_unix_ms,
1637                elapsed_ms: active.started_at.elapsed().as_millis() as u64,
1638            });
1639        let is_busy = gen.is_some();
1640        (models, is_busy, gen)
1641    };
1642
1643    Json(ServerStatus {
1644        version: env!("CARGO_PKG_VERSION").to_string(),
1645        git_sha: if mold_core::build_info::GIT_SHA == "unknown" {
1646            None
1647        } else {
1648            Some(mold_core::build_info::GIT_SHA.to_string())
1649        },
1650        build_date: if mold_core::build_info::BUILD_DATE == "unknown" {
1651            None
1652        } else {
1653            Some(mold_core::build_info::BUILD_DATE.to_string())
1654        },
1655        models_loaded,
1656        busy,
1657        current_generation,
1658        gpu_info: query_gpu_info(),
1659        uptime_secs: state.start_time.elapsed().as_secs(),
1660        hostname: hostname::get().ok().and_then(|h| h.into_string().ok()),
1661        memory_status: mold_inference::device::memory_status_string(),
1662        gpus: if has_gpus { Some(gpu_statuses) } else { None },
1663        queue_depth: Some(state.queue.pending()),
1664        queue_capacity: Some(state.queue_capacity),
1665    })
1666}
1667
1668// ── /health ───────────────────────────────────────────────────────────────────
1669
1670#[utoipa::path(
1671    get,
1672    path = "/health",
1673    tag = "server",
1674    responses(
1675        (status = 200, description = "Server is healthy"),
1676    )
1677)]
1678async fn health() -> impl IntoResponse {
1679    StatusCode::OK
1680}
1681
1682// ── /api/queue ───────────────────────────────────────────────────────────────
1683
1684/// Snapshot of every job currently queued or running on the server. Clients
1685/// (notably the web SPA) poll this to reconcile their local card list — any
1686/// "running" card whose server id isn't here is a zombie left over from a
1687/// dropped SSE stream and should be dead-lettered.
1688#[utoipa::path(
1689    get,
1690    path = "/api/queue",
1691    tag = "queue",
1692    responses(
1693        (status = 200, description = "Queue snapshot", body = crate::job_registry::QueueListing),
1694    )
1695)]
1696async fn list_queue(State(state): State<AppState>) -> Json<crate::job_registry::QueueListing> {
1697    Json(state.job_registry.snapshot())
1698}
1699
1700// ── /api/capabilities ────────────────────────────────────────────────────────
1701
1702/// Report the feature toggles a client needs to render correctly (hide the
1703/// delete button when delete isn't allowed, etc.). No auth required — this
1704/// is a read-only introspection endpoint.
1705async fn server_capabilities() -> Json<mold_core::ServerCapabilities> {
1706    let catalog_available = std::env::var("MOLD_CATALOG_DISABLE")
1707        .map(|v| v != "1" && !v.eq_ignore_ascii_case("true"))
1708        .unwrap_or(true);
1709
1710    Json(mold_core::ServerCapabilities {
1711        gallery: mold_core::GalleryCapabilities { can_delete: true },
1712        catalog: mold_core::CatalogCapabilities {
1713            available: catalog_available,
1714            families: mold_catalog::families::ALL_FAMILIES
1715                .iter()
1716                .map(|f| f.as_str().to_string())
1717                .collect::<Vec<_>>(),
1718        },
1719    })
1720}
1721
1722// ── /api/capabilities/chain-limits ───────────────────────────────────────────
1723
1724#[utoipa::path(
1725    get,
1726    path = "/api/capabilities/chain-limits",
1727    tag = "server",
1728    params(
1729        ("model" = String, Query, description = "Model name (e.g. ltx-2-19b-distilled:fp8)")
1730    ),
1731    responses(
1732        (status = 200, description = "Chain limits for the requested model",
1733         body = crate::chain_limits::ChainLimits),
1734        (status = 400, description = "Missing required 'model' query parameter"),
1735        (status = 404, description = "Unknown or unsupported model"),
1736    )
1737)]
1738async fn capabilities_chain_limits(
1739    axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
1740) -> axum::response::Response {
1741    let raw_model = match params.get("model") {
1742        Some(m) => m.clone(),
1743        None => {
1744            return (
1745                StatusCode::BAD_REQUEST,
1746                "missing required 'model' query parameter\n",
1747            )
1748                .into_response();
1749        }
1750    };
1751
1752    let resolved = mold_core::manifest::resolve_model_name(&raw_model);
1753    let Some(manifest) = mold_core::manifest::find_manifest(&resolved) else {
1754        return (StatusCode::NOT_FOUND, "unknown model\n").into_response();
1755    };
1756    let family = manifest.family.clone();
1757
1758    if crate::chain_limits::family_cap(&family).is_none() {
1759        return (StatusCode::NOT_FOUND, "model is not chain-capable\n").into_response();
1760    }
1761
1762    let quant = resolved
1763        .split_once(':')
1764        .map(|(_, tag)| tag.to_string())
1765        .unwrap_or_default();
1766
1767    // TODO(sub-project D): pass live free VRAM from AppState.
1768    let limits = crate::chain_limits::compute_limits(&resolved, &family, &quant, 0);
1769    Json(limits).into_response()
1770}
1771
1772// ── /api/shutdown ─────────────────────────────────────────────────────────────
1773
1774/// Trigger graceful server shutdown.
1775///
1776/// When API key auth is enabled, the auth middleware protects this endpoint.
1777/// When auth is disabled, only requests from loopback addresses (127.0.0.1, ::1)
1778/// are accepted to prevent remote shutdown.
1779#[utoipa::path(
1780    post,
1781    path = "/api/shutdown",
1782    tag = "server",
1783    responses(
1784        (status = 200, description = "Shutdown initiated"),
1785        (status = 403, description = "Forbidden — remote shutdown requires API key auth"),
1786    )
1787)]
1788async fn shutdown_server(State(state): State<AppState>, request: Request) -> impl IntoResponse {
1789    // When auth is disabled (no AuthState extension or AuthState is None),
1790    // restrict shutdown to loopback addresses only.
1791    let auth_enabled = request
1792        .extensions()
1793        .get::<crate::auth::AuthState>()
1794        .is_some_and(|s| s.is_some());
1795
1796    if !auth_enabled {
1797        let is_loopback = request
1798            .extensions()
1799            .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
1800            .map(|ci| ci.0.ip().is_loopback())
1801            .unwrap_or(false);
1802        if !is_loopback {
1803            return (
1804                StatusCode::FORBIDDEN,
1805                "shutdown requires API key auth or localhost access\n",
1806            );
1807        }
1808    }
1809
1810    tracing::info!("shutdown requested via API");
1811    if let Some(tx) = state.shutdown_tx.lock().await.take() {
1812        let _ = tx.send(());
1813    }
1814    (StatusCode::OK, "shutdown initiated\n")
1815}
1816
1817// ── /api/gallery ──────────────────────────────────────────────────────────────
1818
1819/// List gallery images from the server's output directory.
1820///
1821/// Prefers the SQLite metadata DB when available so listings stay fast on
1822/// large galleries (no per-request directory walk). Falls back to the
1823/// filesystem scan when the DB is disabled, can't be opened, or — as a
1824/// safety net — has no rows for this directory yet (e.g. the reconciliation
1825/// background task has not finished on first startup).
1826async fn list_gallery(
1827    State(state): State<AppState>,
1828) -> Result<Json<Vec<mold_core::GalleryImage>>, ApiError> {
1829    let config = state.config.read().await;
1830    if config.is_output_disabled() {
1831        return Ok(Json(Vec::new()));
1832    }
1833    let output_dir = config.effective_output_dir();
1834    drop(config);
1835
1836    if !output_dir.is_dir() {
1837        return Ok(Json(Vec::new()));
1838    }
1839
1840    if state.metadata_db.is_some() {
1841        let db_arc = state.metadata_db.clone();
1842        let dir = output_dir.clone();
1843        let listed = tokio::task::spawn_blocking(move || {
1844            db_arc
1845                .as_ref()
1846                .as_ref()
1847                .map(|db| db.list(Some(&dir)))
1848                .transpose()
1849        })
1850        .await
1851        .map_err(|e| ApiError::internal(format!("gallery DB query failed: {e}")))?
1852        .map_err(|e| ApiError::internal(format!("gallery DB query failed: {e:#}")))?;
1853        if let Some(rows) = listed {
1854            if !rows.is_empty() {
1855                let images = rows.iter().map(|r| r.to_gallery_image()).collect();
1856                return Ok(Json(images));
1857            }
1858        }
1859    }
1860
1861    let images = tokio::task::spawn_blocking(move || scan_gallery_dir(&output_dir))
1862        .await
1863        .map_err(|e| ApiError::internal(format!("gallery scan failed: {e}")))?;
1864
1865    Ok(Json(images))
1866}
1867
1868/// Serve a gallery file by filename.
1869///
1870/// Supports HTTP `Range` requests so `<video>` elements can scrub MP4
1871/// outputs without downloading the whole clip up front. Partial responses
1872/// stream straight from disk via `tokio_util::io::ReaderStream` — nothing
1873/// buffers the full file in server RAM, which matters once a gallery
1874/// contains multi-GB LTX-2 outputs. Non-range requests still return the
1875/// whole file (streamed) with `Accept-Ranges: bytes` so the client knows
1876/// it can seek on subsequent requests.
1877async fn get_gallery_image(
1878    State(state): State<AppState>,
1879    headers: HeaderMap,
1880    axum::extract::Path(filename): axum::extract::Path<String>,
1881) -> Result<axum::response::Response, ApiError> {
1882    let config = state.config.read().await;
1883    if config.is_output_disabled() {
1884        return Err(ApiError::not_found("image output is disabled"));
1885    }
1886    let output_dir = config.effective_output_dir();
1887    drop(config);
1888
1889    // Sanitize: prevent directory traversal
1890    let clean_name = std::path::Path::new(&filename)
1891        .file_name()
1892        .map(|f| f.to_string_lossy().to_string())
1893        .unwrap_or_default();
1894
1895    if clean_name.is_empty() || clean_name != filename {
1896        return Err(ApiError::validation("invalid filename"));
1897    }
1898
1899    let path = output_dir.join(&clean_name);
1900    let meta = match tokio::fs::metadata(&path).await {
1901        Ok(m) if m.is_file() => m,
1902        _ => {
1903            return Err(ApiError::not_found(format!(
1904                "image not found: {clean_name}"
1905            )));
1906        }
1907    };
1908    let total_len = meta.len();
1909    let content_type = content_type_for_filename(&clean_name);
1910
1911    let range_header = headers
1912        .get(header::RANGE)
1913        .and_then(|v| v.to_str().ok())
1914        .map(|s| s.to_string());
1915
1916    let file = tokio::fs::File::open(&path)
1917        .await
1918        .map_err(|e| ApiError::internal(format!("failed to open file: {e}")))?;
1919
1920    if let Some(raw) = range_header {
1921        if let Some((start, end)) = parse_byte_range(&raw, total_len) {
1922            return serve_range(file, start, end, total_len, content_type).await;
1923        } else {
1924            // A `Range` header we can't satisfy ⇒ 416 per RFC 9110 §15.6.2.
1925            return Ok(axum::response::Response::builder()
1926                .status(StatusCode::RANGE_NOT_SATISFIABLE)
1927                .header(header::CONTENT_RANGE, format!("bytes */{total_len}"))
1928                .body(axum::body::Body::empty())
1929                .unwrap());
1930        }
1931    }
1932
1933    // Full response: stream the file rather than buffer it in RAM.
1934    let stream = tokio_util::io::ReaderStream::new(file);
1935    let body = axum::body::Body::from_stream(stream);
1936    Ok(axum::response::Response::builder()
1937        .status(StatusCode::OK)
1938        .header(header::CONTENT_TYPE, content_type)
1939        .header(header::ACCEPT_RANGES, "bytes")
1940        .header(header::CONTENT_LENGTH, total_len)
1941        .header(header::CACHE_CONTROL, "public, max-age=3600")
1942        .body(body)
1943        .unwrap())
1944}
1945
1946/// Parse a `Range: bytes=start-end` header into a concrete (start, end)
1947/// byte range inclusive on both ends. Returns `None` for unsatisfiable or
1948/// malformed ranges — the caller translates that into a 416 response.
1949///
1950/// Only the single-range form is supported (multipart ranges are vanishingly
1951/// rare in practice and substantially more complex to implement correctly;
1952/// browsers for `<video>` always send single ranges).
1953fn parse_byte_range(header: &str, total_len: u64) -> Option<(u64, u64)> {
1954    let spec = header.strip_prefix("bytes=")?;
1955    if spec.contains(',') {
1956        return None;
1957    }
1958    let (start_s, end_s) = spec.split_once('-')?;
1959    let start_s = start_s.trim();
1960    let end_s = end_s.trim();
1961
1962    if total_len == 0 {
1963        return None;
1964    }
1965
1966    if start_s.is_empty() {
1967        // Suffix range: `bytes=-N` means "the last N bytes".
1968        let suffix: u64 = end_s.parse().ok()?;
1969        if suffix == 0 {
1970            return None;
1971        }
1972        let start = total_len.saturating_sub(suffix);
1973        return Some((start, total_len - 1));
1974    }
1975
1976    let start: u64 = start_s.parse().ok()?;
1977    if start >= total_len {
1978        return None;
1979    }
1980    let end: u64 = if end_s.is_empty() {
1981        total_len - 1
1982    } else {
1983        end_s.parse().ok()?
1984    };
1985    let end = end.min(total_len - 1);
1986    if end < start {
1987        return None;
1988    }
1989    Some((start, end))
1990}
1991
1992/// Emit a `206 Partial Content` response streaming `[start, end]` inclusive
1993/// from the already-open file handle. `take(len)` bounds the reader so the
1994/// body terminates exactly at `end + 1` instead of reading the tail.
1995async fn serve_range(
1996    mut file: tokio::fs::File,
1997    start: u64,
1998    end: u64,
1999    total_len: u64,
2000    content_type: &'static str,
2001) -> Result<axum::response::Response, ApiError> {
2002    use tokio::io::{AsyncReadExt, AsyncSeekExt};
2003    file.seek(std::io::SeekFrom::Start(start))
2004        .await
2005        .map_err(|e| ApiError::internal(format!("seek failed: {e}")))?;
2006    let len = end - start + 1;
2007    let stream = tokio_util::io::ReaderStream::new(file.take(len));
2008    let body = axum::body::Body::from_stream(stream);
2009    Ok(axum::response::Response::builder()
2010        .status(StatusCode::PARTIAL_CONTENT)
2011        .header(header::CONTENT_TYPE, content_type)
2012        .header(header::ACCEPT_RANGES, "bytes")
2013        .header(header::CONTENT_LENGTH, len)
2014        .header(
2015            header::CONTENT_RANGE,
2016            format!("bytes {start}-{end}/{total_len}"),
2017        )
2018        // Partial content is less cacheable at intermediaries than a plain
2019        // 200; keep a short TTL so the client's own cache still helps.
2020        .header(header::CACHE_CONTROL, "public, max-age=300")
2021        .body(body)
2022        .unwrap())
2023}
2024
2025/// Pick an HTTP Content-Type for a gallery filename. Covers every format
2026/// `OutputFormat` can emit plus a safe default.
2027fn content_type_for_filename(name: &str) -> &'static str {
2028    let lower = name.to_ascii_lowercase();
2029    if lower.ends_with(".png") {
2030        "image/png"
2031    } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
2032        "image/jpeg"
2033    } else if lower.ends_with(".gif") {
2034        "image/gif"
2035    } else if lower.ends_with(".webp") {
2036        "image/webp"
2037    } else if lower.ends_with(".apng") {
2038        "image/apng"
2039    } else if lower.ends_with(".mp4") {
2040        "video/mp4"
2041    } else {
2042        "application/octet-stream"
2043    }
2044}
2045
2046/// Delete a gallery image and its server-side thumbnail.
2047///
2048/// Destructive, but always enabled — pair with the `MOLD_API_KEY` middleware
2049/// when the server is exposed beyond localhost.
2050async fn delete_gallery_image(
2051    State(state): State<AppState>,
2052    axum::extract::Path(filename): axum::extract::Path<String>,
2053) -> Result<impl IntoResponse, ApiError> {
2054    let config = state.config.read().await;
2055    if config.is_output_disabled() {
2056        return Err(ApiError::not_found("image output is disabled"));
2057    }
2058    let output_dir = config.effective_output_dir();
2059    drop(config);
2060
2061    let clean_name = std::path::Path::new(&filename)
2062        .file_name()
2063        .map(|f| f.to_string_lossy().to_string())
2064        .unwrap_or_default();
2065
2066    if clean_name.is_empty() || clean_name != filename {
2067        return Err(ApiError::validation("invalid filename"));
2068    }
2069
2070    let path = output_dir.join(&clean_name);
2071    if path.is_file() {
2072        std::fs::remove_file(&path)
2073            .map_err(|e| ApiError::internal(format!("failed to delete image: {e}")))?;
2074    }
2075
2076    // Also remove server-side thumbnail (both legacy no-suffix and current
2077    // `.png`-suffixed cache layouts) and the animated preview sidecar so
2078    // `/api/gallery/preview/:filename` doesn't keep serving the GIF after
2079    // the source MP4 is gone.
2080    let thumb_dir = server_thumbnail_dir();
2081    let _ = std::fs::remove_file(thumb_dir.join(&clean_name));
2082    let _ = std::fs::remove_file(thumb_dir.join(format!("{clean_name}.png")));
2083    let _ =
2084        std::fs::remove_file(server_preview_gif_dir().join(format!("{clean_name}.preview.gif")));
2085
2086    // Drop the matching metadata row if the DB is enabled. Errors here are
2087    // logged — they don't roll back the disk delete since the file is the
2088    // source of truth and reconciliation will re-sync on the next restart.
2089    if let Some(db) = state.metadata_db.as_ref().as_ref() {
2090        match db.delete(&output_dir, &clean_name) {
2091            Ok(true) => {}
2092            Ok(false) => tracing::debug!(
2093                "delete: no metadata row for {}",
2094                output_dir.join(&clean_name).display()
2095            ),
2096            Err(e) => tracing::warn!(
2097                "metadata DB delete failed for {}: {e:#}",
2098                output_dir.join(&clean_name).display()
2099            ),
2100        }
2101    }
2102
2103    Ok(StatusCode::NO_CONTENT)
2104}
2105
2106/// Serve a thumbnail for a gallery image. Generated on-demand and cached
2107/// at ~/.mold/cache/thumbnails/ on the server side.
2108async fn get_gallery_thumbnail(
2109    State(state): State<AppState>,
2110    axum::extract::Path(filename): axum::extract::Path<String>,
2111) -> Result<impl IntoResponse, ApiError> {
2112    let config = state.config.read().await;
2113    if config.is_output_disabled() {
2114        return Err(ApiError::not_found("image output is disabled"));
2115    }
2116    let output_dir = config.effective_output_dir();
2117    drop(config);
2118
2119    let clean_name = std::path::Path::new(&filename)
2120        .file_name()
2121        .map(|f| f.to_string_lossy().to_string())
2122        .unwrap_or_default();
2123
2124    if clean_name.is_empty() || clean_name != filename {
2125        return Err(ApiError::validation("invalid filename"));
2126    }
2127
2128    let source_path = output_dir.join(&clean_name);
2129    if !source_path.is_file() {
2130        return Err(ApiError::not_found(format!(
2131            "image not found: {clean_name}"
2132        )));
2133    }
2134
2135    // Thumbnail cache path: always `.png` regardless of the source extension,
2136    // so mp4 / gif / apng / webp / jpg all coexist cleanly in the same cache
2137    // dir and `image.save()` doesn't pick the wrong format from the path.
2138    let thumb_dir = server_thumbnail_dir();
2139    let thumb_path = thumb_dir.join(format!("{clean_name}.png"));
2140    let lower = clean_name.to_ascii_lowercase();
2141    let is_video = lower.ends_with(".mp4");
2142
2143    if !thumb_path.is_file() {
2144        // Generate thumbnail on-demand. Videos go through openh264 for a real
2145        // first-frame extract; everything else decodes via the `image` crate.
2146        // If either path fails, we fall back to serving the source bytes
2147        // directly — browsers are more lenient about partial / checksum-
2148        // mismatched images than either decoder, and the SPA would rather
2149        // show something than a 500.
2150        let source = source_path.clone();
2151        let dest = thumb_path.clone();
2152        let gen_result = tokio::task::spawn_blocking(move || {
2153            if is_video {
2154                generate_video_thumbnail(&source, &dest)
2155            } else {
2156                generate_server_thumbnail(&source, &dest)
2157            }
2158        })
2159        .await
2160        .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?;
2161
2162        if let Err(err) = gen_result {
2163            tracing::warn!(
2164                file = %clean_name,
2165                error = %err,
2166                "thumbnail decode failed; falling back to source bytes"
2167            );
2168            // For videos, the browser can't render the raw mp4 as an <img>
2169            // either, so serving the source doesn't help — fall back to the
2170            // SVG play-icon placeholder instead.
2171            if is_video {
2172                let mut headers = HeaderMap::new();
2173                headers.insert(
2174                    header::CONTENT_TYPE,
2175                    HeaderValue::from_static("image/svg+xml"),
2176                );
2177                headers.insert(
2178                    header::CACHE_CONTROL,
2179                    HeaderValue::from_static("public, max-age=300"),
2180                );
2181                return Ok((headers, VIDEO_PLACEHOLDER_SVG.as_bytes().to_vec()));
2182            }
2183            let raw = tokio::fs::read(&source_path)
2184                .await
2185                .map_err(|e| ApiError::internal(format!("failed to read source: {e}")))?;
2186            let mut headers = HeaderMap::new();
2187            headers.insert(
2188                header::CONTENT_TYPE,
2189                HeaderValue::from_static(content_type_for_filename(&clean_name)),
2190            );
2191            headers.insert(
2192                header::CACHE_CONTROL,
2193                HeaderValue::from_static("public, max-age=300"),
2194            );
2195            return Ok((headers, raw));
2196        }
2197    }
2198
2199    let data = tokio::fs::read(&thumb_path)
2200        .await
2201        .map_err(|e| ApiError::internal(format!("failed to read thumbnail: {e}")))?;
2202
2203    let mut headers = HeaderMap::new();
2204    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
2205    headers.insert(
2206        header::CACHE_CONTROL,
2207        HeaderValue::from_static("public, max-age=3600"),
2208    );
2209
2210    Ok((headers, data))
2211}
2212
2213const VIDEO_PLACEHOLDER_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1e293b"/><stop offset="1" stop-color="#0f172a"/></linearGradient></defs><rect width="256" height="256" fill="url(#g)"/><circle cx="128" cy="128" r="52" fill="rgba(255,255,255,0.08)"/><polygon points="112,100 112,156 160,128" fill="rgba(226,232,240,0.85)"/></svg>"##;
2214
2215/// Serve a cached animated GIF preview for a gallery video output.
2216///
2217/// Looks up `<preview_dir>/<filename>.preview.gif` (default:
2218/// `~/.mold/cache/previews/`). When present, streams the file back as
2219/// `image/gif`; otherwise returns 404. This exists so the TUI's remote
2220/// gallery detail pane can animate video entries the same way it animates
2221/// local ones — previously it fell through to fetching the raw MP4 over
2222/// `/api/gallery/image/:filename`, which `image::open` couldn't decode,
2223/// leaving the panel on `Loading…` forever.
2224async fn get_gallery_preview(
2225    State(state): State<AppState>,
2226    axum::extract::Path(filename): axum::extract::Path<String>,
2227) -> Result<axum::response::Response, ApiError> {
2228    let config = state.config.read().await;
2229    if config.is_output_disabled() {
2230        return Err(ApiError::not_found("image output is disabled"));
2231    }
2232    let output_dir = config.effective_output_dir();
2233    drop(config);
2234
2235    // Sanitize: prevent directory traversal — the filename must be a bare
2236    // basename with no separators.
2237    let clean_name = std::path::Path::new(&filename)
2238        .file_name()
2239        .map(|f| f.to_string_lossy().to_string())
2240        .unwrap_or_default();
2241    if clean_name.is_empty() || clean_name != filename {
2242        return Err(ApiError::validation("invalid filename"));
2243    }
2244
2245    // The preview cache lifecycle is tied to the underlying gallery file:
2246    // if the MP4 has been deleted (via `DELETE /api/gallery/image/:filename`
2247    // or an out-of-band `rm`), the sidecar may still be on disk but is
2248    // orphaned and must not be served.
2249    // Check the source file first and 404 before touching the cache so a
2250    // stale `.preview.gif` never leaks deleted content.
2251    let source_path = output_dir.join(&clean_name);
2252    if !tokio::fs::metadata(&source_path)
2253        .await
2254        .map(|m| m.is_file())
2255        .unwrap_or(false)
2256    {
2257        return Err(ApiError::not_found(format!(
2258            "image not found: {clean_name}"
2259        )));
2260    }
2261
2262    let preview_path = server_preview_gif_dir().join(format!("{clean_name}.preview.gif"));
2263    let meta = match tokio::fs::metadata(&preview_path).await {
2264        Ok(m) if m.is_file() => m,
2265        _ => {
2266            return Err(ApiError::not_found(format!(
2267                "preview not found: {clean_name}"
2268            )));
2269        }
2270    };
2271    let total_len = meta.len();
2272
2273    let file = tokio::fs::File::open(&preview_path)
2274        .await
2275        .map_err(|e| ApiError::internal(format!("failed to open preview: {e}")))?;
2276    let stream = tokio_util::io::ReaderStream::new(file);
2277    let body = axum::body::Body::from_stream(stream);
2278    Ok(axum::response::Response::builder()
2279        .status(StatusCode::OK)
2280        .header(header::CONTENT_TYPE, "image/gif")
2281        .header(header::CONTENT_LENGTH, total_len)
2282        .header(header::CACHE_CONTROL, "public, max-age=3600")
2283        .body(body)
2284        .unwrap())
2285}
2286
2287/// Server-side GIF preview cache directory. Mirrors the layout the TUI
2288/// writes to (`crates/mold-tui/src/thumbnails.rs::preview_dir`) so a
2289/// single preview.gif authored on either side is reachable via this
2290/// endpoint.
2291fn server_preview_gif_dir() -> std::path::PathBuf {
2292    mold_core::Config::mold_dir()
2293        .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
2294        .join("cache")
2295        .join("previews")
2296}
2297
2298/// Server-side thumbnail cache directory.
2299fn server_thumbnail_dir() -> std::path::PathBuf {
2300    mold_core::Config::mold_dir()
2301        .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
2302        .join("cache")
2303        .join("thumbnails")
2304}
2305
2306/// Generate a 256x256 max thumbnail from source image. The result is always
2307/// written as a PNG regardless of the source format, so callers should pass
2308/// a `.png`-suffixed `dest` to keep the on-disk cache unambiguous.
2309fn generate_server_thumbnail(
2310    source: &std::path::Path,
2311    dest: &std::path::Path,
2312) -> anyhow::Result<()> {
2313    let img = image::open(source)?;
2314    let thumb = img.thumbnail(256, 256);
2315    if let Some(parent) = dest.parent() {
2316        std::fs::create_dir_all(parent)?;
2317    }
2318    thumb.save_with_format(dest, image::ImageFormat::Png)?;
2319    Ok(())
2320}
2321
2322/// Extract the first frame of an MP4 as a PNG thumbnail and downscale to
2323/// 256px max via the `image` crate. Uses the openh264 pipeline that
2324/// `mold_inference::ltx2::media` already ships for video probes.
2325///
2326/// The full-frame PNG is written to a sibling temp path first, then decoded
2327/// and resized — this keeps `mold_inference`'s existing helper surface stable
2328/// while still producing a compact thumbnail.
2329fn generate_video_thumbnail(
2330    source: &std::path::Path,
2331    dest: &std::path::Path,
2332) -> anyhow::Result<()> {
2333    if let Some(parent) = dest.parent() {
2334        std::fs::create_dir_all(parent)?;
2335    }
2336    // Decode the first frame to a temporary full-resolution PNG, then
2337    // thumbnail-resize via the `image` crate. We stage through a temp file
2338    // rather than through memory to reuse `extract_thumbnail`'s existing
2339    // I/O-based API.
2340    let tmp = dest.with_extension("firstframe.png");
2341    mold_inference::ltx2::media::extract_thumbnail(source, &tmp)?;
2342    let decode_result = (|| -> anyhow::Result<()> {
2343        let img = image::open(&tmp)?;
2344        let thumb = img.thumbnail(256, 256);
2345        thumb.save_with_format(dest, image::ImageFormat::Png)?;
2346        Ok(())
2347    })();
2348    let _ = std::fs::remove_file(&tmp);
2349    decode_result
2350}
2351
2352/// Pre-generate thumbnails for all gallery images on server startup.
2353pub fn spawn_thumbnail_warmup(config: &mold_core::Config) {
2354    if !thumbnail_warmup_enabled() {
2355        tracing::info!("thumbnail warmup disabled; thumbnails will be generated on demand");
2356        return;
2357    }
2358
2359    let output_dir = config.effective_output_dir();
2360    std::thread::spawn(move || {
2361        if !output_dir.is_dir() {
2362            return;
2363        }
2364        let thumb_dir = server_thumbnail_dir();
2365        let walker = walkdir::WalkDir::new(&output_dir).max_depth(1).into_iter();
2366        for entry in walker.filter_map(|e| e.ok()) {
2367            let path = entry.path();
2368            if !path.is_file() {
2369                continue;
2370            }
2371            let ext = path
2372                .extension()
2373                .and_then(|e| e.to_str())
2374                .map(|e| e.to_lowercase());
2375            let is_raster = matches!(
2376                ext.as_deref(),
2377                Some("png" | "jpg" | "jpeg" | "gif" | "apng" | "webp")
2378            );
2379            let is_video = matches!(ext.as_deref(), Some("mp4"));
2380            if !is_raster && !is_video {
2381                continue;
2382            }
2383            let filename = path
2384                .file_name()
2385                .map(|f| f.to_string_lossy().to_string())
2386                .unwrap_or_default();
2387            let thumb_path = thumb_dir.join(format!("{filename}.png"));
2388            if thumb_path.is_file() {
2389                continue;
2390            }
2391            let result = if is_video {
2392                generate_video_thumbnail(path, &thumb_path)
2393            } else {
2394                generate_server_thumbnail(path, &thumb_path)
2395            };
2396            if let Err(e) = result {
2397                tracing::warn!("failed to generate thumbnail for {}: {e}", path.display());
2398            }
2399        }
2400        tracing::info!("thumbnail warmup complete");
2401    });
2402}
2403
2404fn thumbnail_warmup_enabled() -> bool {
2405    std::env::var("MOLD_THUMBNAIL_WARMUP")
2406        .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
2407        .unwrap_or(false)
2408}
2409
2410/// Scan a directory for gallery outputs (images + videos).
2411///
2412/// Picks up every format `OutputFormat` can emit: png / jpg / jpeg / gif /
2413/// apng / webp / mp4. For files with no embedded `mold:parameters` chunk
2414/// (notably gif / webp / mp4), we synthesize a stub `OutputMetadata` from
2415/// the filename so the UI can still display them alongside annotated items.
2416///
2417/// Invalid files are filtered out at scan time rather than surfaced as
2418/// broken tiles in the UI. "Invalid" here means any of:
2419/// - below a format-specific size floor (tiny stubs left by abandoned
2420///   writes, aborted generations, or test harnesses)
2421/// - no decodable image header (raster formats)
2422/// - no `ftyp` box at the start of the file (mp4)
2423///
2424/// This is a header-only validation, not a full pixel decode, so a file
2425/// that passes the check can still be corrupt mid-stream (e.g. broken
2426/// IDAT CRC). Those fall through to the thumbnail endpoint which serves
2427/// the raw bytes as a last resort.
2428fn scan_gallery_dir(dir: &std::path::Path) -> Vec<mold_core::GalleryImage> {
2429    let mut images = Vec::new();
2430
2431    let walker = walkdir::WalkDir::new(dir).max_depth(1).into_iter();
2432    for entry in walker.filter_map(|e| e.ok()) {
2433        let path = entry.path();
2434        if !path.is_file() {
2435            continue;
2436        }
2437
2438        let ext = path
2439            .extension()
2440            .and_then(|e| e.to_str())
2441            .map(|e| e.to_lowercase());
2442        let format = match ext.as_deref() {
2443            Some("png") => Some(mold_core::OutputFormat::Png),
2444            Some("jpg") | Some("jpeg") => Some(mold_core::OutputFormat::Jpeg),
2445            Some("gif") => Some(mold_core::OutputFormat::Gif),
2446            Some("apng") => Some(mold_core::OutputFormat::Apng),
2447            Some("webp") => Some(mold_core::OutputFormat::Webp),
2448            Some("mp4") => Some(mold_core::OutputFormat::Mp4),
2449            _ => None,
2450        };
2451        let Some(format) = format else { continue };
2452
2453        let fs_meta = entry.metadata().ok();
2454        let timestamp = fs_meta
2455            .as_ref()
2456            .and_then(|m| m.modified().ok())
2457            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2458            .map(|d| d.as_secs())
2459            .unwrap_or(0);
2460        let size_bytes = fs_meta.as_ref().map(|m| m.len()).unwrap_or(0);
2461
2462        // Size floor: anything below this is guaranteed not a real output.
2463        if size_bytes < min_valid_size(format) {
2464            continue;
2465        }
2466
2467        // Header-level validation (fast, O(1) bytes per file).
2468        let header_ok = match format {
2469            mold_core::OutputFormat::Mp4 => has_ftyp_box(path),
2470            _ => image_header_dims(path).is_some(),
2471        };
2472        if !header_ok {
2473            continue;
2474        }
2475
2476        // Solid-black detection. Only inspect small files where a solid-color
2477        // image is plausible (real renderings at any meaningful resolution
2478        // weigh tens of KB or more). For those, we decode and sample a 16×16
2479        // thumbnail so a failed / empty generation doesn't pollute the feed.
2480        if !matches!(format, mold_core::OutputFormat::Mp4)
2481            && is_probably_solid_black(path, format, size_bytes)
2482        {
2483            continue;
2484        }
2485
2486        let filename = path
2487            .file_name()
2488            .map(|f| f.to_string_lossy().to_string())
2489            .unwrap_or_default();
2490
2491        // Try embedded metadata first — PNG text chunks (also covers APNG
2492        // since APNG files are valid PNGs) and JPEG COM markers.
2493        let embedded = match ext.as_deref() {
2494            Some("png") | Some("apng") => read_png_metadata(path),
2495            Some("jpg") | Some("jpeg") => read_jpeg_metadata(path),
2496            _ => None,
2497        };
2498
2499        let (metadata, synthetic) = match embedded {
2500            Some(m) => (m, false),
2501            None => {
2502                // Synthesize. If the file is a raster whose header decodes,
2503                // use its real dimensions so the UI can render the card at
2504                // the correct aspect ratio even without mold metadata.
2505                let mut meta = synthesize_metadata_from_filename(&filename, timestamp);
2506                if !matches!(format, mold_core::OutputFormat::Mp4) {
2507                    if let Some((w, h)) = image_header_dims(path) {
2508                        meta.width = w;
2509                        meta.height = h;
2510                    }
2511                }
2512                (meta, true)
2513            }
2514        };
2515
2516        images.push(mold_core::GalleryImage {
2517            filename,
2518            metadata,
2519            timestamp,
2520            format: Some(format),
2521            size_bytes: Some(size_bytes),
2522            metadata_synthetic: synthetic,
2523        });
2524    }
2525
2526    images.sort_by_key(|img| std::cmp::Reverse(img.timestamp));
2527    images
2528}
2529
2530/// Minimum on-disk size (in bytes) below which a file is treated as a
2531/// corrupt / aborted output and hidden from the gallery listing. The
2532/// thresholds are well below any real mold-generated output but above any
2533/// parseable-but-empty stub — a 1×1 pixel PNG is ~67 bytes, a real 512×512
2534/// PNG is at least tens of KB.
2535fn min_valid_size(format: mold_core::OutputFormat) -> u64 {
2536    match format {
2537        // Raster images: any real mold output is multi-KB. The 256-byte
2538        // floor comfortably filters truncated PNG stubs (signature + IHDR
2539        // only, ~45 bytes) and similar degenerate cases without touching
2540        // legitimate tiny gifs (e.g. a few hundred bytes for a 1-frame GIF).
2541        mold_core::OutputFormat::Png
2542        | mold_core::OutputFormat::Apng
2543        | mold_core::OutputFormat::Jpeg
2544        | mold_core::OutputFormat::Webp => 256,
2545        mold_core::OutputFormat::Gif => 128,
2546        // An mp4 with a single frame and no audio is still many KB.
2547        mold_core::OutputFormat::Mp4 => 4096,
2548    }
2549}
2550
2551/// Fast "does this decode as an image?" check. Returns the image's
2552/// pixel dimensions (width, height) on success. Only reads the header —
2553/// typically under 1 KB — so it's safe to call for every file on every
2554/// `/api/gallery` request.
2555fn image_header_dims(path: &std::path::Path) -> Option<(u32, u32)> {
2556    image::ImageReader::open(path)
2557        .ok()?
2558        .with_guessed_format()
2559        .ok()?
2560        .into_dimensions()
2561        .ok()
2562}
2563
2564/// Heuristic detector for "solid black" (or near-black) raster images —
2565/// typically the artefact of an aborted / NaN-poisoned generation that
2566/// wrote an all-zero image tensor. We only even consider images below a
2567/// format-specific suspect size (any real content at meaningful resolution
2568/// compresses to tens of KB at minimum; a solid-color PNG fits in a few
2569/// hundred bytes), then decode and sample a 16×16 thumbnail to check
2570/// whether any pixel's max channel exceeds a small threshold.
2571fn is_probably_solid_black(
2572    path: &std::path::Path,
2573    format: mold_core::OutputFormat,
2574    size_bytes: u64,
2575) -> bool {
2576    const SAMPLE_DIM: u32 = 16;
2577    // Allow any single channel up to this intensity before we conclude the
2578    // file is "real" content. 16 out of 255 is ~6%: enough to accept dark
2579    // images that aren't literal black, but tight enough to reject the
2580    // artefacts we actually want to filter.
2581    const CHANNEL_CEILING: u8 = 16;
2582
2583    let suspect_threshold: u64 = match format {
2584        // PNG / APNG: zlib-compressed raw pixels; 8 KB is comfortably above
2585        // any solid-color encoding at 1k-ish resolution.
2586        mold_core::OutputFormat::Png | mold_core::OutputFormat::Apng => 8 * 1024,
2587        // JPEG compresses solid color to a few hundred bytes; generous ceiling.
2588        mold_core::OutputFormat::Jpeg => 4 * 1024,
2589        mold_core::OutputFormat::Gif | mold_core::OutputFormat::Webp => 4 * 1024,
2590        mold_core::OutputFormat::Mp4 => return false,
2591    };
2592    if size_bytes > suspect_threshold {
2593        return false;
2594    }
2595
2596    let Ok(img) = image::open(path) else {
2597        return false;
2598    };
2599    let thumb = img.thumbnail(SAMPLE_DIM, SAMPLE_DIM).to_rgb8();
2600    let mut max_channel: u8 = 0;
2601    for pixel in thumb.pixels() {
2602        let m = pixel.0[0].max(pixel.0[1]).max(pixel.0[2]);
2603        if m > max_channel {
2604            max_channel = m;
2605        }
2606        if max_channel > CHANNEL_CEILING {
2607            return false;
2608        }
2609    }
2610    max_channel <= CHANNEL_CEILING
2611}
2612
2613/// Check for the ISO BMFF `ftyp` box at offset 4 of the file. A real mp4
2614/// always starts with a top-level `ftyp` box; files that fail this check
2615/// are typically truncated writes or wrong-extension text files.
2616fn has_ftyp_box(path: &std::path::Path) -> bool {
2617    use std::io::Read;
2618    let Ok(mut f) = std::fs::File::open(path) else {
2619        return false;
2620    };
2621    let mut buf = [0u8; 12];
2622    if f.read_exact(&mut buf).is_err() {
2623        return false;
2624    }
2625    &buf[4..8] == b"ftyp"
2626}
2627
2628/// Build a best-effort `OutputMetadata` from a filename like
2629/// `mold-<model>-<unix>[-<idx>].<ext>`. Fields we can't recover (seed, steps,
2630/// guidance, resolution, prompt) are left at zero / empty so the UI can
2631/// render them as "unknown". The client reads `metadata_synthetic=true`
2632/// from the enclosing `GalleryImage` to treat these as placeholders.
2633fn synthesize_metadata_from_filename(filename: &str, timestamp: u64) -> mold_core::OutputMetadata {
2634    let stem = std::path::Path::new(filename)
2635        .file_stem()
2636        .and_then(|s| s.to_str())
2637        .unwrap_or("");
2638
2639    let model = stem
2640        .strip_prefix("mold-")
2641        .and_then(|rest| {
2642            // Trim trailing `-<unix>` and optional `-<idx>` suffixes by
2643            // walking back across numeric segments.
2644            let mut parts: Vec<&str> = rest.split('-').collect();
2645            while parts
2646                .last()
2647                .map(|p| p.chars().all(|c| c.is_ascii_digit()))
2648                .unwrap_or(false)
2649                && parts.len() > 1
2650            {
2651                parts.pop();
2652            }
2653            if parts.is_empty() {
2654                None
2655            } else {
2656                Some(parts.join("-"))
2657            }
2658        })
2659        .unwrap_or_else(|| "unknown".to_string());
2660
2661    mold_core::OutputMetadata {
2662        prompt: String::new(),
2663        negative_prompt: None,
2664        original_prompt: None,
2665        model,
2666        seed: 0,
2667        steps: 0,
2668        guidance: 0.0,
2669        width: 0,
2670        height: 0,
2671        strength: None,
2672        scheduler: None,
2673        lora: None,
2674        lora_scale: None,
2675        frames: None,
2676        fps: None,
2677        version: format!("synthesized@{timestamp}"),
2678    }
2679}
2680
2681/// Read OutputMetadata from a PNG file's text chunks.
2682fn read_png_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
2683    let file = std::fs::File::open(path).ok()?;
2684    let decoder = png::Decoder::new(std::io::BufReader::new(file));
2685    let reader = decoder.read_info().ok()?;
2686    let info = reader.info();
2687
2688    for chunk in &info.uncompressed_latin1_text {
2689        if chunk.keyword == "mold:parameters" {
2690            if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&chunk.text) {
2691                return Some(meta);
2692            }
2693        }
2694    }
2695    for chunk in &info.utf8_text {
2696        if chunk.keyword == "mold:parameters" {
2697            if let Ok(text) = chunk.get_text() {
2698                if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&text) {
2699                    return Some(meta);
2700                }
2701            }
2702        }
2703    }
2704    None
2705}
2706
2707/// Read OutputMetadata from a JPEG file's COM marker.
2708fn read_jpeg_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
2709    let data = std::fs::read(path).ok()?;
2710    let mut i = 0;
2711    while i + 1 < data.len() {
2712        if data[i] != 0xFF {
2713            i += 1;
2714            continue;
2715        }
2716        let marker = data[i + 1];
2717        match marker {
2718            // Standalone markers (no length field): SOI, EOI, RST0-7, TEM
2719            0xD8 | 0x01 => {
2720                i += 2;
2721            }
2722            0xD9 => break, // EOI — end of image
2723            0xD0..=0xD7 => {
2724                i += 2; // RST markers
2725            }
2726            // COM marker — check for mold:parameters
2727            0xFE => {
2728                if i + 3 >= data.len() {
2729                    break;
2730                }
2731                let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2732                if len < 2 || i + 2 + len > data.len() {
2733                    break;
2734                }
2735                let comment = &data[i + 4..i + 2 + len];
2736                if let Ok(text) = std::str::from_utf8(comment) {
2737                    if let Some(json) = text.strip_prefix("mold:parameters ") {
2738                        if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(json) {
2739                            return Some(meta);
2740                        }
2741                    }
2742                }
2743                i += 2 + len;
2744            }
2745            // All other markers have a 2-byte length field
2746            _ => {
2747                if i + 3 >= data.len() {
2748                    break;
2749                }
2750                let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2751                if len < 2 || i + 2 + len > data.len() {
2752                    break;
2753                }
2754                i += 2 + len;
2755            }
2756        }
2757    }
2758    None
2759}
2760
2761// ── /api/config/model/:name/placement (Agent C, model-ui-overhaul §3) ────────
2762
2763async fn put_model_placement(
2764    State(state): State<AppState>,
2765    axum::extract::Path(name): axum::extract::Path<String>,
2766    Json(placement): Json<mold_core::types::DevicePlacement>,
2767) -> Result<Json<serde_json::Value>, ApiError> {
2768    validate_multi_gpu_placement(&state, Some(&placement))?;
2769    {
2770        let mut cfg = state.config.write().await;
2771        cfg.set_model_placement(&name, Some(placement.clone()));
2772        cfg.save().map_err(|e| {
2773            tracing::warn!("failed to persist placement to config.toml: {e}");
2774            ApiError::internal(format!("failed to persist placement to config.toml: {e}"))
2775        })?;
2776    }
2777    Ok(Json(serde_json::json!({
2778        "ok": true,
2779        "model": name,
2780    })))
2781}
2782
2783async fn delete_model_placement(
2784    State(state): State<AppState>,
2785    axum::extract::Path(name): axum::extract::Path<String>,
2786) -> Result<Json<serde_json::Value>, ApiError> {
2787    let mut cfg = state.config.write().await;
2788    cfg.set_model_placement(&name, None);
2789    cfg.save().map_err(|e| {
2790        tracing::warn!("failed to persist placement removal to config.toml: {e}");
2791        ApiError::internal(format!(
2792            "failed to persist placement removal to config.toml: {e}"
2793        ))
2794    })?;
2795    Ok(Json(serde_json::json!({ "ok": true })))
2796}
2797
2798// ── /api/openapi.json ─────────────────────────────────────────────────────────
2799
2800async fn openapi_json() -> impl IntoResponse {
2801    Json(ApiDoc::openapi())
2802}
2803
2804// ── /api/docs ─────────────────────────────────────────────────────────────────
2805
2806async fn scalar_docs() -> impl IntoResponse {
2807    (
2808        [(header::CONTENT_TYPE, "text/html")],
2809        r#"<!DOCTYPE html>
2810<html>
2811<head>
2812  <title>mold API</title>
2813  <meta charset="utf-8" />
2814  <meta name="viewport" content="width=device-width, initial-scale=1" />
2815</head>
2816<body>
2817  <script id="api-reference" data-url="/api/openapi.json"></script>
2818  <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
2819</body>
2820</html>"#,
2821    )
2822}
2823
2824// ── GPU info ──────────────────────────────────────────────────────────────────
2825
2826fn query_gpu_info() -> Option<GpuInfo> {
2827    let nvidia_smi = if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
2828        "/run/current-system/sw/bin/nvidia-smi"
2829    } else {
2830        "nvidia-smi"
2831    };
2832
2833    let output = std::process::Command::new(nvidia_smi)
2834        .args([
2835            "--query-gpu=name,memory.total,memory.used",
2836            "--format=csv,noheader,nounits",
2837        ])
2838        .output()
2839        .ok()?;
2840
2841    if !output.status.success() {
2842        return None;
2843    }
2844
2845    let text = String::from_utf8(output.stdout).ok()?;
2846    let line = text.lines().next()?;
2847    let parts: Vec<&str> = line.split(',').map(str::trim).collect();
2848    if parts.len() < 3 {
2849        return None;
2850    }
2851
2852    Some(GpuInfo {
2853        name: parts[0].to_string(),
2854        vram_total_mb: parts[1].parse().ok()?,
2855        vram_used_mb: parts[2].parse().ok()?,
2856    })
2857}
2858
2859// ─── Downloads UI (Agent A) ──────────────────────────────────────────────────
2860
2861#[derive(serde::Deserialize, utoipa::ToSchema)]
2862pub struct CreateDownloadBody {
2863    pub model: String,
2864}
2865
2866#[derive(serde::Serialize, utoipa::ToSchema)]
2867pub struct CreateDownloadResponse {
2868    pub id: String,
2869    pub position: usize,
2870}
2871
2872#[utoipa::path(
2873    post,
2874    path = "/api/downloads",
2875    tag = "downloads",
2876    request_body = CreateDownloadBody,
2877    responses(
2878        (status = 200, description = "Enqueued; position 0 = will start immediately", body = CreateDownloadResponse),
2879        (status = 400, description = "Unknown model"),
2880        (status = 409, description = "Already active or queued; body contains existing id", body = CreateDownloadResponse),
2881    )
2882)]
2883pub async fn create_download(
2884    State(state): State<AppState>,
2885    Json(body): Json<CreateDownloadBody>,
2886) -> axum::response::Response {
2887    use crate::downloads::{EnqueueError, EnqueueOutcome};
2888    match state.downloads.enqueue(body.model.clone()).await {
2889        Ok((id, position, EnqueueOutcome::Created)) => (
2890            StatusCode::OK,
2891            Json(CreateDownloadResponse { id, position }),
2892        )
2893            .into_response(),
2894        Ok((id, position, EnqueueOutcome::AlreadyPresent)) => (
2895            StatusCode::CONFLICT,
2896            Json(CreateDownloadResponse { id, position }),
2897        )
2898            .into_response(),
2899        Err(EnqueueError::UnknownModel(_)) => (
2900            StatusCode::BAD_REQUEST,
2901            Json(serde_json::json!({
2902                "error": format!("unknown model '{}'. Run 'mold list' to see available models.", body.model)
2903            })),
2904        )
2905            .into_response(),
2906        Err(EnqueueError::LockPoisoned) => (
2907            StatusCode::INTERNAL_SERVER_ERROR,
2908            Json(serde_json::json!({ "error": "download queue state is corrupt" })),
2909        )
2910            .into_response(),
2911    }
2912}
2913
2914#[utoipa::path(
2915    delete,
2916    path = "/api/downloads/{id}",
2917    tag = "downloads",
2918    params(("id" = String, Path, description = "Job id")),
2919    responses(
2920        (status = 204, description = "Cancelled"),
2921        (status = 404, description = "Unknown id"),
2922    )
2923)]
2924pub async fn delete_download(
2925    State(state): State<AppState>,
2926    axum::extract::Path(id): axum::extract::Path<String>,
2927) -> axum::response::Response {
2928    if state.downloads.cancel(&id).await {
2929        StatusCode::NO_CONTENT.into_response()
2930    } else {
2931        (
2932            StatusCode::NOT_FOUND,
2933            Json(serde_json::json!({ "error": format!("unknown download id '{id}'") })),
2934        )
2935            .into_response()
2936    }
2937}
2938
2939#[utoipa::path(
2940    get,
2941    path = "/api/downloads",
2942    tag = "downloads",
2943    responses((status = 200, description = "Current queue state"))
2944)]
2945pub async fn list_downloads(State(state): State<AppState>) -> axum::response::Response {
2946    Json(state.downloads.listing().await).into_response()
2947}
2948
2949#[utoipa::path(
2950    get,
2951    path = "/api/downloads/stream",
2952    tag = "downloads",
2953    responses((status = 200, description = "SSE stream of DownloadEvent JSON")),
2954)]
2955pub async fn stream_downloads(
2956    State(state): State<AppState>,
2957) -> Sse<
2958    impl futures_core::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>,
2959> {
2960    use axum::response::sse::Event;
2961    use tokio_stream::wrappers::BroadcastStream;
2962    use tokio_stream::StreamExt as _;
2963
2964    // Subscribe BEFORE snapshotting so any event arriving during the
2965    // snapshot read is queued in the broadcast channel instead of being
2966    // missed. The first frame we yield is `Snapshot { listing }` —
2967    // mirrors `/api/resources/stream`'s initial-snapshot pattern so a
2968    // freshly-mounted SPA paints current state without waiting for the
2969    // next delta.
2970    let rx = state.downloads.subscribe();
2971    let initial = state.downloads.listing().await;
2972    let snapshot_event = mold_core::types::DownloadEvent::Snapshot { listing: initial };
2973
2974    let stream = async_stream::stream! {
2975        let data = serde_json::to_string(&snapshot_event).unwrap_or_else(|_| "{}".to_string());
2976        yield Ok::<_, std::convert::Infallible>(Event::default().event("download").data(data));
2977
2978        let mut bs = BroadcastStream::new(rx);
2979        while let Some(item) = bs.next().await {
2980            match item {
2981                Ok(event) => {
2982                    let data = serde_json::to_string(&event)
2983                        .unwrap_or_else(|_| "{}".to_string());
2984                    yield Ok(Event::default().event("download").data(data));
2985                }
2986                // Slow subscribers see lag silently; the snapshot above
2987                // already carries the full state so we don't need to
2988                // resync on every drop.
2989                Err(_lagged) => continue,
2990            }
2991        }
2992    };
2993
2994    Sse::new(stream).keep_alive(
2995        KeepAlive::new()
2996            .interval(std::time::Duration::from_secs(15))
2997            .text("ping"),
2998    )
2999}
3000
3001// ── Resource telemetry (Agent B scope) ───────────────────────────────────────
3002
3003/// `GET /api/resources` — one-shot JSON snapshot from the aggregator cache.
3004/// Returns 503 if the aggregator has not yet fired (first 1 s after startup
3005/// and before `spawn_aggregator` has run).
3006async fn get_resources(State(state): State<AppState>) -> Result<Json<ResourceSnapshot>, ApiError> {
3007    match state.resources.latest() {
3008        Some(snap) => Ok(Json(snap)),
3009        None => Err(ApiError::internal_with_status(
3010            "resource telemetry not ready",
3011            StatusCode::SERVICE_UNAVAILABLE,
3012        )),
3013    }
3014}
3015
3016/// `GET /api/resources/stream` — SSE stream of `ResourceSnapshot` frames.
3017/// Event name: `snapshot`. Matches the keepalive cadence of `/api/generate/stream`.
3018async fn get_resources_stream(
3019    State(state): State<AppState>,
3020) -> Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>> {
3021    use tokio_stream::wrappers::BroadcastStream;
3022
3023    let rx = state.resources.subscribe();
3024    // Attach the cached `latest` snapshot as the first frame so clients
3025    // don't wait up to one full tick for their initial value.
3026    let initial = state.resources.latest();
3027
3028    let stream = async_stream::stream! {
3029        if let Some(snap) = initial {
3030            yield Ok::<_, Infallible>(snapshot_to_sse(&snap));
3031        }
3032        let mut bs = BroadcastStream::new(rx);
3033        while let Some(item) = bs.next().await {
3034            match item {
3035                Ok(snap) => yield Ok(snapshot_to_sse(&snap)),
3036                // Lag is normal for slow clients — skip dropped frames
3037                // silently; the next one will catch them up.
3038                Err(_lagged) => continue,
3039            }
3040        }
3041    };
3042
3043    Sse::new(stream).keep_alive(
3044        KeepAlive::new()
3045            .interval(std::time::Duration::from_secs(15))
3046            .text("ping"),
3047    )
3048}
3049
3050fn snapshot_to_sse(snap: &ResourceSnapshot) -> SseEvent {
3051    match serde_json::to_string(snap) {
3052        Ok(data) => SseEvent::default().event("snapshot").data(data),
3053        Err(e) => SseEvent::default()
3054            .event("error")
3055            .data(format!("{{\"message\":\"serialize failed: {e}\"}}")),
3056    }
3057}
3058
3059#[cfg(test)]
3060mod tests {
3061    use super::*;
3062
3063    fn env_lock() -> &'static std::sync::Mutex<()> {
3064        static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3065        &ENV_LOCK
3066    }
3067
3068    #[test]
3069    fn clean_error_message_strips_backtrace() {
3070        let err = anyhow::anyhow!(
3071            "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")\n\
3072             \x20  0: candle_core::error::Error::bt\n\
3073             \x20           at /home/user/.cargo/git/candle/src/error.rs:264:25\n\
3074             \x20  1: <core::result::Result<O,E> as candle_core::cuda_backend::error::WrapErr<O>>::w\n\
3075             \x20           at /home/user/.cargo/git/candle/src/cuda_backend/error.rs:60:65"
3076        );
3077        let msg = clean_error_message(&err);
3078        assert_eq!(
3079            msg,
3080            "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")"
3081        );
3082    }
3083
3084    #[test]
3085    fn clean_error_message_preserves_simple_error() {
3086        let err = anyhow::anyhow!("model not found: flux-dev:q4");
3087        let msg = clean_error_message(&err);
3088        assert_eq!(msg, "model not found: flux-dev:q4");
3089    }
3090
3091    #[test]
3092    fn clean_error_message_preserves_multiline_without_backtrace() {
3093        let err = anyhow::anyhow!("validation failed\nprompt is empty\nsteps must be > 0");
3094        let msg = clean_error_message(&err);
3095        assert_eq!(msg, "validation failed\nprompt is empty\nsteps must be > 0");
3096    }
3097
3098    #[test]
3099    fn clean_error_message_strips_high_numbered_frames() {
3100        let err = anyhow::anyhow!(
3101            "some error\n\
3102             \x20 10: tokio::runtime::task::core::Core<T,S>::poll at /home/user/.cargo/tokio/src/core.rs:375\n\
3103             \x20 11: std::panicking::catch_unwind at /nix/store/rust/src/panicking.rs:544"
3104        );
3105        let msg = clean_error_message(&err);
3106        assert_eq!(msg, "some error");
3107    }
3108
3109    #[test]
3110    fn clean_error_message_empty_fallback() {
3111        // An error whose Display starts immediately with a backtrace-like line
3112        let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
3113        let msg = clean_error_message(&err);
3114        // Should fall back to root_cause since all lines look like backtrace
3115        assert!(!msg.is_empty());
3116    }
3117
3118    #[test]
3119    fn clean_error_message_renders_full_anyhow_chain() {
3120        // Wrapped errors must surface the root cause; previously the outer
3121        // `with_context` swallowed everything below it (cv:2739091 truncated
3122        // checkpoint surfaced as "mmap single-file checkpoint at …" with no
3123        // hint that the safetensors data was short).
3124        let root = std::io::Error::new(std::io::ErrorKind::InvalidData, "bytes past end");
3125        let err: anyhow::Error = anyhow::Error::new(root)
3126            .context("validate single-file checkpoint at /tmp/foo.safetensors");
3127        let msg = clean_error_message(&err);
3128        assert!(
3129            msg.contains("validate single-file checkpoint") && msg.contains("bytes past end"),
3130            "expected both context layers in the rendered chain, got: {msg}",
3131        );
3132    }
3133
3134    #[test]
3135    fn save_image_to_dir_creates_directory_and_writes_file() {
3136        let dir = std::env::temp_dir().join(format!(
3137            "mold-save-test-{}",
3138            std::time::SystemTime::now()
3139                .duration_since(std::time::UNIX_EPOCH)
3140                .unwrap()
3141                .as_nanos()
3142        ));
3143        assert!(!dir.exists());
3144
3145        let img = mold_core::ImageData {
3146            data: vec![0x89, 0x50, 0x4E, 0x47], // PNG magic bytes
3147            format: mold_core::OutputFormat::Png,
3148            width: 64,
3149            height: 64,
3150            index: 0,
3151        };
3152
3153        save_image_to_dir(&dir, &img, "test-model:q8", 1);
3154
3155        assert!(dir.exists(), "directory should be created");
3156        let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
3157        assert_eq!(files.len(), 1, "should have exactly one file");
3158        let file = files[0].as_ref().unwrap();
3159        let filename = file.file_name().to_str().unwrap().to_string();
3160        assert!(filename.starts_with("mold-test-model-q8-"), "{filename}");
3161        assert!(filename.ends_with(".png"), "{filename}");
3162        let contents = std::fs::read(file.path()).unwrap();
3163        assert_eq!(contents, vec![0x89, 0x50, 0x4E, 0x47]);
3164
3165        std::fs::remove_dir_all(&dir).ok();
3166    }
3167
3168    #[test]
3169    fn save_image_to_dir_batch_includes_index() {
3170        let dir = std::env::temp_dir().join(format!(
3171            "mold-save-batch-{}",
3172            std::time::SystemTime::now()
3173                .duration_since(std::time::UNIX_EPOCH)
3174                .unwrap()
3175                .as_nanos()
3176        ));
3177
3178        let img = mold_core::ImageData {
3179            data: vec![0xFF, 0xD8], // JPEG magic
3180            format: mold_core::OutputFormat::Jpeg,
3181            width: 64,
3182            height: 64,
3183            index: 2,
3184        };
3185
3186        save_image_to_dir(&dir, &img, "flux-dev", 4);
3187
3188        let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
3189        assert_eq!(files.len(), 1);
3190        let filename = files[0]
3191            .as_ref()
3192            .unwrap()
3193            .file_name()
3194            .to_str()
3195            .unwrap()
3196            .to_string();
3197        assert!(
3198            filename.contains("-2.jpeg"),
3199            "batch index in name: {filename}"
3200        );
3201
3202        std::fs::remove_dir_all(&dir).ok();
3203    }
3204
3205    #[test]
3206    fn save_image_to_dir_invalid_path_logs_warning_no_panic() {
3207        // Saving to a path that can't be created should not panic
3208        let img = mold_core::ImageData {
3209            data: vec![0x00],
3210            format: mold_core::OutputFormat::Png,
3211            width: 1,
3212            height: 1,
3213            index: 0,
3214        };
3215        // /dev/null/impossible can't be created as a directory
3216        save_image_to_dir(
3217            std::path::Path::new("/dev/null/impossible"),
3218            &img,
3219            "test",
3220            1,
3221        );
3222        // Test passes if no panic occurred
3223    }
3224
3225    #[test]
3226    fn thumbnail_warmup_is_disabled_by_default() {
3227        let _guard = env_lock().lock().unwrap();
3228        unsafe {
3229            std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
3230        }
3231        assert!(!thumbnail_warmup_enabled());
3232    }
3233
3234    #[test]
3235    fn thumbnail_warmup_accepts_truthy_env_values() {
3236        let _guard = env_lock().lock().unwrap();
3237        unsafe {
3238            std::env::set_var("MOLD_THUMBNAIL_WARMUP", "1");
3239        }
3240        assert!(thumbnail_warmup_enabled());
3241        unsafe {
3242            std::env::set_var("MOLD_THUMBNAIL_WARMUP", "true");
3243        }
3244        assert!(thumbnail_warmup_enabled());
3245        unsafe {
3246            std::env::set_var("MOLD_THUMBNAIL_WARMUP", "YES");
3247        }
3248        assert!(thumbnail_warmup_enabled());
3249        unsafe {
3250            std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
3251        }
3252    }
3253
3254    #[test]
3255    fn thumbnail_warmup_rejects_falsey_env_values() {
3256        let _guard = env_lock().lock().unwrap();
3257        unsafe {
3258            std::env::set_var("MOLD_THUMBNAIL_WARMUP", "0");
3259        }
3260        assert!(!thumbnail_warmup_enabled());
3261        unsafe {
3262            std::env::set_var("MOLD_THUMBNAIL_WARMUP", "false");
3263        }
3264        assert!(!thumbnail_warmup_enabled());
3265        unsafe {
3266            std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
3267        }
3268    }
3269
3270    #[test]
3271    fn content_type_covers_every_output_format() {
3272        assert_eq!(content_type_for_filename("a.png"), "image/png");
3273        assert_eq!(content_type_for_filename("a.PNG"), "image/png");
3274        assert_eq!(content_type_for_filename("a.jpg"), "image/jpeg");
3275        assert_eq!(content_type_for_filename("a.jpeg"), "image/jpeg");
3276        assert_eq!(content_type_for_filename("a.gif"), "image/gif");
3277        assert_eq!(content_type_for_filename("a.webp"), "image/webp");
3278        assert_eq!(content_type_for_filename("a.apng"), "image/apng");
3279        assert_eq!(content_type_for_filename("a.mp4"), "video/mp4");
3280        assert_eq!(
3281            content_type_for_filename("a.unknown"),
3282            "application/octet-stream"
3283        );
3284    }
3285
3286    #[test]
3287    fn synthesized_metadata_parses_model_from_filename() {
3288        let meta = synthesize_metadata_from_filename("mold-flux-dev-q8-1710000000.mp4", 1710000000);
3289        // Trailing unix timestamp should be stripped; model tag preserved.
3290        assert_eq!(meta.model, "flux-dev-q8");
3291        assert_eq!(meta.prompt, "");
3292        assert_eq!(meta.seed, 0);
3293        assert!(meta.version.starts_with("synthesized@"));
3294
3295        // Batch suffix (trailing `-<idx>`) also stripped along with timestamp.
3296        let meta =
3297            synthesize_metadata_from_filename("mold-ltx-video-bf16-1710000030-2.gif", 1710000030);
3298        assert_eq!(meta.model, "ltx-video-bf16");
3299
3300        // Non-mold filename falls back to "unknown".
3301        let meta = synthesize_metadata_from_filename("unrelated.png", 0);
3302        assert_eq!(meta.model, "unknown");
3303    }
3304
3305    // ── Gallery validation ───────────────────────────────────────────────
3306
3307    /// Create a scratch directory unique to this test and delete it on drop.
3308    /// Using `std::env::temp_dir()` rather than pulling in a `tempfile`
3309    /// dev-dep for three tests' worth of fixtures.
3310    struct TempDir(std::path::PathBuf);
3311    impl TempDir {
3312        fn new(tag: &str) -> Self {
3313            let mut p = std::env::temp_dir();
3314            p.push(format!("mold-gallery-test-{tag}-{}", uuid::Uuid::new_v4()));
3315            std::fs::create_dir_all(&p).expect("create tempdir");
3316            Self(p)
3317        }
3318        fn path(&self) -> &std::path::Path {
3319            &self.0
3320        }
3321    }
3322    impl Drop for TempDir {
3323        fn drop(&mut self) {
3324            let _ = std::fs::remove_dir_all(&self.0);
3325        }
3326    }
3327
3328    /// Encode a noisy PNG in-memory via the `image` crate. The checkerboard
3329    /// pattern resists zlib compression so the encoded bytes exceed the
3330    /// gallery size floor — a solid-color PNG of the same dimensions would
3331    /// compress to ~80 bytes and be filtered out by `min_valid_size`.
3332    fn make_png_bytes(width: u32, height: u32) -> Vec<u8> {
3333        let img = image::RgbImage::from_fn(width, height, |x, y| {
3334            let n = (x.wrapping_mul(37) ^ y.wrapping_mul(131)) as u8;
3335            image::Rgb([n, n.wrapping_add(85), n.wrapping_sub(17)])
3336        });
3337        let mut buf = Vec::new();
3338        image::DynamicImage::ImageRgb8(img)
3339            .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
3340            .expect("encode png");
3341        buf
3342    }
3343
3344    #[test]
3345    fn min_valid_size_thresholds_are_sensible() {
3346        // Raster formats: high enough to catch every truncated / stub file
3347        // we've seen in dev harnesses (largest known bad fixture was ~200 B).
3348        assert!(min_valid_size(mold_core::OutputFormat::Png) >= 128);
3349        assert!(min_valid_size(mold_core::OutputFormat::Jpeg) >= 128);
3350        assert!(min_valid_size(mold_core::OutputFormat::Apng) >= 128);
3351        assert!(min_valid_size(mold_core::OutputFormat::Webp) >= 128);
3352        // But not so high we filter out legitimate tiny GIFs.
3353        assert!(min_valid_size(mold_core::OutputFormat::Gif) <= 512);
3354        // MP4: no real rendering is < 1 KB; 4 KB is a comfortable floor.
3355        assert!(min_valid_size(mold_core::OutputFormat::Mp4) >= 1024);
3356    }
3357
3358    #[test]
3359    fn has_ftyp_box_accepts_real_header_and_rejects_garbage() {
3360        let td = TempDir::new("ftyp");
3361
3362        // Real-ish mp4 header: `\0\0\0\x20 ftypisom ...`
3363        let mut real = Vec::new();
3364        real.extend_from_slice(&[0x00, 0x00, 0x00, 0x20]);
3365        real.extend_from_slice(b"ftyp");
3366        real.extend_from_slice(b"isom\x00\x00\x02\x00isomiso2mp41");
3367        let real_path = td.path().join("real.mp4");
3368        std::fs::write(&real_path, &real).unwrap();
3369        assert!(has_ftyp_box(&real_path));
3370
3371        // Wrong magic — random text with an mp4 extension.
3372        let fake_path = td.path().join("fake.mp4");
3373        std::fs::write(&fake_path, b"this is not an mp4 file at all").unwrap();
3374        assert!(!has_ftyp_box(&fake_path));
3375
3376        // Too short (fewer than 12 bytes) — can't contain an ftyp box.
3377        let trunc_path = td.path().join("truncated.mp4");
3378        std::fs::write(&trunc_path, b"\x00\x00\x00\x20").unwrap();
3379        assert!(!has_ftyp_box(&trunc_path));
3380
3381        // Missing entirely.
3382        assert!(!has_ftyp_box(&td.path().join("nope.mp4")));
3383    }
3384
3385    #[test]
3386    fn image_header_dims_returns_real_dimensions() {
3387        let td = TempDir::new("header");
3388        let p = td.path().join("valid.png");
3389        std::fs::write(&p, make_png_bytes(42, 24)).unwrap();
3390        assert_eq!(image_header_dims(&p), Some((42, 24)));
3391
3392        // Truncated: PNG signature only, no IHDR.
3393        let stub = td.path().join("stub.png");
3394        std::fs::write(&stub, b"\x89PNG\r\n\x1a\n").unwrap();
3395        assert!(image_header_dims(&stub).is_none());
3396
3397        // Non-image bytes entirely.
3398        let text = td.path().join("text.png");
3399        std::fs::write(&text, b"hello world, not a png").unwrap();
3400        assert!(image_header_dims(&text).is_none());
3401    }
3402
3403    #[test]
3404    fn scan_gallery_dir_filters_invalid_and_keeps_valid() {
3405        let td = TempDir::new("scan");
3406        let dir = td.path();
3407
3408        // A valid PNG large enough to exceed the 256-byte raster size floor.
3409        std::fs::write(dir.join("mold-model-1000.png"), make_png_bytes(32, 32)).unwrap();
3410
3411        // Truncated raster that passes size floor but has no valid header.
3412        let mut junk = vec![0u8; 512];
3413        junk[..4].copy_from_slice(b"JUNK");
3414        std::fs::write(dir.join("mold-broken-2000.png"), &junk).unwrap();
3415
3416        // Tiny raster under the size floor (sub-IHDR).
3417        std::fs::write(
3418            dir.join("mold-tiny-3000.png"),
3419            b"\x89PNG\r\n\x1a\n", // 8 bytes: signature only
3420        )
3421        .unwrap();
3422
3423        // Valid-enough mp4 (ftyp at offset 4) — should survive.
3424        let mut mp4 = Vec::new();
3425        mp4.extend_from_slice(&[0x00, 0x00, 0x00, 0x20]);
3426        mp4.extend_from_slice(b"ftyp");
3427        mp4.extend_from_slice(b"isom\x00\x00\x02\x00");
3428        // Pad above the 4096-byte mp4 size floor so it isn't filtered on
3429        // size alone — the scan still checks ftyp either way.
3430        mp4.resize(8192, 0);
3431        std::fs::write(dir.join("mold-ltx-4000.mp4"), &mp4).unwrap();
3432
3433        // Mp4 extension but no ftyp.
3434        let bad_mp4 = vec![0u8; 8192];
3435        std::fs::write(dir.join("mold-no-ftyp-5000.mp4"), &bad_mp4).unwrap();
3436
3437        // Unsupported extension — ignored entirely.
3438        std::fs::write(dir.join("random.txt"), b"not an output").unwrap();
3439
3440        let results = scan_gallery_dir(dir);
3441        let names: Vec<&str> = results.iter().map(|i| i.filename.as_str()).collect();
3442        assert!(
3443            names.contains(&"mold-model-1000.png"),
3444            "valid PNG should survive: {names:?}"
3445        );
3446        assert!(
3447            names.contains(&"mold-ltx-4000.mp4"),
3448            "valid MP4 with ftyp should survive: {names:?}"
3449        );
3450        assert!(
3451            !names.contains(&"mold-broken-2000.png"),
3452            "PNG with no valid header should be filtered: {names:?}"
3453        );
3454        assert!(
3455            !names.contains(&"mold-tiny-3000.png"),
3456            "under-size PNG stub should be filtered: {names:?}"
3457        );
3458        assert!(
3459            !names.contains(&"mold-no-ftyp-5000.mp4"),
3460            "MP4 without ftyp should be filtered: {names:?}"
3461        );
3462        assert_eq!(names.len(), 2, "only the 2 valid fixtures remain");
3463    }
3464
3465    #[test]
3466    fn solid_black_png_is_filtered_at_scan_time() {
3467        let td = TempDir::new("black");
3468        let dir = td.path();
3469
3470        // A 256×256 solid-black PNG — definitely below the suspect-size
3471        // threshold (compresses to a few hundred bytes) and every pixel is
3472        // below the channel ceiling.
3473        let black = image::RgbImage::from_pixel(256, 256, image::Rgb([0, 0, 0]));
3474        let mut buf = Vec::new();
3475        image::DynamicImage::ImageRgb8(black)
3476            .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
3477            .unwrap();
3478        std::fs::write(dir.join("mold-noisy-1000.png"), &buf).unwrap();
3479
3480        // A normal noisy PNG with the same dimensions — should survive.
3481        std::fs::write(dir.join("mold-valid-2000.png"), make_png_bytes(256, 256)).unwrap();
3482
3483        let results = scan_gallery_dir(dir);
3484        let names: Vec<&str> = results.iter().map(|i| i.filename.as_str()).collect();
3485        assert!(
3486            !names.contains(&"mold-noisy-1000.png"),
3487            "solid-black PNG should be filtered: {names:?}"
3488        );
3489        assert!(
3490            names.contains(&"mold-valid-2000.png"),
3491            "noisy PNG should survive: {names:?}"
3492        );
3493    }
3494
3495    #[test]
3496    fn probably_solid_black_ignores_large_files() {
3497        // Files above the per-format suspect size are trusted without a full
3498        // decode (the check is purely a cheap heuristic to catch NaN /
3499        // abort-flavored dev outputs). Verify we bail out on size alone.
3500        let td = TempDir::new("bigblack");
3501        let big_path = td.path().join("big.png");
3502        // Write arbitrary bytes — we never decode because of the size guard.
3503        std::fs::write(&big_path, vec![0u8; 20 * 1024]).unwrap();
3504        assert!(!is_probably_solid_black(
3505            &big_path,
3506            mold_core::OutputFormat::Png,
3507            20 * 1024,
3508        ));
3509    }
3510
3511    #[test]
3512    fn parse_byte_range_handles_common_forms() {
3513        // `bytes=0-499` — first 500 bytes
3514        assert_eq!(parse_byte_range("bytes=0-499", 2000), Some((0, 499)));
3515        // open-ended `bytes=100-` — from byte 100 to EOF
3516        assert_eq!(parse_byte_range("bytes=100-", 2000), Some((100, 1999)));
3517        // suffix `bytes=-500` — last 500 bytes
3518        assert_eq!(parse_byte_range("bytes=-500", 2000), Some((1500, 1999)));
3519        // end past EOF — clamped to last byte
3520        assert_eq!(parse_byte_range("bytes=0-9999", 2000), Some((0, 1999)));
3521        // whole file
3522        assert_eq!(parse_byte_range("bytes=0-1999", 2000), Some((0, 1999)));
3523    }
3524
3525    #[test]
3526    fn parse_byte_range_rejects_malformed_and_unsatisfiable() {
3527        assert_eq!(parse_byte_range("bytes=", 1000), None);
3528        assert_eq!(parse_byte_range("bytes=abc-100", 1000), None);
3529        // start past EOF
3530        assert_eq!(parse_byte_range("bytes=2000-", 1000), None);
3531        // end before start
3532        assert_eq!(parse_byte_range("bytes=500-100", 1000), None);
3533        // multi-range not supported
3534        assert_eq!(parse_byte_range("bytes=0-10,20-30", 1000), None);
3535        // suffix of 0 bytes is meaningless
3536        assert_eq!(parse_byte_range("bytes=-0", 1000), None);
3537        // empty file can't satisfy any range
3538        assert_eq!(parse_byte_range("bytes=0-10", 0), None);
3539        // wrong unit prefix
3540        assert_eq!(parse_byte_range("items=0-10", 1000), None);
3541    }
3542
3543    #[test]
3544    fn scan_populates_real_dimensions_for_synthesized_metadata() {
3545        // Files without an embedded mold:parameters chunk still get their
3546        // actual width/height filled in from the header decode — useful for
3547        // the SPA's aspect-ratio-preserving layout.
3548        let td = TempDir::new("dims");
3549        let dir = td.path();
3550        std::fs::write(dir.join("mold-nometa-1000.png"), make_png_bytes(128, 96)).unwrap();
3551
3552        let results = scan_gallery_dir(dir);
3553        assert_eq!(results.len(), 1);
3554        let entry = &results[0];
3555        assert!(entry.metadata_synthetic);
3556        assert_eq!(entry.metadata.width, 128);
3557        assert_eq!(entry.metadata.height, 96);
3558    }
3559}