Skip to main content

studio_worker/
local.rs

1//! Local image generation — submit a prompt straight to the engine, no studio.
2//!
3//! Mirrors the studio job path (`ws::session::run_offered_job`): resolve the
4//! model's [`ModelSource`] (here from the local [`Catalog`] instead of a studio
5//! offer), build a [`Task::Image`], dispatch, and record the finished job — into
6//! the dedicated local-queue ring so it shows up in the app.
7
8use 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/// A local image-generation request. Optional fields fall back to the model's
18/// CLI defaults from the catalog.
19#[derive(Debug, Clone, Default)]
20pub struct LocalImageRequest {
21    pub prompt: String,
22    /// Model id; `None` uses the catalog's default image model.
23    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/// Why a local generation could not run.
33#[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
51/// Run one local image job: resolve the model from `catalog`, dispatch it on
52/// `engine`, record it in the local-queue ring, and return the image bytes.
53pub 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        // The studio ring stays empty — local jobs are their own queue.
171        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}