1use std::sync::atomic::{AtomicU64, Ordering};
9
10use chrono::Utc;
11
12use crate::catalog::Catalog;
13use crate::engine::Engine;
14use crate::runtime::{record_local_job, truncate_prompt, JobOutcome, RecentJob, WorkerObservers};
15use crate::types::{ImageParams, Task, TaskKind, TaskResult};
16
17#[derive(Debug, Clone, Default)]
20pub struct LocalImageRequest {
21 pub prompt: String,
22 pub model: Option<String>,
24 pub negative_prompt: Option<String>,
25 pub width: Option<u32>,
26 pub height: Option<u32>,
27 pub steps: Option<u32>,
28 pub seed: Option<u64>,
29 pub ext: Option<String>,
30}
31
32#[derive(Debug, thiserror::Error)]
34pub enum LocalError {
35 #[error("unknown model '{0}' (not in the local catalog)")]
36 UnknownModel(String),
37 #[error("no image model configured in the local catalog")]
38 NoDefaultModel,
39 #[error("model '{0}' is not an image model")]
40 NotImageModel(String),
41 #[error("engine error: {0}")]
42 Engine(String),
43}
44
45fn next_job_id() -> String {
46 static COUNTER: AtomicU64 = AtomicU64::new(0);
47 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
48 format!("local-{}-{n}", Utc::now().timestamp_millis())
49}
50
51pub fn run_image(
54 engine: &dyn Engine,
55 catalog: &Catalog,
56 observers: &WorkerObservers,
57 req: &LocalImageRequest,
58) -> Result<TaskResult, LocalError> {
59 let model = match &req.model {
60 Some(id) => catalog
61 .get(id)
62 .ok_or_else(|| LocalError::UnknownModel(id.clone()))?,
63 None => catalog
64 .default_image_model()
65 .ok_or(LocalError::NoDefaultModel)?,
66 };
67 if model.kind != TaskKind::Image {
68 return Err(LocalError::NotImageModel(model.id.clone()));
69 }
70
71 let defaults = &model.source.cli_defaults;
72 let params = ImageParams {
73 prompt: req.prompt.clone(),
74 negative_prompt: req.negative_prompt.clone(),
75 width: req.width.unwrap_or(defaults.width).max(1),
76 height: req.height.unwrap_or(defaults.height).max(1),
77 steps: req.steps.unwrap_or(defaults.steps).max(1),
78 seed: req.seed,
79 cfg_scale: Some(defaults.cfg_scale),
80 sampling_method: defaults.sampling_method.clone(),
81 ext: req.ext.clone().unwrap_or_else(|| "webp".to_string()),
82 ..Default::default()
83 };
84
85 let job_id = next_job_id();
86 let started_at = Utc::now();
87 let result = engine.dispatch_with_source(&model.id, Task::Image(params), &model.source);
88 let finished_at = Utc::now();
89
90 let outcome = match &result {
91 Ok(_) => JobOutcome::Completed,
92 Err(err) => JobOutcome::Failed {
93 reason: err.to_string(),
94 },
95 };
96 record_local_job(
97 observers,
98 RecentJob {
99 job_id,
100 kind: TaskKind::Image,
101 model: model.id.clone(),
102 prompt: truncate_prompt(&req.prompt),
103 outcome,
104 started_at,
105 finished_at,
106 },
107 );
108
109 result.map_err(|err| LocalError::Engine(err.to_string()))
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use crate::catalog::CatalogModel;
116 use crate::engine::SyntheticEngine;
117 use crate::types::{ModelCliDefaults, ModelEngine, ModelSource};
118
119 fn synthetic_model(id: &str, kind: TaskKind) -> CatalogModel {
120 CatalogModel {
121 id: id.into(),
122 display_name: id.into(),
123 kind,
124 vram_gb_estimate: 0.0,
125 description: None,
126 source: ModelSource {
127 engine: ModelEngine::Synthetic,
128 files: vec![],
129 cli_defaults: ModelCliDefaults {
130 cfg_scale: 1.0,
131 steps: 4,
132 width: 64,
133 height: 64,
134 ..Default::default()
135 },
136 },
137 enabled: true,
138 }
139 }
140
141 fn catalog_with(models: Vec<CatalogModel>) -> Catalog {
142 Catalog { models }
143 }
144
145 #[test]
146 fn generates_image_and_records_local_job() {
147 let engine = SyntheticEngine::new();
148 let catalog = catalog_with(vec![synthetic_model("synthetic-img", TaskKind::Image)]);
149 let observers = WorkerObservers::default();
150 let req = LocalImageRequest {
151 prompt: "a red fox".into(),
152 ..Default::default()
153 };
154
155 let result = run_image(&engine, &catalog, &observers, &req).unwrap();
156 match result {
157 TaskResult::Image { bytes, ext } => {
158 assert!(!bytes.is_empty());
159 assert_eq!(ext, "webp");
160 }
161 other => panic!("expected image, got {other:?}"),
162 }
163
164 let ring = observers.local_jobs.lock();
165 assert_eq!(ring.len(), 1);
166 let job = &ring[0];
167 assert_eq!(job.model, "synthetic-img");
168 assert_eq!(job.outcome, JobOutcome::Completed);
169 assert_eq!(job.prompt, "a red fox");
170 assert!(observers.recent_jobs.lock().is_empty());
172 }
173
174 #[test]
175 fn defaults_to_the_only_image_model_when_unspecified() {
176 let engine = SyntheticEngine::new();
177 let catalog = catalog_with(vec![synthetic_model("only-img", TaskKind::Image)]);
178 let observers = WorkerObservers::default();
179 let req = LocalImageRequest {
180 prompt: "x".into(),
181 model: None,
182 ..Default::default()
183 };
184 let out = run_image(&engine, &catalog, &observers, &req).unwrap();
185 assert!(matches!(out, TaskResult::Image { .. }));
186 assert_eq!(observers.local_jobs.lock()[0].model, "only-img");
187 }
188
189 #[test]
190 fn unknown_model_is_rejected() {
191 let engine = SyntheticEngine::new();
192 let catalog = catalog_with(vec![synthetic_model("a", TaskKind::Image)]);
193 let observers = WorkerObservers::default();
194 let req = LocalImageRequest {
195 prompt: "x".into(),
196 model: Some("missing".into()),
197 ..Default::default()
198 };
199 let err = run_image(&engine, &catalog, &observers, &req).unwrap_err();
200 assert!(matches!(err, LocalError::UnknownModel(m) if m == "missing"));
201 assert!(observers.local_jobs.lock().is_empty());
202 }
203
204 #[test]
205 fn no_image_model_yields_no_default() {
206 let engine = SyntheticEngine::new();
207 let catalog = catalog_with(vec![]);
208 let observers = WorkerObservers::default();
209 let req = LocalImageRequest {
210 prompt: "x".into(),
211 ..Default::default()
212 };
213 let err = run_image(&engine, &catalog, &observers, &req).unwrap_err();
214 assert!(matches!(err, LocalError::NoDefaultModel));
215 }
216
217 #[test]
218 fn non_image_model_is_rejected() {
219 let engine = SyntheticEngine::new();
220 let catalog = catalog_with(vec![synthetic_model("chat", TaskKind::Llm)]);
221 let observers = WorkerObservers::default();
222 let req = LocalImageRequest {
223 prompt: "x".into(),
224 model: Some("chat".into()),
225 ..Default::default()
226 };
227 let err = run_image(&engine, &catalog, &observers, &req).unwrap_err();
228 assert!(matches!(err, LocalError::NotImageModel(m) if m == "chat"));
229 }
230}