Skip to main content

blazen_image_diffusion/
model_impl.rs

1//! Bridge between [`crate::DiffusionProvider`] and the
2//! [`ImageGeneration`](blazen_llm_core::compute::ImageGeneration) trait.
3//!
4//! `diffusion-rs` (the underlying `stable-diffusion.cpp` wrapper) is a
5//! synchronous, single-call engine: one
6//! [`ImageGeneration::generate_image`] call drives one `gen_img`
7//! invocation and returns one
8//! [`ImageResult`](blazen_llm_core::compute::ImageResult). There is no
9//! per-step callback exposed through Blazen because upstream's `Progress`
10//! type has private fields (see the crate-level docs on
11//! `blazen-image-diffusion`).
12//!
13//! As with the other local backends, the [`ComputeProvider`] job-queue
14//! verbs (`submit` / `status` / `result` / `cancel`) return
15//! [`BlazenError::Unsupported`] -- callers should invoke
16//! [`ImageGeneration::generate_image`] directly.
17//!
18//! Without the `engine` feature the underlying provider's inherent engine
19//! methods are not compiled in, so every call through this bridge surfaces
20//! as a [`BlazenError::Provider`] wrapping `EngineNotAvailable`.
21
22use async_trait::async_trait;
23#[cfg(feature = "engine")]
24use base64::Engine as _;
25#[cfg(feature = "engine")]
26use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
27
28use blazen_llm_core::compute::{
29    ComputeProvider, ComputeRequest, ComputeResult, ImageGeneration, ImageRequest, ImageResult,
30    JobHandle, JobStatus, UpscaleRequest,
31};
32use blazen_llm_core::error::BlazenError;
33#[cfg(feature = "engine")]
34use blazen_llm_core::media::{GeneratedImage, MediaOutput, MediaType};
35use blazen_llm_core::traits::LocalModel;
36#[cfg(feature = "engine")]
37use blazen_llm_core::types::RequestTiming;
38
39use crate::{DiffusionError, DiffusionProvider};
40
41// ---------------------------------------------------------------------------
42// Helpers
43// ---------------------------------------------------------------------------
44
45/// Convert a [`DiffusionError`] into a [`BlazenError`] preserving the
46/// distinction between configuration errors and runtime failures.
47fn map_err(e: DiffusionError) -> BlazenError {
48    match e {
49        DiffusionError::InvalidOptions(msg) => {
50            BlazenError::provider("diffusion-rs", format!("invalid options: {msg}"))
51        }
52        DiffusionError::EngineNotAvailable => BlazenError::provider(
53            "diffusion-rs",
54            "engine feature not enabled -- rebuild blazen-image-diffusion with `engine`",
55        ),
56        DiffusionError::ModelLoad(msg) | DiffusionError::Generation(msg) => {
57            BlazenError::provider("diffusion-rs", msg)
58        }
59    }
60}
61
62/// Convert a freshly-generated raw image into Blazen's typed
63/// [`GeneratedImage`]. Only invoked from the engine code path; gated to
64/// the `engine` feature because [`crate::GeneratedImage`] only exists with
65/// `engine` on.
66#[cfg(feature = "engine")]
67fn to_generated_image(raw: crate::GeneratedImage) -> GeneratedImage {
68    let crate::GeneratedImage {
69        bytes,
70        width,
71        height,
72    } = raw;
73    let media_type = MediaType::detect(&bytes).unwrap_or(MediaType::Png);
74    let size_bytes = u64::try_from(bytes.len()).ok();
75    let base64 = BASE64_STANDARD.encode(&bytes);
76    let mut media = MediaOutput::from_base64(base64, media_type);
77    media.file_size = size_bytes;
78    GeneratedImage {
79        media,
80        width: Some(width),
81        height: Some(height),
82    }
83}
84
85// ---------------------------------------------------------------------------
86// ComputeProvider
87// ---------------------------------------------------------------------------
88
89#[async_trait]
90impl ComputeProvider for DiffusionProvider {
91    #[allow(clippy::unnecessary_literal_bound)]
92    fn provider_id(&self) -> &str {
93        "diffusion-rs"
94    }
95
96    async fn submit(&self, _request: ComputeRequest) -> Result<JobHandle, BlazenError> {
97        Err(BlazenError::unsupported(
98            "diffusion-rs runs locally and does not use the ComputeRequest job API; \
99             call `ImageGeneration::generate_image` directly instead",
100        ))
101    }
102
103    async fn status(&self, _job: &JobHandle) -> Result<JobStatus, BlazenError> {
104        Err(BlazenError::unsupported(
105            "diffusion-rs does not expose a job queue -- generation is synchronous",
106        ))
107    }
108
109    async fn result(&self, _job: JobHandle) -> Result<ComputeResult, BlazenError> {
110        Err(BlazenError::unsupported(
111            "diffusion-rs does not expose a job queue -- generation is synchronous",
112        ))
113    }
114
115    async fn cancel(&self, _job: &JobHandle) -> Result<(), BlazenError> {
116        Err(BlazenError::unsupported(
117            "diffusion-rs generation is synchronous and cannot be cancelled",
118        ))
119    }
120}
121
122// ---------------------------------------------------------------------------
123// ImageGeneration
124// ---------------------------------------------------------------------------
125
126#[async_trait]
127impl ImageGeneration for DiffusionProvider {
128    async fn generate_image(&self, request: ImageRequest) -> Result<ImageResult, BlazenError> {
129        if request.prompt.trim().is_empty() {
130            return Err(BlazenError::provider(
131                "diffusion-rs",
132                "prompt must not be empty",
133            ));
134        }
135        if let Some(w) = request.width
136            && w == 0
137        {
138            return Err(BlazenError::provider(
139                "diffusion-rs",
140                "request width must be greater than zero",
141            ));
142        }
143        if let Some(h) = request.height
144            && h == 0
145        {
146            return Err(BlazenError::provider(
147                "diffusion-rs",
148                "request height must be greater than zero",
149            ));
150        }
151        if let Some(n) = request.num_images
152            && n > 1
153        {
154            return Err(BlazenError::unsupported(
155                "diffusion-rs bridge currently runs one image per call; \
156                 invoke generate_image multiple times for batches",
157            ));
158        }
159
160        #[cfg(feature = "engine")]
161        {
162            let start = std::time::Instant::now();
163            let raw = DiffusionProvider::generate_image_inherent(
164                self,
165                request.prompt,
166                request.negative_prompt,
167                request.width,
168                request.height,
169            )
170            .await
171            .map_err(map_err)?;
172            #[allow(clippy::cast_possible_truncation)]
173            let total_ms = start.elapsed().as_millis() as u64;
174            let image = to_generated_image(raw);
175            Ok(ImageResult {
176                images: vec![image],
177                timing: RequestTiming {
178                    queue_ms: None,
179                    execution_ms: Some(total_ms),
180                    total_ms: Some(total_ms),
181                },
182                cost: None,
183                usage: None,
184                image_count: 1,
185                metadata: serde_json::Value::Null,
186            })
187        }
188        #[cfg(not(feature = "engine"))]
189        {
190            let _ = request;
191            Err(map_err(DiffusionError::EngineNotAvailable))
192        }
193    }
194
195    async fn upscale_image(&self, _request: UpscaleRequest) -> Result<ImageResult, BlazenError> {
196        Err(BlazenError::unsupported(
197            "diffusion-rs does not support image upscaling through this bridge -- \
198             configure a dedicated upscaler (ESRGAN) at the provider level or \
199             use a remote upscale provider",
200        ))
201    }
202}
203
204// ---------------------------------------------------------------------------
205// LocalModel
206// ---------------------------------------------------------------------------
207
208/// `LocalModel` bridge: gives `ModelManager` explicit load/unload control
209/// over the underlying diffusion-rs pipeline. The implementation forwards
210/// to the inherent methods on [`DiffusionProvider`] and wraps
211/// [`DiffusionError`] into [`BlazenError::Provider`] via [`map_err`].
212#[async_trait]
213impl LocalModel for DiffusionProvider {
214    async fn load(&self) -> Result<(), BlazenError> {
215        DiffusionProvider::load(self).await.map_err(map_err)
216    }
217
218    async fn unload(&self) -> Result<(), BlazenError> {
219        DiffusionProvider::unload(self).await.map_err(map_err)
220    }
221
222    async fn is_loaded(&self) -> bool {
223        DiffusionProvider::is_loaded(self).await
224    }
225
226    fn device(&self) -> blazen_llm_core::device::Device {
227        blazen_llm_core::device::Device::parse(self.device_str())
228            .unwrap_or(blazen_llm_core::device::Device::Cpu)
229    }
230
231    async fn load_adapter(
232        &self,
233        _adapter_dir: &std::path::Path,
234        _options: blazen_llm_core::AdapterOptions,
235    ) -> Result<blazen_llm_core::AdapterHandle, BlazenError> {
236        Err(BlazenError::unsupported(
237            "diffusion-rs does not support LoRA adapters through this bridge -- \
238             attach LoRAs at construction time via diffusion-rs modifiers instead",
239        ))
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Tests
245// ---------------------------------------------------------------------------
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::DiffusionOptions;
251
252    fn provider() -> DiffusionProvider {
253        DiffusionProvider::from_options(DiffusionOptions::default())
254            .expect("default options should validate")
255    }
256
257    #[tokio::test]
258    async fn provider_id_is_diffusion_rs() {
259        assert_eq!(ComputeProvider::provider_id(&provider()), "diffusion-rs");
260    }
261
262    #[tokio::test]
263    async fn submit_is_unsupported() {
264        let request = ComputeRequest {
265            model: "diffusion-rs".into(),
266            input: serde_json::Value::Null,
267            webhook: None,
268        };
269        let err = provider().submit(request).await.unwrap_err();
270        assert!(matches!(err, BlazenError::Unsupported { .. }));
271    }
272
273    #[tokio::test]
274    async fn cancel_is_unsupported() {
275        let handle = JobHandle {
276            id: "fake".into(),
277            provider: "diffusion-rs".into(),
278            model: "diffusion-rs".into(),
279            submitted_at: chrono::Utc::now(),
280        };
281        let err = provider().cancel(&handle).await.unwrap_err();
282        assert!(matches!(err, BlazenError::Unsupported { .. }));
283    }
284
285    #[tokio::test]
286    async fn upscale_is_unsupported() {
287        let req = UpscaleRequest::new("file:///nope.png", 2.0);
288        let err = ImageGeneration::upscale_image(&provider(), req)
289            .await
290            .unwrap_err();
291        assert!(matches!(err, BlazenError::Unsupported { .. }));
292    }
293
294    #[tokio::test]
295    async fn invalid_request_zero_width_rejected() {
296        let req = ImageRequest::new("a cat").with_size(0, 256);
297        let err = ImageGeneration::generate_image(&provider(), req)
298            .await
299            .unwrap_err();
300        assert!(
301            matches!(err, BlazenError::Provider { .. }),
302            "expected Provider error, got: {err:?}"
303        );
304    }
305
306    #[tokio::test]
307    async fn invalid_request_empty_prompt_rejected() {
308        let req = ImageRequest::new("   ");
309        let err = ImageGeneration::generate_image(&provider(), req)
310            .await
311            .unwrap_err();
312        assert!(matches!(err, BlazenError::Provider { .. }));
313    }
314
315    #[tokio::test]
316    async fn batch_requests_are_unsupported() {
317        let req = ImageRequest::new("a cat").with_count(3);
318        let err = ImageGeneration::generate_image(&provider(), req)
319            .await
320            .unwrap_err();
321        assert!(matches!(err, BlazenError::Unsupported { .. }));
322    }
323}