1use crate::config::Config;
11use crate::types::*;
12use anyhow::Result;
13use image::{ImageBuffer, Rgb, RgbImage};
14use sha2::{Digest, Sha256};
15use std::collections::BTreeMap;
16use std::io::Cursor;
17use std::time::Instant;
18use tracing::{debug, info, warn};
19
20const TRACE_TARGET_SYNTHETIC: &str = "studio_worker::engine::synthetic";
23
24const TRACE_TARGET_BUILD: &str = "studio_worker::engine";
27
28fn log_engine_roster(engines: &[Box<dyn Engine>]) {
36 let names: Vec<&str> = engines.iter().map(|e| e.name()).collect();
37 info!(
38 target: TRACE_TARGET_BUILD,
39 op = "build",
40 engine_count = names.len(),
41 engines = %names.join(","),
42 "engine roster assembled"
43 );
44}
45
46#[derive(Debug, Clone, Default)]
48pub struct EngineCapabilities {
49 pub supported_models_per_kind: BTreeMap<TaskKind, Vec<String>>,
52}
53
54impl EngineCapabilities {
55 pub fn supports(&self, kind: TaskKind, model: &str) -> bool {
56 self.supported_models_per_kind
57 .get(&kind)
58 .map(|ms| ms.iter().any(|m| m == model))
59 .unwrap_or(false)
60 }
61
62 pub fn kinds(&self) -> Vec<TaskKind> {
63 self.supported_models_per_kind.keys().copied().collect()
64 }
65
66 pub fn flat_models(&self) -> Vec<String> {
67 self.supported_models_per_kind
68 .values()
69 .flat_map(|ms| ms.iter().cloned())
70 .collect()
71 }
72}
73
74#[cfg(feature = "image-candle")]
75pub mod candle_image;
76pub mod download;
77#[cfg(all(feature = "llama", not(target_os = "windows")))]
80pub mod llama;
81pub mod multi;
82#[cfg(feature = "image-onnx")]
83pub mod onnx;
84pub mod sd_provision;
85pub mod sdcpp;
86#[cfg(feature = "tts")]
87pub mod tts;
88#[cfg(feature = "video")]
89pub mod video;
90#[cfg(feature = "whisper")]
91pub mod whisper;
92
93pub trait Engine: Send + Sync {
94 fn name(&self) -> &'static str;
95 fn capabilities(&self) -> EngineCapabilities;
96 fn dispatch(&self, model: &str, task: Task) -> Result<TaskResult>;
97
98 fn dispatch_with_source(
103 &self,
104 model: &str,
105 task: Task,
106 _source: &crate::types::ModelSource,
107 ) -> Result<TaskResult> {
108 self.dispatch(model, task)
109 }
110}
111
112pub fn build(cfg: &Config) -> Result<Box<dyn Engine>> {
123 #[allow(clippy::vec_init_then_push)]
128 let engines: Vec<Box<dyn Engine>> = {
129 let mut v: Vec<Box<dyn Engine>> = Vec::new();
130 #[cfg(all(feature = "llama", not(target_os = "windows")))]
131 v.push(Box::new(llama::LlamaEngine::new(cfg.models_root.clone())?));
132 #[cfg(feature = "whisper")]
133 v.push(Box::new(whisper::WhisperEngine::new(
134 cfg.models_root.clone(),
135 )));
136 #[cfg(feature = "image-candle")]
137 v.push(Box::new(candle_image::CandleImageEngine::new()));
138 #[cfg(feature = "image-onnx")]
143 v.push(Box::new(onnx::OnnxImageEngine::new(
144 cfg.models_root.clone(),
145 )));
146 #[cfg(feature = "video")]
147 v.push(Box::new(video::VideoEngine::new()));
148 #[cfg(feature = "tts")]
149 v.push(Box::new(tts::TtsEngine::new()));
150 v.push(Box::new(sdcpp::SdCppEngine::new(&cfg.models_root)));
156 v.push(Box::new(SyntheticEngine::new()));
157 v
158 };
159
160 log_engine_roster(&engines);
161 Ok(Box::new(multi::MultiEngine::new(engines)))
162}
163
164pub fn default_models_root() -> std::path::PathBuf {
167 crate::config::default_models_root()
168}
169
170pub struct SyntheticEngine;
176
177impl SyntheticEngine {
178 pub fn new() -> Self {
179 Self
180 }
181}
182
183impl Default for SyntheticEngine {
184 fn default() -> Self {
185 Self::new()
186 }
187}
188
189pub const MODEL_WILDCARD: &str = "*";
196
197const DEFAULT_IMAGE_MODELS: &[&str] = &["synthetic", "synthetic-image"];
201const DEFAULT_LLM_MODELS: &[&str] = &["synthetic", "synthetic-llm"];
202const DEFAULT_STT_MODELS: &[&str] = &["synthetic", "synthetic-stt"];
203const DEFAULT_TTS_MODELS: &[&str] = &["synthetic", "synthetic-tts"];
204const DEFAULT_VIDEO_MODELS: &[&str] = &["synthetic", "synthetic-video"];
205
206fn models(list: &[&str]) -> Vec<String> {
207 list.iter().map(|s| (*s).to_string()).collect()
208}
209
210impl Engine for SyntheticEngine {
211 fn name(&self) -> &'static str {
212 "synthetic"
213 }
214
215 fn capabilities(&self) -> EngineCapabilities {
216 let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
217 map.insert(TaskKind::Image, models(DEFAULT_IMAGE_MODELS));
218 map.insert(TaskKind::Llm, models(DEFAULT_LLM_MODELS));
219 map.insert(TaskKind::AudioStt, models(DEFAULT_STT_MODELS));
220 map.insert(TaskKind::AudioTts, models(DEFAULT_TTS_MODELS));
221 map.insert(TaskKind::Video, models(DEFAULT_VIDEO_MODELS));
222 EngineCapabilities {
223 supported_models_per_kind: map,
224 }
225 }
226
227 fn dispatch(&self, model: &str, task: Task) -> Result<TaskResult> {
228 let kind = task.kind();
229 let started = Instant::now();
230 let result = match task {
231 Task::Image(p) => render_procedural(&p.prompt, &p.ext)
232 .map(|bytes| TaskResult::Image { bytes, ext: p.ext }),
233 Task::Llm(p) => {
234 let prompt = p
235 .messages
236 .iter()
237 .map(|m| format!("{}: {}", m.role, m.content))
238 .collect::<Vec<_>>()
239 .join("\n");
240 Ok(TaskResult::Llm {
241 json: synthetic_llm_response(&prompt),
242 })
243 }
244 Task::AudioStt(p) => Ok(TaskResult::AudioStt {
245 json: synthetic_stt_response(&p.input_url, p.language.as_deref()),
246 }),
247 Task::AudioTts(p) => render_wav(&p.text).map(|bytes| TaskResult::AudioTts {
248 bytes,
249 ext: "wav".into(),
250 }),
251 Task::Video(p) => {
252 render_animated_webp(&p.prompt, p.width, p.height, p.seconds).map(|bytes| {
256 TaskResult::Video {
257 bytes,
258 ext: "webp".into(),
259 }
260 })
261 }
262 };
263 let elapsed_ms = started.elapsed().as_millis() as u64;
264 match &result {
265 Ok(_) => debug!(
266 target: TRACE_TARGET_SYNTHETIC,
267 op = "dispatch",
268 kind = kind.as_str(),
269 model,
270 elapsed_ms,
271 "ok"
272 ),
273 Err(e) => warn!(
274 target: TRACE_TARGET_SYNTHETIC,
275 op = "dispatch",
276 kind = kind.as_str(),
277 model,
278 elapsed_ms,
279 error = %e,
280 "failed"
281 ),
282 }
283 result
284 }
285}
286
287pub fn render_procedural(prompt: &str, ext: &str) -> Result<Vec<u8>> {
293 let digest = sha256_bytes(prompt);
294 let palette = [
295 Rgb([digest[0], digest[1], digest[2]]),
296 Rgb([digest[3], digest[4], digest[5]]),
297 Rgb([digest[6], digest[7], digest[8]]),
298 Rgb([digest[9], digest[10], digest[11]]),
299 ];
300
301 let size: u32 = 512;
302 let mut img: RgbImage = ImageBuffer::new(size, size);
303 for (x, y, pixel) in img.enumerate_pixels_mut() {
304 let cx = size as f32 / 2.0;
305 let cy = size as f32 / 2.0;
306 let dx = (x as f32 - cx).abs();
307 let dy = (y as f32 - cy).abs();
308 let chebyshev = dx.max(dy) / cx;
309 let ring = (chebyshev * 6.0).floor() as usize;
310 let base = palette[ring.min(palette.len() - 1)];
311 let phase = ((x as f32 / 24.0).sin() + (y as f32 / 24.0).cos()) * 12.0;
312 *pixel = Rgb([
313 base.0[0].saturating_add(phase as i8 as u8),
314 base.0[1].saturating_add((phase * 0.7) as i8 as u8),
315 base.0[2].saturating_add((phase * 1.3) as i8 as u8),
316 ]);
317 }
318
319 let mut out = Cursor::new(Vec::<u8>::new());
320 let dyn_img = image::DynamicImage::ImageRgb8(img);
321 match ext {
322 "webp" => dyn_img.write_to(&mut out, image::ImageFormat::WebP)?,
323 _ => dyn_img.write_to(&mut out, image::ImageFormat::Png)?,
324 }
325 Ok(out.into_inner())
326}
327
328pub fn synthetic_llm_response(prompt: &str) -> serde_json::Value {
331 let hash = hex::encode(sha256_bytes(prompt));
332 serde_json::json!({
333 "object": "chat.completion",
334 "model": "synthetic-llm",
335 "choices": [{
336 "index": 0,
337 "message": {
338 "role": "assistant",
339 "content": format!("[synthetic] reply to prompt #{}", &hash[..16]),
340 },
341 "finish_reason": "stop",
342 }],
343 "usage": {
344 "prompt_tokens": prompt.split_whitespace().count(),
345 "completion_tokens": 8,
346 "total_tokens": prompt.split_whitespace().count() + 8,
347 },
348 })
349}
350
351pub fn synthetic_stt_response(input_url: &str, language: Option<&str>) -> serde_json::Value {
353 let hash = hex::encode(sha256_bytes(input_url));
354 serde_json::json!({
355 "text": format!("[synthetic] transcript of {}", &hash[..16]),
356 "language": language.unwrap_or("en"),
357 "duration": 1.0,
358 })
359}
360
361pub fn render_wav(text: &str) -> Result<Vec<u8>> {
364 use hound::{SampleFormat, WavSpec, WavWriter};
365 let digest = sha256_bytes(text);
366 let freq_hz = 220.0 + (digest[0] as f32) * (660.0 / 255.0); let sample_rate: u32 = 22_050;
368 let spec = WavSpec {
369 channels: 1,
370 sample_rate,
371 bits_per_sample: 16,
372 sample_format: SampleFormat::Int,
373 };
374
375 let mut buf = Cursor::new(Vec::<u8>::new());
376 {
377 let mut writer = WavWriter::new(&mut buf, spec)?;
378 let total_samples = sample_rate; for n in 0..total_samples {
380 let t = n as f32 / sample_rate as f32;
381 let amplitude = (t * 2.0 * std::f32::consts::PI * freq_hz).sin();
382 let s = (amplitude * 0.4 * i16::MAX as f32) as i16;
383 writer.write_sample(s)?;
384 }
385 writer.finalize()?;
386 }
387 Ok(buf.into_inner())
388}
389
390pub fn render_animated_webp(prompt: &str, _w: u32, _h: u32, seconds: f32) -> Result<Vec<u8>> {
394 let _ = seconds;
400 render_procedural(prompt, "webp")
401}
402
403fn sha256_bytes(input: &str) -> [u8; 32] {
404 let mut hasher = Sha256::new();
405 hasher.update(input.as_bytes());
406 let digest = hasher.finalize();
407 let mut out = [0u8; 32];
408 out.copy_from_slice(&digest);
409 out
410}
411
412#[cfg(test)]
417mod tests {
418 use super::*;
419 use std::io::Cursor;
420
421 #[test]
422 fn synthetic_image_round_trips_as_webp() {
423 let engine = SyntheticEngine::new();
424 let task = Task::Image(ImageParams {
425 prompt: "hello world".into(),
426 width: 512,
427 height: 512,
428 steps: 20,
429 ext: "webp".into(),
430 ..Default::default()
431 });
432 let result = engine.dispatch("synthetic", task).unwrap();
433 let (bytes, ext) = match result {
434 TaskResult::Image { bytes, ext } => (bytes, ext),
435 other => panic!("expected image, got {:?}", other.kind()),
436 };
437 assert_eq!(ext, "webp");
438 assert!(bytes.len() > 100);
439 let reader = image::ImageReader::new(Cursor::new(&bytes))
440 .with_guessed_format()
441 .unwrap();
442 assert_eq!(reader.format().unwrap(), image::ImageFormat::WebP);
443 }
444
445 #[test]
446 fn synthetic_llm_returns_chat_completion_shape() {
447 let engine = SyntheticEngine::new();
448 let task = Task::Llm(LlmParams {
449 messages: vec![ChatMessage {
450 role: "user".into(),
451 content: "what is the capital of france?".into(),
452 }],
453 max_tokens: 64,
454 temperature: 0.5,
455 ..Default::default()
456 });
457 let result = engine.dispatch("synthetic", task).unwrap();
458 let json = match result {
459 TaskResult::Llm { json } => json,
460 other => panic!("expected llm, got {:?}", other.kind()),
461 };
462 assert_eq!(json["object"], "chat.completion");
463 assert!(json["choices"][0]["message"]["content"]
464 .as_str()
465 .unwrap()
466 .starts_with("[synthetic]"));
467 }
468
469 #[test]
470 fn synthetic_stt_returns_whisper_shape() {
471 let engine = SyntheticEngine::new();
472 let task = Task::AudioStt(AudioSttParams {
473 input_url: "https://example.com/audio.wav".into(),
474 language: Some("nl".into()),
475 ..Default::default()
476 });
477 let result = engine.dispatch("synthetic", task).unwrap();
478 let json = match result {
479 TaskResult::AudioStt { json } => json,
480 other => panic!("expected stt, got {:?}", other.kind()),
481 };
482 assert_eq!(json["language"], "nl");
483 assert!(json["text"].as_str().unwrap().starts_with("[synthetic]"));
484 }
485
486 #[test]
487 fn synthetic_tts_produces_real_wav() {
488 let engine = SyntheticEngine::new();
489 let task = Task::AudioTts(AudioTtsParams {
490 text: "hello world".into(),
491 voice: "default".into(),
492 ext: "wav".into(),
493 ..Default::default()
494 });
495 let result = engine.dispatch("synthetic", task).unwrap();
496 let (bytes, ext) = match result {
497 TaskResult::AudioTts { bytes, ext } => (bytes, ext),
498 other => panic!("expected tts, got {:?}", other.kind()),
499 };
500 assert_eq!(ext, "wav");
501 let mut reader = hound::WavReader::new(Cursor::new(bytes)).expect("real WAV should decode");
503 let spec = reader.spec();
504 assert_eq!(spec.sample_rate, 22_050);
505 assert_eq!(spec.channels, 1);
506 let samples = reader
507 .samples::<i16>()
508 .collect::<std::result::Result<Vec<_>, _>>()
509 .expect("samples should decode");
510 assert_eq!(samples.len(), 22_050); }
512
513 #[test]
514 fn synthetic_video_emits_decodable_bytes() {
515 let engine = SyntheticEngine::new();
516 let task = Task::Video(VideoParams {
517 prompt: "a tiny dragon".into(),
518 seconds: 1.0,
519 width: 256,
520 height: 256,
521 ext: "mp4".into(), ..Default::default()
523 });
524 let result = engine.dispatch("synthetic", task).unwrap();
525 let (bytes, ext) = match result {
526 TaskResult::Video { bytes, ext } => (bytes, ext),
527 other => panic!("expected video, got {:?}", other.kind()),
528 };
529 assert_eq!(ext, "webp");
530 let reader = image::ImageReader::new(Cursor::new(&bytes))
531 .with_guessed_format()
532 .unwrap();
533 assert_eq!(reader.format().unwrap(), image::ImageFormat::WebP);
534 }
535
536 #[test]
537 fn synthetic_engine_advertises_all_kinds() {
538 let engine = SyntheticEngine::new();
539 let caps = engine.capabilities();
540 for k in TaskKind::ALL {
541 assert!(
542 caps.supported_models_per_kind.contains_key(&k),
543 "{} should be advertised",
544 k.as_str()
545 );
546 }
547 assert!(caps.supports(TaskKind::Image, "synthetic"));
548 assert!(
549 !caps.supports(TaskKind::Image, "*"),
550 "synthetic engine MUST NOT advertise the wildcard \
551 (it would happily fulfil real-model jobs with placeholder \
552 bytes, which is destructive on a live queue)"
553 );
554 }
555
556 #[test]
557 fn build_default_yields_multi_engine_with_synthetic_inside() {
558 let cfg = crate::config::Config::default();
562 let eng = build(&cfg).unwrap();
563 assert_eq!(eng.name(), "multi");
564 let caps = eng.capabilities();
565 for k in TaskKind::ALL {
566 assert!(caps.supported_models_per_kind.contains_key(&k));
567 }
568 assert!(caps.supports(TaskKind::Image, "synthetic"));
569 assert!(caps.supports(TaskKind::Llm, "synthetic"));
570 }
571
572 #[test]
573 fn build_emits_engine_roster_breadcrumb() {
574 let logs = crate::test_support::capture(|| {
581 let cfg = crate::config::Config::default();
582 let _ = build(&cfg).unwrap();
583 });
584 assert!(
585 logs.contains("studio_worker::engine"),
586 "expected engine target, got: {logs}"
587 );
588 assert!(logs.contains("op=\"build\""), "expected op=build: {logs}");
589 assert!(
590 logs.contains("engine roster assembled"),
591 "expected roster message: {logs}"
592 );
593 assert!(
594 logs.contains("synthetic"),
595 "expected synthetic in the roster: {logs}"
596 );
597 }
598
599 #[test]
600 fn log_engine_roster_reports_count_and_comma_joined_names() {
601 let logs = crate::test_support::capture(|| {
605 let engines: Vec<Box<dyn Engine>> = vec![
606 Box::new(SyntheticEngine::new()),
607 Box::new(SyntheticEngine::new()),
608 ];
609 log_engine_roster(&engines);
610 });
611 assert!(
612 logs.contains("engine_count=2"),
613 "expected engine_count=2, got: {logs}"
614 );
615 assert!(
616 logs.contains("engines=synthetic,synthetic"),
617 "expected comma-joined names, got: {logs}"
618 );
619 }
620
621 #[test]
622 fn synthetic_engine_is_deterministic_per_prompt() {
623 let engine = SyntheticEngine::new();
624 let task = || {
625 Task::Image(ImageParams {
626 prompt: "deterministic".into(),
627 width: 512,
628 height: 512,
629 steps: 20,
630 ext: "webp".into(),
631 ..Default::default()
632 })
633 };
634 let a = engine.dispatch("synthetic", task()).unwrap();
635 let b = engine.dispatch("synthetic", task()).unwrap();
636 match (a, b) {
637 (TaskResult::Image { bytes: a, .. }, TaskResult::Image { bytes: b, .. }) => {
638 assert_eq!(a, b);
639 }
640 _ => panic!("expected images"),
641 }
642 }
643}