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