blazen_image_diffusion/
provider.rs1use std::fmt;
10
11use crate::DiffusionOptions;
12
13#[derive(Debug)]
15pub enum DiffusionError {
16 InvalidOptions(String),
18 ModelLoad(String),
20 Generation(String),
22 EngineNotAvailable,
26}
27
28impl fmt::Display for DiffusionError {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 Self::InvalidOptions(msg) => write!(f, "diffusion-rs invalid options: {msg}"),
32 Self::ModelLoad(msg) => write!(f, "diffusion-rs model load failed: {msg}"),
33 Self::Generation(msg) => write!(f, "diffusion-rs generation failed: {msg}"),
34 Self::EngineNotAvailable => f.write_str(
35 "diffusion-rs runtime is not linked -- rebuild blazen-image-diffusion \
36 with the `engine` feature (or a forwarding feature such as `cuda` / \
37 `metal`) to enable image generation",
38 ),
39 }
40 }
41}
42
43impl std::error::Error for DiffusionError {}
44
45pub struct DiffusionProvider {
52 options: DiffusionOptions,
54 #[cfg(feature = "engine")]
55 engine: tokio::sync::OnceCell<std::sync::Arc<crate::engine::Engine>>,
56}
57
58impl DiffusionProvider {
59 pub fn from_options(opts: DiffusionOptions) -> Result<Self, DiffusionError> {
69 if let Some(ref device) = opts.device
70 && device.is_empty()
71 {
72 return Err(DiffusionError::InvalidOptions(
73 "device must not be empty when specified".into(),
74 ));
75 }
76
77 if let Some(ref model_id) = opts.model_id
78 && model_id.is_empty()
79 {
80 return Err(DiffusionError::InvalidOptions(
81 "model_id must not be empty when specified".into(),
82 ));
83 }
84
85 if let Some(width) = opts.width
86 && width == 0
87 {
88 return Err(DiffusionError::InvalidOptions(
89 "width must be greater than zero".into(),
90 ));
91 }
92
93 if let Some(height) = opts.height
94 && height == 0
95 {
96 return Err(DiffusionError::InvalidOptions(
97 "height must be greater than zero".into(),
98 ));
99 }
100
101 if let Some(steps) = opts.num_inference_steps
102 && steps == 0
103 {
104 return Err(DiffusionError::InvalidOptions(
105 "num_inference_steps must be greater than zero".into(),
106 ));
107 }
108
109 if let Some(scale) = opts.guidance_scale
110 && scale <= 0.0
111 {
112 return Err(DiffusionError::InvalidOptions(
113 "guidance_scale must be positive".into(),
114 ));
115 }
116
117 Ok(Self {
118 options: opts,
119 #[cfg(feature = "engine")]
120 engine: tokio::sync::OnceCell::new(),
121 })
122 }
123
124 #[must_use]
126 pub fn device_str(&self) -> &str {
127 self.options.device.as_deref().unwrap_or("cpu")
128 }
129
130 #[must_use]
132 pub fn model_id(&self) -> &str {
133 self.options.model_id.as_deref().unwrap_or("sd-1.5")
134 }
135
136 #[must_use]
138 pub fn width(&self) -> u32 {
139 self.options.width.unwrap_or(512)
140 }
141
142 #[must_use]
144 pub fn height(&self) -> u32 {
145 self.options.height.unwrap_or(512)
146 }
147
148 #[must_use]
150 pub fn num_inference_steps(&self) -> u32 {
151 self.options.num_inference_steps.unwrap_or(20)
152 }
153
154 #[must_use]
156 pub fn guidance_scale(&self) -> f32 {
157 self.options.guidance_scale.unwrap_or(7.5)
158 }
159
160 #[must_use]
162 pub const fn scheduler(&self) -> crate::DiffusionScheduler {
163 self.options.scheduler
164 }
165
166 #[allow(clippy::unused_async)] pub async fn load(&self) -> Result<(), DiffusionError> {
178 #[cfg(feature = "engine")]
179 {
180 let opts = self.options.clone();
181 self.engine
182 .get_or_try_init(|| async move {
183 tokio::task::spawn_blocking(move || crate::engine::Engine::new(&opts))
184 .await
185 .map_err(|e| DiffusionError::ModelLoad(format!("join: {e}")))?
186 .map(std::sync::Arc::new)
187 })
188 .await?;
189 Ok(())
190 }
191 #[cfg(not(feature = "engine"))]
192 {
193 Err(DiffusionError::EngineNotAvailable)
194 }
195 }
196
197 #[allow(clippy::unused_async)]
211 pub async fn unload(&self) -> Result<(), DiffusionError> {
212 Ok(())
213 }
214
215 #[allow(clippy::unused_async)]
218 pub async fn is_loaded(&self) -> bool {
219 #[cfg(feature = "engine")]
220 {
221 self.engine.initialized()
222 }
223 #[cfg(not(feature = "engine"))]
224 {
225 false
226 }
227 }
228}
229
230#[cfg(feature = "engine")]
231impl DiffusionProvider {
232 pub async fn generate_image_inherent(
242 &self,
243 prompt: String,
244 negative_prompt: Option<String>,
245 width: Option<u32>,
246 height: Option<u32>,
247 ) -> Result<crate::engine::GeneratedImage, DiffusionError> {
248 let opts = self.options.clone();
250 let engine = self
251 .engine
252 .get_or_try_init(|| async move {
253 tokio::task::spawn_blocking(move || crate::engine::Engine::new(&opts))
254 .await
255 .map_err(|e| DiffusionError::ModelLoad(format!("join: {e}")))?
256 .map(std::sync::Arc::new)
257 })
258 .await?
259 .clone();
260
261 let w = width.unwrap_or_else(|| self.width());
262 let h = height.unwrap_or_else(|| self.height());
263 let steps = self.num_inference_steps();
264 let scale = self.guidance_scale();
265
266 tokio::task::spawn_blocking(move || {
267 engine.txt2img(&prompt, negative_prompt.as_deref(), w, h, steps, scale)
268 })
269 .await
270 .map_err(|e| DiffusionError::Generation(format!("join: {e}")))?
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::{DiffusionOptions, DiffusionScheduler};
278
279 #[test]
280 fn from_options_with_defaults() {
281 let opts = DiffusionOptions::default();
282 let provider = DiffusionProvider::from_options(opts).expect("should succeed");
283 assert_eq!(provider.width(), 512);
284 assert_eq!(provider.height(), 512);
285 assert_eq!(provider.num_inference_steps(), 20);
286 assert!((provider.guidance_scale() - 7.5).abs() < f32::EPSILON);
287 assert_eq!(provider.scheduler(), DiffusionScheduler::EulerA);
288 }
289
290 #[cfg(feature = "engine")]
297 #[tokio::test]
298 #[ignore = "downloads an SD-Turbo diffusion model + generates an image"]
299 async fn smoke_generate_image() {
300 let opts = DiffusionOptions {
301 model_id: Some("sd-turbo".into()),
302 num_inference_steps: Some(1),
303 ..DiffusionOptions::default()
304 };
305 let provider = DiffusionProvider::from_options(opts).expect("options valid");
306 let image = provider
307 .generate_image_inherent("a red square".into(), None, Some(512), Some(512))
308 .await
309 .expect("image generation should succeed");
310 assert!(
311 !image.bytes.is_empty(),
312 "should produce non-empty image bytes"
313 );
314 assert!(
315 image.width > 0 && image.height > 0,
316 "image should have positive dimensions, got {}x{}",
317 image.width,
318 image.height
319 );
320 }
321
322 #[test]
323 fn from_options_with_custom_values() {
324 let opts = DiffusionOptions {
325 model_id: Some("stabilityai/stable-diffusion-2-1".into()),
326 width: Some(1024),
327 height: Some(768),
328 num_inference_steps: Some(30),
329 guidance_scale: Some(10.0),
330 scheduler: DiffusionScheduler::Dpm,
331 ..DiffusionOptions::default()
332 };
333 let provider = DiffusionProvider::from_options(opts).expect("should succeed");
334 assert_eq!(provider.width(), 1024);
335 assert_eq!(provider.height(), 768);
336 assert_eq!(provider.num_inference_steps(), 30);
337 assert!((provider.guidance_scale() - 10.0).abs() < f32::EPSILON);
338 assert_eq!(provider.scheduler(), DiffusionScheduler::Dpm);
339 }
340
341 #[test]
342 fn from_options_rejects_empty_device() {
343 let opts = DiffusionOptions {
344 device: Some(String::new()),
345 ..DiffusionOptions::default()
346 };
347 assert!(DiffusionProvider::from_options(opts).is_err());
348 }
349
350 #[test]
351 fn from_options_rejects_empty_model_id() {
352 let opts = DiffusionOptions {
353 model_id: Some(String::new()),
354 ..DiffusionOptions::default()
355 };
356 assert!(DiffusionProvider::from_options(opts).is_err());
357 }
358
359 #[test]
360 fn from_options_rejects_zero_width() {
361 let opts = DiffusionOptions {
362 width: Some(0),
363 ..DiffusionOptions::default()
364 };
365 assert!(DiffusionProvider::from_options(opts).is_err());
366 }
367
368 #[test]
369 fn from_options_rejects_zero_height() {
370 let opts = DiffusionOptions {
371 height: Some(0),
372 ..DiffusionOptions::default()
373 };
374 assert!(DiffusionProvider::from_options(opts).is_err());
375 }
376
377 #[test]
378 fn from_options_rejects_zero_steps() {
379 let opts = DiffusionOptions {
380 num_inference_steps: Some(0),
381 ..DiffusionOptions::default()
382 };
383 assert!(DiffusionProvider::from_options(opts).is_err());
384 }
385
386 #[test]
387 fn from_options_rejects_non_positive_guidance() {
388 let opts = DiffusionOptions {
389 guidance_scale: Some(0.0),
390 ..DiffusionOptions::default()
391 };
392 assert!(DiffusionProvider::from_options(opts).is_err());
393
394 let opts = DiffusionOptions {
395 guidance_scale: Some(-1.0),
396 ..DiffusionOptions::default()
397 };
398 assert!(DiffusionProvider::from_options(opts).is_err());
399 }
400
401 #[test]
402 fn from_options_accepts_valid_device() {
403 let opts = DiffusionOptions {
404 device: Some("cuda:0".into()),
405 ..DiffusionOptions::default()
406 };
407 let provider = DiffusionProvider::from_options(opts).expect("should succeed");
408 assert_eq!(provider.width(), 512);
409 }
410}