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 \u2014 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//! \u2026
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;
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.filename, &file.url)?;
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        let out_path = out_dir.join(format!("{stem}.webp"));
191
192        // Own the scratch files from the moment their paths exist so
193        // every failure path (sd-cli error, unreadable output) cleans
194        // up instead of leaking them into the temp dir.
195        let mut temp_files = TempFileGuard::new();
196        temp_files.push(out_path.clone());
197
198        // If the task carries an init image URL, stream it to a
199        // tempfile so we can hand the path to `sd-cli --init-img`.
200        // This is mandatory — the worker refuses i2i jobs whose
201        // init image fails to download (no silent fallback to t2i).
202        // The local extension mirrors the URL's so sd-cli's image
203        // loader can sniff the format.
204        let init_img_path = match params.init_image_url.as_deref() {
205            Some(url) if !url.is_empty() => {
206                let ext = init_image_extension(url);
207                let init_path = out_dir.join(format!("{stem}-init.{ext}"));
208                download::download_file(url, &init_path).with_context(|| {
209                    format!("downloading init image {} -> {}", url, init_path.display())
210                })?;
211                temp_files.push(init_path.clone());
212                Some(init_path)
213            }
214            _ => None,
215        };
216
217        // A mask constrains the edit region — valid alongside either an init image (img2img
218        // inpaint) or a reference image (instruction edit). Download it whenever a base image is
219        // present and a mask URL was supplied; white pixels mark the region the model may change.
220        let has_base = init_img_path.is_some() || params.ref_image_url.as_deref().is_some();
221        let mask_path = match (has_base, params.mask_url.as_deref()) {
222            (true, Some(url)) if !url.is_empty() => {
223                let ext = init_image_extension(url);
224                let path = out_dir.join(format!("{stem}-mask.{ext}"));
225                download::download_file(url, &path)
226                    .with_context(|| format!("downloading mask {} -> {}", url, path.display()))?;
227                temp_files.push(path.clone());
228                Some(path)
229            }
230            _ => None,
231        };
232
233        // Reference image for instruction-edit models (`sd-cli -r`). Downloaded like the init image;
234        // when present the arg builder uses reference mode instead of the img2img/mask path.
235        let ref_img_path = match params.ref_image_url.as_deref() {
236            Some(url) if !url.is_empty() => {
237                let ext = init_image_extension(url);
238                let path = out_dir.join(format!("{stem}-ref.{ext}"));
239                download::download_file(url, &path).with_context(|| {
240                    format!("downloading reference image {} -> {}", url, path.display())
241                })?;
242                temp_files.push(path.clone());
243                Some(path)
244            }
245            _ => None,
246        };
247
248        let args = build_sdcli_args(
249            &params,
250            source,
251            diffusion_model,
252            vae,
253            text_encoder,
254            text_encoder_vision,
255            &out_path,
256            init_img_path.as_deref(),
257            mask_path.as_deref(),
258            ref_img_path.as_deref(),
259            full_checkpoint,
260        );
261        let mut cmd = Command::new(&sd_cli);
262        cmd.args(&args);
263        apply_library_path(&mut cmd, &sd_cli);
264
265        debug!(
266            target: TRACE_TARGET,
267            op = "spawn",
268            sd_cli = %sd_cli.display(),
269            model,
270            i2i = init_img_path.is_some(),
271            arg_count = args.len(),
272            "running sd-cli"
273        );
274
275        let started = Instant::now();
276        let output = cmd
277            .output()
278            .with_context(|| format!("running {}", sd_cli.display()))?;
279        let elapsed_ms = started.elapsed().as_millis() as u64;
280        if !output.status.success() {
281            let stderr = String::from_utf8_lossy(&output.stderr);
282            warn!(
283                target: TRACE_TARGET,
284                op = "spawn",
285                model,
286                elapsed_ms,
287                exit = ?output.status.code(),
288                stderr = %stderr,
289                "sd-cli failed"
290            );
291            bail!(
292                "sd-cli exited with {:?}: {}",
293                output.status.code(),
294                stderr.lines().last().unwrap_or("(no stderr)")
295            );
296        }
297
298        let bytes = std::fs::read(&out_path)
299            .with_context(|| format!("reading sd-cli output at {}", out_path.display()))?;
300        info!(
301            target: TRACE_TARGET,
302            op = "dispatch",
303            model,
304            elapsed_ms,
305            bytes = bytes.len(),
306            "ok"
307        );
308
309        Ok(TaskResult::Image {
310            bytes,
311            ext: "webp".to_string(),
312        })
313    }
314}
315
316impl Engine for SdCppEngine {
317    fn name(&self) -> &'static str {
318        "sdcpp"
319    }
320
321    fn capabilities(&self) -> EngineCapabilities {
322        // Image kind only.  The studio's selection is kind-based now
323        // and the offer carries the model-source, so we don't need to
324        // enumerate model names ourselves.  We still list a single
325        // sentinel string so downstream code that reads
326        // `supportedModels` for display sees "any sd-cpp model".
327        let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
328        map.insert(TaskKind::Image, vec!["sd-cpp:*".to_string()]);
329        EngineCapabilities {
330            supported_models_per_kind: map,
331        }
332    }
333
334    fn dispatch(&self, _model: &str, _task: Task) -> Result<TaskResult> {
335        bail!(
336            "sdcpp engine requires a ModelSource on the offer; legacy push-based offers \
337             (no modelSource) cannot be served - re-promote the job through the studio"
338        )
339    }
340
341    fn dispatch_with_source(
342        &self,
343        model: &str,
344        task: Task,
345        source: &ModelSource,
346    ) -> Result<TaskResult> {
347        let kind = task.kind();
348        match task {
349            Task::Image(p) => self.dispatch_image(model, p, source),
350            _ => bail!("sdcpp engine cannot serve {} tasks", kind.as_str()),
351        }
352    }
353}
354
355// ---------------------------------------------------------------------------
356// Helpers
357// ---------------------------------------------------------------------------
358
359/// Best-effort removal of a temporary file (a per-job `sd-cli` output,
360/// an init image, or a half-written `.part` download).  Removal is
361/// non-fatal — the artefact has already been read or the job already
362/// failed — but a remove that keeps failing silently leaks temp files
363/// and can quietly fill the worker's disk over a long-running session,
364/// so we surface the failure instead of swallowing it.  A `NotFound`
365/// is the desired end state (something already cleaned it up), so it's
366/// not logged.
367fn remove_temp_file(path: &Path) {
368    if let Err(e) = std::fs::remove_file(path) {
369        if e.kind() != std::io::ErrorKind::NotFound {
370            warn!(
371                target: TRACE_TARGET,
372                op = "cleanup",
373                path = %path.display(),
374                error = %e,
375                "failed to remove temp file"
376            );
377        }
378    }
379}
380
381/// RAII owner of a job's scratch files (the `sd-cli` output image and a
382/// downloaded init image).  Registering them up front means every exit
383/// path - the success return, an `sd-cli` non-zero exit, an unreadable
384/// output file, even a panic - removes them on drop instead of leaking
385/// them into the temp dir and slowly filling the worker's disk over a
386/// long-running session.  Removal is best-effort via [`remove_temp_file`],
387/// so a path that never materialised (job failed before `sd-cli` wrote
388/// anything) is silently tolerated.
389struct TempFileGuard {
390    paths: Vec<PathBuf>,
391}
392
393impl TempFileGuard {
394    fn new() -> Self {
395        Self { paths: Vec::new() }
396    }
397
398    fn push(&mut self, path: PathBuf) {
399        self.paths.push(path);
400    }
401}
402
403impl Drop for TempFileGuard {
404    fn drop(&mut self) {
405        for path in &self.paths {
406            remove_temp_file(path);
407        }
408    }
409}
410
411fn file_for_role(files: &[(ModelFileRole, PathBuf)], role: ModelFileRole) -> Option<&Path> {
412    files
413        .iter()
414        .find(|(r, _)| *r == role)
415        .map(|(_, p)| p.as_path())
416}
417
418/// Resolve final per-job width / height / steps / cfg / sampler /
419/// negative-prompt by layering `params` over `source.cli_defaults`
420/// with the agreed precedence (per-job override beats model default
421/// beats engine fallback).  Pure for testability.
422fn resolve_image_args(params: &ImageParams, source: &ModelSource) -> ResolvedImageArgs {
423    let width = if params.width > 0 {
424        params.width
425    } else if source.cli_defaults.width > 0 {
426        source.cli_defaults.width
427    } else {
428        1024
429    };
430    let height = if params.height > 0 {
431        params.height
432    } else if source.cli_defaults.height > 0 {
433        source.cli_defaults.height
434    } else {
435        1024
436    };
437    // Steps: per-job override wins (treat the deserialiser default of
438    // 20 as "caller didn't pick" so the model's tuned step count
439    // doesn't get clobbered by a stale default).
440    let steps = if params.steps > 0 && params.steps != 20 {
441        params.steps
442    } else if source.cli_defaults.steps > 0 {
443        source.cli_defaults.steps
444    } else {
445        STEPS_FALLBACK
446    };
447    let source_cfg = if source.cli_defaults.cfg_scale > 0.0 {
448        source.cli_defaults.cfg_scale
449    } else {
450        1.0
451    };
452    let cfg_scale = params.cfg_scale.filter(|v| *v > 0.0).unwrap_or(source_cfg);
453    let sampling_method = params
454        .sampling_method
455        .clone()
456        .or_else(|| source.cli_defaults.sampling_method.clone());
457    ResolvedImageArgs {
458        width,
459        height,
460        steps,
461        cfg_scale,
462        sampling_method,
463    }
464}
465
466/// Resolved per-job sd-cli numerics.  Output of [`resolve_image_args`].
467#[derive(Debug, Clone, PartialEq)]
468struct ResolvedImageArgs {
469    width: u32,
470    height: u32,
471    steps: u32,
472    cfg_scale: f32,
473    sampling_method: Option<String>,
474}
475
476/// Build the full `sd-cli` argv for one image job.  Pure (no I/O):
477/// the caller resolves files / out-path / init-image-path, this
478/// function only assembles the flag list so it can be asserted in
479/// unit tests without spawning the binary.
480// Eight model-path + i2i components; grouping them adds indirection without
481// improving readability (mirrors the `#[allow]` already used in ws::session).
482#[allow(clippy::too_many_arguments)]
483fn build_sdcli_args(
484    params: &ImageParams,
485    source: &ModelSource,
486    diffusion_model: &Path,
487    vae: Option<&Path>,
488    text_encoder: Option<&Path>,
489    text_encoder_vision: Option<&Path>,
490    out_path: &Path,
491    init_img_path: Option<&Path>,
492    mask_path: Option<&Path>,
493    ref_img_path: Option<&Path>,
494    full_checkpoint: bool,
495) -> Vec<OsString> {
496    let resolved = resolve_image_args(params, source);
497    let mut args: Vec<OsString> = Vec::with_capacity(32);
498
499    // A full checkpoint loads via `-m`/`--model`; standalone diffusion weights via
500    // `--diffusion-model` (alongside split vae/clip files).
501    args.push(
502        if full_checkpoint {
503            "--model"
504        } else {
505            "--diffusion-model"
506        }
507        .into(),
508    );
509    args.push(diffusion_model.into());
510    if let Some(p) = vae {
511        args.push("--vae".into());
512        args.push(p.into());
513    }
514    if let Some(p) = text_encoder {
515        args.push("--llm".into());
516        args.push(p.into());
517    }
518    if let Some(p) = text_encoder_vision {
519        args.push("--llm_vision".into());
520        args.push(p.into());
521    }
522    args.push("-p".into());
523    args.push((&params.prompt as &str).into());
524    if let Some(neg) = params.negative_prompt.as_deref() {
525        if !neg.is_empty() {
526            args.push("--negative-prompt".into());
527            args.push(neg.into());
528        }
529    }
530    if let Some(reference) = ref_img_path {
531        // Reference / instruction-edit mode (Qwen-Image-Edit, Flux Kontext): the model regenerates
532        // the image from the reference per the prompt. Mutually exclusive with the `--init-img`
533        // img2img path. A `--mask` is honoured here too: it constrains the edit to the masked
534        // region (white = editable) and leaves the rest, so the studio can place the edit inside
535        // the author's drawn shape. No `--strength` (that's an img2img-only knob).
536        args.push("-r".into());
537        args.push(reference.into());
538        if let Some(mask) = mask_path {
539            args.push("--mask".into());
540            args.push(mask.into());
541        }
542    } else if let Some(init) = init_img_path {
543        args.push("--init-img".into());
544        args.push(init.into());
545        // `--strength` only makes sense alongside an init image
546        // (sd-cli ignores it otherwise).  Default to 0.75 (sd-cli's
547        // own default) when the caller didn't pick a value.
548        let strength = params.denoise.unwrap_or(0.75);
549        args.push("--strength".into());
550        args.push(strength.to_string().into());
551        // Mask-guided inpaint: only valid with an init image.
552        if let Some(mask) = mask_path {
553            args.push("--mask".into());
554            args.push(mask.into());
555        }
556    }
557    args.push("--cfg-scale".into());
558    args.push(resolved.cfg_scale.to_string().into());
559    args.push("--steps".into());
560    args.push(resolved.steps.to_string().into());
561    args.push("-W".into());
562    args.push(resolved.width.to_string().into());
563    args.push("-H".into());
564    args.push(resolved.height.to_string().into());
565    args.push("-o".into());
566    args.push(out_path.into());
567    if let Some(seed) = params.seed {
568        args.push("--seed".into());
569        args.push(seed.to_string().into());
570    }
571    if let Some(method) = resolved.sampling_method.as_deref() {
572        args.push("--sampling-method".into());
573        args.push(method.into());
574    }
575    // Flow / instruction-edit model flags (model-level constants from the registry). Only emitted
576    // when the model declares them, so SDXL-style models are unaffected.
577    if let Some(shift) = source.cli_defaults.flow_shift {
578        args.push("--flow-shift".into());
579        args.push(shift.to_string().into());
580    }
581    if source.cli_defaults.zero_cond_t == Some(true) {
582        args.push("--qwen-image-zero-cond-t".into());
583    }
584    if source.cli_defaults.offload_to_cpu == Some(true) {
585        args.push("--offload-to-cpu".into());
586    }
587    // VRAM-saving flags that are safe on every box.
588    args.push("--diffusion-fa".into());
589    args
590}
591
592/// Point the per-job `Command`'s dynamic linker at the shared library
593/// that ships next to an auto-provisioned `sd-cli` (Linux / macOS).
594/// No-op on Windows (sibling DLLs resolve automatically) and when the
595/// resolved binary has no sibling library (operator wrapper-script
596/// installs manage their own load path).  Prepends to any inherited
597/// value so a pre-set `LD_LIBRARY_PATH` isn't clobbered.
598#[cfg_attr(coverage_nightly, coverage(off))]
599fn apply_library_path(cmd: &mut Command, sd_cli: &Path) {
600    let Some((var, dir)) = sd_provision::library_path_env(sd_cli) else {
601        return;
602    };
603    let value = match std::env::var_os(var) {
604        Some(existing) => {
605            let mut paths = vec![dir.clone()];
606            paths.extend(std::env::split_paths(&existing));
607            // `join_paths` only fails if a path contains the platform
608            // separator; fall back to our dir alone, the entry that
609            // matters for finding the sibling library.
610            std::env::join_paths(paths).unwrap_or_else(|_| dir.into_os_string())
611        }
612        None => dir.into_os_string(),
613    };
614    cmd.env(var, value);
615}
616
617/// Look up `sd-cli` in env override -> `<models_root>/bin` ->
618/// `~/.local/bin` -> `$PATH`.  The `<models_root>/bin` slot is where a
619/// self-provisioned binary lands, so the auto-provisioner can drop it
620/// next to the cached models and have the worker pick it up with no
621/// PATH fiddling.  Excluded from coverage: touches several host paths
622/// only one of which matches per host, and CI doesn't ship `sd-cli`.
623#[cfg_attr(coverage_nightly, coverage(off))]
624fn resolve_sd_cli(models_root: &Path) -> Option<PathBuf> {
625    let bin = sd_provision::binary_name();
626    if let Ok(p) = std::env::var("STUDIO_WORKER_SD_CLI") {
627        let path = PathBuf::from(p);
628        if path.is_file() {
629            return Some(path);
630        }
631    }
632    let in_models = models_root.join("bin").join(bin);
633    if in_models.is_file() {
634        return Some(in_models);
635    }
636    if let Some(home) = std::env::var_os("HOME") {
637        let candidate = PathBuf::from(home).join(".local/bin").join(bin);
638        if candidate.is_file() {
639            return Some(candidate);
640        }
641    }
642    which(bin)
643}
644
645/// `$PATH` lookup for a bare binary name.  Excluded from coverage
646/// for the same reason as `resolve_sd_cli`.
647#[cfg_attr(coverage_nightly, coverage(off))]
648fn which(bin: &str) -> Option<PathBuf> {
649    let path = std::env::var_os("PATH")?;
650    for entry in std::env::split_paths(&path) {
651        let candidate = entry.join(bin);
652        if candidate.is_file() {
653            return Some(candidate);
654        }
655    }
656    None
657}
658
659/// Pick an extension to use for the init-image tempfile that sd-cli's
660/// image loader can sniff.  Reads the trailing `.<ext>` from the URL's
661/// path (ignoring query + fragment).  Defaults to `webp` when no
662/// recognisable extension is present.
663fn init_image_extension(url: &str) -> &'static str {
664    let path = url.split(['?', '#']).next().unwrap_or(url);
665    let lower_tail = path
666        .rsplit('.')
667        .next()
668        .map(|t| t.to_ascii_lowercase())
669        .unwrap_or_default();
670    match lower_tail.as_str() {
671        "png" => "png",
672        "jpg" | "jpeg" => "jpg",
673        "webp" => "webp",
674        "bmp" => "bmp",
675        "gif" => "gif",
676        "tif" | "tiff" => "tif",
677        _ => "webp",
678    }
679}
680
681// ---------------------------------------------------------------------------
682// Tests
683// ---------------------------------------------------------------------------
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use crate::types::{ModelCliDefaults, ModelEngine, ModelFile, ModelFileRole};
689    use tempfile::tempdir;
690
691    fn fake_source(files: Vec<ModelFile>) -> ModelSource {
692        ModelSource {
693            engine: ModelEngine::SdCpp,
694            files,
695            cli_defaults: ModelCliDefaults {
696                cfg_scale: 1.0,
697                steps: 8,
698                width: 1024,
699                height: 1024,
700                sampling_method: Some("euler".to_string()),
701                ..Default::default()
702            },
703        }
704    }
705
706    #[test]
707    fn temp_file_guard_removes_every_registered_file_on_drop() {
708        let dir = tempdir().unwrap();
709        let out = dir.path().join("out.webp");
710        let init = dir.path().join("out-init.png");
711        std::fs::write(&out, b"image").unwrap();
712        std::fs::write(&init, b"init").unwrap();
713        {
714            let mut guard = TempFileGuard::new();
715            guard.push(out.clone());
716            guard.push(init.clone());
717            assert!(out.exists() && init.exists(), "files present before drop");
718        }
719        assert!(!out.exists(), "sd-cli output temp must be removed on drop");
720        assert!(!init.exists(), "init-image temp must be removed on drop");
721    }
722
723    #[test]
724    fn temp_file_guard_tolerates_a_file_that_never_materialised() {
725        // The output path is registered before sd-cli runs, so a job
726        // that fails before writing anything drops a guard pointing at
727        // a path that never existed.  That is the desired end state,
728        // not a cleanup warning.
729        let dir = tempdir().unwrap();
730        let missing = dir.path().join("never-written.webp");
731        let out = crate::test_support::capture(move || {
732            let mut guard = TempFileGuard::new();
733            guard.push(missing);
734            drop(guard);
735        });
736        assert!(
737            !out.contains("failed to remove temp file"),
738            "a never-created temp file must not warn on cleanup: {out:?}"
739        );
740    }
741
742    #[test]
743    fn remove_temp_file_deletes_an_existing_file_quietly() {
744        let dir = tempdir().unwrap();
745        let f = dir.path().join("artefact.webp");
746        std::fs::write(&f, b"bytes").unwrap();
747        let out = crate::test_support::capture({
748            let f = f.clone();
749            move || remove_temp_file(&f)
750        });
751        assert!(!f.exists(), "file should be gone after cleanup");
752        assert!(
753            !out.contains("failed to remove temp file"),
754            "the success path must not warn: {out:?}"
755        );
756    }
757
758    #[test]
759    fn remove_temp_file_ignores_an_already_missing_file() {
760        let dir = tempdir().unwrap();
761        let missing = dir.path().join("never-existed.webp");
762        let out = crate::test_support::capture(move || remove_temp_file(&missing));
763        assert!(
764            !out.contains("failed to remove temp file"),
765            "a not-found file is the desired end state, not a warning: {out:?}"
766        );
767    }
768
769    #[test]
770    fn remove_temp_file_surfaces_a_failed_removal() {
771        // Pointing the helper at a directory makes `remove_file` fail
772        // on every platform (it refuses to unlink a dir): the closest
773        // portable stand-in for a locked / permission-denied temp file.
774        let dir = tempdir().unwrap();
775        let stubborn = dir.path().join("subdir");
776        std::fs::create_dir(&stubborn).unwrap();
777        let out = crate::test_support::capture(move || remove_temp_file(&stubborn));
778        assert!(
779            out.contains("failed to remove temp file"),
780            "a failed removal must surface in the logs: {out:?}"
781        );
782        assert!(
783            out.contains("subdir"),
784            "the warning must name the offending path: {out:?}"
785        );
786        assert!(
787            out.contains("cleanup"),
788            "the warning should tag the cleanup op: {out:?}"
789        );
790    }
791
792    #[test]
793    fn file_for_role_picks_matching_file() {
794        let files = vec![
795            (ModelFileRole::DiffusionModel, PathBuf::from("/d.gguf")),
796            (ModelFileRole::Vae, PathBuf::from("/v.safetensors")),
797        ];
798        assert_eq!(
799            file_for_role(&files, ModelFileRole::DiffusionModel),
800            Some(Path::new("/d.gguf"))
801        );
802        assert_eq!(
803            file_for_role(&files, ModelFileRole::Vae),
804            Some(Path::new("/v.safetensors"))
805        );
806        assert!(file_for_role(&files, ModelFileRole::TextEncoder).is_none());
807    }
808
809    #[test]
810    fn ensure_files_skips_already_present() {
811        let dir = tempdir().unwrap();
812        let cached = dir.path().join("cached.gguf");
813        std::fs::write(&cached, b"already here").unwrap();
814        let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
815        let source = fake_source(vec![ModelFile {
816            role: ModelFileRole::DiffusionModel,
817            url: "https://example.invalid/cached.gguf".into(),
818            filename: "cached.gguf".into(),
819            approx_bytes: None,
820        }]);
821        let resolved = engine.ensure_files(&source).expect("cached file used");
822        assert_eq!(resolved.len(), 1);
823        assert_eq!(resolved[0].0, ModelFileRole::DiffusionModel);
824        assert_eq!(resolved[0].1, cached);
825        // Untouched on disk \u2014 our "download" never ran.
826        assert_eq!(std::fs::read(&cached).unwrap(), b"already here");
827    }
828
829    #[test]
830    fn dispatch_rejects_non_image_tasks() {
831        use crate::types::AudioTtsParams;
832        let dir = tempdir().unwrap();
833        let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
834        let task = Task::AudioTts(AudioTtsParams {
835            text: "hi".into(),
836            voice: "v".into(),
837            ext: "wav".into(),
838            ..Default::default()
839        });
840        let source = fake_source(vec![]);
841        let err = engine
842            .dispatch_with_source("anything", task, &source)
843            .unwrap_err();
844        assert!(err.to_string().contains("cannot serve audio_tts"));
845    }
846
847    // The legacy `dispatch_requires_model_source` test is gone: the
848    // trait signature now takes `&ModelSource` so the compiler enforces
849    // it at every call site.  No runtime fallback to police.
850
851    // -----------------------------------------------------------------
852    // Pure arg-builder tests — lock down the sd-cli invocation contract
853    // without needing the binary on the box.
854    // -----------------------------------------------------------------
855
856    fn args_to_strings(args: &[OsString]) -> Vec<String> {
857        args.iter()
858            .map(|s| s.to_string_lossy().into_owned())
859            .collect()
860    }
861
862    fn idx_after(args: &[String], flag: &str) -> Option<usize> {
863        args.iter().position(|a| a == flag).map(|i| i + 1)
864    }
865
866    #[test]
867    fn build_sdcli_args_includes_required_flags() {
868        let params = ImageParams {
869            prompt: "hello".into(),
870            width: 768,
871            height: 512,
872            steps: 20, // "caller didn't pick" → source default wins
873            ..Default::default()
874        };
875        let source = fake_source(vec![]);
876        let args = build_sdcli_args(
877            &params,
878            &source,
879            Path::new("/d.gguf"),
880            Some(Path::new("/v.safetensors")),
881            Some(Path::new("/llm.gguf")),
882            None,
883            Path::new("/tmp/out.webp"),
884            None,
885            None,
886            None,
887            false,
888        );
889        let s = args_to_strings(&args);
890        assert_eq!(s[idx_after(&s, "--diffusion-model").unwrap()], "/d.gguf");
891        assert_eq!(s[idx_after(&s, "--vae").unwrap()], "/v.safetensors");
892        assert_eq!(s[idx_after(&s, "--llm").unwrap()], "/llm.gguf");
893        assert_eq!(s[idx_after(&s, "-p").unwrap()], "hello");
894        assert_eq!(s[idx_after(&s, "-W").unwrap()], "768");
895        assert_eq!(s[idx_after(&s, "-H").unwrap()], "512");
896        // source default cfg_scale=1.0
897        assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "1");
898        // source default steps=8 wins (param.steps==20 treated as default)
899        assert_eq!(s[idx_after(&s, "--steps").unwrap()], "8");
900        assert_eq!(s[idx_after(&s, "--sampling-method").unwrap()], "euler");
901        assert_eq!(s[idx_after(&s, "-o").unwrap()], "/tmp/out.webp");
902        assert!(s.contains(&"--diffusion-fa".to_string()));
903        // Never includes init-only flags when no init image present.
904        assert!(!s.contains(&"--init-img".to_string()));
905        assert!(!s.contains(&"--strength".to_string()));
906    }
907
908    #[test]
909    fn build_sdcli_args_includes_negative_prompt_when_set() {
910        let params = ImageParams {
911            prompt: "hi".into(),
912            negative_prompt: Some("text, watermark, low quality".into()),
913            ..Default::default()
914        };
915        let source = fake_source(vec![]);
916        let args = build_sdcli_args(
917            &params,
918            &source,
919            Path::new("/d.gguf"),
920            None,
921            None,
922            None,
923            Path::new("/tmp/out.webp"),
924            None,
925            None,
926            None,
927            false,
928        );
929        let s = args_to_strings(&args);
930        assert_eq!(
931            s[idx_after(&s, "--negative-prompt").unwrap()],
932            "text, watermark, low quality"
933        );
934    }
935
936    #[test]
937    fn build_sdcli_args_omits_negative_prompt_when_empty_string() {
938        let params = ImageParams {
939            prompt: "hi".into(),
940            negative_prompt: Some(String::new()),
941            ..Default::default()
942        };
943        let source = fake_source(vec![]);
944        let args = build_sdcli_args(
945            &params,
946            &source,
947            Path::new("/d.gguf"),
948            None,
949            None,
950            None,
951            Path::new("/tmp/out.webp"),
952            None,
953            None,
954            None,
955            false,
956        );
957        let s = args_to_strings(&args);
958        assert!(!s.contains(&"--negative-prompt".to_string()));
959    }
960
961    #[test]
962    fn build_sdcli_args_includes_init_image_and_strength() {
963        let params = ImageParams {
964            prompt: "hi".into(),
965            denoise: Some(0.55),
966            ..Default::default()
967        };
968        let source = fake_source(vec![]);
969        let args = build_sdcli_args(
970            &params,
971            &source,
972            Path::new("/d.gguf"),
973            None,
974            None,
975            None,
976            Path::new("/tmp/out.webp"),
977            Some(Path::new("/tmp/init.webp")),
978            None,
979            None,
980            false,
981        );
982        let s = args_to_strings(&args);
983        assert_eq!(s[idx_after(&s, "--init-img").unwrap()], "/tmp/init.webp");
984        assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.55");
985        // No mask supplied → no inpaint flag.
986        assert!(!s.contains(&"--mask".to_string()));
987    }
988
989    #[test]
990    fn build_sdcli_args_includes_mask_for_inpaint() {
991        let params = ImageParams {
992            prompt: "remove the tree".into(),
993            denoise: Some(0.8),
994            ..Default::default()
995        };
996        let source = fake_source(vec![]);
997        let args = build_sdcli_args(
998            &params,
999            &source,
1000            Path::new("/d.gguf"),
1001            None,
1002            None,
1003            None,
1004            Path::new("/tmp/out.webp"),
1005            Some(Path::new("/tmp/init.webp")),
1006            Some(Path::new("/tmp/mask.png")),
1007            None,
1008            false,
1009        );
1010        let s = args_to_strings(&args);
1011        assert_eq!(s[idx_after(&s, "--init-img").unwrap()], "/tmp/init.webp");
1012        assert_eq!(s[idx_after(&s, "--mask").unwrap()], "/tmp/mask.png");
1013        assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.8");
1014    }
1015
1016    #[test]
1017    fn build_sdcli_args_uses_model_flag_for_full_checkpoint() {
1018        let params = ImageParams {
1019            prompt: "hi".into(),
1020            ..Default::default()
1021        };
1022        let source = fake_source(vec![]);
1023        let args = build_sdcli_args(
1024            &params,
1025            &source,
1026            Path::new("/checkpoint.safetensors"),
1027            Some(Path::new("/v.safetensors")),
1028            None,
1029            None,
1030            Path::new("/tmp/out.webp"),
1031            None,
1032            None,
1033            None,
1034            true,
1035        );
1036        let s = args_to_strings(&args);
1037        // A full checkpoint loads via -m/--model, not --diffusion-model.
1038        assert_eq!(
1039            s[idx_after(&s, "--model").unwrap()],
1040            "/checkpoint.safetensors"
1041        );
1042        assert!(!s.contains(&"--diffusion-model".to_string()));
1043    }
1044
1045    #[test]
1046    fn build_sdcli_args_defaults_denoise_when_init_image_present_but_denoise_none() {
1047        let params = ImageParams {
1048            prompt: "hi".into(),
1049            denoise: None,
1050            ..Default::default()
1051        };
1052        let source = fake_source(vec![]);
1053        let args = build_sdcli_args(
1054            &params,
1055            &source,
1056            Path::new("/d.gguf"),
1057            None,
1058            None,
1059            None,
1060            Path::new("/tmp/out.webp"),
1061            Some(Path::new("/tmp/init.webp")),
1062            None,
1063            None,
1064            false,
1065        );
1066        let s = args_to_strings(&args);
1067        assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.75");
1068    }
1069
1070    #[test]
1071    fn build_sdcli_args_per_job_cfg_scale_overrides_model_default() {
1072        let params = ImageParams {
1073            prompt: "hi".into(),
1074            cfg_scale: Some(7.5),
1075            ..Default::default()
1076        };
1077        let source = fake_source(vec![]);
1078        let args = build_sdcli_args(
1079            &params,
1080            &source,
1081            Path::new("/d.gguf"),
1082            None,
1083            None,
1084            None,
1085            Path::new("/tmp/out.webp"),
1086            None,
1087            None,
1088            None,
1089            false,
1090        );
1091        let s = args_to_strings(&args);
1092        assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "7.5");
1093    }
1094
1095    #[test]
1096    fn build_sdcli_args_per_job_sampling_method_overrides_model_default() {
1097        let params = ImageParams {
1098            prompt: "hi".into(),
1099            sampling_method: Some("dpm++2m".into()),
1100            ..Default::default()
1101        };
1102        let source = fake_source(vec![]);
1103        let args = build_sdcli_args(
1104            &params,
1105            &source,
1106            Path::new("/d.gguf"),
1107            None,
1108            None,
1109            None,
1110            Path::new("/tmp/out.webp"),
1111            None,
1112            None,
1113            None,
1114            false,
1115        );
1116        let s = args_to_strings(&args);
1117        assert_eq!(s[idx_after(&s, "--sampling-method").unwrap()], "dpm++2m");
1118    }
1119
1120    #[test]
1121    fn build_sdcli_args_per_job_steps_overrides_when_non_default() {
1122        let params = ImageParams {
1123            prompt: "hi".into(),
1124            steps: 30, // != 20 → treat as caller override
1125            ..Default::default()
1126        };
1127        let source = fake_source(vec![]);
1128        let args = build_sdcli_args(
1129            &params,
1130            &source,
1131            Path::new("/d.gguf"),
1132            None,
1133            None,
1134            None,
1135            Path::new("/tmp/out.webp"),
1136            None,
1137            None,
1138            None,
1139            false,
1140        );
1141        let s = args_to_strings(&args);
1142        assert_eq!(s[idx_after(&s, "--steps").unwrap()], "30");
1143    }
1144
1145    #[test]
1146    fn build_sdcli_args_seed_included_when_set() {
1147        let params = ImageParams {
1148            prompt: "hi".into(),
1149            seed: Some(42),
1150            ..Default::default()
1151        };
1152        let source = fake_source(vec![]);
1153        let args = build_sdcli_args(
1154            &params,
1155            &source,
1156            Path::new("/d.gguf"),
1157            None,
1158            None,
1159            None,
1160            Path::new("/tmp/out.webp"),
1161            None,
1162            None,
1163            None,
1164            false,
1165        );
1166        let s = args_to_strings(&args);
1167        assert_eq!(s[idx_after(&s, "--seed").unwrap()], "42");
1168    }
1169
1170    /// A model source carrying the Qwen-Image-Edit flow flags.
1171    fn qwen_edit_source() -> ModelSource {
1172        ModelSource {
1173            engine: ModelEngine::SdCpp,
1174            files: vec![],
1175            cli_defaults: ModelCliDefaults {
1176                cfg_scale: 4.0,
1177                steps: 20,
1178                width: 1024,
1179                height: 1024,
1180                sampling_method: Some("euler".to_string()),
1181                flow_shift: Some(3.0),
1182                zero_cond_t: Some(true),
1183                offload_to_cpu: Some(true),
1184            },
1185        }
1186    }
1187
1188    #[test]
1189    fn build_sdcli_args_reference_mode_for_instruction_edit() {
1190        let params = ImageParams {
1191            prompt: "add a red beach ball".into(),
1192            denoise: Some(0.9),
1193            ..Default::default()
1194        };
1195        let source = qwen_edit_source();
1196        let args = build_sdcli_args(
1197            &params,
1198            &source,
1199            Path::new("/qwen.gguf"),
1200            Some(Path::new("/vae.safetensors")),
1201            Some(Path::new("/llm.gguf")),
1202            Some(Path::new("/mmproj.gguf")),
1203            Path::new("/tmp/out.webp"),
1204            None,
1205            Some(Path::new("/tmp/mask.png")),
1206            Some(Path::new("/tmp/ref.webp")),
1207            false,
1208        );
1209        let s = args_to_strings(&args);
1210        // Reference mode: `-r` set, a `--mask` constrains the edit region, and the img2img-only
1211        // `--init-img` / `--strength` flags are suppressed.
1212        assert_eq!(s[idx_after(&s, "-r").unwrap()], "/tmp/ref.webp");
1213        assert_eq!(s[idx_after(&s, "--mask").unwrap()], "/tmp/mask.png");
1214        assert!(!s.contains(&"--init-img".to_string()));
1215        assert!(!s.contains(&"--strength".to_string()));
1216        // Vision encoder + Qwen flow flags emitted.
1217        assert_eq!(s[idx_after(&s, "--llm_vision").unwrap()], "/mmproj.gguf");
1218        assert_eq!(s[idx_after(&s, "--flow-shift").unwrap()], "3");
1219        assert!(s.contains(&"--qwen-image-zero-cond-t".to_string()));
1220        assert!(s.contains(&"--offload-to-cpu".to_string()));
1221    }
1222
1223    #[test]
1224    fn build_sdcli_args_omits_qwen_flags_for_plain_model() {
1225        let params = ImageParams {
1226            prompt: "hi".into(),
1227            ..Default::default()
1228        };
1229        // fake_source has no flow_shift / zero_cond_t / offload_to_cpu.
1230        let source = fake_source(vec![]);
1231        let args = build_sdcli_args(
1232            &params,
1233            &source,
1234            Path::new("/d.gguf"),
1235            None,
1236            None,
1237            None,
1238            Path::new("/tmp/out.webp"),
1239            None,
1240            None,
1241            None,
1242            false,
1243        );
1244        let s = args_to_strings(&args);
1245        assert!(!s.contains(&"--flow-shift".to_string()));
1246        assert!(!s.contains(&"--qwen-image-zero-cond-t".to_string()));
1247        assert!(!s.contains(&"--offload-to-cpu".to_string()));
1248        assert!(!s.contains(&"--llm_vision".to_string()));
1249        assert!(!s.contains(&"-r".to_string()));
1250    }
1251
1252    #[test]
1253    fn capabilities_advertises_only_image_kind() {
1254        let dir = tempdir().unwrap();
1255        let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
1256        let caps = engine.capabilities();
1257        assert!(caps
1258            .supported_models_per_kind
1259            .contains_key(&TaskKind::Image));
1260        assert_eq!(caps.supported_models_per_kind.len(), 1);
1261    }
1262
1263    #[test]
1264    fn init_image_extension_reads_url_tail() {
1265        assert_eq!(init_image_extension("https://x/y/latest.webp"), "webp");
1266        assert_eq!(init_image_extension("https://x/y/latest.PNG"), "png");
1267        assert_eq!(init_image_extension("https://x/y/latest.jpg"), "jpg");
1268        assert_eq!(init_image_extension("https://x/y/latest.jpeg"), "jpg");
1269        // Query strings + fragments don't trick the parser.
1270        assert_eq!(
1271            init_image_extension("https://x/y/latest.webp?v=42&t=now"),
1272            "webp"
1273        );
1274        assert_eq!(init_image_extension("https://x/y/latest.webp#frag"), "webp");
1275        // Unknown extension falls back to webp.
1276        assert_eq!(
1277            init_image_extension("https://x/y/latest.unknownext"),
1278            "webp"
1279        );
1280        assert_eq!(init_image_extension("https://x/y/no-ext"), "webp");
1281    }
1282}