Skip to main content

comfyui_rs/
client.rs

1use futures_util::StreamExt;
2use reqwest::Client;
3use serde_json::Value;
4use std::time::Duration;
5
6use crate::error::{ComfyError, Result};
7use crate::types::*;
8
9fn normalize(endpoint: String) -> String {
10    endpoint.trim_end_matches('/').to_string()
11}
12
13/// Async client for a ComfyUI server instance.
14///
15/// Provides REST methods for prompt queuing, history retrieval, image
16/// download, and model discovery. Supports both WebSocket-based real-time
17/// progress tracking and polling-based fallback.
18///
19/// # Example
20/// ```no_run
21/// use comfyui_rs::ComfyClient;
22///
23/// # async fn example() -> comfyui_rs::Result<()> {
24/// let client = ComfyClient::new("http://127.0.0.1:8188");
25/// let healthy = client.health().await?;
26/// # Ok(())
27/// # }
28/// ```
29#[derive(Debug, Clone)]
30pub struct ComfyClient {
31    http: Client,
32    endpoint: String,
33    client_id: String,
34}
35
36impl ComfyClient {
37    /// Create a new client pointing at the given ComfyUI endpoint.
38    pub fn new(endpoint: impl Into<String>) -> Self {
39        Self {
40            http: Client::new(),
41            endpoint: normalize(endpoint.into()),
42            client_id: "comfyui-rs".to_string(),
43        }
44    }
45
46    /// Use a custom `reqwest::Client` (for connection pooling, timeouts, TLS).
47    pub fn with_http_client(mut self, client: Client) -> Self {
48        self.http = client;
49        self
50    }
51
52    /// Set the client ID used for WebSocket filtering and prompt association.
53    pub fn with_client_id(mut self, id: impl Into<String>) -> Self {
54        self.client_id = id.into();
55        self
56    }
57
58    /// Returns the configured endpoint URL.
59    pub fn endpoint(&self) -> &str {
60        &self.endpoint
61    }
62
63    /// Returns the configured client ID.
64    pub fn client_id(&self) -> &str {
65        &self.client_id
66    }
67
68    // ── Health ──────────────────────────────────────────────────────
69
70    /// Check whether ComfyUI is reachable. Any HTTP response (even 500)
71    /// means the server is running. Only connection/timeout failures return false.
72    pub async fn health(&self) -> Result<bool> {
73        let url = format!("{}/queue", self.endpoint);
74        match self
75            .http
76            .get(&url)
77            .timeout(Duration::from_secs(5))
78            .send()
79            .await
80        {
81            Ok(_) => Ok(true),
82            Err(e) if e.is_connect() || e.is_timeout() => Ok(false),
83            Err(e) => Err(ComfyError::Network {
84                context: format!(
85                    "Cannot connect to ComfyUI at {} \u{2014} is the service running?",
86                    self.endpoint
87                ),
88                source: e,
89            }),
90        }
91    }
92
93    // ── Prompt ──────────────────────────────────────────────────────
94
95    /// Queue a workflow for execution. Returns the `prompt_id`.
96    pub async fn queue_prompt(&self, workflow: &Value) -> Result<String> {
97        let url = format!("{}/prompt", self.endpoint);
98        let body = serde_json::json!({
99            "prompt": workflow,
100            "client_id": self.client_id,
101        });
102
103        let resp = self
104            .http
105            .post(&url)
106            .timeout(Duration::from_secs(30))
107            .json(&body)
108            .send()
109            .await
110            .map_err(|e| ComfyError::Network {
111                context: format!(
112                    "Cannot connect to ComfyUI at {} \u{2014} is the service running?",
113                    self.endpoint
114                ),
115                source: e,
116            })?;
117
118        if !resp.status().is_success() {
119            let status = resp.status().as_u16();
120            let body_text = resp.text().await.unwrap_or_default();
121            return Err(ComfyError::Http {
122                status,
123                body: body_text,
124            });
125        }
126
127        let json: Value = resp.json().await.map_err(|e| ComfyError::Network {
128            context: "Failed to parse ComfyUI /prompt response".into(),
129            source: e,
130        })?;
131
132        // Check for node errors
133        if let Some(errors) = json.get("node_errors") {
134            if let Some(obj) = errors.as_object() {
135                if !obj.is_empty() {
136                    return Err(ComfyError::NodeErrors(
137                        serde_json::to_string_pretty(errors).unwrap_or_default(),
138                    ));
139                }
140            }
141        }
142
143        json.get("prompt_id")
144            .and_then(|v| v.as_str())
145            .map(|s| s.to_string())
146            .ok_or_else(|| ComfyError::InvalidResponse("Response missing prompt_id".into()))
147    }
148
149    // ── History ─────────────────────────────────────────────────────
150
151    /// Fetch the history entry for a prompt. Returns `None` if not yet available.
152    pub async fn history(&self, prompt_id: &str) -> Result<Option<PromptHistory>> {
153        let url = format!("{}/history/{}", self.endpoint, prompt_id);
154        let resp = self
155            .http
156            .get(&url)
157            .timeout(Duration::from_secs(10))
158            .send()
159            .await
160            .map_err(|e| ComfyError::Network {
161                context: "Failed to fetch ComfyUI history".into(),
162                source: e,
163            })?;
164
165        if !resp.status().is_success() {
166            return Ok(None);
167        }
168
169        let json: Value = resp.json().await.map_err(|e| ComfyError::Network {
170            context: "Failed to parse ComfyUI history response".into(),
171            source: e,
172        })?;
173
174        let entry = match json.get(prompt_id) {
175            Some(e) => e,
176            None => return Ok(None),
177        };
178
179        let status_str = entry
180            .pointer("/status/status_str")
181            .and_then(|v| v.as_str())
182            .unwrap_or("unknown");
183
184        let completed = entry
185            .pointer("/status/completed")
186            .and_then(|v| v.as_bool())
187            .unwrap_or(false);
188
189        let mut images = Vec::new();
190        if let Some(outputs) = entry.get("outputs").and_then(|o| o.as_object()) {
191            for (_node_id, node_output) in outputs {
192                if let Some(imgs) = node_output.get("images").and_then(|i| i.as_array()) {
193                    for img in imgs {
194                        if let Some(filename) = img.get("filename").and_then(|f| f.as_str()) {
195                            let subfolder =
196                                img.get("subfolder").and_then(|s| s.as_str()).unwrap_or("");
197                            let img_type =
198                                img.get("type").and_then(|t| t.as_str()).unwrap_or("output");
199                            images.push(ImageRef {
200                                filename: filename.to_string(),
201                                subfolder: subfolder.to_string(),
202                                img_type: img_type.to_string(),
203                            });
204                        }
205                    }
206                }
207            }
208        }
209
210        Ok(Some(PromptHistory {
211            status: status_str.to_string(),
212            completed,
213            images,
214        }))
215    }
216
217    // ── Image download ──────────────────────────────────────────────
218
219    /// Download an output image by its reference. Returns raw bytes.
220    pub async fn image(&self, img: &ImageRef) -> Result<Vec<u8>> {
221        let url = reqwest::Url::parse_with_params(
222            &format!("{}/view", self.endpoint),
223            &[
224                ("filename", img.filename.as_str()),
225                ("subfolder", img.subfolder.as_str()),
226                ("type", img.img_type.as_str()),
227            ],
228        )
229        .map_err(|e| ComfyError::InvalidResponse(format!("Bad image URL: {}", e)))?;
230
231        let resp = self
232            .http
233            .get(url)
234            .timeout(Duration::from_secs(30))
235            .send()
236            .await
237            .map_err(|e| ComfyError::Network {
238                context: format!("Failed to fetch image {} from ComfyUI", img.filename),
239                source: e,
240            })?;
241
242        if !resp.status().is_success() {
243            return Err(ComfyError::Http {
244                status: resp.status().as_u16(),
245                body: format!("Failed to fetch image {}", img.filename),
246            });
247        }
248
249        let bytes = resp.bytes().await.map_err(|e| ComfyError::Network {
250            context: "Failed to read image bytes".into(),
251            source: e,
252        })?;
253        Ok(bytes.to_vec())
254    }
255
256    // ── Queue status ────────────────────────────────────────────────
257
258    /// Get the current ComfyUI queue state (running + pending counts).
259    pub async fn queue_status(&self) -> Result<QueueStatus> {
260        let url = format!("{}/queue", self.endpoint);
261        let resp = self
262            .http
263            .get(&url)
264            .timeout(Duration::from_secs(5))
265            .send()
266            .await
267            .map_err(|e| ComfyError::Network {
268                context: "Failed to fetch ComfyUI queue status".into(),
269                source: e,
270            })?;
271
272        let json: Value = resp.json().await.map_err(|e| ComfyError::Network {
273            context: "Failed to parse ComfyUI queue response".into(),
274            source: e,
275        })?;
276
277        let running = json
278            .get("queue_running")
279            .and_then(|v| v.as_array())
280            .map(|a| a.len() as u32)
281            .unwrap_or(0);
282        let pending = json
283            .get("queue_pending")
284            .and_then(|v| v.as_array())
285            .map(|a| a.len() as u32)
286            .unwrap_or(0);
287
288        Ok(QueueStatus { running, pending })
289    }
290
291    // ── Control ─────────────────────────────────────────────────────
292
293    /// Free VRAM. If `unload_models` is true, all models are unloaded.
294    pub async fn free_memory(&self, unload_models: bool) -> Result<()> {
295        let url = format!("{}/free", self.endpoint);
296        let body = if unload_models {
297            serde_json::json!({"unload_models": true})
298        } else {
299            serde_json::json!({"free_memory": true})
300        };
301        self.http
302            .post(&url)
303            .timeout(Duration::from_secs(30))
304            .json(&body)
305            .send()
306            .await
307            .map_err(|e| ComfyError::Network {
308                context: "Failed to send free memory request".into(),
309                source: e,
310            })?;
311        Ok(())
312    }
313
314    /// Interrupt the currently running generation.
315    pub async fn interrupt(&self) -> Result<()> {
316        let url = format!("{}/interrupt", self.endpoint);
317        self.http
318            .post(&url)
319            .timeout(Duration::from_secs(5))
320            .send()
321            .await
322            .map_err(|e| ComfyError::Network {
323                context: "Failed to send interrupt".into(),
324                source: e,
325            })?;
326        Ok(())
327    }
328
329    // ── Image upload ─────────────────────────────────────────────────
330
331    /// Upload an image to ComfyUI's input directory via `/upload/image`.
332    /// Returns the filename as stored by ComfyUI.
333    pub async fn upload_image(
334        &self,
335        image_bytes: &[u8],
336        filename: &str,
337        overwrite: bool,
338    ) -> Result<String> {
339        let url = format!("{}/upload/image", self.endpoint);
340
341        let form = reqwest::multipart::Form::new()
342            .part(
343                "image",
344                reqwest::multipart::Part::bytes(image_bytes.to_vec())
345                    .file_name(filename.to_string())
346                    .mime_str("image/png")
347                    .map_err(|e| ComfyError::InvalidResponse(format!("Invalid MIME: {}", e)))?,
348            )
349            .text("type", "input")
350            .text("overwrite", if overwrite { "true" } else { "false" });
351
352        let resp = self
353            .http
354            .post(&url)
355            .timeout(Duration::from_secs(30))
356            .multipart(form)
357            .send()
358            .await
359            .map_err(|e| ComfyError::Network {
360                context: format!("Failed to upload image to ComfyUI at {}", self.endpoint),
361                source: e,
362            })?;
363
364        if !resp.status().is_success() {
365            let status = resp.status().as_u16();
366            let body_text = resp.text().await.unwrap_or_default();
367            return Err(ComfyError::Http {
368                status,
369                body: body_text,
370            });
371        }
372
373        let json: Value = resp.json().await.map_err(|e| ComfyError::Network {
374            context: "Failed to parse ComfyUI /upload/image response".into(),
375            source: e,
376        })?;
377
378        json["name"]
379            .as_str()
380            .map(|s| s.to_string())
381            .ok_or_else(|| ComfyError::InvalidResponse("Upload response missing name field".into()))
382    }
383
384    // ── Model discovery ─────────────────────────────────────────────
385
386    /// List available checkpoint models from ComfyUI.
387    pub async fn checkpoints(&self) -> Result<Vec<String>> {
388        self.object_info_list(
389            "CheckpointLoaderSimple",
390            "/CheckpointLoaderSimple/input/required/ckpt_name/0",
391        )
392        .await
393    }
394
395    /// List available sampler algorithms from ComfyUI.
396    pub async fn samplers(&self) -> Result<Vec<String>> {
397        self.object_info_list("KSampler", "/KSampler/input/required/sampler_name/0")
398            .await
399    }
400
401    /// List available scheduler algorithms from ComfyUI.
402    pub async fn schedulers(&self) -> Result<Vec<String>> {
403        self.object_info_list("KSampler", "/KSampler/input/required/scheduler/0")
404            .await
405    }
406
407    async fn object_info_list(&self, node: &str, pointer: &str) -> Result<Vec<String>> {
408        let url = format!("{}/object_info/{}", self.endpoint, node);
409        let resp = self
410            .http
411            .get(&url)
412            .timeout(Duration::from_secs(10))
413            .send()
414            .await
415            .map_err(|e| ComfyError::Network {
416                context: format!(
417                    "Cannot connect to ComfyUI at {} \u{2014} is the service running?",
418                    self.endpoint
419                ),
420                source: e,
421            })?;
422
423        if !resp.status().is_success() {
424            return Ok(Vec::new());
425        }
426
427        let json: Value = resp.json().await.map_err(|e| ComfyError::Network {
428            context: format!("Failed to parse {} object_info", node),
429            source: e,
430        })?;
431
432        Ok(json
433            .pointer(pointer)
434            .and_then(|v| v.as_array())
435            .map(|arr| {
436                arr.iter()
437                    .filter_map(|v| v.as_str().map(String::from))
438                    .collect()
439            })
440            .unwrap_or_default())
441    }
442
443    // ── Completion waiting ──────────────────────────────────────────
444
445    /// Poll `/history` until the prompt completes, fails, or times out.
446    pub async fn wait_for_completion(
447        &self,
448        prompt_id: &str,
449        timeout: Duration,
450    ) -> Result<GenerationOutcome> {
451        self.wait_for_completion_poll(prompt_id, Duration::from_secs(2), timeout)
452            .await
453    }
454
455    /// Wait for completion using ComfyUI's WebSocket for real-time step
456    /// progress. Calls `on_progress` for each sampling step. Falls back
457    /// to polling automatically if the WebSocket connection fails.
458    pub async fn wait_for_completion_ws<F>(
459        &self,
460        prompt_id: &str,
461        timeout: Duration,
462        on_progress: F,
463    ) -> Result<GenerationOutcome>
464    where
465        F: FnMut(ProgressUpdate),
466    {
467        self.wait_ws_inner(prompt_id, timeout, on_progress).await
468    }
469
470    async fn wait_for_completion_poll(
471        &self,
472        prompt_id: &str,
473        poll_interval: Duration,
474        timeout: Duration,
475    ) -> Result<GenerationOutcome> {
476        let start = std::time::Instant::now();
477        loop {
478            if start.elapsed() > timeout {
479                return Ok(GenerationOutcome::TimedOut);
480            }
481            if let Some(history) = self.history(prompt_id).await? {
482                if history.completed {
483                    return Ok(GenerationOutcome::Completed {
484                        images: history.images,
485                    });
486                } else if history.status == "error" {
487                    return Ok(GenerationOutcome::Failed {
488                        error: "ComfyUI generation failed".into(),
489                    });
490                }
491            }
492            tokio::time::sleep(poll_interval).await;
493        }
494    }
495
496    async fn wait_ws_inner<F>(
497        &self,
498        prompt_id: &str,
499        timeout: Duration,
500        mut on_progress: F,
501    ) -> Result<GenerationOutcome>
502    where
503        F: FnMut(ProgressUpdate),
504    {
505        let ws_url = format!(
506            "{}/ws?clientId={}",
507            self.endpoint
508                .replace("http://", "ws://")
509                .replace("https://", "wss://"),
510            self.client_id
511        );
512
513        let (mut ws, _) = match tokio_tungstenite::connect_async(&ws_url).await {
514            Ok(c) => c,
515            Err(e) => {
516                tracing::warn!(error = %e, "WebSocket failed, falling back to polling");
517                return self
518                    .wait_for_completion_poll(prompt_id, Duration::from_secs(2), timeout)
519                    .await;
520            }
521        };
522
523        let start = std::time::Instant::now();
524        let mut our_msg_count: usize = 0;
525        let mut total_msg_count: usize = 0;
526        const MAX_OUR_MESSAGES: usize = 10_000;
527        const MAX_TOTAL_MESSAGES: usize = 50_000;
528
529        while let Ok(Some(msg)) = tokio::time::timeout(Duration::from_secs(30), ws.next()).await {
530            total_msg_count += 1;
531            if total_msg_count > MAX_TOTAL_MESSAGES {
532                tracing::warn!(
533                    count = MAX_TOTAL_MESSAGES,
534                    "WebSocket exceeded max total messages, falling back to polling"
535                );
536                break;
537            }
538            if start.elapsed() > timeout {
539                return Ok(GenerationOutcome::TimedOut);
540            }
541
542            let text = match msg {
543                Ok(m) if m.is_text() => m.into_text().unwrap_or_default(),
544                Ok(_) => continue,
545                Err(_) => break,
546            };
547
548            let json: Value = match serde_json::from_str(&text) {
549                Ok(j) => j,
550                Err(_) => continue,
551            };
552
553            let msg_type = json.get("type").and_then(|v| v.as_str()).unwrap_or("");
554            let data = json.get("data");
555            let pid = data
556                .and_then(|d| d.get("prompt_id"))
557                .and_then(|v| v.as_str());
558
559            // Skip messages for other prompts
560            if pid.is_some() && pid != Some(prompt_id) {
561                continue;
562            }
563
564            if pid == Some(prompt_id) {
565                our_msg_count += 1;
566                if our_msg_count > MAX_OUR_MESSAGES {
567                    tracing::warn!(
568                        prompt_id = prompt_id,
569                        count = MAX_OUR_MESSAGES,
570                        "Prompt exceeded max messages, falling back to polling"
571                    );
572                    break;
573                }
574            }
575
576            match msg_type {
577                "progress" => {
578                    if let Some(d) = data {
579                        let val = d.get("value").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
580                        let max = d.get("max").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
581                        on_progress(ProgressUpdate {
582                            current_step: val,
583                            total_steps: max,
584                        });
585                    }
586                }
587                "executing"
588                    if data
589                        .and_then(|d| d.get("node"))
590                        .map(|v| v.is_null())
591                        .unwrap_or(false) =>
592                {
593                    // node: null in executing message means generation is done
594                    return self.fetch_outcome(prompt_id).await;
595                }
596                "execution_error" => {
597                    let err = data
598                        .and_then(|d| d.get("exception_message"))
599                        .and_then(|v| v.as_str())
600                        .unwrap_or("Unknown error");
601                    return Ok(GenerationOutcome::Failed {
602                        error: format!("ComfyUI error: {}", err),
603                    });
604                }
605                _ => {}
606            }
607        }
608
609        // WebSocket closed unexpectedly — fall back to polling
610        self.wait_for_completion_poll(prompt_id, Duration::from_secs(2), timeout)
611            .await
612    }
613
614    // ── Traced variants (stack-ids integration) ──────────────────────
615
616    /// Queue a workflow for execution with optional trace context.
617    ///
618    /// Behaves identically to [`queue_prompt`](Self::queue_prompt) but logs
619    /// the trace ID when a [`stack_ids::TraceCtx`] is provided.
620    pub async fn queue_prompt_traced(
621        &self,
622        workflow: &Value,
623        trace_ctx: Option<&stack_ids::TraceCtx>,
624    ) -> Result<String> {
625        if let Some(ctx) = trace_ctx {
626            tracing::debug!(trace_id = %ctx.trace_id, "Queuing prompt to ComfyUI");
627        }
628        self.queue_prompt(workflow).await
629    }
630
631    /// Fetch prompt history with optional trace context.
632    ///
633    /// Behaves identically to [`history`](Self::history) but logs
634    /// the trace ID when a [`stack_ids::TraceCtx`] is provided.
635    pub async fn history_traced(
636        &self,
637        prompt_id: &str,
638        trace_ctx: Option<&stack_ids::TraceCtx>,
639    ) -> Result<Option<PromptHistory>> {
640        if let Some(ctx) = trace_ctx {
641            tracing::debug!(trace_id = %ctx.trace_id, prompt_id = %prompt_id, "Fetching history from ComfyUI");
642        }
643        self.history(prompt_id).await
644    }
645
646    /// Download an output image with optional trace context.
647    ///
648    /// Behaves identically to [`image`](Self::image) but logs
649    /// the trace ID when a [`stack_ids::TraceCtx`] is provided.
650    pub async fn image_traced(
651        &self,
652        img: &ImageRef,
653        trace_ctx: Option<&stack_ids::TraceCtx>,
654    ) -> Result<Vec<u8>> {
655        if let Some(ctx) = trace_ctx {
656            tracing::debug!(trace_id = %ctx.trace_id, filename = %img.filename, "Downloading image from ComfyUI");
657        }
658        self.image(img).await
659    }
660
661    /// Get queue status with optional trace context.
662    ///
663    /// Behaves identically to [`queue_status`](Self::queue_status) but logs
664    /// the trace ID when a [`stack_ids::TraceCtx`] is provided.
665    pub async fn queue_status_traced(
666        &self,
667        trace_ctx: Option<&stack_ids::TraceCtx>,
668    ) -> Result<QueueStatus> {
669        if let Some(ctx) = trace_ctx {
670            tracing::debug!(trace_id = %ctx.trace_id, "Fetching queue status from ComfyUI");
671        }
672        self.queue_status().await
673    }
674
675    /// Upload an image with optional trace context.
676    ///
677    /// Behaves identically to [`upload_image`](Self::upload_image) but logs
678    /// the trace ID when a [`stack_ids::TraceCtx`] is provided.
679    pub async fn upload_image_traced(
680        &self,
681        image_bytes: &[u8],
682        filename: &str,
683        overwrite: bool,
684        trace_ctx: Option<&stack_ids::TraceCtx>,
685    ) -> Result<String> {
686        if let Some(ctx) = trace_ctx {
687            tracing::debug!(trace_id = %ctx.trace_id, filename = %filename, "Uploading image to ComfyUI");
688        }
689        self.upload_image(image_bytes, filename, overwrite).await
690    }
691
692    /// Interrupt the current generation with optional trace context.
693    ///
694    /// Behaves identically to [`interrupt`](Self::interrupt) but logs
695    /// the trace ID when a [`stack_ids::TraceCtx`] is provided.
696    pub async fn interrupt_traced(&self, trace_ctx: Option<&stack_ids::TraceCtx>) -> Result<()> {
697        if let Some(ctx) = trace_ctx {
698            tracing::debug!(trace_id = %ctx.trace_id, "Interrupting ComfyUI generation");
699        }
700        self.interrupt().await
701    }
702
703    /// Free VRAM with optional trace context.
704    ///
705    /// Behaves identically to [`free_memory`](Self::free_memory) but logs
706    /// the trace ID when a [`stack_ids::TraceCtx`] is provided.
707    pub async fn free_memory_traced(
708        &self,
709        unload_models: bool,
710        trace_ctx: Option<&stack_ids::TraceCtx>,
711    ) -> Result<()> {
712        if let Some(ctx) = trace_ctx {
713            tracing::debug!(trace_id = %ctx.trace_id, unload_models = %unload_models, "Freeing ComfyUI memory");
714        }
715        self.free_memory(unload_models).await
716    }
717
718    /// Wait for prompt completion (polling) with optional trace context.
719    ///
720    /// Behaves identically to [`wait_for_completion`](Self::wait_for_completion)
721    /// but logs the trace ID when a [`stack_ids::TraceCtx`] is provided.
722    pub async fn wait_for_completion_traced(
723        &self,
724        prompt_id: &str,
725        timeout: Duration,
726        trace_ctx: Option<&stack_ids::TraceCtx>,
727    ) -> Result<GenerationOutcome> {
728        if let Some(ctx) = trace_ctx {
729            tracing::debug!(trace_id = %ctx.trace_id, prompt_id = %prompt_id, "Waiting for ComfyUI completion (polling)");
730        }
731        self.wait_for_completion(prompt_id, timeout).await
732    }
733
734    /// Wait for completion via WebSocket with optional trace context.
735    ///
736    /// Behaves identically to [`wait_for_completion_ws`](Self::wait_for_completion_ws)
737    /// but logs the trace ID when a [`stack_ids::TraceCtx`] is provided.
738    pub async fn wait_for_completion_ws_traced<F>(
739        &self,
740        prompt_id: &str,
741        timeout: Duration,
742        on_progress: F,
743        trace_ctx: Option<&stack_ids::TraceCtx>,
744    ) -> Result<GenerationOutcome>
745    where
746        F: FnMut(ProgressUpdate),
747    {
748        if let Some(ctx) = trace_ctx {
749            tracing::debug!(trace_id = %ctx.trace_id, prompt_id = %prompt_id, "Waiting for ComfyUI completion (WebSocket)");
750        }
751        self.wait_for_completion_ws(prompt_id, timeout, on_progress)
752            .await
753    }
754
755    async fn fetch_outcome(&self, prompt_id: &str) -> Result<GenerationOutcome> {
756        match self.history(prompt_id).await? {
757            Some(history) if history.completed => Ok(GenerationOutcome::Completed {
758                images: history.images,
759            }),
760            Some(_) => Ok(GenerationOutcome::Failed {
761                error: "Generation failed".into(),
762            }),
763            None => Ok(GenerationOutcome::Failed {
764                error: "No history found after generation".into(),
765            }),
766        }
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    #[test]
775    fn test_normalize_endpoint() {
776        assert_eq!(
777            normalize("http://localhost:8188/".into()),
778            "http://localhost:8188"
779        );
780        assert_eq!(
781            normalize("http://localhost:8188".into()),
782            "http://localhost:8188"
783        );
784        assert_eq!(normalize("http://host:8188///".into()), "http://host:8188");
785    }
786
787    #[test]
788    fn test_client_builder() {
789        let client = ComfyClient::new("http://127.0.0.1:8188").with_client_id("my-app");
790        assert_eq!(client.endpoint(), "http://127.0.0.1:8188");
791        assert_eq!(client.client_id(), "my-app");
792    }
793
794    #[test]
795    fn test_default_client_id() {
796        let client = ComfyClient::new("http://localhost:8188");
797        assert_eq!(client.client_id(), "comfyui-rs");
798    }
799
800    #[test]
801    fn test_parse_history_response() {
802        let json: Value = serde_json::from_str(
803            r#"{
804            "abc123": {
805                "status": {"status_str": "success", "completed": true},
806                "outputs": {
807                    "9": {
808                        "images": [
809                            {"filename": "ComfyUI_00001_.png", "subfolder": "", "type": "output"}
810                        ]
811                    }
812                }
813            }
814        }"#,
815        )
816        .unwrap();
817
818        let entry = json.get("abc123").unwrap();
819        let status = entry.pointer("/status/status_str").and_then(|v| v.as_str());
820        assert_eq!(status, Some("success"));
821
822        let completed = entry.pointer("/status/completed").and_then(|v| v.as_bool());
823        assert_eq!(completed, Some(true));
824
825        let images = entry
826            .pointer("/outputs/9/images")
827            .and_then(|v| v.as_array());
828        assert!(images.is_some());
829        assert_eq!(images.unwrap()[0]["filename"], "ComfyUI_00001_.png");
830    }
831
832    #[test]
833    fn test_parse_queue_response() {
834        let json: Value = serde_json::from_str(
835            r#"{
836            "queue_running": [["item1"]],
837            "queue_pending": [["item2"], ["item3"]]
838        }"#,
839        )
840        .unwrap();
841
842        let running = json
843            .get("queue_running")
844            .and_then(|v| v.as_array())
845            .map(|a| a.len())
846            .unwrap_or(0);
847        let pending = json
848            .get("queue_pending")
849            .and_then(|v| v.as_array())
850            .map(|a| a.len())
851            .unwrap_or(0);
852        assert_eq!(running, 1);
853        assert_eq!(pending, 2);
854    }
855
856    #[test]
857    fn test_parse_prompt_response() {
858        let json: Value = serde_json::from_str(
859            r#"{
860            "prompt_id": "abc-123-def",
861            "number": 1,
862            "node_errors": {}
863        }"#,
864        )
865        .unwrap();
866
867        let prompt_id = json.get("prompt_id").and_then(|v| v.as_str());
868        assert_eq!(prompt_id, Some("abc-123-def"));
869
870        let errors = json.get("node_errors").and_then(|v| v.as_object());
871        assert!(errors.unwrap().is_empty());
872    }
873
874    #[test]
875    fn test_parse_checkpoint_object_info() {
876        let json: Value = serde_json::from_str(
877            r#"{
878            "CheckpointLoaderSimple": {
879                "input": {
880                    "required": {
881                        "ckpt_name": [
882                            ["dreamshaper_8.safetensors", "deliberate_v3.safetensors"]
883                        ]
884                    }
885                }
886            }
887        }"#,
888        )
889        .unwrap();
890
891        let checkpoints = json
892            .pointer("/CheckpointLoaderSimple/input/required/ckpt_name/0")
893            .and_then(|v| v.as_array())
894            .map(|arr| {
895                arr.iter()
896                    .filter_map(|v| v.as_str().map(String::from))
897                    .collect::<Vec<_>>()
898            })
899            .unwrap_or_default();
900
901        assert_eq!(checkpoints.len(), 2);
902        assert_eq!(checkpoints[0], "dreamshaper_8.safetensors");
903    }
904
905    #[test]
906    fn test_parse_sampler_object_info() {
907        let json: Value = serde_json::from_str(
908            r#"{
909            "KSampler": {
910                "input": {
911                    "required": {
912                        "sampler_name": [["euler", "dpmpp_2m", "dpmpp_sde"]],
913                        "scheduler": [["normal", "karras", "exponential"]]
914                    }
915                }
916            }
917        }"#,
918        )
919        .unwrap();
920
921        let samplers = json
922            .pointer("/KSampler/input/required/sampler_name/0")
923            .and_then(|v| v.as_array())
924            .map(|arr| {
925                arr.iter()
926                    .filter_map(|v| v.as_str().map(String::from))
927                    .collect::<Vec<_>>()
928            })
929            .unwrap_or_default();
930
931        assert_eq!(samplers.len(), 3);
932        assert!(samplers.contains(&"dpmpp_2m".to_string()));
933    }
934
935    #[test]
936    fn test_empty_object_info() {
937        let json: Value = serde_json::from_str(r#"{}"#).unwrap();
938
939        let checkpoints = json
940            .pointer("/CheckpointLoaderSimple/input/required/ckpt_name/0")
941            .and_then(|v| v.as_array())
942            .map(|arr| {
943                arr.iter()
944                    .filter_map(|v| v.as_str().map(String::from))
945                    .collect::<Vec<_>>()
946            })
947            .unwrap_or_default();
948
949        assert!(checkpoints.is_empty());
950    }
951
952    #[test]
953    fn test_image_ref() {
954        let img = ImageRef {
955            filename: "test.png".to_string(),
956            subfolder: "".to_string(),
957            img_type: "output".to_string(),
958        };
959        assert_eq!(img.filename, "test.png");
960
961        let json = serde_json::to_string(&img).unwrap();
962        assert!(json.contains("\"filename\":\"test.png\""));
963    }
964
965    #[test]
966    fn test_queue_status_serialization() {
967        let status = QueueStatus {
968            running: 1,
969            pending: 3,
970        };
971        let json = serde_json::to_string(&status).unwrap();
972        assert!(json.contains("\"running\":1"));
973        assert!(json.contains("\"pending\":3"));
974    }
975
976    // ── Traced variant tests ────────────────────────────────────────
977
978    /// Compile-time check that all traced methods exist with correct signatures.
979    /// This async function is never called but the compiler verifies it.
980    #[allow(dead_code)]
981    async fn _assert_traced_signatures_compile(client: &ComfyClient) {
982        let ctx = stack_ids::TraceCtx::generate();
983        let workflow = serde_json::json!({});
984        let img = ImageRef {
985            filename: "test.png".into(),
986            subfolder: "".into(),
987            img_type: "output".into(),
988        };
989
990        let _ = client.queue_prompt_traced(&workflow, Some(&ctx)).await;
991        let _ = client.queue_prompt_traced(&workflow, None).await;
992        let _ = client.history_traced("id", Some(&ctx)).await;
993        let _ = client.history_traced("id", None).await;
994        let _ = client.image_traced(&img, Some(&ctx)).await;
995        let _ = client.image_traced(&img, None).await;
996        let _ = client.queue_status_traced(Some(&ctx)).await;
997        let _ = client.queue_status_traced(None).await;
998        let _ = client
999            .upload_image_traced(b"png", "f.png", false, Some(&ctx))
1000            .await;
1001        let _ = client
1002            .upload_image_traced(b"png", "f.png", false, None)
1003            .await;
1004        let _ = client.interrupt_traced(Some(&ctx)).await;
1005        let _ = client.interrupt_traced(None).await;
1006        let _ = client.free_memory_traced(true, Some(&ctx)).await;
1007        let _ = client.free_memory_traced(false, None).await;
1008        let _ = client
1009            .wait_for_completion_traced("id", Duration::from_secs(10), Some(&ctx))
1010            .await;
1011        let _ = client
1012            .wait_for_completion_ws_traced("id", Duration::from_secs(10), |_| {}, Some(&ctx))
1013            .await;
1014    }
1015
1016    #[test]
1017    fn test_trace_ctx_creation() {
1018        // Verify stack_ids::TraceCtx integrates correctly.
1019        let ctx = stack_ids::TraceCtx::generate();
1020        assert!(!ctx.trace_id.is_empty());
1021
1022        let ctx2 = stack_ids::TraceCtx::from_legacy_trace_id("test-trace-123");
1023        assert_eq!(ctx2.trace_id, "test-trace-123");
1024    }
1025}