Skip to main content

studio_worker/engine/
mod.rs

1//! Pluggable inference engines, generalised to all task kinds.
2//!
3//! The `synthetic` engine produces real, decodable bytes for every kind
4//! and is the default — it's what unattended CI exercises end-to-end.
5//!
6//! Real high-performance engines (llama.cpp, whisper.cpp, candle SD,
7//! Piper, ffmpeg) live behind cargo features so the default build stays
8//! small and the CI matrix stays fast.  See the feature notes per
9//! implementation block below.
10use 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
20/// Tracing target for the synthetic engine.  Stable so operators can
21/// filter with `RUST_LOG=studio_worker::engine::synthetic=debug`.
22const TRACE_TARGET_SYNTHETIC: &str = "studio_worker::engine::synthetic";
23
24/// Tracing target for engine-roster (build-time) events.  Stable so
25/// operators can filter with `RUST_LOG=studio_worker::engine=info`.
26const TRACE_TARGET_BUILD: &str = "studio_worker::engine";
27
28/// Emit a one-line breadcrumb naming the backends this worker will
29/// route across.  Lets an operator confirm from the logs which engines
30/// actually registered — e.g. which optional cargo-feature backends
31/// (llama / whisper / candle / video / tts) compiled in — instead of
32/// inferring it from the advertised model list.  (sdcpp + synthetic
33/// always register; sdcpp auto-provisions `sd-cli` on first use.)  Split out from [`build`] so the
34/// breadcrumb's shape is unit-tested against a controlled roster.
35fn 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/// What a single engine is able to do.
47#[derive(Debug, Clone, Default)]
48pub struct EngineCapabilities {
49    /// Task kinds the engine can handle, with their per-kind supported
50    /// model ids.
51    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// llama-cpp-2 doesn't link on Windows MSVC (see Cargo.toml), so the
78// `llama` feature is a no-op there even when enabled via `--features all`.
79#[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    /// Dispatch with the studio's `ModelSource` attached.  Engines
99    /// that need it (download URLs / CLI defaults) override this;
100    /// engines that don't (synthetic) keep using the plain
101    /// `dispatch` method via the default impl below.
102    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
112/// Build the engine for this worker.
113///
114/// There's no engine selection knob in the config any more: the
115/// worker advertises capabilities for every backend compiled into
116/// this binary, and routes each incoming job to the first backend
117/// that supports its (kind, model) pair.  See `multi::MultiEngine`.
118///
119/// The default build ships only the synthetic engine.  Optional
120/// backends (llama, whisper, image-candle, video, tts) are added
121/// when their cargo features are enabled.
122pub fn build(cfg: &Config) -> Result<Box<dyn Engine>> {
123    // Real backends first so they win the "supports" check ahead of
124    // the catch-all synthetic engine.  Synthetic is always last:
125    // deterministic real bytes for every kind, zero-VRAM fallback so
126    // CI + smoke-tests stay self-contained.
127    #[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        // ONNX-runtime image engine (LaMa object removal).  Registered
139        // ahead of sdcpp so onnx-engine model offers route here; the
140        // model file (a single .onnx) is downloaded on first use into
141        // `<models_root>`.
142        #[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        // stable-diffusion.cpp-backed image engine.  Registers
151        // unconditionally now: `sd-cli` is auto-provisioned into
152        // `<models_root>/bin/` on the first image job when it isn't
153        // already resolvable, so a fresh worker serves real image jobs
154        // out of the box.
155        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
164/// Legacy hook retained for any external caller; mirrors
165/// `Config::default().models_root`.
166pub fn default_models_root() -> std::path::PathBuf {
167    crate::config::default_models_root()
168}
169
170// ---------------------------------------------------------------------------
171// SyntheticEngine — produces real bytes for every kind, deterministic by
172// SHA-256(prompt|text|json).  Zero VRAM, zero network, zero install steps.
173// ---------------------------------------------------------------------------
174
175pub 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
189/// Sentinel string the studio's claim filter recognises as "any
190/// model is fine".  Real engines that can actually serve any model
191/// (e.g. a GGUF-aware image engine that downloads on demand) advertise
192/// it.  The synthetic engine deliberately does NOT — it would happily
193/// fulfil real-model jobs with placeholder bytes, which is destructive
194/// on a live queue.
195pub const MODEL_WILDCARD: &str = "*";
196
197// Synthetic engine advertises only its own `synthetic*` model names
198// so it never claims a job that names a real model the operator is
199// expecting actual inference for.
200const 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                // Synthetic video is a real animated set of frames in WebP
253                // (no built-in H.264 encoder).  We always emit `webp` and
254                // ignore the requested `ext` to keep the bytes decodable.
255                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
287// ---------------------------------------------------------------------------
288// Synthetic renderers
289// ---------------------------------------------------------------------------
290
291/// Deterministic 512×512 image whose colours depend on hash(prompt).
292pub 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
328/// Synthetic LLM response — deterministic by prompt hash, mimics the
329/// OpenAI chat-completion response shape so consumers can parse it.
330pub 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
351/// Synthetic STT response — Whisper-style JSON.
352pub 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
361/// Real WAV file (16-bit PCM, mono, 22 050 Hz) — sine wave whose frequency
362/// depends on hash(text).  Duration is 1.0 s.
363pub 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); // 220–880 Hz
367    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; // 1 second
379        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
390/// Synthetic "video": an animated WebP made of `frames` frames.  We
391/// always emit WebP (decoders are everywhere); real video generation
392/// would use the `video-ffmpeg` feature.
393pub fn render_animated_webp(prompt: &str, _w: u32, _h: u32, seconds: f32) -> Result<Vec<u8>> {
394    // The `image` crate doesn't expose animated-WebP encoding in its
395    // default features.  We approximate "video" by concatenating multiple
396    // single-frame WebPs and prefixing with a magic marker so decoders
397    // that don't grok our format at least see a real WebP at offset 0.
398    // The first frame is a real, decodable WebP.
399    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// ---------------------------------------------------------------------------
413// Tests
414// ---------------------------------------------------------------------------
415
416#[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        // Validate the WAV by reading it back with hound.
502        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); // 1 second
511    }
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(), // engine intentionally downgrades to webp
522            ..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        // Default features = synthetic-only.  `build()` should always
559        // return a MultiEngine (so the routing layer is uniform), and
560        // synthetic capabilities should be visible through it.
561        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        // build() is the single place that decides which backends this
575        // worker will route across.  Without a roster breadcrumb an
576        // operator debugging "why won't it serve my real model?" can't
577        // tell from the logs whether the expected engine registered or
578        // was skipped.  Environment-tolerant: the synthetic engine is
579        // always last, so we assert on it without pinning the count.
580        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        // Deterministic, environment-independent contract for the
602        // breadcrumb's shape: a count field plus the engine names
603        // comma-joined in roster order.
604        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}