Skip to main content

studio_worker/engine/
sdcpp.rs

1//! Engine that runs real image inference by subprocess-invoking the
2//! `stable-diffusion.cpp` (`sd-cli`) binary.
3//!
4//! The studio's offer carries a [`ModelSource`] with everything we
5//! need: an engine identifier (`sd-cpp`), the list of files to
6//! download (diffusion-model + text-encoder + VAE, each with a public
7//! URL + filename), and CLI defaults (cfg-scale, steps, dimensions).
8//! The worker has zero hardcoded model knowledge — it caches
9//! whatever the studio asks for under `cfg.models_root` and invokes
10//! `sd-cli` with the files arranged by role.
11//!
12//! Layout under `cfg.models_root` (default `~/models`):
13//! ```text
14//! ~/models/<filename1>
15//! ~/models/<filename2>
16//! …
17//! ```
18//! Files are downloaded on first use - skipped when already present
19//! under `cfg.models_root`.  The streamed body is checked against the
20//! server's `Content-Length` so a truncated download is rejected and
21//! cleaned up instead of being renamed into place as a corrupt model
22//! that every later job would fail to load.  Cached files are re-used
23//! across every subsequent job that names them.
24//!
25//! The engine self-registers only when `sd-cli` is present on the box
26//! (either at `$STUDIO_WORKER_SD_CLI`, or `~/.local/bin/sd-cli`, or on
27//! `$PATH`).  Without `sd-cli` the worker can't run real-image jobs
28//! at all so it skips registration and the multi engine falls through
29//! to synthetic for any kind it doesn't have a real backend for.
30
31use crate::engine::download::{self, TempFileGuard};
32use crate::engine::sd_provision;
33use crate::engine::{Engine, EngineCapabilities};
34use crate::types::{ImageParams, ModelFileRole, ModelSource, Task, TaskKind, TaskResult};
35use anyhow::{anyhow, bail, Context, Result};
36use parking_lot::Mutex;
37use std::collections::BTreeMap;
38use std::ffi::OsString;
39use std::path::{Path, PathBuf};
40use std::process::Command;
41use std::time::Instant;
42use tracing::{debug, info, warn};
43
44const TRACE_TARGET: &str = "studio_worker::engine::sdcpp";
45
46/// Default sample-steps when the studio's `ImageParams.steps` is the
47/// upstream default (20).  Z-Image-Turbo is an 8-step distilled
48/// schedule so 20 wastes time; we honour `ModelSource.cliDefaults.steps`
49/// instead.  Only used as the very last fallback.
50const STEPS_FALLBACK: u32 = 8;
51
52/// Worker-side engine that drives `sd-cli` per job.
53///
54/// `sd-cli` is resolved lazily on the first image job and cached: an
55/// operator install (env / PATH / `~/.local/bin`) wins, otherwise the
56/// binary is auto-provisioned into `<models_root>/bin/`.  The `Mutex`
57/// serialises that one-time resolution so two concurrent jobs can't
58/// race the download.
59pub struct SdCppEngine {
60    sd_cli: Mutex<Option<PathBuf>>,
61    models_root: PathBuf,
62}
63
64impl SdCppEngine {
65    /// Build the engine.  Always registers: `sd-cli` is resolved (and
66    /// provisioned into `<models_root>/bin/` if missing) lazily on the
67    /// first image job, so the engine serves real image work even on a
68    /// box that has never had a stable-diffusion.cpp build installed.
69    /// `models_root` is created on demand by the provisioner / model
70    /// downloader, so registration touches no filesystem.
71    pub fn new(models_root: &Path) -> Self {
72        info!(
73            target: TRACE_TARGET,
74            op = "register",
75            models_root = %models_root.display(),
76            sd_cli_name = sd_provision::binary_name(),
77            "sdcpp engine registered (sd-cli resolved/provisioned on first image job)"
78        );
79        Self {
80            sd_cli: Mutex::new(None),
81            models_root: models_root.to_path_buf(),
82        }
83    }
84
85    /// For tests: build with explicit paths (bypasses sd-cli lookup +
86    /// provisioning by seeding the resolved-path cache).
87    #[cfg(test)]
88    pub fn with_paths(sd_cli: PathBuf, models_root: PathBuf) -> Self {
89        Self {
90            sd_cli: Mutex::new(Some(sd_cli)),
91            models_root,
92        }
93    }
94
95    /// Resolve the `sd-cli` binary, provisioning it on first use.
96    /// Resolution order (operator installs win): a cached path from a
97    /// previous job, then env / `<models_root>/bin` / `~/.local/bin` /
98    /// `$PATH`, then an auto-provisioned download into
99    /// `<models_root>/bin/`.  The result is cached for the worker's
100    /// lifetime.
101    #[cfg_attr(coverage_nightly, coverage(off))]
102    fn ensure_sd_cli(&self) -> Result<PathBuf> {
103        let mut guard = self.sd_cli.lock();
104        if let Some(p) = guard.as_ref() {
105            if p.is_file() {
106                return Ok(p.clone());
107            }
108        }
109        let resolved = match resolve_sd_cli(&self.models_root) {
110            Some(p) => {
111                info!(
112                    target: TRACE_TARGET,
113                    op = "resolve",
114                    sd_cli = %p.display(),
115                    "using existing sd-cli"
116                );
117                p
118            }
119            None => sd_provision::provision(&self.models_root)
120                .context("auto-provisioning sd-cli (stable-diffusion.cpp)")?,
121        };
122        *guard = Some(resolved.clone());
123        Ok(resolved)
124    }
125
126    /// Ensure each file in `source.files` is present under
127    /// `self.models_root`.  Downloads anything missing.  Returns the
128    /// resolved local path for each file (in the same order).
129    #[cfg_attr(coverage_nightly, coverage(off))]
130    fn ensure_files(&self, source: &ModelSource) -> Result<Vec<(ModelFileRole, PathBuf)>> {
131        let mut out = Vec::with_capacity(source.files.len());
132        for file in &source.files {
133            let local = download::ensure_file(&self.models_root, file)?;
134            out.push((file.role, local));
135        }
136        Ok(out)
137    }
138
139    /// Subprocess to `sd-cli` with the resolved diffusion / VAE /
140    /// text-encoder files.  Excluded from coverage: requires an
141    /// actual `sd-cli` binary + cached model files on disk, neither
142    /// of which exists on the CI runner.  Exercised end-to-end via
143    /// the live dev loop.
144    #[cfg_attr(coverage_nightly, coverage(off))]
145    fn dispatch_image(
146        &self,
147        model: &str,
148        params: ImageParams,
149        source: &ModelSource,
150    ) -> Result<TaskResult> {
151        // Resolve (provisioning on first use) the sd-cli binary before
152        // we touch model files, so a missing binary fails fast with the
153        // provisioning error rather than after a multi-GB weight pull.
154        let sd_cli = self.ensure_sd_cli()?;
155        // Preflight the GPU runtime next: a missing Vulkan loader can't be
156        // auto-provisioned (it ships with the driver / a system package),
157        // so surface the actionable remedy now instead of after a
158        // multi-GB weight pull and a cryptic sd-cli crash.
159        if let Err(e) = sd_provision::vulkan_runtime_status() {
160            warn!(
161                target: TRACE_TARGET,
162                op = "preflight",
163                model,
164                error = %e,
165                "GPU runtime missing; refusing image job"
166            );
167            return Err(e);
168        }
169        let files = self.ensure_files(source)?;
170        // A `diffusion-model` file is the standalone diffusion weights (sd-cli `--diffusion-model`,
171        // used with split vae/clip); a `model` file is a full checkpoint (sd-cli `-m`/`--model`).
172        // Prefer the explicit diffusion-model role; fall back to a full checkpoint.
173        let diffusion_only = file_for_role(&files, ModelFileRole::DiffusionModel);
174        let full_checkpoint = diffusion_only.is_none();
175        let diffusion_model = diffusion_only
176            .or_else(|| file_for_role(&files, ModelFileRole::Model))
177            .ok_or_else(|| anyhow!("modelSource has no diffusion-model / model file"))?;
178        let vae = file_for_role(&files, ModelFileRole::Vae);
179        let text_encoder = file_for_role(&files, ModelFileRole::TextEncoder);
180        let text_encoder_vision = file_for_role(&files, ModelFileRole::TextEncoderVision);
181
182        let out_dir = std::env::temp_dir().join("studio-worker-sdcpp");
183        std::fs::create_dir_all(&out_dir)
184            .with_context(|| format!("creating sdcpp output dir {}", out_dir.display()))?;
185        let stem = format!(
186            "out-{}-{}",
187            std::process::id(),
188            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
189        );
190        // sd-cli picks the encoder from the output file extension, so honour the
191        // requested `ext` (png/jpg/webp); anything else falls back to webp.
192        let out_ext = normalize_output_ext(&params.ext);
193        debug!(target: TRACE_TARGET, op = "dispatch", requested_ext = %params.ext, out_ext = %out_ext, "resolved output extension");
194        let out_path = out_dir.join(format!("{stem}.{out_ext}"));
195
196        // Own the scratch files from the moment their paths exist so
197        // every failure path (sd-cli error, unreadable output) cleans
198        // up instead of leaking them into the temp dir.
199        let mut temp_files = TempFileGuard::new();
200        temp_files.push(out_path.clone());
201
202        // If the task carries an init image URL, stream it to a
203        // tempfile so we can hand the path to `sd-cli --init-img`.
204        // This is mandatory — the worker refuses i2i jobs whose
205        // init image fails to download (no silent fallback to t2i).
206        // The local extension first mirrors the URL's, then is corrected
207        // to the file's real content format — studio asset URLs lie
208        // (`latest.webp` is often JPEG bytes) and sd-cli picks its image
209        // decoder purely from the extension.
210        let init_img_path = match params.init_image_url.as_deref() {
211            Some(url) if !url.is_empty() => {
212                let ext = init_image_extension(url);
213                let init_path = out_dir.join(format!("{stem}-init.{ext}"));
214                download::download_file(url, &init_path).with_context(|| {
215                    format!("downloading init image {} -> {}", url, init_path.display())
216                })?;
217                temp_files.push(init_path.clone());
218                let usable = download::ensure_correct_image_extension(&init_path)?;
219                if usable != init_path {
220                    temp_files.push(usable.clone());
221                }
222                Some(usable)
223            }
224            _ => None,
225        };
226
227        // A mask constrains the edit region — valid alongside either an init image (img2img
228        // inpaint) or a reference image (instruction edit). Download it whenever a base image is
229        // present and a mask URL was supplied; white pixels mark the region the model may change.
230        let has_base = init_img_path.is_some() || params.ref_image_url.as_deref().is_some();
231        let mask_path = match (has_base, params.mask_url.as_deref()) {
232            (true, Some(url)) if !url.is_empty() => {
233                let ext = init_image_extension(url);
234                let path = out_dir.join(format!("{stem}-mask.{ext}"));
235                download::download_file(url, &path)
236                    .with_context(|| format!("downloading mask {} -> {}", url, path.display()))?;
237                temp_files.push(path.clone());
238                let usable = download::ensure_correct_image_extension(&path)?;
239                if usable != path {
240                    temp_files.push(usable.clone());
241                }
242                Some(usable)
243            }
244            _ => None,
245        };
246
247        // Reference image for instruction-edit models (`sd-cli -r`). Downloaded like the init image;
248        // when present the arg builder uses reference mode instead of the img2img/mask path.
249        let ref_img_path = match params.ref_image_url.as_deref() {
250            Some(url) if !url.is_empty() => {
251                let ext = init_image_extension(url);
252                let path = out_dir.join(format!("{stem}-ref.{ext}"));
253                download::download_file(url, &path).with_context(|| {
254                    format!("downloading reference image {} -> {}", url, path.display())
255                })?;
256                temp_files.push(path.clone());
257                let usable = download::ensure_correct_image_extension(&path)?;
258                if usable != path {
259                    temp_files.push(usable.clone());
260                }
261                Some(usable)
262            }
263            _ => None,
264        };
265
266        let args = build_sdcli_args(
267            &params,
268            source,
269            diffusion_model,
270            vae,
271            text_encoder,
272            text_encoder_vision,
273            &out_path,
274            init_img_path.as_deref(),
275            mask_path.as_deref(),
276            ref_img_path.as_deref(),
277            full_checkpoint,
278        );
279        let mut cmd = Command::new(&sd_cli);
280        cmd.args(&args);
281        apply_library_path(&mut cmd, &sd_cli);
282
283        debug!(
284            target: TRACE_TARGET,
285            op = "spawn",
286            sd_cli = %sd_cli.display(),
287            model,
288            i2i = init_img_path.is_some(),
289            arg_count = args.len(),
290            "running sd-cli"
291        );
292
293        let started = Instant::now();
294        let output = cmd
295            .output()
296            .with_context(|| format!("running {}", sd_cli.display()))?;
297        let elapsed_ms = started.elapsed().as_millis() as u64;
298        if !output.status.success() {
299            let stderr = String::from_utf8_lossy(&output.stderr);
300            warn!(
301                target: TRACE_TARGET,
302                op = "spawn",
303                model,
304                elapsed_ms,
305                exit = ?output.status.code(),
306                stderr = %stderr,
307                "sd-cli failed"
308            );
309            bail!(
310                "sd-cli exited with {:?}: {}",
311                output.status.code(),
312                stderr.lines().last().unwrap_or("(no stderr)")
313            );
314        }
315
316        let bytes = std::fs::read(&out_path)
317            .with_context(|| format!("reading sd-cli output at {}", out_path.display()))?;
318        info!(
319            target: TRACE_TARGET,
320            op = "dispatch",
321            model,
322            elapsed_ms,
323            bytes = bytes.len(),
324            "ok"
325        );
326
327        Ok(TaskResult::Image {
328            bytes,
329            ext: out_ext,
330        })
331    }
332}
333
334/// Map a requested image extension onto one `sd-cli` can encode, defaulting to
335/// `webp`. Keeps the returned `TaskResult` ext in lock-step with the bytes.
336fn normalize_output_ext(ext: &str) -> String {
337    match ext.trim().to_ascii_lowercase().as_str() {
338        "png" => "png",
339        "jpg" | "jpeg" => "jpg",
340        "bmp" => "bmp",
341        _ => "webp",
342    }
343    .to_string()
344}
345
346impl Engine for SdCppEngine {
347    fn name(&self) -> &'static str {
348        "sdcpp"
349    }
350
351    fn capabilities(&self) -> EngineCapabilities {
352        // Image kind only.  The studio's selection is kind-based now
353        // and the offer carries the model-source, so we don't need to
354        // enumerate model names ourselves.  We still list a single
355        // sentinel string so downstream code that reads
356        // `supportedModels` for display sees "any sd-cpp model".
357        let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
358        map.insert(TaskKind::Image, vec!["sd-cpp:*".to_string()]);
359        EngineCapabilities {
360            supported_models_per_kind: map,
361        }
362    }
363
364    fn dispatch(&self, _model: &str, _task: Task) -> Result<TaskResult> {
365        bail!(
366            "sdcpp engine requires a ModelSource on the offer; legacy push-based offers \
367             (no modelSource) cannot be served - re-promote the job through the studio"
368        )
369    }
370
371    fn dispatch_with_source(
372        &self,
373        model: &str,
374        task: Task,
375        source: &ModelSource,
376    ) -> Result<TaskResult> {
377        match task {
378            Task::Image(p) => self.dispatch_image(model, p, source),
379            other => {
380                // Surface the rejection at this engine's own target,
381                // matching the onnx/llama/whisper/candle engines.
382                // Without it an operator filtering
383                // `RUST_LOG=studio_worker::engine::sdcpp=debug` sees
384                // nothing when sdcpp refuses a non-image task.
385                let kind = other.kind();
386                warn!(
387                    target: TRACE_TARGET,
388                    op = "dispatch",
389                    model,
390                    kind = kind.as_str(),
391                    "sdcpp engine only serves image jobs"
392                );
393                Err(crate::engine::UnsupportedTask::new("sdcpp", kind).into())
394            }
395        }
396    }
397}
398
399// ---------------------------------------------------------------------------
400// Helpers
401// ---------------------------------------------------------------------------
402
403// The per-job scratch cleanup primitives (`remove_temp_file` +
404// `TempFileGuard`) live in `engine::download` so this engine and the
405// onnx engine share one tested implementation.
406
407fn file_for_role(files: &[(ModelFileRole, PathBuf)], role: ModelFileRole) -> Option<&Path> {
408    files
409        .iter()
410        .find(|(r, _)| *r == role)
411        .map(|(_, p)| p.as_path())
412}
413
414/// Resolve final per-job width / height / steps / cfg / sampler /
415/// negative-prompt by layering `params` over `source.cli_defaults`
416/// with the agreed precedence (per-job override beats model default
417/// beats engine fallback).  Pure for testability.
418fn resolve_image_args(params: &ImageParams, source: &ModelSource) -> ResolvedImageArgs {
419    let width = if params.width > 0 {
420        params.width
421    } else if source.cli_defaults.width > 0 {
422        source.cli_defaults.width
423    } else {
424        1024
425    };
426    let height = if params.height > 0 {
427        params.height
428    } else if source.cli_defaults.height > 0 {
429        source.cli_defaults.height
430    } else {
431        1024
432    };
433    // Steps: per-job override wins (treat the deserialiser default of
434    // 20 as "caller didn't pick" so the model's tuned step count
435    // doesn't get clobbered by a stale default).
436    let steps = if params.steps > 0 && params.steps != 20 {
437        params.steps
438    } else if source.cli_defaults.steps > 0 {
439        source.cli_defaults.steps
440    } else {
441        STEPS_FALLBACK
442    };
443    let source_cfg = if source.cli_defaults.cfg_scale > 0.0 {
444        source.cli_defaults.cfg_scale
445    } else {
446        1.0
447    };
448    let cfg_scale = params.cfg_scale.filter(|v| *v > 0.0).unwrap_or(source_cfg);
449    let sampling_method = params
450        .sampling_method
451        .clone()
452        .or_else(|| source.cli_defaults.sampling_method.clone());
453    ResolvedImageArgs {
454        width,
455        height,
456        steps,
457        cfg_scale,
458        sampling_method,
459    }
460}
461
462/// Resolved per-job sd-cli numerics.  Output of [`resolve_image_args`].
463#[derive(Debug, Clone, PartialEq)]
464struct ResolvedImageArgs {
465    width: u32,
466    height: u32,
467    steps: u32,
468    cfg_scale: f32,
469    sampling_method: Option<String>,
470}
471
472/// Build the full `sd-cli` argv for one image job.  Pure (no I/O):
473/// the caller resolves files / out-path / init-image-path, this
474/// function only assembles the flag list so it can be asserted in
475/// unit tests without spawning the binary.
476// Eight model-path + i2i components; grouping them adds indirection without
477// improving readability (mirrors the `#[allow]` already used in ws::session).
478#[allow(clippy::too_many_arguments)]
479fn build_sdcli_args(
480    params: &ImageParams,
481    source: &ModelSource,
482    diffusion_model: &Path,
483    vae: Option<&Path>,
484    text_encoder: Option<&Path>,
485    text_encoder_vision: Option<&Path>,
486    out_path: &Path,
487    init_img_path: Option<&Path>,
488    mask_path: Option<&Path>,
489    ref_img_path: Option<&Path>,
490    full_checkpoint: bool,
491) -> Vec<OsString> {
492    let resolved = resolve_image_args(params, source);
493    let mut args: Vec<OsString> = Vec::with_capacity(32);
494
495    // A full checkpoint loads via `-m`/`--model`; standalone diffusion weights via
496    // `--diffusion-model` (alongside split vae/clip files).
497    args.push(
498        if full_checkpoint {
499            "--model"
500        } else {
501            "--diffusion-model"
502        }
503        .into(),
504    );
505    args.push(diffusion_model.into());
506    if let Some(p) = vae {
507        args.push("--vae".into());
508        args.push(p.into());
509    }
510    if let Some(p) = text_encoder {
511        args.push("--llm".into());
512        args.push(p.into());
513    }
514    if let Some(p) = text_encoder_vision {
515        args.push("--llm_vision".into());
516        args.push(p.into());
517    }
518    args.push("-p".into());
519    args.push((&params.prompt as &str).into());
520    if let Some(neg) = params.negative_prompt.as_deref() {
521        if !neg.is_empty() {
522            args.push("--negative-prompt".into());
523            args.push(neg.into());
524        }
525    }
526    if let Some(reference) = ref_img_path {
527        // Reference / instruction-edit mode (Qwen-Image-Edit, Flux Kontext): the model regenerates
528        // the image from the reference per the prompt. Mutually exclusive with the `--init-img`
529        // img2img path. A `--mask` is honoured here too: it constrains the edit to the masked
530        // region (white = editable) and leaves the rest, so the studio can place the edit inside
531        // the author's drawn shape. No `--strength` (that's an img2img-only knob).
532        args.push("-r".into());
533        args.push(reference.into());
534        if let Some(mask) = mask_path {
535            args.push("--mask".into());
536            args.push(mask.into());
537        }
538    } else if let Some(init) = init_img_path {
539        args.push("--init-img".into());
540        args.push(init.into());
541        // `--strength` only makes sense alongside an init image
542        // (sd-cli ignores it otherwise).  Default to 0.75 (sd-cli's
543        // own default) when the caller didn't pick a value.
544        let strength = params.denoise.unwrap_or(0.75);
545        args.push("--strength".into());
546        args.push(strength.to_string().into());
547        // Mask-guided inpaint: only valid with an init image.
548        if let Some(mask) = mask_path {
549            args.push("--mask".into());
550            args.push(mask.into());
551        }
552    }
553    args.push("--cfg-scale".into());
554    args.push(resolved.cfg_scale.to_string().into());
555    args.push("--steps".into());
556    args.push(resolved.steps.to_string().into());
557    args.push("-W".into());
558    args.push(resolved.width.to_string().into());
559    args.push("-H".into());
560    args.push(resolved.height.to_string().into());
561    args.push("-o".into());
562    args.push(out_path.into());
563    if let Some(seed) = params.seed {
564        args.push("--seed".into());
565        args.push(seed.to_string().into());
566    }
567    if let Some(method) = resolved.sampling_method.as_deref() {
568        args.push("--sampling-method".into());
569        args.push(method.into());
570    }
571    // Flow / instruction-edit model flags (model-level constants from the registry). Only emitted
572    // when the model declares them, so SDXL-style models are unaffected.
573    if let Some(shift) = source.cli_defaults.flow_shift {
574        args.push("--flow-shift".into());
575        args.push(shift.to_string().into());
576    }
577    if source.cli_defaults.zero_cond_t == Some(true) {
578        args.push("--qwen-image-zero-cond-t".into());
579    }
580    if source.cli_defaults.offload_to_cpu == Some(true) {
581        args.push("--offload-to-cpu".into());
582    }
583    // VRAM-saving flags that are safe on every box.
584    args.push("--diffusion-fa".into());
585    args
586}
587
588/// Point the per-job `Command`'s dynamic linker at the shared library
589/// that ships next to an auto-provisioned `sd-cli` (Linux / macOS).
590/// No-op on Windows (sibling DLLs resolve automatically) and when the
591/// resolved binary has no sibling library (operator wrapper-script
592/// installs manage their own load path).  Prepends to any inherited
593/// value so a pre-set `LD_LIBRARY_PATH` isn't clobbered.
594#[cfg_attr(coverage_nightly, coverage(off))]
595fn apply_library_path(cmd: &mut Command, sd_cli: &Path) {
596    let Some((var, dir)) = sd_provision::library_path_env(sd_cli) else {
597        return;
598    };
599    let value = match std::env::var_os(var) {
600        Some(existing) => {
601            let mut paths = vec![dir.clone()];
602            paths.extend(std::env::split_paths(&existing));
603            // `join_paths` only fails if a path contains the platform
604            // separator; fall back to our dir alone, the entry that
605            // matters for finding the sibling library.
606            std::env::join_paths(paths).unwrap_or_else(|_| dir.into_os_string())
607        }
608        None => dir.into_os_string(),
609    };
610    cmd.env(var, value);
611}
612
613/// Look up `sd-cli` in env override -> `<models_root>/bin` ->
614/// `~/.local/bin` -> `$PATH`.  The `<models_root>/bin` slot is where a
615/// self-provisioned binary lands, so the auto-provisioner can drop it
616/// next to the cached models and have the worker pick it up with no
617/// PATH fiddling.  Excluded from coverage: touches several host paths
618/// only one of which matches per host, and CI doesn't ship `sd-cli`.
619#[cfg_attr(coverage_nightly, coverage(off))]
620fn resolve_sd_cli(models_root: &Path) -> Option<PathBuf> {
621    let bin = sd_provision::binary_name();
622    if let Ok(p) = std::env::var("STUDIO_WORKER_SD_CLI") {
623        let path = PathBuf::from(p);
624        if path.is_file() {
625            return Some(path);
626        }
627    }
628    let in_models = models_root.join("bin").join(bin);
629    if in_models.is_file() {
630        return Some(in_models);
631    }
632    if let Some(home) = std::env::var_os("HOME") {
633        let candidate = PathBuf::from(home).join(".local/bin").join(bin);
634        if candidate.is_file() {
635            return Some(candidate);
636        }
637    }
638    which(bin)
639}
640
641/// `$PATH` lookup for a bare binary name.  Excluded from coverage
642/// for the same reason as `resolve_sd_cli`.
643#[cfg_attr(coverage_nightly, coverage(off))]
644fn which(bin: &str) -> Option<PathBuf> {
645    let path = std::env::var_os("PATH")?;
646    for entry in std::env::split_paths(&path) {
647        let candidate = entry.join(bin);
648        if candidate.is_file() {
649            return Some(candidate);
650        }
651    }
652    None
653}
654
655/// Pick an extension to use for the init-image tempfile that sd-cli's
656/// image loader can sniff.  Reads the trailing `.<ext>` from the URL's
657/// path (ignoring query + fragment).  Defaults to `webp` when no
658/// recognisable extension is present.
659fn init_image_extension(url: &str) -> &'static str {
660    let path = url.split(['?', '#']).next().unwrap_or(url);
661    let lower_tail = path
662        .rsplit('.')
663        .next()
664        .map(|t| t.to_ascii_lowercase())
665        .unwrap_or_default();
666    match lower_tail.as_str() {
667        "png" => "png",
668        "jpg" | "jpeg" => "jpg",
669        "webp" => "webp",
670        "bmp" => "bmp",
671        "gif" => "gif",
672        "tif" | "tiff" => "tif",
673        _ => "webp",
674    }
675}
676
677// ---------------------------------------------------------------------------
678// Tests
679// ---------------------------------------------------------------------------
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use crate::types::{ModelCliDefaults, ModelEngine, ModelFile, ModelFileRole};
685    use tempfile::tempdir;
686
687    fn fake_source(files: Vec<ModelFile>) -> ModelSource {
688        ModelSource {
689            engine: ModelEngine::SdCpp,
690            files,
691            cli_defaults: ModelCliDefaults {
692                cfg_scale: 1.0,
693                steps: 8,
694                width: 1024,
695                height: 1024,
696                sampling_method: Some("euler".to_string()),
697                ..Default::default()
698            },
699        }
700    }
701
702    #[test]
703    fn file_for_role_picks_matching_file() {
704        let files = vec![
705            (ModelFileRole::DiffusionModel, PathBuf::from("/d.gguf")),
706            (ModelFileRole::Vae, PathBuf::from("/v.safetensors")),
707        ];
708        assert_eq!(
709            file_for_role(&files, ModelFileRole::DiffusionModel),
710            Some(Path::new("/d.gguf"))
711        );
712        assert_eq!(
713            file_for_role(&files, ModelFileRole::Vae),
714            Some(Path::new("/v.safetensors"))
715        );
716        assert!(file_for_role(&files, ModelFileRole::TextEncoder).is_none());
717    }
718
719    #[test]
720    fn ensure_files_skips_already_present() {
721        let dir = tempdir().unwrap();
722        let cached = dir.path().join("cached.gguf");
723        std::fs::write(&cached, b"already here").unwrap();
724        let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
725        let source = fake_source(vec![ModelFile {
726            role: ModelFileRole::DiffusionModel,
727            url: "https://example.invalid/cached.gguf".into(),
728            filename: "cached.gguf".into(),
729            approx_bytes: None,
730            sha256: None,
731        }]);
732        let resolved = engine.ensure_files(&source).expect("cached file used");
733        assert_eq!(resolved.len(), 1);
734        assert_eq!(resolved[0].0, ModelFileRole::DiffusionModel);
735        assert_eq!(resolved[0].1, cached);
736        // Untouched on disk — our "download" never ran.
737        assert_eq!(std::fs::read(&cached).unwrap(), b"already here");
738    }
739
740    #[test]
741    fn dispatch_rejects_non_image_tasks() {
742        use crate::types::AudioTtsParams;
743        let dir = tempdir().unwrap();
744        let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
745        let task = Task::AudioTts(AudioTtsParams {
746            text: "hi".into(),
747            voice: "v".into(),
748            ext: "wav".into(),
749            ..Default::default()
750        });
751        let source = fake_source(vec![]);
752        let err = engine
753            .dispatch_with_source("anything", task, &source)
754            .unwrap_err();
755        assert!(err.to_string().contains("cannot serve audio_tts"));
756    }
757
758    // The legacy `dispatch_requires_model_source` test is gone: the
759    // trait signature now takes `&ModelSource` so the compiler enforces
760    // it at every call site.  No runtime fallback to police.
761
762    // -----------------------------------------------------------------
763    // Pure arg-builder tests — lock down the sd-cli invocation contract
764    // without needing the binary on the box.
765    // -----------------------------------------------------------------
766
767    fn args_to_strings(args: &[OsString]) -> Vec<String> {
768        args.iter()
769            .map(|s| s.to_string_lossy().into_owned())
770            .collect()
771    }
772
773    fn idx_after(args: &[String], flag: &str) -> Option<usize> {
774        args.iter().position(|a| a == flag).map(|i| i + 1)
775    }
776
777    #[test]
778    fn build_sdcli_args_includes_required_flags() {
779        let params = ImageParams {
780            prompt: "hello".into(),
781            width: 768,
782            height: 512,
783            steps: 20, // "caller didn't pick" → source default wins
784            ..Default::default()
785        };
786        let source = fake_source(vec![]);
787        let args = build_sdcli_args(
788            &params,
789            &source,
790            Path::new("/d.gguf"),
791            Some(Path::new("/v.safetensors")),
792            Some(Path::new("/llm.gguf")),
793            None,
794            Path::new("/tmp/out.webp"),
795            None,
796            None,
797            None,
798            false,
799        );
800        let s = args_to_strings(&args);
801        assert_eq!(s[idx_after(&s, "--diffusion-model").unwrap()], "/d.gguf");
802        assert_eq!(s[idx_after(&s, "--vae").unwrap()], "/v.safetensors");
803        assert_eq!(s[idx_after(&s, "--llm").unwrap()], "/llm.gguf");
804        assert_eq!(s[idx_after(&s, "-p").unwrap()], "hello");
805        assert_eq!(s[idx_after(&s, "-W").unwrap()], "768");
806        assert_eq!(s[idx_after(&s, "-H").unwrap()], "512");
807        // source default cfg_scale=1.0
808        assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "1");
809        // source default steps=8 wins (param.steps==20 treated as default)
810        assert_eq!(s[idx_after(&s, "--steps").unwrap()], "8");
811        assert_eq!(s[idx_after(&s, "--sampling-method").unwrap()], "euler");
812        assert_eq!(s[idx_after(&s, "-o").unwrap()], "/tmp/out.webp");
813        assert!(s.contains(&"--diffusion-fa".to_string()));
814        // Never includes init-only flags when no init image present.
815        assert!(!s.contains(&"--init-img".to_string()));
816        assert!(!s.contains(&"--strength".to_string()));
817    }
818
819    #[test]
820    fn build_sdcli_args_includes_negative_prompt_when_set() {
821        let params = ImageParams {
822            prompt: "hi".into(),
823            negative_prompt: Some("text, watermark, low quality".into()),
824            ..Default::default()
825        };
826        let source = fake_source(vec![]);
827        let args = build_sdcli_args(
828            &params,
829            &source,
830            Path::new("/d.gguf"),
831            None,
832            None,
833            None,
834            Path::new("/tmp/out.webp"),
835            None,
836            None,
837            None,
838            false,
839        );
840        let s = args_to_strings(&args);
841        assert_eq!(
842            s[idx_after(&s, "--negative-prompt").unwrap()],
843            "text, watermark, low quality"
844        );
845    }
846
847    #[test]
848    fn build_sdcli_args_omits_negative_prompt_when_empty_string() {
849        let params = ImageParams {
850            prompt: "hi".into(),
851            negative_prompt: Some(String::new()),
852            ..Default::default()
853        };
854        let source = fake_source(vec![]);
855        let args = build_sdcli_args(
856            &params,
857            &source,
858            Path::new("/d.gguf"),
859            None,
860            None,
861            None,
862            Path::new("/tmp/out.webp"),
863            None,
864            None,
865            None,
866            false,
867        );
868        let s = args_to_strings(&args);
869        assert!(!s.contains(&"--negative-prompt".to_string()));
870    }
871
872    #[test]
873    fn build_sdcli_args_includes_init_image_and_strength() {
874        let params = ImageParams {
875            prompt: "hi".into(),
876            denoise: Some(0.55),
877            ..Default::default()
878        };
879        let source = fake_source(vec![]);
880        let args = build_sdcli_args(
881            &params,
882            &source,
883            Path::new("/d.gguf"),
884            None,
885            None,
886            None,
887            Path::new("/tmp/out.webp"),
888            Some(Path::new("/tmp/init.webp")),
889            None,
890            None,
891            false,
892        );
893        let s = args_to_strings(&args);
894        assert_eq!(s[idx_after(&s, "--init-img").unwrap()], "/tmp/init.webp");
895        assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.55");
896        // No mask supplied → no inpaint flag.
897        assert!(!s.contains(&"--mask".to_string()));
898    }
899
900    #[test]
901    fn build_sdcli_args_includes_mask_for_inpaint() {
902        let params = ImageParams {
903            prompt: "remove the tree".into(),
904            denoise: Some(0.8),
905            ..Default::default()
906        };
907        let source = fake_source(vec![]);
908        let args = build_sdcli_args(
909            &params,
910            &source,
911            Path::new("/d.gguf"),
912            None,
913            None,
914            None,
915            Path::new("/tmp/out.webp"),
916            Some(Path::new("/tmp/init.webp")),
917            Some(Path::new("/tmp/mask.png")),
918            None,
919            false,
920        );
921        let s = args_to_strings(&args);
922        assert_eq!(s[idx_after(&s, "--init-img").unwrap()], "/tmp/init.webp");
923        assert_eq!(s[idx_after(&s, "--mask").unwrap()], "/tmp/mask.png");
924        assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.8");
925    }
926
927    #[test]
928    fn build_sdcli_args_uses_model_flag_for_full_checkpoint() {
929        let params = ImageParams {
930            prompt: "hi".into(),
931            ..Default::default()
932        };
933        let source = fake_source(vec![]);
934        let args = build_sdcli_args(
935            &params,
936            &source,
937            Path::new("/checkpoint.safetensors"),
938            Some(Path::new("/v.safetensors")),
939            None,
940            None,
941            Path::new("/tmp/out.webp"),
942            None,
943            None,
944            None,
945            true,
946        );
947        let s = args_to_strings(&args);
948        // A full checkpoint loads via -m/--model, not --diffusion-model.
949        assert_eq!(
950            s[idx_after(&s, "--model").unwrap()],
951            "/checkpoint.safetensors"
952        );
953        assert!(!s.contains(&"--diffusion-model".to_string()));
954    }
955
956    #[test]
957    fn build_sdcli_args_defaults_denoise_when_init_image_present_but_denoise_none() {
958        let params = ImageParams {
959            prompt: "hi".into(),
960            denoise: None,
961            ..Default::default()
962        };
963        let source = fake_source(vec![]);
964        let args = build_sdcli_args(
965            &params,
966            &source,
967            Path::new("/d.gguf"),
968            None,
969            None,
970            None,
971            Path::new("/tmp/out.webp"),
972            Some(Path::new("/tmp/init.webp")),
973            None,
974            None,
975            false,
976        );
977        let s = args_to_strings(&args);
978        assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.75");
979    }
980
981    #[test]
982    fn build_sdcli_args_per_job_cfg_scale_overrides_model_default() {
983        let params = ImageParams {
984            prompt: "hi".into(),
985            cfg_scale: Some(7.5),
986            ..Default::default()
987        };
988        let source = fake_source(vec![]);
989        let args = build_sdcli_args(
990            &params,
991            &source,
992            Path::new("/d.gguf"),
993            None,
994            None,
995            None,
996            Path::new("/tmp/out.webp"),
997            None,
998            None,
999            None,
1000            false,
1001        );
1002        let s = args_to_strings(&args);
1003        assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "7.5");
1004    }
1005
1006    #[test]
1007    fn build_sdcli_args_per_job_sampling_method_overrides_model_default() {
1008        let params = ImageParams {
1009            prompt: "hi".into(),
1010            sampling_method: Some("dpm++2m".into()),
1011            ..Default::default()
1012        };
1013        let source = fake_source(vec![]);
1014        let args = build_sdcli_args(
1015            &params,
1016            &source,
1017            Path::new("/d.gguf"),
1018            None,
1019            None,
1020            None,
1021            Path::new("/tmp/out.webp"),
1022            None,
1023            None,
1024            None,
1025            false,
1026        );
1027        let s = args_to_strings(&args);
1028        assert_eq!(s[idx_after(&s, "--sampling-method").unwrap()], "dpm++2m");
1029    }
1030
1031    #[test]
1032    fn build_sdcli_args_per_job_steps_overrides_when_non_default() {
1033        let params = ImageParams {
1034            prompt: "hi".into(),
1035            steps: 30, // != 20 → treat as caller override
1036            ..Default::default()
1037        };
1038        let source = fake_source(vec![]);
1039        let args = build_sdcli_args(
1040            &params,
1041            &source,
1042            Path::new("/d.gguf"),
1043            None,
1044            None,
1045            None,
1046            Path::new("/tmp/out.webp"),
1047            None,
1048            None,
1049            None,
1050            false,
1051        );
1052        let s = args_to_strings(&args);
1053        assert_eq!(s[idx_after(&s, "--steps").unwrap()], "30");
1054    }
1055
1056    #[test]
1057    fn build_sdcli_args_seed_included_when_set() {
1058        let params = ImageParams {
1059            prompt: "hi".into(),
1060            seed: Some(42),
1061            ..Default::default()
1062        };
1063        let source = fake_source(vec![]);
1064        let args = build_sdcli_args(
1065            &params,
1066            &source,
1067            Path::new("/d.gguf"),
1068            None,
1069            None,
1070            None,
1071            Path::new("/tmp/out.webp"),
1072            None,
1073            None,
1074            None,
1075            false,
1076        );
1077        let s = args_to_strings(&args);
1078        assert_eq!(s[idx_after(&s, "--seed").unwrap()], "42");
1079    }
1080
1081    /// A model source carrying the Qwen-Image-Edit flow flags.
1082    fn qwen_edit_source() -> ModelSource {
1083        ModelSource {
1084            engine: ModelEngine::SdCpp,
1085            files: vec![],
1086            cli_defaults: ModelCliDefaults {
1087                cfg_scale: 4.0,
1088                steps: 20,
1089                width: 1024,
1090                height: 1024,
1091                sampling_method: Some("euler".to_string()),
1092                flow_shift: Some(3.0),
1093                zero_cond_t: Some(true),
1094                offload_to_cpu: Some(true),
1095            },
1096        }
1097    }
1098
1099    #[test]
1100    fn build_sdcli_args_reference_mode_for_instruction_edit() {
1101        let params = ImageParams {
1102            prompt: "add a red beach ball".into(),
1103            denoise: Some(0.9),
1104            ..Default::default()
1105        };
1106        let source = qwen_edit_source();
1107        let args = build_sdcli_args(
1108            &params,
1109            &source,
1110            Path::new("/qwen.gguf"),
1111            Some(Path::new("/vae.safetensors")),
1112            Some(Path::new("/llm.gguf")),
1113            Some(Path::new("/mmproj.gguf")),
1114            Path::new("/tmp/out.webp"),
1115            None,
1116            Some(Path::new("/tmp/mask.png")),
1117            Some(Path::new("/tmp/ref.webp")),
1118            false,
1119        );
1120        let s = args_to_strings(&args);
1121        // Reference mode: `-r` set, a `--mask` constrains the edit region, and the img2img-only
1122        // `--init-img` / `--strength` flags are suppressed.
1123        assert_eq!(s[idx_after(&s, "-r").unwrap()], "/tmp/ref.webp");
1124        assert_eq!(s[idx_after(&s, "--mask").unwrap()], "/tmp/mask.png");
1125        assert!(!s.contains(&"--init-img".to_string()));
1126        assert!(!s.contains(&"--strength".to_string()));
1127        // Vision encoder + Qwen flow flags emitted.
1128        assert_eq!(s[idx_after(&s, "--llm_vision").unwrap()], "/mmproj.gguf");
1129        assert_eq!(s[idx_after(&s, "--flow-shift").unwrap()], "3");
1130        assert!(s.contains(&"--qwen-image-zero-cond-t".to_string()));
1131        assert!(s.contains(&"--offload-to-cpu".to_string()));
1132    }
1133
1134    #[test]
1135    fn build_sdcli_args_omits_qwen_flags_for_plain_model() {
1136        let params = ImageParams {
1137            prompt: "hi".into(),
1138            ..Default::default()
1139        };
1140        // fake_source has no flow_shift / zero_cond_t / offload_to_cpu.
1141        let source = fake_source(vec![]);
1142        let args = build_sdcli_args(
1143            &params,
1144            &source,
1145            Path::new("/d.gguf"),
1146            None,
1147            None,
1148            None,
1149            Path::new("/tmp/out.webp"),
1150            None,
1151            None,
1152            None,
1153            false,
1154        );
1155        let s = args_to_strings(&args);
1156        assert!(!s.contains(&"--flow-shift".to_string()));
1157        assert!(!s.contains(&"--qwen-image-zero-cond-t".to_string()));
1158        assert!(!s.contains(&"--offload-to-cpu".to_string()));
1159        assert!(!s.contains(&"--llm_vision".to_string()));
1160        assert!(!s.contains(&"-r".to_string()));
1161    }
1162
1163    #[test]
1164    fn capabilities_advertises_only_image_kind() {
1165        let dir = tempdir().unwrap();
1166        let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
1167        let caps = engine.capabilities();
1168        assert!(caps
1169            .supported_models_per_kind
1170            .contains_key(&TaskKind::Image));
1171        assert_eq!(caps.supported_models_per_kind.len(), 1);
1172    }
1173
1174    #[test]
1175    fn init_image_extension_reads_url_tail() {
1176        assert_eq!(init_image_extension("https://x/y/latest.webp"), "webp");
1177        assert_eq!(init_image_extension("https://x/y/latest.PNG"), "png");
1178        assert_eq!(init_image_extension("https://x/y/latest.jpg"), "jpg");
1179        assert_eq!(init_image_extension("https://x/y/latest.jpeg"), "jpg");
1180        // Query strings + fragments don't trick the parser.
1181        assert_eq!(
1182            init_image_extension("https://x/y/latest.webp?v=42&t=now"),
1183            "webp"
1184        );
1185        assert_eq!(init_image_extension("https://x/y/latest.webp#frag"), "webp");
1186        // Unknown extension falls back to webp.
1187        assert_eq!(
1188            init_image_extension("https://x/y/latest.unknownext"),
1189            "webp"
1190        );
1191        assert_eq!(init_image_extension("https://x/y/no-ext"), "webp");
1192    }
1193
1194    #[test]
1195    fn normalize_output_ext_honours_known_and_defaults_webp() {
1196        assert_eq!(normalize_output_ext("png"), "png");
1197        assert_eq!(normalize_output_ext("PNG"), "png");
1198        assert_eq!(normalize_output_ext("jpg"), "jpg");
1199        assert_eq!(normalize_output_ext("jpeg"), "jpg");
1200        assert_eq!(normalize_output_ext("bmp"), "bmp");
1201        assert_eq!(normalize_output_ext("webp"), "webp");
1202        assert_eq!(normalize_output_ext(""), "webp");
1203        assert_eq!(normalize_output_ext("gif"), "webp");
1204    }
1205}