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))]
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 #[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 #[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 let sd_cli = self.ensure_sd_cli()?;
161 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 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 let out_ext = normalize_output_ext(¶ms.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 let mut temp_files = TempFileGuard::new();
206 temp_files.push(out_path.clone());
207
208 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 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 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 ¶ms,
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
340fn 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 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 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
405fn 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
420fn 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 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#[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#[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 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((¶ms.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 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 let strength = params.denoise.unwrap_or(0.75);
551 args.push("--strength".into());
552 args.push(strength.to_string().into());
553 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 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 args.push("--diffusion-fa".into());
591 args
592}
593
594#[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 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#[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#[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
661fn 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#[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 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 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 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, ..Default::default()
795 };
796 let source = fake_source(vec![]);
797 let args = build_sdcli_args(
798 ¶ms,
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 assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "1");
819 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 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 ¶ms,
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 ¶ms,
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 ¶ms,
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 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 ¶ms,
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 ¶ms,
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 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 ¶ms,
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 ¶ms,
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 ¶ms,
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, ..Default::default()
1047 };
1048 let source = fake_source(vec![]);
1049 let args = build_sdcli_args(
1050 ¶ms,
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 ¶ms,
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 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 ¶ms,
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 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 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 let source = fake_source(vec![]);
1154 let args = build_sdcli_args(
1155 ¶ms,
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 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 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}