use crate::engine::download::{self, TempFileGuard};
use crate::engine::sd_provision;
use crate::engine::{Engine, EngineCapabilities};
use crate::types::{ImageParams, ModelFileRole, ModelSource, Task, TaskKind, TaskResult};
use anyhow::{anyhow, bail, Context, Result};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use tracing::{debug, info, warn};
const TRACE_TARGET: &str = "studio_worker::engine::sdcpp";
const STEPS_FALLBACK: u32 = 8;
pub struct SdCppEngine {
sd_cli: Mutex<Option<PathBuf>>,
models_root: PathBuf,
}
impl SdCppEngine {
pub fn new(models_root: &Path) -> Self {
info!(
target: TRACE_TARGET,
op = "register",
models_root = %models_root.display(),
sd_cli_name = sd_provision::binary_name(),
"sdcpp engine registered (sd-cli resolved/provisioned on first image job)"
);
Self {
sd_cli: Mutex::new(None),
models_root: models_root.to_path_buf(),
}
}
#[cfg(test)]
pub fn with_paths(sd_cli: PathBuf, models_root: PathBuf) -> Self {
Self {
sd_cli: Mutex::new(Some(sd_cli)),
models_root,
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn ensure_sd_cli(&self) -> Result<PathBuf> {
let mut guard = self.sd_cli.lock();
if let Some(p) = guard.as_ref() {
if p.is_file() {
return Ok(p.clone());
}
}
let resolved = match resolve_sd_cli(&self.models_root) {
Some(p) => {
info!(
target: TRACE_TARGET,
op = "resolve",
sd_cli = %p.display(),
"using existing sd-cli"
);
p
}
None => sd_provision::provision(&self.models_root)
.context("auto-provisioning sd-cli (stable-diffusion.cpp)")?,
};
*guard = Some(resolved.clone());
Ok(resolved)
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn ensure_files(&self, source: &ModelSource) -> Result<Vec<(ModelFileRole, PathBuf)>> {
let mut out = Vec::with_capacity(source.files.len());
for file in &source.files {
let local = download::ensure_file(&self.models_root, file)?;
out.push((file.role, local));
}
Ok(out)
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn dispatch_image(
&self,
model: &str,
params: ImageParams,
source: &ModelSource,
) -> Result<TaskResult> {
let sd_cli = self.ensure_sd_cli()?;
if let Err(e) = sd_provision::vulkan_runtime_status() {
warn!(
target: TRACE_TARGET,
op = "preflight",
model,
error = %e,
"GPU runtime missing; refusing image job"
);
return Err(e);
}
let files = self.ensure_files(source)?;
let diffusion_only = file_for_role(&files, ModelFileRole::DiffusionModel);
let full_checkpoint = diffusion_only.is_none();
let diffusion_model = diffusion_only
.or_else(|| file_for_role(&files, ModelFileRole::Model))
.ok_or_else(|| anyhow!("modelSource has no diffusion-model / model file"))?;
let vae = file_for_role(&files, ModelFileRole::Vae);
let text_encoder = file_for_role(&files, ModelFileRole::TextEncoder);
let text_encoder_vision = file_for_role(&files, ModelFileRole::TextEncoderVision);
let out_dir = std::env::temp_dir().join("studio-worker-sdcpp");
std::fs::create_dir_all(&out_dir)
.with_context(|| format!("creating sdcpp output dir {}", out_dir.display()))?;
let stem = format!(
"out-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
);
let out_path = out_dir.join(format!("{stem}.webp"));
let mut temp_files = TempFileGuard::new();
temp_files.push(out_path.clone());
let init_img_path = match params.init_image_url.as_deref() {
Some(url) if !url.is_empty() => {
let ext = init_image_extension(url);
let init_path = out_dir.join(format!("{stem}-init.{ext}"));
download::download_file(url, &init_path).with_context(|| {
format!("downloading init image {} -> {}", url, init_path.display())
})?;
temp_files.push(init_path.clone());
Some(init_path)
}
_ => None,
};
let has_base = init_img_path.is_some() || params.ref_image_url.as_deref().is_some();
let mask_path = match (has_base, params.mask_url.as_deref()) {
(true, Some(url)) if !url.is_empty() => {
let ext = init_image_extension(url);
let path = out_dir.join(format!("{stem}-mask.{ext}"));
download::download_file(url, &path)
.with_context(|| format!("downloading mask {} -> {}", url, path.display()))?;
temp_files.push(path.clone());
Some(path)
}
_ => None,
};
let ref_img_path = match params.ref_image_url.as_deref() {
Some(url) if !url.is_empty() => {
let ext = init_image_extension(url);
let path = out_dir.join(format!("{stem}-ref.{ext}"));
download::download_file(url, &path).with_context(|| {
format!("downloading reference image {} -> {}", url, path.display())
})?;
temp_files.push(path.clone());
Some(path)
}
_ => None,
};
let args = build_sdcli_args(
¶ms,
source,
diffusion_model,
vae,
text_encoder,
text_encoder_vision,
&out_path,
init_img_path.as_deref(),
mask_path.as_deref(),
ref_img_path.as_deref(),
full_checkpoint,
);
let mut cmd = Command::new(&sd_cli);
cmd.args(&args);
apply_library_path(&mut cmd, &sd_cli);
debug!(
target: TRACE_TARGET,
op = "spawn",
sd_cli = %sd_cli.display(),
model,
i2i = init_img_path.is_some(),
arg_count = args.len(),
"running sd-cli"
);
let started = Instant::now();
let output = cmd
.output()
.with_context(|| format!("running {}", sd_cli.display()))?;
let elapsed_ms = started.elapsed().as_millis() as u64;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
target: TRACE_TARGET,
op = "spawn",
model,
elapsed_ms,
exit = ?output.status.code(),
stderr = %stderr,
"sd-cli failed"
);
bail!(
"sd-cli exited with {:?}: {}",
output.status.code(),
stderr.lines().last().unwrap_or("(no stderr)")
);
}
let bytes = std::fs::read(&out_path)
.with_context(|| format!("reading sd-cli output at {}", out_path.display()))?;
info!(
target: TRACE_TARGET,
op = "dispatch",
model,
elapsed_ms,
bytes = bytes.len(),
"ok"
);
Ok(TaskResult::Image {
bytes,
ext: "webp".to_string(),
})
}
}
impl Engine for SdCppEngine {
fn name(&self) -> &'static str {
"sdcpp"
}
fn capabilities(&self) -> EngineCapabilities {
let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
map.insert(TaskKind::Image, vec!["sd-cpp:*".to_string()]);
EngineCapabilities {
supported_models_per_kind: map,
}
}
fn dispatch(&self, _model: &str, _task: Task) -> Result<TaskResult> {
bail!(
"sdcpp engine requires a ModelSource on the offer; legacy push-based offers \
(no modelSource) cannot be served - re-promote the job through the studio"
)
}
fn dispatch_with_source(
&self,
model: &str,
task: Task,
source: &ModelSource,
) -> Result<TaskResult> {
match task {
Task::Image(p) => self.dispatch_image(model, p, source),
other => {
let kind = other.kind();
warn!(
target: TRACE_TARGET,
op = "dispatch",
model,
kind = kind.as_str(),
"sdcpp engine only serves image jobs"
);
Err(crate::engine::UnsupportedTask::new("sdcpp", kind).into())
}
}
}
}
fn file_for_role(files: &[(ModelFileRole, PathBuf)], role: ModelFileRole) -> Option<&Path> {
files
.iter()
.find(|(r, _)| *r == role)
.map(|(_, p)| p.as_path())
}
fn resolve_image_args(params: &ImageParams, source: &ModelSource) -> ResolvedImageArgs {
let width = if params.width > 0 {
params.width
} else if source.cli_defaults.width > 0 {
source.cli_defaults.width
} else {
1024
};
let height = if params.height > 0 {
params.height
} else if source.cli_defaults.height > 0 {
source.cli_defaults.height
} else {
1024
};
let steps = if params.steps > 0 && params.steps != 20 {
params.steps
} else if source.cli_defaults.steps > 0 {
source.cli_defaults.steps
} else {
STEPS_FALLBACK
};
let source_cfg = if source.cli_defaults.cfg_scale > 0.0 {
source.cli_defaults.cfg_scale
} else {
1.0
};
let cfg_scale = params.cfg_scale.filter(|v| *v > 0.0).unwrap_or(source_cfg);
let sampling_method = params
.sampling_method
.clone()
.or_else(|| source.cli_defaults.sampling_method.clone());
ResolvedImageArgs {
width,
height,
steps,
cfg_scale,
sampling_method,
}
}
#[derive(Debug, Clone, PartialEq)]
struct ResolvedImageArgs {
width: u32,
height: u32,
steps: u32,
cfg_scale: f32,
sampling_method: Option<String>,
}
#[allow(clippy::too_many_arguments)]
fn build_sdcli_args(
params: &ImageParams,
source: &ModelSource,
diffusion_model: &Path,
vae: Option<&Path>,
text_encoder: Option<&Path>,
text_encoder_vision: Option<&Path>,
out_path: &Path,
init_img_path: Option<&Path>,
mask_path: Option<&Path>,
ref_img_path: Option<&Path>,
full_checkpoint: bool,
) -> Vec<OsString> {
let resolved = resolve_image_args(params, source);
let mut args: Vec<OsString> = Vec::with_capacity(32);
args.push(
if full_checkpoint {
"--model"
} else {
"--diffusion-model"
}
.into(),
);
args.push(diffusion_model.into());
if let Some(p) = vae {
args.push("--vae".into());
args.push(p.into());
}
if let Some(p) = text_encoder {
args.push("--llm".into());
args.push(p.into());
}
if let Some(p) = text_encoder_vision {
args.push("--llm_vision".into());
args.push(p.into());
}
args.push("-p".into());
args.push((¶ms.prompt as &str).into());
if let Some(neg) = params.negative_prompt.as_deref() {
if !neg.is_empty() {
args.push("--negative-prompt".into());
args.push(neg.into());
}
}
if let Some(reference) = ref_img_path {
args.push("-r".into());
args.push(reference.into());
if let Some(mask) = mask_path {
args.push("--mask".into());
args.push(mask.into());
}
} else if let Some(init) = init_img_path {
args.push("--init-img".into());
args.push(init.into());
let strength = params.denoise.unwrap_or(0.75);
args.push("--strength".into());
args.push(strength.to_string().into());
if let Some(mask) = mask_path {
args.push("--mask".into());
args.push(mask.into());
}
}
args.push("--cfg-scale".into());
args.push(resolved.cfg_scale.to_string().into());
args.push("--steps".into());
args.push(resolved.steps.to_string().into());
args.push("-W".into());
args.push(resolved.width.to_string().into());
args.push("-H".into());
args.push(resolved.height.to_string().into());
args.push("-o".into());
args.push(out_path.into());
if let Some(seed) = params.seed {
args.push("--seed".into());
args.push(seed.to_string().into());
}
if let Some(method) = resolved.sampling_method.as_deref() {
args.push("--sampling-method".into());
args.push(method.into());
}
if let Some(shift) = source.cli_defaults.flow_shift {
args.push("--flow-shift".into());
args.push(shift.to_string().into());
}
if source.cli_defaults.zero_cond_t == Some(true) {
args.push("--qwen-image-zero-cond-t".into());
}
if source.cli_defaults.offload_to_cpu == Some(true) {
args.push("--offload-to-cpu".into());
}
args.push("--diffusion-fa".into());
args
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn apply_library_path(cmd: &mut Command, sd_cli: &Path) {
let Some((var, dir)) = sd_provision::library_path_env(sd_cli) else {
return;
};
let value = match std::env::var_os(var) {
Some(existing) => {
let mut paths = vec![dir.clone()];
paths.extend(std::env::split_paths(&existing));
std::env::join_paths(paths).unwrap_or_else(|_| dir.into_os_string())
}
None => dir.into_os_string(),
};
cmd.env(var, value);
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn resolve_sd_cli(models_root: &Path) -> Option<PathBuf> {
let bin = sd_provision::binary_name();
if let Ok(p) = std::env::var("STUDIO_WORKER_SD_CLI") {
let path = PathBuf::from(p);
if path.is_file() {
return Some(path);
}
}
let in_models = models_root.join("bin").join(bin);
if in_models.is_file() {
return Some(in_models);
}
if let Some(home) = std::env::var_os("HOME") {
let candidate = PathBuf::from(home).join(".local/bin").join(bin);
if candidate.is_file() {
return Some(candidate);
}
}
which(bin)
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn which(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for entry in std::env::split_paths(&path) {
let candidate = entry.join(bin);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn init_image_extension(url: &str) -> &'static str {
let path = url.split(['?', '#']).next().unwrap_or(url);
let lower_tail = path
.rsplit('.')
.next()
.map(|t| t.to_ascii_lowercase())
.unwrap_or_default();
match lower_tail.as_str() {
"png" => "png",
"jpg" | "jpeg" => "jpg",
"webp" => "webp",
"bmp" => "bmp",
"gif" => "gif",
"tif" | "tiff" => "tif",
_ => "webp",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ModelCliDefaults, ModelEngine, ModelFile, ModelFileRole};
use tempfile::tempdir;
fn fake_source(files: Vec<ModelFile>) -> ModelSource {
ModelSource {
engine: ModelEngine::SdCpp,
files,
cli_defaults: ModelCliDefaults {
cfg_scale: 1.0,
steps: 8,
width: 1024,
height: 1024,
sampling_method: Some("euler".to_string()),
..Default::default()
},
}
}
#[test]
fn file_for_role_picks_matching_file() {
let files = vec![
(ModelFileRole::DiffusionModel, PathBuf::from("/d.gguf")),
(ModelFileRole::Vae, PathBuf::from("/v.safetensors")),
];
assert_eq!(
file_for_role(&files, ModelFileRole::DiffusionModel),
Some(Path::new("/d.gguf"))
);
assert_eq!(
file_for_role(&files, ModelFileRole::Vae),
Some(Path::new("/v.safetensors"))
);
assert!(file_for_role(&files, ModelFileRole::TextEncoder).is_none());
}
#[test]
fn ensure_files_skips_already_present() {
let dir = tempdir().unwrap();
let cached = dir.path().join("cached.gguf");
std::fs::write(&cached, b"already here").unwrap();
let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
let source = fake_source(vec![ModelFile {
role: ModelFileRole::DiffusionModel,
url: "https://example.invalid/cached.gguf".into(),
filename: "cached.gguf".into(),
approx_bytes: None,
sha256: None,
}]);
let resolved = engine.ensure_files(&source).expect("cached file used");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].0, ModelFileRole::DiffusionModel);
assert_eq!(resolved[0].1, cached);
assert_eq!(std::fs::read(&cached).unwrap(), b"already here");
}
#[test]
fn dispatch_rejects_non_image_tasks() {
use crate::types::AudioTtsParams;
let dir = tempdir().unwrap();
let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
let task = Task::AudioTts(AudioTtsParams {
text: "hi".into(),
voice: "v".into(),
ext: "wav".into(),
..Default::default()
});
let source = fake_source(vec![]);
let err = engine
.dispatch_with_source("anything", task, &source)
.unwrap_err();
assert!(err.to_string().contains("cannot serve audio_tts"));
}
fn args_to_strings(args: &[OsString]) -> Vec<String> {
args.iter()
.map(|s| s.to_string_lossy().into_owned())
.collect()
}
fn idx_after(args: &[String], flag: &str) -> Option<usize> {
args.iter().position(|a| a == flag).map(|i| i + 1)
}
#[test]
fn build_sdcli_args_includes_required_flags() {
let params = ImageParams {
prompt: "hello".into(),
width: 768,
height: 512,
steps: 20, ..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/d.gguf"),
Some(Path::new("/v.safetensors")),
Some(Path::new("/llm.gguf")),
None,
Path::new("/tmp/out.webp"),
None,
None,
None,
false,
);
let s = args_to_strings(&args);
assert_eq!(s[idx_after(&s, "--diffusion-model").unwrap()], "/d.gguf");
assert_eq!(s[idx_after(&s, "--vae").unwrap()], "/v.safetensors");
assert_eq!(s[idx_after(&s, "--llm").unwrap()], "/llm.gguf");
assert_eq!(s[idx_after(&s, "-p").unwrap()], "hello");
assert_eq!(s[idx_after(&s, "-W").unwrap()], "768");
assert_eq!(s[idx_after(&s, "-H").unwrap()], "512");
assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "1");
assert_eq!(s[idx_after(&s, "--steps").unwrap()], "8");
assert_eq!(s[idx_after(&s, "--sampling-method").unwrap()], "euler");
assert_eq!(s[idx_after(&s, "-o").unwrap()], "/tmp/out.webp");
assert!(s.contains(&"--diffusion-fa".to_string()));
assert!(!s.contains(&"--init-img".to_string()));
assert!(!s.contains(&"--strength".to_string()));
}
#[test]
fn build_sdcli_args_includes_negative_prompt_when_set() {
let params = ImageParams {
prompt: "hi".into(),
negative_prompt: Some("text, watermark, low quality".into()),
..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/d.gguf"),
None,
None,
None,
Path::new("/tmp/out.webp"),
None,
None,
None,
false,
);
let s = args_to_strings(&args);
assert_eq!(
s[idx_after(&s, "--negative-prompt").unwrap()],
"text, watermark, low quality"
);
}
#[test]
fn build_sdcli_args_omits_negative_prompt_when_empty_string() {
let params = ImageParams {
prompt: "hi".into(),
negative_prompt: Some(String::new()),
..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/d.gguf"),
None,
None,
None,
Path::new("/tmp/out.webp"),
None,
None,
None,
false,
);
let s = args_to_strings(&args);
assert!(!s.contains(&"--negative-prompt".to_string()));
}
#[test]
fn build_sdcli_args_includes_init_image_and_strength() {
let params = ImageParams {
prompt: "hi".into(),
denoise: Some(0.55),
..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/d.gguf"),
None,
None,
None,
Path::new("/tmp/out.webp"),
Some(Path::new("/tmp/init.webp")),
None,
None,
false,
);
let s = args_to_strings(&args);
assert_eq!(s[idx_after(&s, "--init-img").unwrap()], "/tmp/init.webp");
assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.55");
assert!(!s.contains(&"--mask".to_string()));
}
#[test]
fn build_sdcli_args_includes_mask_for_inpaint() {
let params = ImageParams {
prompt: "remove the tree".into(),
denoise: Some(0.8),
..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/d.gguf"),
None,
None,
None,
Path::new("/tmp/out.webp"),
Some(Path::new("/tmp/init.webp")),
Some(Path::new("/tmp/mask.png")),
None,
false,
);
let s = args_to_strings(&args);
assert_eq!(s[idx_after(&s, "--init-img").unwrap()], "/tmp/init.webp");
assert_eq!(s[idx_after(&s, "--mask").unwrap()], "/tmp/mask.png");
assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.8");
}
#[test]
fn build_sdcli_args_uses_model_flag_for_full_checkpoint() {
let params = ImageParams {
prompt: "hi".into(),
..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/checkpoint.safetensors"),
Some(Path::new("/v.safetensors")),
None,
None,
Path::new("/tmp/out.webp"),
None,
None,
None,
true,
);
let s = args_to_strings(&args);
assert_eq!(
s[idx_after(&s, "--model").unwrap()],
"/checkpoint.safetensors"
);
assert!(!s.contains(&"--diffusion-model".to_string()));
}
#[test]
fn build_sdcli_args_defaults_denoise_when_init_image_present_but_denoise_none() {
let params = ImageParams {
prompt: "hi".into(),
denoise: None,
..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/d.gguf"),
None,
None,
None,
Path::new("/tmp/out.webp"),
Some(Path::new("/tmp/init.webp")),
None,
None,
false,
);
let s = args_to_strings(&args);
assert_eq!(s[idx_after(&s, "--strength").unwrap()], "0.75");
}
#[test]
fn build_sdcli_args_per_job_cfg_scale_overrides_model_default() {
let params = ImageParams {
prompt: "hi".into(),
cfg_scale: Some(7.5),
..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/d.gguf"),
None,
None,
None,
Path::new("/tmp/out.webp"),
None,
None,
None,
false,
);
let s = args_to_strings(&args);
assert_eq!(s[idx_after(&s, "--cfg-scale").unwrap()], "7.5");
}
#[test]
fn build_sdcli_args_per_job_sampling_method_overrides_model_default() {
let params = ImageParams {
prompt: "hi".into(),
sampling_method: Some("dpm++2m".into()),
..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/d.gguf"),
None,
None,
None,
Path::new("/tmp/out.webp"),
None,
None,
None,
false,
);
let s = args_to_strings(&args);
assert_eq!(s[idx_after(&s, "--sampling-method").unwrap()], "dpm++2m");
}
#[test]
fn build_sdcli_args_per_job_steps_overrides_when_non_default() {
let params = ImageParams {
prompt: "hi".into(),
steps: 30, ..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/d.gguf"),
None,
None,
None,
Path::new("/tmp/out.webp"),
None,
None,
None,
false,
);
let s = args_to_strings(&args);
assert_eq!(s[idx_after(&s, "--steps").unwrap()], "30");
}
#[test]
fn build_sdcli_args_seed_included_when_set() {
let params = ImageParams {
prompt: "hi".into(),
seed: Some(42),
..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/d.gguf"),
None,
None,
None,
Path::new("/tmp/out.webp"),
None,
None,
None,
false,
);
let s = args_to_strings(&args);
assert_eq!(s[idx_after(&s, "--seed").unwrap()], "42");
}
fn qwen_edit_source() -> ModelSource {
ModelSource {
engine: ModelEngine::SdCpp,
files: vec![],
cli_defaults: ModelCliDefaults {
cfg_scale: 4.0,
steps: 20,
width: 1024,
height: 1024,
sampling_method: Some("euler".to_string()),
flow_shift: Some(3.0),
zero_cond_t: Some(true),
offload_to_cpu: Some(true),
},
}
}
#[test]
fn build_sdcli_args_reference_mode_for_instruction_edit() {
let params = ImageParams {
prompt: "add a red beach ball".into(),
denoise: Some(0.9),
..Default::default()
};
let source = qwen_edit_source();
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/qwen.gguf"),
Some(Path::new("/vae.safetensors")),
Some(Path::new("/llm.gguf")),
Some(Path::new("/mmproj.gguf")),
Path::new("/tmp/out.webp"),
None,
Some(Path::new("/tmp/mask.png")),
Some(Path::new("/tmp/ref.webp")),
false,
);
let s = args_to_strings(&args);
assert_eq!(s[idx_after(&s, "-r").unwrap()], "/tmp/ref.webp");
assert_eq!(s[idx_after(&s, "--mask").unwrap()], "/tmp/mask.png");
assert!(!s.contains(&"--init-img".to_string()));
assert!(!s.contains(&"--strength".to_string()));
assert_eq!(s[idx_after(&s, "--llm_vision").unwrap()], "/mmproj.gguf");
assert_eq!(s[idx_after(&s, "--flow-shift").unwrap()], "3");
assert!(s.contains(&"--qwen-image-zero-cond-t".to_string()));
assert!(s.contains(&"--offload-to-cpu".to_string()));
}
#[test]
fn build_sdcli_args_omits_qwen_flags_for_plain_model() {
let params = ImageParams {
prompt: "hi".into(),
..Default::default()
};
let source = fake_source(vec![]);
let args = build_sdcli_args(
¶ms,
&source,
Path::new("/d.gguf"),
None,
None,
None,
Path::new("/tmp/out.webp"),
None,
None,
None,
false,
);
let s = args_to_strings(&args);
assert!(!s.contains(&"--flow-shift".to_string()));
assert!(!s.contains(&"--qwen-image-zero-cond-t".to_string()));
assert!(!s.contains(&"--offload-to-cpu".to_string()));
assert!(!s.contains(&"--llm_vision".to_string()));
assert!(!s.contains(&"-r".to_string()));
}
#[test]
fn capabilities_advertises_only_image_kind() {
let dir = tempdir().unwrap();
let engine = SdCppEngine::with_paths(PathBuf::from("/usr/bin/true"), dir.path().into());
let caps = engine.capabilities();
assert!(caps
.supported_models_per_kind
.contains_key(&TaskKind::Image));
assert_eq!(caps.supported_models_per_kind.len(), 1);
}
#[test]
fn init_image_extension_reads_url_tail() {
assert_eq!(init_image_extension("https://x/y/latest.webp"), "webp");
assert_eq!(init_image_extension("https://x/y/latest.PNG"), "png");
assert_eq!(init_image_extension("https://x/y/latest.jpg"), "jpg");
assert_eq!(init_image_extension("https://x/y/latest.jpeg"), "jpg");
assert_eq!(
init_image_extension("https://x/y/latest.webp?v=42&t=now"),
"webp"
);
assert_eq!(init_image_extension("https://x/y/latest.webp#frag"), "webp");
assert_eq!(
init_image_extension("https://x/y/latest.unknownext"),
"webp"
);
assert_eq!(init_image_extension("https://x/y/no-ext"), "webp");
}
}