1use 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
41fn 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#[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#[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#[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#[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#[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}