1use crate::engine::download;
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.filename, &file.url)?;
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_path = out_dir.join(format!("{stem}.webp"));
191
192 let mut temp_files = TempFileGuard::new();
196 temp_files.push(out_path.clone());
197
198 let init_img_path = match params.init_image_url.as_deref() {
205 Some(url) if !url.is_empty() => {
206 let ext = init_image_extension(url);
207 let init_path = out_dir.join(format!("{stem}-init.{ext}"));
208 download::download_file(url, &init_path).with_context(|| {
209 format!("downloading init image {} -> {}", url, init_path.display())
210 })?;
211 temp_files.push(init_path.clone());
212 Some(init_path)
213 }
214 _ => None,
215 };
216
217 let has_base = init_img_path.is_some() || params.ref_image_url.as_deref().is_some();
221 let mask_path = match (has_base, params.mask_url.as_deref()) {
222 (true, Some(url)) if !url.is_empty() => {
223 let ext = init_image_extension(url);
224 let path = out_dir.join(format!("{stem}-mask.{ext}"));
225 download::download_file(url, &path)
226 .with_context(|| format!("downloading mask {} -> {}", url, path.display()))?;
227 temp_files.push(path.clone());
228 Some(path)
229 }
230 _ => None,
231 };
232
233 let ref_img_path = match params.ref_image_url.as_deref() {
236 Some(url) if !url.is_empty() => {
237 let ext = init_image_extension(url);
238 let path = out_dir.join(format!("{stem}-ref.{ext}"));
239 download::download_file(url, &path).with_context(|| {
240 format!("downloading reference image {} -> {}", url, path.display())
241 })?;
242 temp_files.push(path.clone());
243 Some(path)
244 }
245 _ => None,
246 };
247
248 let args = build_sdcli_args(
249 ¶ms,
250 source,
251 diffusion_model,
252 vae,
253 text_encoder,
254 text_encoder_vision,
255 &out_path,
256 init_img_path.as_deref(),
257 mask_path.as_deref(),
258 ref_img_path.as_deref(),
259 full_checkpoint,
260 );
261 let mut cmd = Command::new(&sd_cli);
262 cmd.args(&args);
263 apply_library_path(&mut cmd, &sd_cli);
264
265 debug!(
266 target: TRACE_TARGET,
267 op = "spawn",
268 sd_cli = %sd_cli.display(),
269 model,
270 i2i = init_img_path.is_some(),
271 arg_count = args.len(),
272 "running sd-cli"
273 );
274
275 let started = Instant::now();
276 let output = cmd
277 .output()
278 .with_context(|| format!("running {}", sd_cli.display()))?;
279 let elapsed_ms = started.elapsed().as_millis() as u64;
280 if !output.status.success() {
281 let stderr = String::from_utf8_lossy(&output.stderr);
282 warn!(
283 target: TRACE_TARGET,
284 op = "spawn",
285 model,
286 elapsed_ms,
287 exit = ?output.status.code(),
288 stderr = %stderr,
289 "sd-cli failed"
290 );
291 bail!(
292 "sd-cli exited with {:?}: {}",
293 output.status.code(),
294 stderr.lines().last().unwrap_or("(no stderr)")
295 );
296 }
297
298 let bytes = std::fs::read(&out_path)
299 .with_context(|| format!("reading sd-cli output at {}", out_path.display()))?;
300 info!(
301 target: TRACE_TARGET,
302 op = "dispatch",
303 model,
304 elapsed_ms,
305 bytes = bytes.len(),
306 "ok"
307 );
308
309 Ok(TaskResult::Image {
310 bytes,
311 ext: "webp".to_string(),
312 })
313 }
314}
315
316impl Engine for SdCppEngine {
317 fn name(&self) -> &'static str {
318 "sdcpp"
319 }
320
321 fn capabilities(&self) -> EngineCapabilities {
322 let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
328 map.insert(TaskKind::Image, vec!["sd-cpp:*".to_string()]);
329 EngineCapabilities {
330 supported_models_per_kind: map,
331 }
332 }
333
334 fn dispatch(&self, _model: &str, _task: Task) -> Result<TaskResult> {
335 bail!(
336 "sdcpp engine requires a ModelSource on the offer; legacy push-based offers \
337 (no modelSource) cannot be served - re-promote the job through the studio"
338 )
339 }
340
341 fn dispatch_with_source(
342 &self,
343 model: &str,
344 task: Task,
345 source: &ModelSource,
346 ) -> Result<TaskResult> {
347 let kind = task.kind();
348 match task {
349 Task::Image(p) => self.dispatch_image(model, p, source),
350 _ => bail!("sdcpp engine cannot serve {} tasks", kind.as_str()),
351 }
352 }
353}
354
355fn remove_temp_file(path: &Path) {
368 if let Err(e) = std::fs::remove_file(path) {
369 if e.kind() != std::io::ErrorKind::NotFound {
370 warn!(
371 target: TRACE_TARGET,
372 op = "cleanup",
373 path = %path.display(),
374 error = %e,
375 "failed to remove temp file"
376 );
377 }
378 }
379}
380
381struct TempFileGuard {
390 paths: Vec<PathBuf>,
391}
392
393impl TempFileGuard {
394 fn new() -> Self {
395 Self { paths: Vec::new() }
396 }
397
398 fn push(&mut self, path: PathBuf) {
399 self.paths.push(path);
400 }
401}
402
403impl Drop for TempFileGuard {
404 fn drop(&mut self) {
405 for path in &self.paths {
406 remove_temp_file(path);
407 }
408 }
409}
410
411fn file_for_role(files: &[(ModelFileRole, PathBuf)], role: ModelFileRole) -> Option<&Path> {
412 files
413 .iter()
414 .find(|(r, _)| *r == role)
415 .map(|(_, p)| p.as_path())
416}
417
418fn resolve_image_args(params: &ImageParams, source: &ModelSource) -> ResolvedImageArgs {
423 let width = if params.width > 0 {
424 params.width
425 } else if source.cli_defaults.width > 0 {
426 source.cli_defaults.width
427 } else {
428 1024
429 };
430 let height = if params.height > 0 {
431 params.height
432 } else if source.cli_defaults.height > 0 {
433 source.cli_defaults.height
434 } else {
435 1024
436 };
437 let steps = if params.steps > 0 && params.steps != 20 {
441 params.steps
442 } else if source.cli_defaults.steps > 0 {
443 source.cli_defaults.steps
444 } else {
445 STEPS_FALLBACK
446 };
447 let source_cfg = if source.cli_defaults.cfg_scale > 0.0 {
448 source.cli_defaults.cfg_scale
449 } else {
450 1.0
451 };
452 let cfg_scale = params.cfg_scale.filter(|v| *v > 0.0).unwrap_or(source_cfg);
453 let sampling_method = params
454 .sampling_method
455 .clone()
456 .or_else(|| source.cli_defaults.sampling_method.clone());
457 ResolvedImageArgs {
458 width,
459 height,
460 steps,
461 cfg_scale,
462 sampling_method,
463 }
464}
465
466#[derive(Debug, Clone, PartialEq)]
468struct ResolvedImageArgs {
469 width: u32,
470 height: u32,
471 steps: u32,
472 cfg_scale: f32,
473 sampling_method: Option<String>,
474}
475
476#[allow(clippy::too_many_arguments)]
483fn build_sdcli_args(
484 params: &ImageParams,
485 source: &ModelSource,
486 diffusion_model: &Path,
487 vae: Option<&Path>,
488 text_encoder: Option<&Path>,
489 text_encoder_vision: Option<&Path>,
490 out_path: &Path,
491 init_img_path: Option<&Path>,
492 mask_path: Option<&Path>,
493 ref_img_path: Option<&Path>,
494 full_checkpoint: bool,
495) -> Vec<OsString> {
496 let resolved = resolve_image_args(params, source);
497 let mut args: Vec<OsString> = Vec::with_capacity(32);
498
499 args.push(
502 if full_checkpoint {
503 "--model"
504 } else {
505 "--diffusion-model"
506 }
507 .into(),
508 );
509 args.push(diffusion_model.into());
510 if let Some(p) = vae {
511 args.push("--vae".into());
512 args.push(p.into());
513 }
514 if let Some(p) = text_encoder {
515 args.push("--llm".into());
516 args.push(p.into());
517 }
518 if let Some(p) = text_encoder_vision {
519 args.push("--llm_vision".into());
520 args.push(p.into());
521 }
522 args.push("-p".into());
523 args.push((¶ms.prompt as &str).into());
524 if let Some(neg) = params.negative_prompt.as_deref() {
525 if !neg.is_empty() {
526 args.push("--negative-prompt".into());
527 args.push(neg.into());
528 }
529 }
530 if let Some(reference) = ref_img_path {
531 args.push("-r".into());
537 args.push(reference.into());
538 if let Some(mask) = mask_path {
539 args.push("--mask".into());
540 args.push(mask.into());
541 }
542 } else if let Some(init) = init_img_path {
543 args.push("--init-img".into());
544 args.push(init.into());
545 let strength = params.denoise.unwrap_or(0.75);
549 args.push("--strength".into());
550 args.push(strength.to_string().into());
551 if let Some(mask) = mask_path {
553 args.push("--mask".into());
554 args.push(mask.into());
555 }
556 }
557 args.push("--cfg-scale".into());
558 args.push(resolved.cfg_scale.to_string().into());
559 args.push("--steps".into());
560 args.push(resolved.steps.to_string().into());
561 args.push("-W".into());
562 args.push(resolved.width.to_string().into());
563 args.push("-H".into());
564 args.push(resolved.height.to_string().into());
565 args.push("-o".into());
566 args.push(out_path.into());
567 if let Some(seed) = params.seed {
568 args.push("--seed".into());
569 args.push(seed.to_string().into());
570 }
571 if let Some(method) = resolved.sampling_method.as_deref() {
572 args.push("--sampling-method".into());
573 args.push(method.into());
574 }
575 if let Some(shift) = source.cli_defaults.flow_shift {
578 args.push("--flow-shift".into());
579 args.push(shift.to_string().into());
580 }
581 if source.cli_defaults.zero_cond_t == Some(true) {
582 args.push("--qwen-image-zero-cond-t".into());
583 }
584 if source.cli_defaults.offload_to_cpu == Some(true) {
585 args.push("--offload-to-cpu".into());
586 }
587 args.push("--diffusion-fa".into());
589 args
590}
591
592#[cfg_attr(coverage_nightly, coverage(off))]
599fn apply_library_path(cmd: &mut Command, sd_cli: &Path) {
600 let Some((var, dir)) = sd_provision::library_path_env(sd_cli) else {
601 return;
602 };
603 let value = match std::env::var_os(var) {
604 Some(existing) => {
605 let mut paths = vec![dir.clone()];
606 paths.extend(std::env::split_paths(&existing));
607 std::env::join_paths(paths).unwrap_or_else(|_| dir.into_os_string())
611 }
612 None => dir.into_os_string(),
613 };
614 cmd.env(var, value);
615}
616
617#[cfg_attr(coverage_nightly, coverage(off))]
624fn resolve_sd_cli(models_root: &Path) -> Option<PathBuf> {
625 let bin = sd_provision::binary_name();
626 if let Ok(p) = std::env::var("STUDIO_WORKER_SD_CLI") {
627 let path = PathBuf::from(p);
628 if path.is_file() {
629 return Some(path);
630 }
631 }
632 let in_models = models_root.join("bin").join(bin);
633 if in_models.is_file() {
634 return Some(in_models);
635 }
636 if let Some(home) = std::env::var_os("HOME") {
637 let candidate = PathBuf::from(home).join(".local/bin").join(bin);
638 if candidate.is_file() {
639 return Some(candidate);
640 }
641 }
642 which(bin)
643}
644
645#[cfg_attr(coverage_nightly, coverage(off))]
648fn which(bin: &str) -> Option<PathBuf> {
649 let path = std::env::var_os("PATH")?;
650 for entry in std::env::split_paths(&path) {
651 let candidate = entry.join(bin);
652 if candidate.is_file() {
653 return Some(candidate);
654 }
655 }
656 None
657}
658
659fn init_image_extension(url: &str) -> &'static str {
664 let path = url.split(['?', '#']).next().unwrap_or(url);
665 let lower_tail = path
666 .rsplit('.')
667 .next()
668 .map(|t| t.to_ascii_lowercase())
669 .unwrap_or_default();
670 match lower_tail.as_str() {
671 "png" => "png",
672 "jpg" | "jpeg" => "jpg",
673 "webp" => "webp",
674 "bmp" => "bmp",
675 "gif" => "gif",
676 "tif" | "tiff" => "tif",
677 _ => "webp",
678 }
679}
680
681#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::types::{ModelCliDefaults, ModelEngine, ModelFile, ModelFileRole};
689 use tempfile::tempdir;
690
691 fn fake_source(files: Vec<ModelFile>) -> ModelSource {
692 ModelSource {
693 engine: ModelEngine::SdCpp,
694 files,
695 cli_defaults: ModelCliDefaults {
696 cfg_scale: 1.0,
697 steps: 8,
698 width: 1024,
699 height: 1024,
700 sampling_method: Some("euler".to_string()),
701 ..Default::default()
702 },
703 }
704 }
705
706 #[test]
707 fn temp_file_guard_removes_every_registered_file_on_drop() {
708 let dir = tempdir().unwrap();
709 let out = dir.path().join("out.webp");
710 let init = dir.path().join("out-init.png");
711 std::fs::write(&out, b"image").unwrap();
712 std::fs::write(&init, b"init").unwrap();
713 {
714 let mut guard = TempFileGuard::new();
715 guard.push(out.clone());
716 guard.push(init.clone());
717 assert!(out.exists() && init.exists(), "files present before drop");
718 }
719 assert!(!out.exists(), "sd-cli output temp must be removed on drop");
720 assert!(!init.exists(), "init-image temp must be removed on drop");
721 }
722
723 #[test]
724 fn temp_file_guard_tolerates_a_file_that_never_materialised() {
725 let dir = tempdir().unwrap();
730 let missing = dir.path().join("never-written.webp");
731 let out = crate::test_support::capture(move || {
732 let mut guard = TempFileGuard::new();
733 guard.push(missing);
734 drop(guard);
735 });
736 assert!(
737 !out.contains("failed to remove temp file"),
738 "a never-created temp file must not warn on cleanup: {out:?}"
739 );
740 }
741
742 #[test]
743 fn remove_temp_file_deletes_an_existing_file_quietly() {
744 let dir = tempdir().unwrap();
745 let f = dir.path().join("artefact.webp");
746 std::fs::write(&f, b"bytes").unwrap();
747 let out = crate::test_support::capture({
748 let f = f.clone();
749 move || remove_temp_file(&f)
750 });
751 assert!(!f.exists(), "file should be gone after cleanup");
752 assert!(
753 !out.contains("failed to remove temp file"),
754 "the success path must not warn: {out:?}"
755 );
756 }
757
758 #[test]
759 fn remove_temp_file_ignores_an_already_missing_file() {
760 let dir = tempdir().unwrap();
761 let missing = dir.path().join("never-existed.webp");
762 let out = crate::test_support::capture(move || remove_temp_file(&missing));
763 assert!(
764 !out.contains("failed to remove temp file"),
765 "a not-found file is the desired end state, not a warning: {out:?}"
766 );
767 }
768
769 #[test]
770 fn remove_temp_file_surfaces_a_failed_removal() {
771 let dir = tempdir().unwrap();
775 let stubborn = dir.path().join("subdir");
776 std::fs::create_dir(&stubborn).unwrap();
777 let out = crate::test_support::capture(move || remove_temp_file(&stubborn));
778 assert!(
779 out.contains("failed to remove temp file"),
780 "a failed removal must surface in the logs: {out:?}"
781 );
782 assert!(
783 out.contains("subdir"),
784 "the warning must name the offending path: {out:?}"
785 );
786 assert!(
787 out.contains("cleanup"),
788 "the warning should tag the cleanup op: {out:?}"
789 );
790 }
791
792 #[test]
793 fn file_for_role_picks_matching_file() {
794 let files = vec![
795 (ModelFileRole::DiffusionModel, PathBuf::from("/d.gguf")),
796 (ModelFileRole::Vae, PathBuf::from("/v.safetensors")),
797 ];
798 assert_eq!(
799 file_for_role(&files, ModelFileRole::DiffusionModel),
800 Some(Path::new("/d.gguf"))
801 );
802 assert_eq!(
803 file_for_role(&files, ModelFileRole::Vae),
804 Some(Path::new("/v.safetensors"))
805 );
806 assert!(file_for_role(&files, ModelFileRole::TextEncoder).is_none());
807 }
808
809 #[test]
810 fn ensure_files_skips_already_present() {
811 let dir = tempdir().unwrap();
812 let cached = dir.path().join("cached.gguf");
813 std::fs::write(&cached, b"already here").unwrap();
814 let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
815 let source = fake_source(vec![ModelFile {
816 role: ModelFileRole::DiffusionModel,
817 url: "https://example.invalid/cached.gguf".into(),
818 filename: "cached.gguf".into(),
819 approx_bytes: None,
820 }]);
821 let resolved = engine.ensure_files(&source).expect("cached file used");
822 assert_eq!(resolved.len(), 1);
823 assert_eq!(resolved[0].0, ModelFileRole::DiffusionModel);
824 assert_eq!(resolved[0].1, cached);
825 assert_eq!(std::fs::read(&cached).unwrap(), b"already here");
827 }
828
829 #[test]
830 fn dispatch_rejects_non_image_tasks() {
831 use crate::types::AudioTtsParams;
832 let dir = tempdir().unwrap();
833 let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
834 let task = Task::AudioTts(AudioTtsParams {
835 text: "hi".into(),
836 voice: "v".into(),
837 ext: "wav".into(),
838 ..Default::default()
839 });
840 let source = fake_source(vec![]);
841 let err = engine
842 .dispatch_with_source("anything", task, &source)
843 .unwrap_err();
844 assert!(err.to_string().contains("cannot serve audio_tts"));
845 }
846
847 fn args_to_strings(args: &[OsString]) -> Vec<String> {
857 args.iter()
858 .map(|s| s.to_string_lossy().into_owned())
859 .collect()
860 }
861
862 fn idx_after(args: &[String], flag: &str) -> Option<usize> {
863 args.iter().position(|a| a == flag).map(|i| i + 1)
864 }
865
866 #[test]
867 fn build_sdcli_args_includes_required_flags() {
868 let params = ImageParams {
869 prompt: "hello".into(),
870 width: 768,
871 height: 512,
872 steps: 20, ..Default::default()
874 };
875 let source = fake_source(vec![]);
876 let args = build_sdcli_args(
877 ¶ms,
878 &source,
879 Path::new("/d.gguf"),
880 Some(Path::new("/v.safetensors")),
881 Some(Path::new("/llm.gguf")),
882 None,
883 Path::new("/tmp/out.webp"),
884 None,
885 None,
886 None,
887 false,
888 );
889 let s = args_to_strings(&args);
890 assert_eq!(s[idx_after(&s, "--diffusion-model").unwrap()], "/d.gguf");
891 assert_eq!(s[idx_after(&s, "--vae").unwrap()], "/v.safetensors");
892 assert_eq!(s[idx_after(&s, "--llm").unwrap()], "/llm.gguf");
893 assert_eq!(s[idx_after(&s, "-p").unwrap()], "hello");
894 assert_eq!(s[idx_after(&s, "-W").unwrap()], "768");
895 assert_eq!(s[idx_after(&s, "-H").unwrap()], "512");
896 assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "1");
898 assert_eq!(s[idx_after(&s, "--steps").unwrap()], "8");
900 assert_eq!(s[idx_after(&s, "--sampling-method").unwrap()], "euler");
901 assert_eq!(s[idx_after(&s, "-o").unwrap()], "/tmp/out.webp");
902 assert!(s.contains(&"--diffusion-fa".to_string()));
903 assert!(!s.contains(&"--init-img".to_string()));
905 assert!(!s.contains(&"--strength".to_string()));
906 }
907
908 #[test]
909 fn build_sdcli_args_includes_negative_prompt_when_set() {
910 let params = ImageParams {
911 prompt: "hi".into(),
912 negative_prompt: Some("text, watermark, low quality".into()),
913 ..Default::default()
914 };
915 let source = fake_source(vec![]);
916 let args = build_sdcli_args(
917 ¶ms,
918 &source,
919 Path::new("/d.gguf"),
920 None,
921 None,
922 None,
923 Path::new("/tmp/out.webp"),
924 None,
925 None,
926 None,
927 false,
928 );
929 let s = args_to_strings(&args);
930 assert_eq!(
931 s[idx_after(&s, "--negative-prompt").unwrap()],
932 "text, watermark, low quality"
933 );
934 }
935
936 #[test]
937 fn build_sdcli_args_omits_negative_prompt_when_empty_string() {
938 let params = ImageParams {
939 prompt: "hi".into(),
940 negative_prompt: Some(String::new()),
941 ..Default::default()
942 };
943 let source = fake_source(vec![]);
944 let args = build_sdcli_args(
945 ¶ms,
946 &source,
947 Path::new("/d.gguf"),
948 None,
949 None,
950 None,
951 Path::new("/tmp/out.webp"),
952 None,
953 None,
954 None,
955 false,
956 );
957 let s = args_to_strings(&args);
958 assert!(!s.contains(&"--negative-prompt".to_string()));
959 }
960
961 #[test]
962 fn build_sdcli_args_includes_init_image_and_strength() {
963 let params = ImageParams {
964 prompt: "hi".into(),
965 denoise: Some(0.55),
966 ..Default::default()
967 };
968 let source = fake_source(vec![]);
969 let args = build_sdcli_args(
970 ¶ms,
971 &source,
972 Path::new("/d.gguf"),
973 None,
974 None,
975 None,
976 Path::new("/tmp/out.webp"),
977 Some(Path::new("/tmp/init.webp")),
978 None,
979 None,
980 false,
981 );
982 let s = args_to_strings(&args);
983 assert_eq!(s[idx_after(&s, "--init-img").unwrap()], "/tmp/init.webp");
984 assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.55");
985 assert!(!s.contains(&"--mask".to_string()));
987 }
988
989 #[test]
990 fn build_sdcli_args_includes_mask_for_inpaint() {
991 let params = ImageParams {
992 prompt: "remove the tree".into(),
993 denoise: Some(0.8),
994 ..Default::default()
995 };
996 let source = fake_source(vec![]);
997 let args = build_sdcli_args(
998 ¶ms,
999 &source,
1000 Path::new("/d.gguf"),
1001 None,
1002 None,
1003 None,
1004 Path::new("/tmp/out.webp"),
1005 Some(Path::new("/tmp/init.webp")),
1006 Some(Path::new("/tmp/mask.png")),
1007 None,
1008 false,
1009 );
1010 let s = args_to_strings(&args);
1011 assert_eq!(s[idx_after(&s, "--init-img").unwrap()], "/tmp/init.webp");
1012 assert_eq!(s[idx_after(&s, "--mask").unwrap()], "/tmp/mask.png");
1013 assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.8");
1014 }
1015
1016 #[test]
1017 fn build_sdcli_args_uses_model_flag_for_full_checkpoint() {
1018 let params = ImageParams {
1019 prompt: "hi".into(),
1020 ..Default::default()
1021 };
1022 let source = fake_source(vec![]);
1023 let args = build_sdcli_args(
1024 ¶ms,
1025 &source,
1026 Path::new("/checkpoint.safetensors"),
1027 Some(Path::new("/v.safetensors")),
1028 None,
1029 None,
1030 Path::new("/tmp/out.webp"),
1031 None,
1032 None,
1033 None,
1034 true,
1035 );
1036 let s = args_to_strings(&args);
1037 assert_eq!(
1039 s[idx_after(&s, "--model").unwrap()],
1040 "/checkpoint.safetensors"
1041 );
1042 assert!(!s.contains(&"--diffusion-model".to_string()));
1043 }
1044
1045 #[test]
1046 fn build_sdcli_args_defaults_denoise_when_init_image_present_but_denoise_none() {
1047 let params = ImageParams {
1048 prompt: "hi".into(),
1049 denoise: None,
1050 ..Default::default()
1051 };
1052 let source = fake_source(vec![]);
1053 let args = build_sdcli_args(
1054 ¶ms,
1055 &source,
1056 Path::new("/d.gguf"),
1057 None,
1058 None,
1059 None,
1060 Path::new("/tmp/out.webp"),
1061 Some(Path::new("/tmp/init.webp")),
1062 None,
1063 None,
1064 false,
1065 );
1066 let s = args_to_strings(&args);
1067 assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.75");
1068 }
1069
1070 #[test]
1071 fn build_sdcli_args_per_job_cfg_scale_overrides_model_default() {
1072 let params = ImageParams {
1073 prompt: "hi".into(),
1074 cfg_scale: Some(7.5),
1075 ..Default::default()
1076 };
1077 let source = fake_source(vec![]);
1078 let args = build_sdcli_args(
1079 ¶ms,
1080 &source,
1081 Path::new("/d.gguf"),
1082 None,
1083 None,
1084 None,
1085 Path::new("/tmp/out.webp"),
1086 None,
1087 None,
1088 None,
1089 false,
1090 );
1091 let s = args_to_strings(&args);
1092 assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "7.5");
1093 }
1094
1095 #[test]
1096 fn build_sdcli_args_per_job_sampling_method_overrides_model_default() {
1097 let params = ImageParams {
1098 prompt: "hi".into(),
1099 sampling_method: Some("dpm++2m".into()),
1100 ..Default::default()
1101 };
1102 let source = fake_source(vec![]);
1103 let args = build_sdcli_args(
1104 ¶ms,
1105 &source,
1106 Path::new("/d.gguf"),
1107 None,
1108 None,
1109 None,
1110 Path::new("/tmp/out.webp"),
1111 None,
1112 None,
1113 None,
1114 false,
1115 );
1116 let s = args_to_strings(&args);
1117 assert_eq!(s[idx_after(&s, "--sampling-method").unwrap()], "dpm++2m");
1118 }
1119
1120 #[test]
1121 fn build_sdcli_args_per_job_steps_overrides_when_non_default() {
1122 let params = ImageParams {
1123 prompt: "hi".into(),
1124 steps: 30, ..Default::default()
1126 };
1127 let source = fake_source(vec![]);
1128 let args = build_sdcli_args(
1129 ¶ms,
1130 &source,
1131 Path::new("/d.gguf"),
1132 None,
1133 None,
1134 None,
1135 Path::new("/tmp/out.webp"),
1136 None,
1137 None,
1138 None,
1139 false,
1140 );
1141 let s = args_to_strings(&args);
1142 assert_eq!(s[idx_after(&s, "--steps").unwrap()], "30");
1143 }
1144
1145 #[test]
1146 fn build_sdcli_args_seed_included_when_set() {
1147 let params = ImageParams {
1148 prompt: "hi".into(),
1149 seed: Some(42),
1150 ..Default::default()
1151 };
1152 let source = fake_source(vec![]);
1153 let args = build_sdcli_args(
1154 ¶ms,
1155 &source,
1156 Path::new("/d.gguf"),
1157 None,
1158 None,
1159 None,
1160 Path::new("/tmp/out.webp"),
1161 None,
1162 None,
1163 None,
1164 false,
1165 );
1166 let s = args_to_strings(&args);
1167 assert_eq!(s[idx_after(&s, "--seed").unwrap()], "42");
1168 }
1169
1170 fn qwen_edit_source() -> ModelSource {
1172 ModelSource {
1173 engine: ModelEngine::SdCpp,
1174 files: vec![],
1175 cli_defaults: ModelCliDefaults {
1176 cfg_scale: 4.0,
1177 steps: 20,
1178 width: 1024,
1179 height: 1024,
1180 sampling_method: Some("euler".to_string()),
1181 flow_shift: Some(3.0),
1182 zero_cond_t: Some(true),
1183 offload_to_cpu: Some(true),
1184 },
1185 }
1186 }
1187
1188 #[test]
1189 fn build_sdcli_args_reference_mode_for_instruction_edit() {
1190 let params = ImageParams {
1191 prompt: "add a red beach ball".into(),
1192 denoise: Some(0.9),
1193 ..Default::default()
1194 };
1195 let source = qwen_edit_source();
1196 let args = build_sdcli_args(
1197 ¶ms,
1198 &source,
1199 Path::new("/qwen.gguf"),
1200 Some(Path::new("/vae.safetensors")),
1201 Some(Path::new("/llm.gguf")),
1202 Some(Path::new("/mmproj.gguf")),
1203 Path::new("/tmp/out.webp"),
1204 None,
1205 Some(Path::new("/tmp/mask.png")),
1206 Some(Path::new("/tmp/ref.webp")),
1207 false,
1208 );
1209 let s = args_to_strings(&args);
1210 assert_eq!(s[idx_after(&s, "-r").unwrap()], "/tmp/ref.webp");
1213 assert_eq!(s[idx_after(&s, "--mask").unwrap()], "/tmp/mask.png");
1214 assert!(!s.contains(&"--init-img".to_string()));
1215 assert!(!s.contains(&"--strength".to_string()));
1216 assert_eq!(s[idx_after(&s, "--llm_vision").unwrap()], "/mmproj.gguf");
1218 assert_eq!(s[idx_after(&s, "--flow-shift").unwrap()], "3");
1219 assert!(s.contains(&"--qwen-image-zero-cond-t".to_string()));
1220 assert!(s.contains(&"--offload-to-cpu".to_string()));
1221 }
1222
1223 #[test]
1224 fn build_sdcli_args_omits_qwen_flags_for_plain_model() {
1225 let params = ImageParams {
1226 prompt: "hi".into(),
1227 ..Default::default()
1228 };
1229 let source = fake_source(vec![]);
1231 let args = build_sdcli_args(
1232 ¶ms,
1233 &source,
1234 Path::new("/d.gguf"),
1235 None,
1236 None,
1237 None,
1238 Path::new("/tmp/out.webp"),
1239 None,
1240 None,
1241 None,
1242 false,
1243 );
1244 let s = args_to_strings(&args);
1245 assert!(!s.contains(&"--flow-shift".to_string()));
1246 assert!(!s.contains(&"--qwen-image-zero-cond-t".to_string()));
1247 assert!(!s.contains(&"--offload-to-cpu".to_string()));
1248 assert!(!s.contains(&"--llm_vision".to_string()));
1249 assert!(!s.contains(&"-r".to_string()));
1250 }
1251
1252 #[test]
1253 fn capabilities_advertises_only_image_kind() {
1254 let dir = tempdir().unwrap();
1255 let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
1256 let caps = engine.capabilities();
1257 assert!(caps
1258 .supported_models_per_kind
1259 .contains_key(&TaskKind::Image));
1260 assert_eq!(caps.supported_models_per_kind.len(), 1);
1261 }
1262
1263 #[test]
1264 fn init_image_extension_reads_url_tail() {
1265 assert_eq!(init_image_extension("https://x/y/latest.webp"), "webp");
1266 assert_eq!(init_image_extension("https://x/y/latest.PNG"), "png");
1267 assert_eq!(init_image_extension("https://x/y/latest.jpg"), "jpg");
1268 assert_eq!(init_image_extension("https://x/y/latest.jpeg"), "jpg");
1269 assert_eq!(
1271 init_image_extension("https://x/y/latest.webp?v=42&t=now"),
1272 "webp"
1273 );
1274 assert_eq!(init_image_extension("https://x/y/latest.webp#frag"), "webp");
1275 assert_eq!(
1277 init_image_extension("https://x/y/latest.unknownext"),
1278 "webp"
1279 );
1280 assert_eq!(init_image_extension("https://x/y/no-ext"), "webp");
1281 }
1282}