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))]
130 fn ensure_files(&self, source: &ModelSource) -> Result<Vec<(ModelFileRole, PathBuf)>> {
131 let mut out = Vec::with_capacity(source.files.len());
132 for file in &source.files {
133 let local = download::ensure_file(&self.models_root, file)?;
134 out.push((file.role, local));
135 }
136 Ok(out)
137 }
138
139 #[cfg_attr(coverage_nightly, coverage(off))]
145 fn dispatch_image(
146 &self,
147 model: &str,
148 params: ImageParams,
149 source: &ModelSource,
150 ) -> Result<TaskResult> {
151 let sd_cli = self.ensure_sd_cli()?;
155 if let Err(e) = sd_provision::vulkan_runtime_status() {
160 warn!(
161 target: TRACE_TARGET,
162 op = "preflight",
163 model,
164 error = %e,
165 "GPU runtime missing; refusing image job"
166 );
167 return Err(e);
168 }
169 let files = self.ensure_files(source)?;
170 let diffusion_only = file_for_role(&files, ModelFileRole::DiffusionModel);
174 let full_checkpoint = diffusion_only.is_none();
175 let diffusion_model = diffusion_only
176 .or_else(|| file_for_role(&files, ModelFileRole::Model))
177 .ok_or_else(|| anyhow!("modelSource has no diffusion-model / model file"))?;
178 let vae = file_for_role(&files, ModelFileRole::Vae);
179 let text_encoder = file_for_role(&files, ModelFileRole::TextEncoder);
180 let text_encoder_vision = file_for_role(&files, ModelFileRole::TextEncoderVision);
181
182 let out_dir = std::env::temp_dir().join("studio-worker-sdcpp");
183 std::fs::create_dir_all(&out_dir)
184 .with_context(|| format!("creating sdcpp output dir {}", out_dir.display()))?;
185 let stem = format!(
186 "out-{}-{}",
187 std::process::id(),
188 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
189 );
190 let out_ext = normalize_output_ext(¶ms.ext);
193 debug!(target: TRACE_TARGET, op = "dispatch", requested_ext = %params.ext, out_ext = %out_ext, "resolved output extension");
194 let out_path = out_dir.join(format!("{stem}.{out_ext}"));
195
196 let mut temp_files = TempFileGuard::new();
200 temp_files.push(out_path.clone());
201
202 let init_img_path = match params.init_image_url.as_deref() {
211 Some(url) if !url.is_empty() => {
212 let ext = init_image_extension(url);
213 let init_path = out_dir.join(format!("{stem}-init.{ext}"));
214 download::download_file(url, &init_path).with_context(|| {
215 format!("downloading init image {} -> {}", url, init_path.display())
216 })?;
217 temp_files.push(init_path.clone());
218 let usable = download::ensure_correct_image_extension(&init_path)?;
219 if usable != init_path {
220 temp_files.push(usable.clone());
221 }
222 Some(usable)
223 }
224 _ => None,
225 };
226
227 let has_base = init_img_path.is_some() || params.ref_image_url.as_deref().is_some();
231 let mask_path = match (has_base, params.mask_url.as_deref()) {
232 (true, Some(url)) if !url.is_empty() => {
233 let ext = init_image_extension(url);
234 let path = out_dir.join(format!("{stem}-mask.{ext}"));
235 download::download_file(url, &path)
236 .with_context(|| format!("downloading mask {} -> {}", url, path.display()))?;
237 temp_files.push(path.clone());
238 let usable = download::ensure_correct_image_extension(&path)?;
239 if usable != path {
240 temp_files.push(usable.clone());
241 }
242 Some(usable)
243 }
244 _ => None,
245 };
246
247 let ref_img_path = match params.ref_image_url.as_deref() {
250 Some(url) if !url.is_empty() => {
251 let ext = init_image_extension(url);
252 let path = out_dir.join(format!("{stem}-ref.{ext}"));
253 download::download_file(url, &path).with_context(|| {
254 format!("downloading reference image {} -> {}", url, path.display())
255 })?;
256 temp_files.push(path.clone());
257 let usable = download::ensure_correct_image_extension(&path)?;
258 if usable != path {
259 temp_files.push(usable.clone());
260 }
261 Some(usable)
262 }
263 _ => None,
264 };
265
266 let args = build_sdcli_args(
267 ¶ms,
268 source,
269 diffusion_model,
270 vae,
271 text_encoder,
272 text_encoder_vision,
273 &out_path,
274 init_img_path.as_deref(),
275 mask_path.as_deref(),
276 ref_img_path.as_deref(),
277 full_checkpoint,
278 );
279 let mut cmd = Command::new(&sd_cli);
280 cmd.args(&args);
281 apply_library_path(&mut cmd, &sd_cli);
282
283 debug!(
284 target: TRACE_TARGET,
285 op = "spawn",
286 sd_cli = %sd_cli.display(),
287 model,
288 i2i = init_img_path.is_some(),
289 arg_count = args.len(),
290 "running sd-cli"
291 );
292
293 let started = Instant::now();
294 let output = cmd
295 .output()
296 .with_context(|| format!("running {}", sd_cli.display()))?;
297 let elapsed_ms = started.elapsed().as_millis() as u64;
298 if !output.status.success() {
299 let stderr = String::from_utf8_lossy(&output.stderr);
300 warn!(
301 target: TRACE_TARGET,
302 op = "spawn",
303 model,
304 elapsed_ms,
305 exit = ?output.status.code(),
306 stderr = %stderr,
307 "sd-cli failed"
308 );
309 bail!(
310 "sd-cli exited with {:?}: {}",
311 output.status.code(),
312 stderr.lines().last().unwrap_or("(no stderr)")
313 );
314 }
315
316 let bytes = std::fs::read(&out_path)
317 .with_context(|| format!("reading sd-cli output at {}", out_path.display()))?;
318 info!(
319 target: TRACE_TARGET,
320 op = "dispatch",
321 model,
322 elapsed_ms,
323 bytes = bytes.len(),
324 "ok"
325 );
326
327 Ok(TaskResult::Image {
328 bytes,
329 ext: out_ext,
330 })
331 }
332}
333
334fn normalize_output_ext(ext: &str) -> String {
337 match ext.trim().to_ascii_lowercase().as_str() {
338 "png" => "png",
339 "jpg" | "jpeg" => "jpg",
340 "bmp" => "bmp",
341 _ => "webp",
342 }
343 .to_string()
344}
345
346impl Engine for SdCppEngine {
347 fn name(&self) -> &'static str {
348 "sdcpp"
349 }
350
351 fn capabilities(&self) -> EngineCapabilities {
352 let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
358 map.insert(TaskKind::Image, vec!["sd-cpp:*".to_string()]);
359 EngineCapabilities {
360 supported_models_per_kind: map,
361 }
362 }
363
364 fn dispatch(&self, _model: &str, _task: Task) -> Result<TaskResult> {
365 bail!(
366 "sdcpp engine requires a ModelSource on the offer; legacy push-based offers \
367 (no modelSource) cannot be served - re-promote the job through the studio"
368 )
369 }
370
371 fn dispatch_with_source(
372 &self,
373 model: &str,
374 task: Task,
375 source: &ModelSource,
376 ) -> Result<TaskResult> {
377 match task {
378 Task::Image(p) => self.dispatch_image(model, p, source),
379 other => {
380 let kind = other.kind();
386 warn!(
387 target: TRACE_TARGET,
388 op = "dispatch",
389 model,
390 kind = kind.as_str(),
391 "sdcpp engine only serves image jobs"
392 );
393 Err(crate::engine::UnsupportedTask::new("sdcpp", kind).into())
394 }
395 }
396 }
397}
398
399fn file_for_role(files: &[(ModelFileRole, PathBuf)], role: ModelFileRole) -> Option<&Path> {
408 files
409 .iter()
410 .find(|(r, _)| *r == role)
411 .map(|(_, p)| p.as_path())
412}
413
414fn resolve_image_args(params: &ImageParams, source: &ModelSource) -> ResolvedImageArgs {
419 let width = if params.width > 0 {
420 params.width
421 } else if source.cli_defaults.width > 0 {
422 source.cli_defaults.width
423 } else {
424 1024
425 };
426 let height = if params.height > 0 {
427 params.height
428 } else if source.cli_defaults.height > 0 {
429 source.cli_defaults.height
430 } else {
431 1024
432 };
433 let steps = if params.steps > 0 && params.steps != 20 {
437 params.steps
438 } else if source.cli_defaults.steps > 0 {
439 source.cli_defaults.steps
440 } else {
441 STEPS_FALLBACK
442 };
443 let source_cfg = if source.cli_defaults.cfg_scale > 0.0 {
444 source.cli_defaults.cfg_scale
445 } else {
446 1.0
447 };
448 let cfg_scale = params.cfg_scale.filter(|v| *v > 0.0).unwrap_or(source_cfg);
449 let sampling_method = params
450 .sampling_method
451 .clone()
452 .or_else(|| source.cli_defaults.sampling_method.clone());
453 ResolvedImageArgs {
454 width,
455 height,
456 steps,
457 cfg_scale,
458 sampling_method,
459 }
460}
461
462#[derive(Debug, Clone, PartialEq)]
464struct ResolvedImageArgs {
465 width: u32,
466 height: u32,
467 steps: u32,
468 cfg_scale: f32,
469 sampling_method: Option<String>,
470}
471
472#[allow(clippy::too_many_arguments)]
479fn build_sdcli_args(
480 params: &ImageParams,
481 source: &ModelSource,
482 diffusion_model: &Path,
483 vae: Option<&Path>,
484 text_encoder: Option<&Path>,
485 text_encoder_vision: Option<&Path>,
486 out_path: &Path,
487 init_img_path: Option<&Path>,
488 mask_path: Option<&Path>,
489 ref_img_path: Option<&Path>,
490 full_checkpoint: bool,
491) -> Vec<OsString> {
492 let resolved = resolve_image_args(params, source);
493 let mut args: Vec<OsString> = Vec::with_capacity(32);
494
495 args.push(
498 if full_checkpoint {
499 "--model"
500 } else {
501 "--diffusion-model"
502 }
503 .into(),
504 );
505 args.push(diffusion_model.into());
506 if let Some(p) = vae {
507 args.push("--vae".into());
508 args.push(p.into());
509 }
510 if let Some(p) = text_encoder {
511 args.push("--llm".into());
512 args.push(p.into());
513 }
514 if let Some(p) = text_encoder_vision {
515 args.push("--llm_vision".into());
516 args.push(p.into());
517 }
518 args.push("-p".into());
519 args.push((¶ms.prompt as &str).into());
520 if let Some(neg) = params.negative_prompt.as_deref() {
521 if !neg.is_empty() {
522 args.push("--negative-prompt".into());
523 args.push(neg.into());
524 }
525 }
526 if let Some(reference) = ref_img_path {
527 args.push("-r".into());
533 args.push(reference.into());
534 if let Some(mask) = mask_path {
535 args.push("--mask".into());
536 args.push(mask.into());
537 }
538 } else if let Some(init) = init_img_path {
539 args.push("--init-img".into());
540 args.push(init.into());
541 let strength = params.denoise.unwrap_or(0.75);
545 args.push("--strength".into());
546 args.push(strength.to_string().into());
547 if let Some(mask) = mask_path {
549 args.push("--mask".into());
550 args.push(mask.into());
551 }
552 }
553 args.push("--cfg-scale".into());
554 args.push(resolved.cfg_scale.to_string().into());
555 args.push("--steps".into());
556 args.push(resolved.steps.to_string().into());
557 args.push("-W".into());
558 args.push(resolved.width.to_string().into());
559 args.push("-H".into());
560 args.push(resolved.height.to_string().into());
561 args.push("-o".into());
562 args.push(out_path.into());
563 if let Some(seed) = params.seed {
564 args.push("--seed".into());
565 args.push(seed.to_string().into());
566 }
567 if let Some(method) = resolved.sampling_method.as_deref() {
568 args.push("--sampling-method".into());
569 args.push(method.into());
570 }
571 if let Some(shift) = source.cli_defaults.flow_shift {
574 args.push("--flow-shift".into());
575 args.push(shift.to_string().into());
576 }
577 if source.cli_defaults.zero_cond_t == Some(true) {
578 args.push("--qwen-image-zero-cond-t".into());
579 }
580 if source.cli_defaults.offload_to_cpu == Some(true) {
581 args.push("--offload-to-cpu".into());
582 }
583 args.push("--diffusion-fa".into());
585 args
586}
587
588#[cfg_attr(coverage_nightly, coverage(off))]
595fn apply_library_path(cmd: &mut Command, sd_cli: &Path) {
596 let Some((var, dir)) = sd_provision::library_path_env(sd_cli) else {
597 return;
598 };
599 let value = match std::env::var_os(var) {
600 Some(existing) => {
601 let mut paths = vec![dir.clone()];
602 paths.extend(std::env::split_paths(&existing));
603 std::env::join_paths(paths).unwrap_or_else(|_| dir.into_os_string())
607 }
608 None => dir.into_os_string(),
609 };
610 cmd.env(var, value);
611}
612
613#[cfg_attr(coverage_nightly, coverage(off))]
620fn resolve_sd_cli(models_root: &Path) -> Option<PathBuf> {
621 let bin = sd_provision::binary_name();
622 if let Ok(p) = std::env::var("STUDIO_WORKER_SD_CLI") {
623 let path = PathBuf::from(p);
624 if path.is_file() {
625 return Some(path);
626 }
627 }
628 let in_models = models_root.join("bin").join(bin);
629 if in_models.is_file() {
630 return Some(in_models);
631 }
632 if let Some(home) = std::env::var_os("HOME") {
633 let candidate = PathBuf::from(home).join(".local/bin").join(bin);
634 if candidate.is_file() {
635 return Some(candidate);
636 }
637 }
638 which(bin)
639}
640
641#[cfg_attr(coverage_nightly, coverage(off))]
644fn which(bin: &str) -> Option<PathBuf> {
645 let path = std::env::var_os("PATH")?;
646 for entry in std::env::split_paths(&path) {
647 let candidate = entry.join(bin);
648 if candidate.is_file() {
649 return Some(candidate);
650 }
651 }
652 None
653}
654
655fn init_image_extension(url: &str) -> &'static str {
660 let path = url.split(['?', '#']).next().unwrap_or(url);
661 let lower_tail = path
662 .rsplit('.')
663 .next()
664 .map(|t| t.to_ascii_lowercase())
665 .unwrap_or_default();
666 match lower_tail.as_str() {
667 "png" => "png",
668 "jpg" | "jpeg" => "jpg",
669 "webp" => "webp",
670 "bmp" => "bmp",
671 "gif" => "gif",
672 "tif" | "tiff" => "tif",
673 _ => "webp",
674 }
675}
676
677#[cfg(test)]
682mod tests {
683 use super::*;
684 use crate::types::{ModelCliDefaults, ModelEngine, ModelFile, ModelFileRole};
685 use tempfile::tempdir;
686
687 fn fake_source(files: Vec<ModelFile>) -> ModelSource {
688 ModelSource {
689 engine: ModelEngine::SdCpp,
690 files,
691 cli_defaults: ModelCliDefaults {
692 cfg_scale: 1.0,
693 steps: 8,
694 width: 1024,
695 height: 1024,
696 sampling_method: Some("euler".to_string()),
697 ..Default::default()
698 },
699 }
700 }
701
702 #[test]
703 fn file_for_role_picks_matching_file() {
704 let files = vec![
705 (ModelFileRole::DiffusionModel, PathBuf::from("/d.gguf")),
706 (ModelFileRole::Vae, PathBuf::from("/v.safetensors")),
707 ];
708 assert_eq!(
709 file_for_role(&files, ModelFileRole::DiffusionModel),
710 Some(Path::new("/d.gguf"))
711 );
712 assert_eq!(
713 file_for_role(&files, ModelFileRole::Vae),
714 Some(Path::new("/v.safetensors"))
715 );
716 assert!(file_for_role(&files, ModelFileRole::TextEncoder).is_none());
717 }
718
719 #[test]
720 fn ensure_files_skips_already_present() {
721 let dir = tempdir().unwrap();
722 let cached = dir.path().join("cached.gguf");
723 std::fs::write(&cached, b"already here").unwrap();
724 let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
725 let source = fake_source(vec![ModelFile {
726 role: ModelFileRole::DiffusionModel,
727 url: "https://example.invalid/cached.gguf".into(),
728 filename: "cached.gguf".into(),
729 approx_bytes: None,
730 sha256: None,
731 }]);
732 let resolved = engine.ensure_files(&source).expect("cached file used");
733 assert_eq!(resolved.len(), 1);
734 assert_eq!(resolved[0].0, ModelFileRole::DiffusionModel);
735 assert_eq!(resolved[0].1, cached);
736 assert_eq!(std::fs::read(&cached).unwrap(), b"already here");
738 }
739
740 #[test]
741 fn dispatch_rejects_non_image_tasks() {
742 use crate::types::AudioTtsParams;
743 let dir = tempdir().unwrap();
744 let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
745 let task = Task::AudioTts(AudioTtsParams {
746 text: "hi".into(),
747 voice: "v".into(),
748 ext: "wav".into(),
749 ..Default::default()
750 });
751 let source = fake_source(vec![]);
752 let err = engine
753 .dispatch_with_source("anything", task, &source)
754 .unwrap_err();
755 assert!(err.to_string().contains("cannot serve audio_tts"));
756 }
757
758 fn args_to_strings(args: &[OsString]) -> Vec<String> {
768 args.iter()
769 .map(|s| s.to_string_lossy().into_owned())
770 .collect()
771 }
772
773 fn idx_after(args: &[String], flag: &str) -> Option<usize> {
774 args.iter().position(|a| a == flag).map(|i| i + 1)
775 }
776
777 #[test]
778 fn build_sdcli_args_includes_required_flags() {
779 let params = ImageParams {
780 prompt: "hello".into(),
781 width: 768,
782 height: 512,
783 steps: 20, ..Default::default()
785 };
786 let source = fake_source(vec![]);
787 let args = build_sdcli_args(
788 ¶ms,
789 &source,
790 Path::new("/d.gguf"),
791 Some(Path::new("/v.safetensors")),
792 Some(Path::new("/llm.gguf")),
793 None,
794 Path::new("/tmp/out.webp"),
795 None,
796 None,
797 None,
798 false,
799 );
800 let s = args_to_strings(&args);
801 assert_eq!(s[idx_after(&s, "--diffusion-model").unwrap()], "/d.gguf");
802 assert_eq!(s[idx_after(&s, "--vae").unwrap()], "/v.safetensors");
803 assert_eq!(s[idx_after(&s, "--llm").unwrap()], "/llm.gguf");
804 assert_eq!(s[idx_after(&s, "-p").unwrap()], "hello");
805 assert_eq!(s[idx_after(&s, "-W").unwrap()], "768");
806 assert_eq!(s[idx_after(&s, "-H").unwrap()], "512");
807 assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "1");
809 assert_eq!(s[idx_after(&s, "--steps").unwrap()], "8");
811 assert_eq!(s[idx_after(&s, "--sampling-method").unwrap()], "euler");
812 assert_eq!(s[idx_after(&s, "-o").unwrap()], "/tmp/out.webp");
813 assert!(s.contains(&"--diffusion-fa".to_string()));
814 assert!(!s.contains(&"--init-img".to_string()));
816 assert!(!s.contains(&"--strength".to_string()));
817 }
818
819 #[test]
820 fn build_sdcli_args_includes_negative_prompt_when_set() {
821 let params = ImageParams {
822 prompt: "hi".into(),
823 negative_prompt: Some("text, watermark, low quality".into()),
824 ..Default::default()
825 };
826 let source = fake_source(vec![]);
827 let args = build_sdcli_args(
828 ¶ms,
829 &source,
830 Path::new("/d.gguf"),
831 None,
832 None,
833 None,
834 Path::new("/tmp/out.webp"),
835 None,
836 None,
837 None,
838 false,
839 );
840 let s = args_to_strings(&args);
841 assert_eq!(
842 s[idx_after(&s, "--negative-prompt").unwrap()],
843 "text, watermark, low quality"
844 );
845 }
846
847 #[test]
848 fn build_sdcli_args_omits_negative_prompt_when_empty_string() {
849 let params = ImageParams {
850 prompt: "hi".into(),
851 negative_prompt: Some(String::new()),
852 ..Default::default()
853 };
854 let source = fake_source(vec![]);
855 let args = build_sdcli_args(
856 ¶ms,
857 &source,
858 Path::new("/d.gguf"),
859 None,
860 None,
861 None,
862 Path::new("/tmp/out.webp"),
863 None,
864 None,
865 None,
866 false,
867 );
868 let s = args_to_strings(&args);
869 assert!(!s.contains(&"--negative-prompt".to_string()));
870 }
871
872 #[test]
873 fn build_sdcli_args_includes_init_image_and_strength() {
874 let params = ImageParams {
875 prompt: "hi".into(),
876 denoise: Some(0.55),
877 ..Default::default()
878 };
879 let source = fake_source(vec![]);
880 let args = build_sdcli_args(
881 ¶ms,
882 &source,
883 Path::new("/d.gguf"),
884 None,
885 None,
886 None,
887 Path::new("/tmp/out.webp"),
888 Some(Path::new("/tmp/init.webp")),
889 None,
890 None,
891 false,
892 );
893 let s = args_to_strings(&args);
894 assert_eq!(s[idx_after(&s, "--init-img").unwrap()], "/tmp/init.webp");
895 assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.55");
896 assert!(!s.contains(&"--mask".to_string()));
898 }
899
900 #[test]
901 fn build_sdcli_args_includes_mask_for_inpaint() {
902 let params = ImageParams {
903 prompt: "remove the tree".into(),
904 denoise: Some(0.8),
905 ..Default::default()
906 };
907 let source = fake_source(vec![]);
908 let args = build_sdcli_args(
909 ¶ms,
910 &source,
911 Path::new("/d.gguf"),
912 None,
913 None,
914 None,
915 Path::new("/tmp/out.webp"),
916 Some(Path::new("/tmp/init.webp")),
917 Some(Path::new("/tmp/mask.png")),
918 None,
919 false,
920 );
921 let s = args_to_strings(&args);
922 assert_eq!(s[idx_after(&s, "--init-img").unwrap()], "/tmp/init.webp");
923 assert_eq!(s[idx_after(&s, "--mask").unwrap()], "/tmp/mask.png");
924 assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.8");
925 }
926
927 #[test]
928 fn build_sdcli_args_uses_model_flag_for_full_checkpoint() {
929 let params = ImageParams {
930 prompt: "hi".into(),
931 ..Default::default()
932 };
933 let source = fake_source(vec![]);
934 let args = build_sdcli_args(
935 ¶ms,
936 &source,
937 Path::new("/checkpoint.safetensors"),
938 Some(Path::new("/v.safetensors")),
939 None,
940 None,
941 Path::new("/tmp/out.webp"),
942 None,
943 None,
944 None,
945 true,
946 );
947 let s = args_to_strings(&args);
948 assert_eq!(
950 s[idx_after(&s, "--model").unwrap()],
951 "/checkpoint.safetensors"
952 );
953 assert!(!s.contains(&"--diffusion-model".to_string()));
954 }
955
956 #[test]
957 fn build_sdcli_args_defaults_denoise_when_init_image_present_but_denoise_none() {
958 let params = ImageParams {
959 prompt: "hi".into(),
960 denoise: None,
961 ..Default::default()
962 };
963 let source = fake_source(vec![]);
964 let args = build_sdcli_args(
965 ¶ms,
966 &source,
967 Path::new("/d.gguf"),
968 None,
969 None,
970 None,
971 Path::new("/tmp/out.webp"),
972 Some(Path::new("/tmp/init.webp")),
973 None,
974 None,
975 false,
976 );
977 let s = args_to_strings(&args);
978 assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.75");
979 }
980
981 #[test]
982 fn build_sdcli_args_per_job_cfg_scale_overrides_model_default() {
983 let params = ImageParams {
984 prompt: "hi".into(),
985 cfg_scale: Some(7.5),
986 ..Default::default()
987 };
988 let source = fake_source(vec![]);
989 let args = build_sdcli_args(
990 ¶ms,
991 &source,
992 Path::new("/d.gguf"),
993 None,
994 None,
995 None,
996 Path::new("/tmp/out.webp"),
997 None,
998 None,
999 None,
1000 false,
1001 );
1002 let s = args_to_strings(&args);
1003 assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "7.5");
1004 }
1005
1006 #[test]
1007 fn build_sdcli_args_per_job_sampling_method_overrides_model_default() {
1008 let params = ImageParams {
1009 prompt: "hi".into(),
1010 sampling_method: Some("dpm++2m".into()),
1011 ..Default::default()
1012 };
1013 let source = fake_source(vec![]);
1014 let args = build_sdcli_args(
1015 ¶ms,
1016 &source,
1017 Path::new("/d.gguf"),
1018 None,
1019 None,
1020 None,
1021 Path::new("/tmp/out.webp"),
1022 None,
1023 None,
1024 None,
1025 false,
1026 );
1027 let s = args_to_strings(&args);
1028 assert_eq!(s[idx_after(&s, "--sampling-method").unwrap()], "dpm++2m");
1029 }
1030
1031 #[test]
1032 fn build_sdcli_args_per_job_steps_overrides_when_non_default() {
1033 let params = ImageParams {
1034 prompt: "hi".into(),
1035 steps: 30, ..Default::default()
1037 };
1038 let source = fake_source(vec![]);
1039 let args = build_sdcli_args(
1040 ¶ms,
1041 &source,
1042 Path::new("/d.gguf"),
1043 None,
1044 None,
1045 None,
1046 Path::new("/tmp/out.webp"),
1047 None,
1048 None,
1049 None,
1050 false,
1051 );
1052 let s = args_to_strings(&args);
1053 assert_eq!(s[idx_after(&s, "--steps").unwrap()], "30");
1054 }
1055
1056 #[test]
1057 fn build_sdcli_args_seed_included_when_set() {
1058 let params = ImageParams {
1059 prompt: "hi".into(),
1060 seed: Some(42),
1061 ..Default::default()
1062 };
1063 let source = fake_source(vec![]);
1064 let args = build_sdcli_args(
1065 ¶ms,
1066 &source,
1067 Path::new("/d.gguf"),
1068 None,
1069 None,
1070 None,
1071 Path::new("/tmp/out.webp"),
1072 None,
1073 None,
1074 None,
1075 false,
1076 );
1077 let s = args_to_strings(&args);
1078 assert_eq!(s[idx_after(&s, "--seed").unwrap()], "42");
1079 }
1080
1081 fn qwen_edit_source() -> ModelSource {
1083 ModelSource {
1084 engine: ModelEngine::SdCpp,
1085 files: vec![],
1086 cli_defaults: ModelCliDefaults {
1087 cfg_scale: 4.0,
1088 steps: 20,
1089 width: 1024,
1090 height: 1024,
1091 sampling_method: Some("euler".to_string()),
1092 flow_shift: Some(3.0),
1093 zero_cond_t: Some(true),
1094 offload_to_cpu: Some(true),
1095 },
1096 }
1097 }
1098
1099 #[test]
1100 fn build_sdcli_args_reference_mode_for_instruction_edit() {
1101 let params = ImageParams {
1102 prompt: "add a red beach ball".into(),
1103 denoise: Some(0.9),
1104 ..Default::default()
1105 };
1106 let source = qwen_edit_source();
1107 let args = build_sdcli_args(
1108 ¶ms,
1109 &source,
1110 Path::new("/qwen.gguf"),
1111 Some(Path::new("/vae.safetensors")),
1112 Some(Path::new("/llm.gguf")),
1113 Some(Path::new("/mmproj.gguf")),
1114 Path::new("/tmp/out.webp"),
1115 None,
1116 Some(Path::new("/tmp/mask.png")),
1117 Some(Path::new("/tmp/ref.webp")),
1118 false,
1119 );
1120 let s = args_to_strings(&args);
1121 assert_eq!(s[idx_after(&s, "-r").unwrap()], "/tmp/ref.webp");
1124 assert_eq!(s[idx_after(&s, "--mask").unwrap()], "/tmp/mask.png");
1125 assert!(!s.contains(&"--init-img".to_string()));
1126 assert!(!s.contains(&"--strength".to_string()));
1127 assert_eq!(s[idx_after(&s, "--llm_vision").unwrap()], "/mmproj.gguf");
1129 assert_eq!(s[idx_after(&s, "--flow-shift").unwrap()], "3");
1130 assert!(s.contains(&"--qwen-image-zero-cond-t".to_string()));
1131 assert!(s.contains(&"--offload-to-cpu".to_string()));
1132 }
1133
1134 #[test]
1135 fn build_sdcli_args_omits_qwen_flags_for_plain_model() {
1136 let params = ImageParams {
1137 prompt: "hi".into(),
1138 ..Default::default()
1139 };
1140 let source = fake_source(vec![]);
1142 let args = build_sdcli_args(
1143 ¶ms,
1144 &source,
1145 Path::new("/d.gguf"),
1146 None,
1147 None,
1148 None,
1149 Path::new("/tmp/out.webp"),
1150 None,
1151 None,
1152 None,
1153 false,
1154 );
1155 let s = args_to_strings(&args);
1156 assert!(!s.contains(&"--flow-shift".to_string()));
1157 assert!(!s.contains(&"--qwen-image-zero-cond-t".to_string()));
1158 assert!(!s.contains(&"--offload-to-cpu".to_string()));
1159 assert!(!s.contains(&"--llm_vision".to_string()));
1160 assert!(!s.contains(&"-r".to_string()));
1161 }
1162
1163 #[test]
1164 fn capabilities_advertises_only_image_kind() {
1165 let dir = tempdir().unwrap();
1166 let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
1167 let caps = engine.capabilities();
1168 assert!(caps
1169 .supported_models_per_kind
1170 .contains_key(&TaskKind::Image));
1171 assert_eq!(caps.supported_models_per_kind.len(), 1);
1172 }
1173
1174 #[test]
1175 fn init_image_extension_reads_url_tail() {
1176 assert_eq!(init_image_extension("https://x/y/latest.webp"), "webp");
1177 assert_eq!(init_image_extension("https://x/y/latest.PNG"), "png");
1178 assert_eq!(init_image_extension("https://x/y/latest.jpg"), "jpg");
1179 assert_eq!(init_image_extension("https://x/y/latest.jpeg"), "jpg");
1180 assert_eq!(
1182 init_image_extension("https://x/y/latest.webp?v=42&t=now"),
1183 "webp"
1184 );
1185 assert_eq!(init_image_extension("https://x/y/latest.webp#frag"), "webp");
1186 assert_eq!(
1188 init_image_extension("https://x/y/latest.unknownext"),
1189 "webp"
1190 );
1191 assert_eq!(init_image_extension("https://x/y/no-ext"), "webp");
1192 }
1193
1194 #[test]
1195 fn normalize_output_ext_honours_known_and_defaults_webp() {
1196 assert_eq!(normalize_output_ext("png"), "png");
1197 assert_eq!(normalize_output_ext("PNG"), "png");
1198 assert_eq!(normalize_output_ext("jpg"), "jpg");
1199 assert_eq!(normalize_output_ext("jpeg"), "jpg");
1200 assert_eq!(normalize_output_ext("bmp"), "bmp");
1201 assert_eq!(normalize_output_ext("webp"), "webp");
1202 assert_eq!(normalize_output_ext(""), "webp");
1203 assert_eq!(normalize_output_ext("gif"), "webp");
1204 }
1205}