1use 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
46const STEPS_FALLBACK: u32 = 8;
51
52pub struct SdCppEngine {
60 sd_cli: Mutex<Option<PathBuf>>,
61 models_root: PathBuf,
62}
63
64impl SdCppEngine {
65 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 #[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 #[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 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 #[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 #[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 let sd_cli = self.ensure_sd_cli()?;
225 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 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 let out_ext = normalize_output_ext(¶ms.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 let mut temp_files = TempFileGuard::new();
270 temp_files.push(out_path.clone());
271
272 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 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 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 ¶ms,
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
404fn 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 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 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
469fn 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
484fn 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 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#[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#[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 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((¶ms.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 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 let strength = params.denoise.unwrap_or(0.75);
615 args.push("--strength".into());
616 args.push(strength.to_string().into());
617 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 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 args.push("--diffusion-fa".into());
663 args
664}
665
666#[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 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#[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#[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#[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
727fn 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#[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 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 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 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, ..Default::default()
861 };
862 let source = fake_source(vec![]);
863 let args = build_sdcli_args(
864 ¶ms,
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 assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "1");
885 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 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 ¶ms,
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 ¶ms,
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 ¶ms,
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 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 ¶ms,
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 ¶ms,
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 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 ¶ms,
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 ¶ms,
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 ¶ms,
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, ..Default::default()
1113 };
1114 let source = fake_source(vec![]);
1115 let args = build_sdcli_args(
1116 ¶ms,
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 ¶ms,
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 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 ¶ms,
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 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 assert_eq!(s[idx_after(&s, "--llm_vision").unwrap()], "/mmproj.gguf");
1206 assert_eq!(s[idx_after(&s, "--flow-shift").unwrap()], "3");
1207 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 ¶ms,
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 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 let source = fake_source(vec![]);
1253 let args = build_sdcli_args(
1254 ¶ms,
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 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 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}