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, thiserror::Error)]
55#[error("{engine} engine cannot serve {kind} tasks")]
56pub struct UnsupportedTask {
57 pub engine: &'static str,
58 pub kind: &'static str,
59}
60
61impl UnsupportedTask {
62 pub fn new(engine: &'static str, kind: TaskKind) -> Self {
63 Self {
64 engine,
65 kind: kind.as_str(),
66 }
67 }
68}
69
70#[derive(Debug, Clone, Default)]
72pub struct EngineCapabilities {
73 pub supported_models_per_kind: BTreeMap<TaskKind, Vec<String>>,
76}
77
78impl EngineCapabilities {
79 pub fn supports(&self, kind: TaskKind, model: &str) -> bool {
80 self.supported_models_per_kind
81 .get(&kind)
82 .map(|ms| ms.iter().any(|m| m == model))
83 .unwrap_or(false)
84 }
85
86 pub fn kinds(&self) -> Vec<TaskKind> {
87 self.supported_models_per_kind.keys().copied().collect()
88 }
89
90 pub fn flat_models(&self) -> Vec<String> {
91 self.supported_models_per_kind
92 .values()
93 .flat_map(|ms| ms.iter().cloned())
94 .collect()
95 }
96}
97
98#[cfg(feature = "image-candle")]
99pub mod candle_image;
100pub mod chat_template;
101pub mod download;
102pub mod llm_core;
103#[cfg(all(feature = "llama", not(target_os = "windows")))]
106pub mod llama;
107pub mod llama_subprocess;
111pub mod multi;
112#[cfg(feature = "image-onnx")]
113pub mod onnx;
114#[cfg(any(feature = "image-onnx", feature = "stt-stream"))]
115pub mod onnx_provision;
116#[cfg(feature = "stt-stream")]
117pub mod parakeet;
118pub mod sd_provision;
119pub mod sdcpp;
120#[cfg(feature = "tts")]
121pub mod tts;
122#[cfg(feature = "video")]
123pub mod video;
124#[cfg(feature = "whisper")]
125pub mod whisper;
126
127pub trait Engine: Send + Sync {
128 fn name(&self) -> &'static str;
129 fn capabilities(&self) -> EngineCapabilities;
130 fn dispatch(&self, model: &str, task: Task) -> Result<TaskResult>;
131
132 fn dispatch_with_source(
137 &self,
138 model: &str,
139 task: Task,
140 _source: &crate::types::ModelSource,
141 ) -> Result<TaskResult> {
142 self.dispatch(model, task)
143 }
144}
145
146pub fn build(cfg: &Config) -> Result<Box<dyn Engine>> {
157 #[allow(clippy::vec_init_then_push)]
162 let engines: Vec<Box<dyn Engine>> = {
163 let mut v: Vec<Box<dyn Engine>> = Vec::new();
164 #[cfg(all(feature = "llama", not(target_os = "windows")))]
165 v.push(Box::new(llama::LlamaEngine::new(cfg.models_root.clone())?));
166 #[cfg(all(feature = "llama", target_os = "windows"))]
170 v.push(Box::new(llama_subprocess::LlamaSubprocessEngine::new(
171 cfg.models_root.clone(),
172 )));
173 #[cfg(feature = "whisper")]
174 v.push(Box::new(whisper::WhisperEngine::new(
175 cfg.models_root.clone(),
176 )));
177 #[cfg(feature = "image-candle")]
178 v.push(Box::new(candle_image::CandleImageEngine::new()));
179 #[cfg(feature = "image-onnx")]
184 v.push(Box::new(onnx::OnnxImageEngine::new(
185 cfg.models_root.clone(),
186 )));
187 #[cfg(feature = "video")]
188 v.push(Box::new(video::VideoEngine::new()));
189 #[cfg(feature = "tts")]
190 v.push(Box::new(tts::TtsEngine::new()));
191 v.push(Box::new(sdcpp::SdCppEngine::new(&cfg.models_root)));
197 v.push(Box::new(SyntheticEngine::new()));
198 v
199 };
200
201 log_engine_roster(&engines);
202 Ok(Box::new(multi::MultiEngine::new(engines)))
203}
204
205pub fn default_models_root() -> std::path::PathBuf {
208 crate::config::default_models_root()
209}
210
211pub struct SyntheticEngine;
217
218impl SyntheticEngine {
219 pub fn new() -> Self {
220 Self
221 }
222}
223
224impl Default for SyntheticEngine {
225 fn default() -> Self {
226 Self::new()
227 }
228}
229
230pub const MODEL_WILDCARD: &str = "*";
237
238const DEFAULT_IMAGE_MODELS: &[&str] = &["synthetic", "synthetic-image"];
242const DEFAULT_LLM_MODELS: &[&str] = &["synthetic", "synthetic-llm"];
243const DEFAULT_STT_MODELS: &[&str] = &["synthetic", "synthetic-stt"];
244const DEFAULT_TTS_MODELS: &[&str] = &["synthetic", "synthetic-tts"];
245const DEFAULT_VIDEO_MODELS: &[&str] = &["synthetic", "synthetic-video"];
246
247fn models(list: &[&str]) -> Vec<String> {
248 list.iter().map(|s| (*s).to_string()).collect()
249}
250
251impl Engine for SyntheticEngine {
252 fn name(&self) -> &'static str {
253 "synthetic"
254 }
255
256 fn capabilities(&self) -> EngineCapabilities {
257 let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
258 map.insert(TaskKind::Image, models(DEFAULT_IMAGE_MODELS));
259 map.insert(TaskKind::Llm, models(DEFAULT_LLM_MODELS));
260 map.insert(TaskKind::AudioStt, models(DEFAULT_STT_MODELS));
261 map.insert(TaskKind::AudioTts, models(DEFAULT_TTS_MODELS));
262 map.insert(TaskKind::Video, models(DEFAULT_VIDEO_MODELS));
263 EngineCapabilities {
264 supported_models_per_kind: map,
265 }
266 }
267
268 fn dispatch(&self, model: &str, task: Task) -> Result<TaskResult> {
269 let kind = task.kind();
270 let started = Instant::now();
271 let result = match task {
272 Task::Image(p) => render_procedural(&p.prompt, &p.ext)
273 .map(|bytes| TaskResult::Image { bytes, ext: p.ext }),
274 Task::Llm(p) => {
275 let prompt = p
276 .messages
277 .iter()
278 .map(|m| format!("{}: {}", m.role, m.content))
279 .collect::<Vec<_>>()
280 .join("\n");
281 Ok(TaskResult::Llm {
282 json: synthetic_llm_response(&prompt),
283 })
284 }
285 Task::AudioStt(p) => Ok(TaskResult::AudioStt {
286 json: synthetic_stt_response(&p.input_url, p.language.as_deref()),
287 }),
288 Task::AudioTts(p) => render_wav(&p.text).map(|bytes| TaskResult::AudioTts {
289 bytes,
290 ext: "wav".into(),
291 }),
292 Task::Video(p) => {
293 render_animated_webp(&p.prompt, p.width, p.height, p.seconds).map(|bytes| {
297 TaskResult::Video {
298 bytes,
299 ext: "webp".into(),
300 }
301 })
302 }
303 };
304 let elapsed_ms = started.elapsed().as_millis() as u64;
305 match &result {
306 Ok(_) => debug!(
307 target: TRACE_TARGET_SYNTHETIC,
308 op = "dispatch",
309 kind = kind.as_str(),
310 model,
311 elapsed_ms,
312 "ok"
313 ),
314 Err(e) => warn!(
315 target: TRACE_TARGET_SYNTHETIC,
316 op = "dispatch",
317 kind = kind.as_str(),
318 model,
319 elapsed_ms,
320 error = %e,
321 "failed"
322 ),
323 }
324 result
325 }
326}
327
328pub fn render_procedural(prompt: &str, ext: &str) -> Result<Vec<u8>> {
334 let digest = sha256_bytes(prompt);
335 let palette = [
336 Rgb([digest[0], digest[1], digest[2]]),
337 Rgb([digest[3], digest[4], digest[5]]),
338 Rgb([digest[6], digest[7], digest[8]]),
339 Rgb([digest[9], digest[10], digest[11]]),
340 ];
341
342 let size: u32 = 512;
343 let mut img: RgbImage = ImageBuffer::new(size, size);
344 for (x, y, pixel) in img.enumerate_pixels_mut() {
345 let cx = size as f32 / 2.0;
346 let cy = size as f32 / 2.0;
347 let dx = (x as f32 - cx).abs();
348 let dy = (y as f32 - cy).abs();
349 let chebyshev = dx.max(dy) / cx;
350 let ring = (chebyshev * 6.0).floor() as usize;
351 let base = palette[ring.min(palette.len() - 1)];
352 let phase = ((x as f32 / 24.0).sin() + (y as f32 / 24.0).cos()) * 12.0;
353 *pixel = Rgb([
354 base.0[0].saturating_add(phase as i8 as u8),
355 base.0[1].saturating_add((phase * 0.7) as i8 as u8),
356 base.0[2].saturating_add((phase * 1.3) as i8 as u8),
357 ]);
358 }
359
360 let mut out = Cursor::new(Vec::<u8>::new());
361 let dyn_img = image::DynamicImage::ImageRgb8(img);
362 match ext {
363 "webp" => dyn_img.write_to(&mut out, image::ImageFormat::WebP)?,
364 _ => dyn_img.write_to(&mut out, image::ImageFormat::Png)?,
365 }
366 Ok(out.into_inner())
367}
368
369pub fn synthetic_llm_response(prompt: &str) -> serde_json::Value {
372 let hash = hex::encode(sha256_bytes(prompt));
373 serde_json::json!({
374 "object": "chat.completion",
375 "model": "synthetic-llm",
376 "choices": [{
377 "index": 0,
378 "message": {
379 "role": "assistant",
380 "content": format!("[synthetic] reply to prompt #{}", &hash[..16]),
381 },
382 "finish_reason": "stop",
383 }],
384 "usage": {
385 "prompt_tokens": prompt.split_whitespace().count(),
386 "completion_tokens": 8,
387 "total_tokens": prompt.split_whitespace().count() + 8,
388 },
389 })
390}
391
392pub fn synthetic_stt_response(input_url: &str, language: Option<&str>) -> serde_json::Value {
394 let hash = hex::encode(sha256_bytes(input_url));
395 serde_json::json!({
396 "text": format!("[synthetic] transcript of {}", &hash[..16]),
397 "language": language.unwrap_or("en"),
398 "duration": 1.0,
399 })
400}
401
402pub fn render_wav(text: &str) -> Result<Vec<u8>> {
405 use hound::{SampleFormat, WavSpec, WavWriter};
406 let digest = sha256_bytes(text);
407 let freq_hz = 220.0 + (digest[0] as f32) * (660.0 / 255.0); let sample_rate: u32 = 22_050;
409 let spec = WavSpec {
410 channels: 1,
411 sample_rate,
412 bits_per_sample: 16,
413 sample_format: SampleFormat::Int,
414 };
415
416 let mut buf = Cursor::new(Vec::<u8>::new());
417 {
418 let mut writer = WavWriter::new(&mut buf, spec)?;
419 let total_samples = sample_rate; for n in 0..total_samples {
421 let t = n as f32 / sample_rate as f32;
422 let amplitude = (t * 2.0 * std::f32::consts::PI * freq_hz).sin();
423 let s = (amplitude * 0.4 * i16::MAX as f32) as i16;
424 writer.write_sample(s)?;
425 }
426 writer.finalize()?;
427 }
428 Ok(buf.into_inner())
429}
430
431pub fn render_animated_webp(prompt: &str, _w: u32, _h: u32, seconds: f32) -> Result<Vec<u8>> {
435 let _ = seconds;
441 render_procedural(prompt, "webp")
442}
443
444fn sha256_bytes(input: &str) -> [u8; 32] {
445 let mut hasher = Sha256::new();
446 hasher.update(input.as_bytes());
447 let digest = hasher.finalize();
448 let mut out = [0u8; 32];
449 out.copy_from_slice(&digest);
450 out
451}
452
453#[cfg(test)]
458mod tests {
459 use super::*;
460 use std::io::Cursor;
461
462 #[test]
463 fn synthetic_image_round_trips_as_webp() {
464 let engine = SyntheticEngine::new();
465 let task = Task::Image(ImageParams {
466 prompt: "hello world".into(),
467 width: 512,
468 height: 512,
469 steps: 20,
470 ext: "webp".into(),
471 ..Default::default()
472 });
473 let result = engine.dispatch("synthetic", task).unwrap();
474 let (bytes, ext) = match result {
475 TaskResult::Image { bytes, ext } => (bytes, ext),
476 other => panic!("expected image, got {:?}", other.kind()),
477 };
478 assert_eq!(ext, "webp");
479 assert!(bytes.len() > 100);
480 let reader = image::ImageReader::new(Cursor::new(&bytes))
481 .with_guessed_format()
482 .unwrap();
483 assert_eq!(reader.format().unwrap(), image::ImageFormat::WebP);
484 }
485
486 #[test]
487 fn synthetic_image_round_trips_as_png() {
488 let engine = SyntheticEngine::new();
495 let task = Task::Image(ImageParams {
496 prompt: "hello world".into(),
497 width: 512,
498 height: 512,
499 steps: 20,
500 ext: "png".into(),
501 ..Default::default()
502 });
503 let result = engine.dispatch("synthetic", task).unwrap();
504 let (bytes, ext) = match result {
505 TaskResult::Image { bytes, ext } => (bytes, ext),
506 other => panic!("expected image, got {:?}", other.kind()),
507 };
508 assert_eq!(ext, "png", "the requested ext must be preserved");
509 assert!(bytes.len() > 100);
510 let reader = image::ImageReader::new(Cursor::new(&bytes))
511 .with_guessed_format()
512 .unwrap();
513 assert_eq!(reader.format().unwrap(), image::ImageFormat::Png);
514 }
515
516 #[test]
517 fn synthetic_llm_returns_chat_completion_shape() {
518 let engine = SyntheticEngine::new();
519 let task = Task::Llm(LlmParams {
520 messages: vec![ChatMessage {
521 role: "user".into(),
522 content: "what is the capital of france?".into(),
523 }],
524 max_tokens: 64,
525 temperature: 0.5,
526 ..Default::default()
527 });
528 let result = engine.dispatch("synthetic", task).unwrap();
529 let json = match result {
530 TaskResult::Llm { json } => json,
531 other => panic!("expected llm, got {:?}", other.kind()),
532 };
533 assert_eq!(json["object"], "chat.completion");
534 assert!(json["choices"][0]["message"]["content"]
535 .as_str()
536 .unwrap()
537 .starts_with("[synthetic]"));
538 }
539
540 #[test]
541 fn synthetic_stt_returns_whisper_shape() {
542 let engine = SyntheticEngine::new();
543 let task = Task::AudioStt(AudioSttParams {
544 input_url: "https://example.com/audio.wav".into(),
545 language: Some("nl".into()),
546 ..Default::default()
547 });
548 let result = engine.dispatch("synthetic", task).unwrap();
549 let json = match result {
550 TaskResult::AudioStt { json } => json,
551 other => panic!("expected stt, got {:?}", other.kind()),
552 };
553 assert_eq!(json["language"], "nl");
554 assert!(json["text"].as_str().unwrap().starts_with("[synthetic]"));
555 }
556
557 #[test]
558 fn synthetic_tts_produces_real_wav() {
559 let engine = SyntheticEngine::new();
560 let task = Task::AudioTts(AudioTtsParams {
561 text: "hello world".into(),
562 voice: "default".into(),
563 ext: "wav".into(),
564 ..Default::default()
565 });
566 let result = engine.dispatch("synthetic", task).unwrap();
567 let (bytes, ext) = match result {
568 TaskResult::AudioTts { bytes, ext } => (bytes, ext),
569 other => panic!("expected tts, got {:?}", other.kind()),
570 };
571 assert_eq!(ext, "wav");
572 let mut reader = hound::WavReader::new(Cursor::new(bytes)).expect("real WAV should decode");
574 let spec = reader.spec();
575 assert_eq!(spec.sample_rate, 22_050);
576 assert_eq!(spec.channels, 1);
577 let samples = reader
578 .samples::<i16>()
579 .collect::<std::result::Result<Vec<_>, _>>()
580 .expect("samples should decode");
581 assert_eq!(samples.len(), 22_050); }
583
584 #[test]
585 fn synthetic_video_emits_decodable_bytes() {
586 let engine = SyntheticEngine::new();
587 let task = Task::Video(VideoParams {
588 prompt: "a tiny dragon".into(),
589 seconds: 1.0,
590 width: 256,
591 height: 256,
592 ext: "mp4".into(), ..Default::default()
594 });
595 let result = engine.dispatch("synthetic", task).unwrap();
596 let (bytes, ext) = match result {
597 TaskResult::Video { bytes, ext } => (bytes, ext),
598 other => panic!("expected video, got {:?}", other.kind()),
599 };
600 assert_eq!(ext, "webp");
601 let reader = image::ImageReader::new(Cursor::new(&bytes))
602 .with_guessed_format()
603 .unwrap();
604 assert_eq!(reader.format().unwrap(), image::ImageFormat::WebP);
605 }
606
607 #[test]
608 fn synthetic_engine_advertises_all_kinds() {
609 let engine = SyntheticEngine::new();
610 let caps = engine.capabilities();
611 for k in TaskKind::ALL {
612 assert!(
613 caps.supported_models_per_kind.contains_key(&k),
614 "{} should be advertised",
615 k.as_str()
616 );
617 }
618 assert!(caps.supports(TaskKind::Image, "synthetic"));
619 assert!(
620 !caps.supports(TaskKind::Image, "*"),
621 "synthetic engine MUST NOT advertise the wildcard \
622 (it would happily fulfil real-model jobs with placeholder \
623 bytes, which is destructive on a live queue)"
624 );
625 }
626
627 #[test]
628 fn build_default_yields_multi_engine_with_synthetic_inside() {
629 let cfg = crate::config::Config::default();
633 let eng = build(&cfg).unwrap();
634 assert_eq!(eng.name(), "multi");
635 let caps = eng.capabilities();
636 for k in TaskKind::ALL {
637 assert!(caps.supported_models_per_kind.contains_key(&k));
638 }
639 assert!(caps.supports(TaskKind::Image, "synthetic"));
640 assert!(caps.supports(TaskKind::Llm, "synthetic"));
641 }
642
643 #[test]
644 fn build_emits_engine_roster_breadcrumb() {
645 let logs = crate::test_support::capture(|| {
652 let cfg = crate::config::Config::default();
653 let _ = build(&cfg).unwrap();
654 });
655 assert!(
656 logs.contains("studio_worker::engine"),
657 "expected engine target, got: {logs}"
658 );
659 assert!(logs.contains("op=\"build\""), "expected op=build: {logs}");
660 assert!(
661 logs.contains("engine roster assembled"),
662 "expected roster message: {logs}"
663 );
664 assert!(
665 logs.contains("synthetic"),
666 "expected synthetic in the roster: {logs}"
667 );
668 }
669
670 #[test]
671 fn log_engine_roster_reports_count_and_comma_joined_names() {
672 let logs = crate::test_support::capture(|| {
676 let engines: Vec<Box<dyn Engine>> = vec![
677 Box::new(SyntheticEngine::new()),
678 Box::new(SyntheticEngine::new()),
679 ];
680 log_engine_roster(&engines);
681 });
682 assert!(
683 logs.contains("engine_count=2"),
684 "expected engine_count=2, got: {logs}"
685 );
686 assert!(
687 logs.contains("engines=synthetic,synthetic"),
688 "expected comma-joined names, got: {logs}"
689 );
690 }
691
692 #[test]
693 fn synthetic_engine_is_deterministic_per_prompt() {
694 let engine = SyntheticEngine::new();
695 let task = || {
696 Task::Image(ImageParams {
697 prompt: "deterministic".into(),
698 width: 512,
699 height: 512,
700 steps: 20,
701 ext: "webp".into(),
702 ..Default::default()
703 })
704 };
705 let a = engine.dispatch("synthetic", task()).unwrap();
706 let b = engine.dispatch("synthetic", task()).unwrap();
707 match (a, b) {
708 (TaskResult::Image { bytes: a, .. }, TaskResult::Image { bytes: b, .. }) => {
709 assert_eq!(a, b);
710 }
711 _ => panic!("expected images"),
712 }
713 }
714}