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