feat: add comfyui sdk

This commit is contained in:
imeepos
2025-08-08 13:48:38 +08:00
parent 763b4a975c
commit 5f6a302dfd
27 changed files with 4713 additions and 0 deletions

View File

@@ -59,6 +59,7 @@ urlencoding = "2.1"
bincode = "1.3"
zip = "0.6"
sysinfo = "0.30"
comfyui-sdk = "0.1"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["sysinfoapi"] }

View File

@@ -0,0 +1,281 @@
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use tracing::{info, warn, error, debug};
use tokio::time::sleep;
use crate::config::ComfyUISettings;
use crate::data::models::outfit_photo_generation::{
WorkflowProgress, WorkflowNodeReplacement
};
/// ComfyUI SDK 服务包装器
/// 使用 comfyui-sdk crate 提供更好的 ComfyUI 集成
pub struct ComfyUISDKService {
client: comfyui_sdk::ComfyUIClient,
settings: ComfyUISettings,
}
/// 工作流执行结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowExecutionResult {
pub prompt_id: String,
pub outputs: HashMap<String, Vec<String>>,
pub execution_time: Duration,
pub node_outputs: HashMap<String, Value>,
}
/// 工作流执行配置
#[derive(Debug, Clone)]
pub struct SDKExecutionConfig {
pub timeout: Duration,
pub retry_attempts: u32,
pub progress_callback: Option<Box<dyn Fn(WorkflowProgress) + Send + Sync>>,
}
impl Default for SDKExecutionConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(300), // 5分钟默认超时
retry_attempts: 3,
progress_callback: None,
}
}
}
impl ComfyUISDKService {
/// 创建新的 ComfyUI SDK 服务实例
pub fn new(settings: ComfyUISettings) -> Result<Self> {
let client = comfyui_sdk::ComfyUIClient::new(&settings.base_url)?;
Ok(Self {
client,
settings,
})
}
/// 检查 ComfyUI 服务状态
pub async fn check_health(&self) -> Result<bool> {
match self.client.get_system_stats().await {
Ok(_) => {
info!("ComfyUI 服务健康检查通过");
Ok(true)
}
Err(e) => {
warn!("ComfyUI 服务健康检查失败: {}", e);
Ok(false)
}
}
}
/// 获取队列状态
pub async fn get_queue_status(&self) -> Result<QueueStatus> {
let queue_info = self.client.get_queue().await?;
Ok(QueueStatus {
running: queue_info.queue_running.len(),
pending: queue_info.queue_pending.len(),
})
}
/// 执行工作流
pub async fn execute_workflow(
&self,
workflow: Value,
config: SDKExecutionConfig,
) -> Result<WorkflowExecutionResult> {
let start_time = std::time::Instant::now();
// 提交工作流到队列
let prompt_id = self.client.queue_prompt(workflow).await?;
info!("工作流已提交prompt_id: {}", prompt_id);
// 等待执行完成
let result = self.wait_for_completion(&prompt_id, config).await?;
let execution_time = start_time.elapsed();
info!("工作流执行完成,耗时: {:?}", execution_time);
Ok(WorkflowExecutionResult {
prompt_id,
outputs: result.outputs,
execution_time,
node_outputs: result.node_outputs,
})
}
/// 等待工作流执行完成
async fn wait_for_completion(
&self,
prompt_id: &str,
config: SDKExecutionConfig,
) -> Result<ExecutionResult> {
let mut attempts = 0;
let max_attempts = config.retry_attempts;
loop {
match self.check_execution_status(prompt_id).await {
Ok(status) => {
match status {
ExecutionStatus::Completed(result) => return Ok(result),
ExecutionStatus::Running(progress) => {
if let Some(callback) = &config.progress_callback {
callback(progress);
}
sleep(Duration::from_secs(1)).await;
}
ExecutionStatus::Failed(error) => {
return Err(anyhow!("工作流执行失败: {}", error));
}
}
}
Err(e) => {
attempts += 1;
if attempts >= max_attempts {
return Err(anyhow!("检查执行状态失败,已重试 {} 次: {}", attempts, e));
}
warn!("检查执行状态失败,重试中 ({}/{}): {}", attempts, max_attempts, e);
sleep(Duration::from_secs(2)).await;
}
}
}
}
/// 检查执行状态
async fn check_execution_status(&self, prompt_id: &str) -> Result<ExecutionStatus> {
// 检查历史记录
let history = self.client.get_history(Some(prompt_id)).await?;
if let Some(entry) = history.get(prompt_id) {
// 执行完成
let outputs = self.extract_outputs(entry)?;
let node_outputs = self.extract_node_outputs(entry)?;
return Ok(ExecutionStatus::Completed(ExecutionResult {
outputs,
node_outputs,
}));
}
// 检查队列状态
let queue = self.client.get_queue().await?;
// 检查是否在运行队列中
for item in &queue.queue_running {
if item.get("prompt_id").and_then(|v| v.as_str()) == Some(prompt_id) {
let progress = self.extract_progress(item)?;
return Ok(ExecutionStatus::Running(progress));
}
}
// 检查是否在等待队列中
for item in &queue.queue_pending {
if item.get("prompt_id").and_then(|v| v.as_str()) == Some(prompt_id) {
return Ok(ExecutionStatus::Running(WorkflowProgress {
current_node: "等待中".to_string(),
progress: 0.0,
message: "工作流在队列中等待执行".to_string(),
}));
}
}
Err(anyhow!("未找到 prompt_id: {}", prompt_id))
}
/// 从历史记录中提取输出
fn extract_outputs(&self, history_entry: &Value) -> Result<HashMap<String, Vec<String>>> {
let mut outputs = HashMap::new();
if let Some(outputs_obj) = history_entry.get("outputs") {
for (node_id, node_output) in outputs_obj.as_object().unwrap_or(&serde_json::Map::new()) {
if let Some(images) = node_output.get("images") {
if let Some(images_array) = images.as_array() {
let image_urls: Vec<String> = images_array
.iter()
.filter_map(|img| {
img.get("filename")
.and_then(|f| f.as_str())
.map(|filename| {
format!("{}/view?filename={}", self.settings.base_url, filename)
})
})
.collect();
if !image_urls.is_empty() {
outputs.insert(node_id.clone(), image_urls);
}
}
}
}
}
Ok(outputs)
}
/// 从历史记录中提取节点输出
fn extract_node_outputs(&self, history_entry: &Value) -> Result<HashMap<String, Value>> {
let mut node_outputs = HashMap::new();
if let Some(outputs_obj) = history_entry.get("outputs") {
for (node_id, node_output) in outputs_obj.as_object().unwrap_or(&serde_json::Map::new()) {
node_outputs.insert(node_id.clone(), node_output.clone());
}
}
Ok(node_outputs)
}
/// 从队列项中提取进度信息
fn extract_progress(&self, queue_item: &Value) -> Result<WorkflowProgress> {
// 这里需要根据实际的队列项结构来提取进度信息
// 由于 comfyui-sdk 的具体实现可能不同,这里提供一个基本的实现
Ok(WorkflowProgress {
current_node: "执行中".to_string(),
progress: 50.0, // 默认进度
message: "工作流正在执行中".to_string(),
})
}
/// 取消工作流执行
pub async fn cancel_workflow(&self, prompt_id: &str) -> Result<()> {
self.client.interrupt().await?;
info!("已取消工作流执行: {}", prompt_id);
Ok(())
}
/// 获取可用模型列表
pub async fn get_available_models(&self) -> Result<Vec<String>> {
let object_info = self.client.get_object_info().await?;
let mut models = Vec::new();
// 从 object_info 中提取模型信息
// 这里需要根据实际的 API 响应结构来实现
Ok(models)
}
}
/// 队列状态
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueStatus {
pub running: usize,
pub pending: usize,
}
/// 执行状态
#[derive(Debug)]
enum ExecutionStatus {
Running(WorkflowProgress),
Completed(ExecutionResult),
Failed(String),
}
/// 执行结果
#[derive(Debug)]
struct ExecutionResult {
outputs: HashMap<String, Vec<String>>,
node_outputs: HashMap<String, Value>,
}

View File

@@ -0,0 +1,274 @@
//! Main ComfyUI client that combines HTTP and WebSocket functionality
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::time::sleep;
use crate::types::{
ComfyUIClientConfig, PromptRequest, TemplateExecutionResult,
ExecutionOptions, ExecutionCallbacks, ExecutionError, ParameterValues
};
use crate::templates::{TemplateManager, WorkflowTemplate, WorkflowInstance};
use crate::client::{HTTPClient, WebSocketClient};
use crate::error::{ComfyUIError, Result};
/// Main ComfyUI client
pub struct ComfyUIClient {
http_client: HTTPClient,
ws_client: WebSocketClient,
template_manager: TemplateManager,
config: ComfyUIClientConfig,
}
impl ComfyUIClient {
/// Creates a new ComfyUI client
pub fn new(config: ComfyUIClientConfig) -> Result<Self> {
let http_client = HTTPClient::new(config.clone())?;
let ws_client = WebSocketClient::new(config.clone());
let template_manager = TemplateManager::new();
Ok(Self {
http_client,
ws_client,
template_manager,
config,
})
}
/// Gets the HTTP client
pub fn http(&self) -> &HTTPClient {
&self.http_client
}
/// Gets the WebSocket client
pub fn ws(&self) -> &WebSocketClient {
&self.ws_client
}
/// Gets the template manager
pub fn templates(&mut self) -> &mut TemplateManager {
&mut self.template_manager
}
/// Gets the template manager (read-only)
pub fn templates_ref(&self) -> &TemplateManager {
&self.template_manager
}
/// Gets the client configuration
pub fn config(&self) -> &ComfyUIClientConfig {
&self.config
}
/// Connects to ComfyUI server (both HTTP and WebSocket)
pub async fn connect(&mut self) -> Result<()> {
// Test HTTP connection first
self.http_client.get_queue().await
.map_err(|e| ComfyUIError::connection(format!("HTTP connection failed: {}", e)))?;
// Connect WebSocket
self.ws_client.connect().await
.map_err(|e| ComfyUIError::connection(format!("WebSocket connection failed: {}", e)))?;
log::info!("Successfully connected to ComfyUI server");
Ok(())
}
/// Disconnects from ComfyUI server
pub async fn disconnect(&mut self) -> Result<()> {
self.ws_client.disconnect().await?;
log::info!("Disconnected from ComfyUI server");
Ok(())
}
/// Checks if connected to the server
pub async fn is_connected(&self) -> bool {
self.ws_client.is_connected().await
}
/// Executes a workflow template
pub async fn execute_template(
&self,
template: &WorkflowTemplate,
parameters: ParameterValues,
options: ExecutionOptions,
) -> Result<TemplateExecutionResult> {
let start_time = Instant::now();
// Create workflow instance
let instance = template.create_instance(parameters)?;
// Convert to prompt request
let prompt_request = self.instance_to_prompt_request(&instance)?;
// Submit prompt
let prompt_response = self.http_client.queue_prompt(&prompt_request).await?;
let prompt_id = prompt_response.prompt_id.clone();
log::info!("Submitted prompt with ID: {}", prompt_id);
// Wait for completion if WebSocket is connected
if self.ws_client.is_connected().await {
match self.wait_for_completion(&prompt_id, options.timeout).await {
Ok(outputs) => {
let execution_time = start_time.elapsed().as_millis() as u64;
Ok(TemplateExecutionResult::success(prompt_id, outputs, execution_time))
}
Err(error) => {
let execution_time = start_time.elapsed().as_millis() as u64;
let exec_error = ExecutionError {
node_id: None,
message: error.to_string(),
details: None,
timestamp: chrono::Utc::now(),
};
Ok(TemplateExecutionResult::failure(prompt_id, exec_error, execution_time))
}
}
} else {
// If no WebSocket, just return the prompt ID
let execution_time = start_time.elapsed().as_millis() as u64;
Ok(TemplateExecutionResult::success(prompt_id, HashMap::new(), execution_time))
}
}
/// Executes a template with callbacks
pub async fn execute_template_with_callbacks(
&self,
template: &WorkflowTemplate,
parameters: ParameterValues,
options: ExecutionOptions,
callbacks: Arc<dyn ExecutionCallbacks>,
) -> Result<TemplateExecutionResult> {
// Register callbacks
let callback_id = self.ws_client.register_callbacks(callbacks).await;
// Execute template
let result = self.execute_template(template, parameters, options).await;
// Unregister callbacks
self.ws_client.unregister_callbacks(&callback_id).await;
result
}
/// Converts a workflow instance to a prompt request
fn instance_to_prompt_request(&self, instance: &WorkflowInstance) -> Result<PromptRequest> {
let workflow_json = instance.to_workflow_json()?;
let mut prompt = HashMap::new();
if let serde_json::Value::Object(workflow_obj) = workflow_json {
for (key, value) in workflow_obj {
prompt.insert(key, value);
}
}
Ok(PromptRequest {
prompt,
client_id: Some(self.ws_client.client_id().to_string()),
extra_data: None,
})
}
/// Waits for prompt completion
async fn wait_for_completion(
&self,
prompt_id: &str,
timeout: Option<Duration>,
) -> Result<HashMap<String, serde_json::Value>> {
let timeout_duration = timeout.unwrap_or(Duration::from_secs(300)); // 5 minutes default
let start_time = Instant::now();
let check_interval = Duration::from_millis(1000); // Check every second
loop {
// Check timeout
if start_time.elapsed() > timeout_duration {
return Err(ComfyUIError::timeout(format!(
"Execution timed out after {:?}",
timeout_duration
)));
}
// Check history for completion
match self.http_client.get_history_by_prompt(prompt_id).await {
Ok(history) => {
if let Some(history_item) = history.get(prompt_id) {
if history_item.status.completed {
// Extract outputs
let mut outputs = HashMap::new();
for (node_id, node_outputs) in &history_item.outputs {
let mut node_output = HashMap::new();
for (output_name, output_list) in node_outputs {
node_output.insert(output_name.clone(), serde_json::to_value(output_list)?);
}
outputs.insert(node_id.clone(), serde_json::to_value(node_output)?);
}
return Ok(outputs);
}
}
}
Err(e) => {
log::warn!("Error checking history: {}", e);
}
}
sleep(check_interval).await;
}
}
/// Converts outputs to HTTP URLs
pub fn outputs_to_urls(&self, outputs: &HashMap<String, serde_json::Value>) -> Vec<String> {
self.http_client.outputs_to_urls(outputs)
}
/// Interrupts the current execution
pub async fn interrupt(&self) -> Result<()> {
self.http_client.interrupt().await
}
/// Clears the execution queue
pub async fn clear_queue(&self) -> Result<()> {
self.http_client.clear_queue().await
}
/// Gets the current queue status
pub async fn get_queue_status(&self) -> Result<crate::types::QueueStatus> {
self.http_client.get_queue().await
}
/// Gets system statistics
pub async fn get_system_stats(&self) -> Result<crate::types::SystemStats> {
self.http_client.get_system_stats().await
}
/// Gets available nodes information
pub async fn get_object_info(&self) -> Result<crate::types::ObjectInfo> {
self.http_client.get_object_info().await
}
/// Uploads an image file
pub async fn upload_image<P: AsRef<std::path::Path>>(
&self,
image_path: P,
overwrite: bool,
) -> Result<crate::types::UploadImageResponse> {
self.http_client.upload_image(image_path, overwrite).await
}
/// Gets an image by filename
pub async fn get_image(
&self,
filename: &str,
subfolder: Option<&str>,
image_type: Option<&str>,
) -> Result<bytes::Bytes> {
self.http_client.get_image(filename, subfolder, image_type).await
}
/// Frees memory
pub async fn free_memory(&self) -> Result<()> {
self.http_client.free_memory().await
}
}

View File

@@ -0,0 +1,324 @@
//! HTTP client for ComfyUI API
use std::collections::HashMap;
use std::path::Path;
use reqwest::{Client, multipart};
use url::Url;
use crate::types::{
ComfyUIClientConfig, PromptRequest, PromptResponse, QueueStatus,
HistoryItem, SystemStats, ObjectInfo, UploadImageResponse, ViewMetadata
};
use crate::error::{ComfyUIError, Result};
use crate::utils::error_handling::{retry_if_retryable, RetryConfig, with_timeout};
/// HTTP client for ComfyUI API
#[derive(Debug, Clone)]
pub struct HTTPClient {
client: Client,
base_url: Url,
config: ComfyUIClientConfig,
}
impl HTTPClient {
/// Creates a new HTTP client
pub fn new(config: ComfyUIClientConfig) -> Result<Self> {
let base_url = Url::parse(&config.base_url)?;
let mut client_builder = Client::builder();
if let Some(timeout) = config.timeout {
client_builder = client_builder.timeout(timeout);
}
let client = client_builder.build()?;
Ok(Self {
client,
base_url,
config,
})
}
/// Gets the base URL
pub fn base_url(&self) -> &Url {
&self.base_url
}
/// Builds a URL for an endpoint
fn build_url(&self, endpoint: &str) -> Result<Url> {
self.base_url.join(endpoint)
.map_err(ComfyUIError::from)
}
/// Executes a GET request with retry logic
async fn get_with_retry<T>(&self, endpoint: &str) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
let url = self.build_url(endpoint)?;
let retry_config = RetryConfig {
max_attempts: self.config.retry_attempts.unwrap_or(3),
initial_delay: self.config.retry_delay.unwrap_or(std::time::Duration::from_millis(1000)),
..Default::default()
};
let operation = || async {
let mut request = self.client.get(url.clone());
if let Some(headers) = &self.config.headers {
for (key, value) in headers {
request = request.header(key, value);
}
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(ComfyUIError::Http(reqwest::Error::from(
response.error_for_status().unwrap_err()
)));
}
let data: T = response.json().await?;
Ok(data)
};
if let Some(timeout) = self.config.timeout {
with_timeout(retry_if_retryable(operation, retry_config), timeout).await
} else {
retry_if_retryable(operation, retry_config).await
}
}
/// Executes a POST request with retry logic
async fn post_with_retry<T, B>(&self, endpoint: &str, body: &B) -> Result<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize,
{
let url = self.build_url(endpoint)?;
let retry_config = RetryConfig {
max_attempts: self.config.retry_attempts.unwrap_or(3),
initial_delay: self.config.retry_delay.unwrap_or(std::time::Duration::from_millis(1000)),
..Default::default()
};
let operation = || async {
let mut request = self.client.post(url.clone()).json(body);
if let Some(headers) = &self.config.headers {
for (key, value) in headers {
request = request.header(key, value);
}
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(ComfyUIError::Http(reqwest::Error::from(
response.error_for_status().unwrap_err()
)));
}
let data: T = response.json().await?;
Ok(data)
};
if let Some(timeout) = self.config.timeout {
with_timeout(retry_if_retryable(operation, retry_config), timeout).await
} else {
retry_if_retryable(operation, retry_config).await
}
}
/// Gets the current queue status
pub async fn get_queue(&self) -> Result<QueueStatus> {
self.get_with_retry("/queue").await
}
/// Submits a prompt for execution
pub async fn queue_prompt(&self, prompt_request: &PromptRequest) -> Result<PromptResponse> {
self.post_with_retry("/prompt", prompt_request).await
}
/// Gets execution history
pub async fn get_history(&self, max_items: Option<u32>) -> Result<HashMap<String, HistoryItem>> {
let endpoint = if let Some(max) = max_items {
format!("/history?max_items={}", max)
} else {
"/history".to_string()
};
self.get_with_retry(&endpoint).await
}
/// Gets history for a specific prompt
pub async fn get_history_by_prompt(&self, prompt_id: &str) -> Result<HashMap<String, HistoryItem>> {
let endpoint = format!("/history/{}", prompt_id);
self.get_with_retry(&endpoint).await
}
/// Clears the execution queue
pub async fn clear_queue(&self) -> Result<()> {
let url = self.build_url("/queue")?;
let response = self.client.delete(url).send().await?;
if !response.status().is_success() {
return Err(ComfyUIError::Http(reqwest::Error::from(
response.error_for_status().unwrap_err()
)));
}
Ok(())
}
/// Cancels a specific prompt
pub async fn cancel_prompt(&self, prompt_id: &str) -> Result<()> {
let body = serde_json::json!({ "delete": [prompt_id] });
let url = self.build_url("/queue")?;
let response = self.client.post(url).json(&body).send().await?;
if !response.status().is_success() {
return Err(ComfyUIError::Http(reqwest::Error::from(
response.error_for_status().unwrap_err()
)));
}
Ok(())
}
/// Gets system statistics
pub async fn get_system_stats(&self) -> Result<SystemStats> {
self.get_with_retry("/system_stats").await
}
/// Gets object information (available nodes)
pub async fn get_object_info(&self) -> Result<ObjectInfo> {
self.get_with_retry("/object_info").await
}
/// Uploads an image file
pub async fn upload_image<P: AsRef<Path>>(&self, image_path: P, overwrite: bool) -> Result<UploadImageResponse> {
let path = image_path.as_ref();
let filename = path.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| ComfyUIError::new("Invalid filename"))?;
let file_bytes = tokio::fs::read(path).await?;
let part = multipart::Part::bytes(file_bytes)
.file_name(filename.to_string())
.mime_str("image/*")
.map_err(|e| ComfyUIError::new(format!("Invalid MIME type: {}", e)))?;
let form = multipart::Form::new()
.part("image", part)
.text("overwrite", overwrite.to_string());
let url = self.build_url("/upload/image")?;
let response = self.client.post(url).multipart(form).send().await?;
if !response.status().is_success() {
return Err(ComfyUIError::Http(reqwest::Error::from(
response.error_for_status().unwrap_err()
)));
}
let upload_response: UploadImageResponse = response.json().await?;
Ok(upload_response)
}
/// Gets an image by filename
pub async fn get_image(&self, filename: &str, subfolder: Option<&str>, image_type: Option<&str>) -> Result<bytes::Bytes> {
let mut endpoint = format!("/view?filename={}", filename);
if let Some(subfolder) = subfolder {
endpoint.push_str(&format!("&subfolder={}", subfolder));
}
if let Some(image_type) = image_type {
endpoint.push_str(&format!("&type={}", image_type));
}
let url = self.build_url(&endpoint)?;
let response = self.client.get(url).send().await?;
if !response.status().is_success() {
return Err(ComfyUIError::Http(reqwest::Error::from(
response.error_for_status().unwrap_err()
)));
}
let bytes = response.bytes().await?;
Ok(bytes)
}
/// Gets image metadata
pub async fn get_view_metadata(&self, filename: &str, subfolder: Option<&str>) -> Result<ViewMetadata> {
let mut endpoint = format!("/view_metadata/{}", filename);
if let Some(subfolder) = subfolder {
endpoint.push_str(&format!("?subfolder={}", subfolder));
}
self.get_with_retry(&endpoint).await
}
/// Converts outputs to HTTP URLs
pub fn outputs_to_urls(&self, outputs: &HashMap<String, serde_json::Value>) -> Vec<String> {
let mut urls = Vec::new();
for (_node_id, output) in outputs {
if let Some(output_obj) = output.as_object() {
if let Some(images) = output_obj.get("images").and_then(|v| v.as_array()) {
for image in images {
if let Some(image_obj) = image.as_object() {
if let (Some(filename), Some(subfolder), Some(image_type)) = (
image_obj.get("filename").and_then(|v| v.as_str()),
image_obj.get("subfolder").and_then(|v| v.as_str()),
image_obj.get("type").and_then(|v| v.as_str()),
) {
let base_url_str = self.base_url.as_str().trim_end_matches('/');
let url = format!(
"{}/view?filename={}&subfolder={}&type={}",
base_url_str, filename, subfolder, image_type
);
urls.push(url);
}
}
}
}
}
}
urls
}
/// Interrupts the current execution
pub async fn interrupt(&self) -> Result<()> {
let url = self.build_url("/interrupt")?;
let response = self.client.post(url).send().await?;
if !response.status().is_success() {
return Err(ComfyUIError::Http(reqwest::Error::from(
response.error_for_status().unwrap_err()
)));
}
Ok(())
}
/// Frees memory
pub async fn free_memory(&self) -> Result<()> {
let url = self.build_url("/free")?;
let response = self.client.post(url).send().await?;
if !response.status().is_success() {
return Err(ComfyUIError::Http(reqwest::Error::from(
response.error_for_status().unwrap_err()
)));
}
Ok(())
}
}

View File

@@ -0,0 +1,10 @@
//! Client modules for ComfyUI SDK
pub mod http_client;
pub mod websocket_client;
pub mod comfyui_client;
// Re-export main types
pub use http_client::HTTPClient;
pub use websocket_client::WebSocketClient;
pub use comfyui_client::ComfyUIClient;

View File

@@ -0,0 +1,226 @@
//! WebSocket client for ComfyUI real-time events
use std::sync::Arc;
use tokio::sync::{RwLock, mpsc};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use futures_util::StreamExt;
use url::Url;
use uuid::Uuid;
use chrono::Utc;
use crate::types::{
ComfyUIClientConfig, WSMessage, ExecutionProgress, ExecutionResult,
ExecutionError, ExecutionCallbacks
};
use crate::error::{ComfyUIError, Result};
use crate::utils::event_emitter::EventEmitter;
/// WebSocket client for real-time ComfyUI events
pub struct WebSocketClient {
config: ComfyUIClientConfig,
client_id: String,
event_emitter: EventEmitter,
is_connected: Arc<RwLock<bool>>,
shutdown_tx: Option<mpsc::UnboundedSender<()>>,
}
impl WebSocketClient {
/// Creates a new WebSocket client
pub fn new(config: ComfyUIClientConfig) -> Self {
Self {
config,
client_id: Uuid::new_v4().to_string(),
event_emitter: EventEmitter::new(),
is_connected: Arc::new(RwLock::new(false)),
shutdown_tx: None,
}
}
/// Gets the client ID
pub fn client_id(&self) -> &str {
&self.client_id
}
/// Checks if connected
pub async fn is_connected(&self) -> bool {
*self.is_connected.read().await
}
/// Connects to the WebSocket server
pub async fn connect(&mut self) -> Result<()> {
if self.is_connected().await {
return Ok(());
}
let ws_url = self.build_websocket_url()?;
log::info!("Connecting to WebSocket: {}", ws_url);
let (ws_stream, _) = connect_async(&ws_url).await?;
let (_ws_sender, mut ws_receiver) = ws_stream.split();
// Set connected state
*self.is_connected.write().await = true;
// Create shutdown channel
let (shutdown_tx, mut shutdown_rx) = mpsc::unbounded_channel();
self.shutdown_tx = Some(shutdown_tx);
// Clone necessary data for the message handling task
let event_emitter = self.event_emitter.clone();
let is_connected = self.is_connected.clone();
// Spawn message handling task
tokio::spawn(async move {
loop {
tokio::select! {
// Handle incoming messages
message = ws_receiver.next() => {
match message {
Some(Ok(Message::Text(text))) => {
if let Err(e) = Self::handle_message(&text, &event_emitter).await {
log::error!("Error handling WebSocket message: {}", e);
}
}
Some(Ok(Message::Close(_))) => {
log::info!("WebSocket connection closed by server");
break;
}
Some(Err(e)) => {
log::error!("WebSocket error: {}", e);
break;
}
None => {
log::info!("WebSocket stream ended");
break;
}
_ => {} // Ignore other message types
}
}
// Handle shutdown signal
_ = shutdown_rx.recv() => {
log::info!("WebSocket shutdown requested");
break;
}
}
}
// Set disconnected state
*is_connected.write().await = false;
log::info!("WebSocket connection closed");
});
Ok(())
}
/// Disconnects from the WebSocket server
pub async fn disconnect(&mut self) -> Result<()> {
if !self.is_connected().await {
return Ok(());
}
if let Some(shutdown_tx) = &self.shutdown_tx {
let _ = shutdown_tx.send(());
}
// Wait for disconnection
let mut attempts = 0;
while self.is_connected().await && attempts < 50 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
attempts += 1;
}
self.shutdown_tx = None;
Ok(())
}
/// Registers execution callbacks
pub async fn register_callbacks(&self, callbacks: Arc<dyn ExecutionCallbacks>) -> String {
let callback_id = Uuid::new_v4().to_string();
self.event_emitter.register_callbacks(callback_id.clone(), callbacks).await;
callback_id
}
/// Unregisters execution callbacks
pub async fn unregister_callbacks(&self, callback_id: &str) {
self.event_emitter.unregister_callbacks(callback_id).await;
}
/// Builds the WebSocket URL
fn build_websocket_url(&self) -> Result<String> {
let base_url = Url::parse(&self.config.base_url)?;
let scheme = match base_url.scheme() {
"http" => "ws",
"https" => "wss",
_ => return Err(ComfyUIError::new("Invalid URL scheme")),
};
let ws_url = format!(
"{}://{}:{}/ws?clientId={}",
scheme,
base_url.host_str().unwrap_or("localhost"),
base_url.port().unwrap_or(8188),
self.client_id
);
Ok(ws_url)
}
/// Handles incoming WebSocket messages
async fn handle_message(text: &str, event_emitter: &EventEmitter) -> Result<()> {
let message: WSMessage = serde_json::from_str(text)
.map_err(|e| ComfyUIError::new(format!("Failed to parse WebSocket message: {}", e)))?;
match message {
WSMessage::Progress { data } => {
let progress = ExecutionProgress {
node_id: data.node.unwrap_or_else(|| "unknown".to_string()),
progress: data.value,
max: data.max,
timestamp: Utc::now(),
};
event_emitter.emit_progress(progress).await;
}
WSMessage::Executing { data } => {
if let Some(node_id) = data.node {
event_emitter.emit_executing(node_id).await;
}
}
WSMessage::Executed { data } => {
let result = ExecutionResult {
prompt_id: data.prompt_id,
outputs: data.output,
execution_time: 0, // WebSocket doesn't provide execution time
timestamp: Utc::now(),
};
event_emitter.emit_executed(result).await;
}
WSMessage::ExecutionError { data } => {
let error = ExecutionError {
node_id: data.node_id,
message: data.message,
details: data.details,
timestamp: Utc::now(),
};
event_emitter.emit_error(error).await;
}
WSMessage::Unknown => {
log::debug!("Received unknown WebSocket message: {}", text);
}
}
Ok(())
}
/// Gets the event emitter for direct access
pub fn event_emitter(&self) -> &EventEmitter {
&self.event_emitter
}
}
impl Drop for WebSocketClient {
fn drop(&mut self) {
if let Some(shutdown_tx) = &self.shutdown_tx {
let _ = shutdown_tx.send(());
}
}
}

View File

@@ -0,0 +1,86 @@
//! Error types for the ComfyUI SDK
use thiserror::Error;
/// Result type alias for ComfyUI SDK operations
pub type Result<T> = std::result::Result<T, ComfyUIError>;
/// Main error type for ComfyUI SDK
#[derive(Error, Debug)]
pub enum ComfyUIError {
/// HTTP request errors
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
/// WebSocket errors
#[error("WebSocket error: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
/// JSON serialization/deserialization errors
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
/// URL parsing errors
#[error("URL error: {0}")]
Url(#[from] url::ParseError),
/// Template validation errors
#[error("Template validation error: {0}")]
TemplateValidation(String),
/// Parameter validation errors
#[error("Parameter validation error: {0}")]
ParameterValidation(String),
/// Connection errors
#[error("Connection error: {0}")]
Connection(String),
/// Execution errors
#[error("Execution error: {0}")]
Execution(String),
/// Timeout errors
#[error("Timeout error: {0}")]
Timeout(String),
/// Generic errors
#[error("Error: {0}")]
Generic(String),
/// IO errors
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
impl ComfyUIError {
/// Create a new generic error
pub fn new(message: impl Into<String>) -> Self {
Self::Generic(message.into())
}
/// Create a new template validation error
pub fn template_validation(message: impl Into<String>) -> Self {
Self::TemplateValidation(message.into())
}
/// Create a new parameter validation error
pub fn parameter_validation(message: impl Into<String>) -> Self {
Self::ParameterValidation(message.into())
}
/// Create a new connection error
pub fn connection(message: impl Into<String>) -> Self {
Self::Connection(message.into())
}
/// Create a new execution error
pub fn execution(message: impl Into<String>) -> Self {
Self::Execution(message.into())
}
/// Create a new timeout error
pub fn timeout(message: impl Into<String>) -> Self {
Self::Timeout(message.into())
}
}

View File

@@ -0,0 +1,271 @@
//! ComfyUI SDK Examples
//!
//! This example demonstrates how to use the ComfyUI SDK for Rust
use std::collections::HashMap;
use std::sync::Arc;
use comfyui_sdk::{
ComfyUIClient, ComfyUIClientConfig, ExecutionOptions,
AI_MODEL_FACE_HAIR_FIX_TEMPLATE
};
use comfyui_sdk::utils::SimpleCallbacks;
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging (optional)
// env_logger::init();
println!("🚀 Starting ComfyUI SDK Rust Examples");
// Run the AI model face hair fix example
ai_model_face_hair_fix_example().await?;
// Run template validation example
template_validation_example().await?;
println!("✅ All examples completed successfully!");
Ok(())
}
/// AI Model Face & Hair Detail Fix Example
async fn ai_model_face_hair_fix_example() -> Result<(), Box<dyn std::error::Error>> {
println!("\n🎨 AI Model Face & Hair Detail Fix Example");
println!("===========================================");
// Initialize client
let mut client = ComfyUIClient::new(ComfyUIClientConfig {
base_url: "http://192.168.0.193:8188".to_string(),
..Default::default()
})?;
// Connect to ComfyUI server
println!("📡 Connecting to ComfyUI server...");
client.connect().await?;
println!("✅ Connected successfully!");
// Register the built-in template
println!("📝 Registering AI Model Face & Hair Fix template...");
client.templates().register_from_data(AI_MODEL_FACE_HAIR_FIX_TEMPLATE.clone())?;
println!("✅ Template registered!");
// Get the registered template
let template = client.templates_ref()
.get_by_id(&AI_MODEL_FACE_HAIR_FIX_TEMPLATE.metadata.id)
.ok_or("Failed to get template")?;
println!("📋 Template ID: {}", template.id());
println!("📋 Template Name: {}", template.name());
if let Some(description) = template.description() {
println!("📋 Template Description: {}", description);
}
// Display template parameters
println!("\n🔍 Template Parameters:");
for (name, schema) in template.parameters() {
println!(" - {}: {:?} ({})",
name,
schema.param_type,
schema.description.as_deref().unwrap_or("No description")
);
}
// Create execution callbacks
let callbacks = Arc::new(
SimpleCallbacks::new()
.with_progress(|progress| {
let percentage = (progress.progress as f64 / progress.max as f64 * 100.0) as u32;
println!("⏳ Progress: {}% ({}/{}) - Node: {}",
percentage, progress.progress, progress.max, progress.node_id);
})
.with_executing(|node_id| {
println!("🔄 Executing node: {}", node_id);
})
.with_executed(|result| {
println!("✅ Node executed - Prompt ID: {}", result.prompt_id);
})
.with_error(|error| {
println!("❌ Execution error: {}", error.message);
})
);
// Execute the template with test parameters
println!("\n🎨 Executing AI Model Face & Hair Enhancement...");
let mut parameters = HashMap::new();
parameters.insert("input_image".to_string(), json!("20250806-190606.jpg"));
parameters.insert("face_prompt".to_string(), json!("beautiful woman, perfect skin, detailed facial features"));
parameters.insert("face_denoise".to_string(), json!("0.25"));
let execution_options = ExecutionOptions {
timeout: Some(std::time::Duration::from_secs(120)), // 2 minutes timeout
priority: None,
};
let result = client.execute_template_with_callbacks(
template,
parameters,
execution_options,
callbacks,
).await?;
if result.success {
println!("\n🎉 AI Model Enhancement completed successfully!");
println!("📊 Execution time: {}ms", result.execution_time);
println!("🆔 Prompt ID: {}", result.prompt_id);
if let Some(outputs) = &result.outputs {
println!("📁 Outputs: {}", serde_json::to_string_pretty(outputs)?);
// Convert outputs to HTTP URLs
let image_urls = client.outputs_to_urls(outputs);
if !image_urls.is_empty() {
println!("🖼️ Enhanced Image URLs:");
for url in image_urls {
println!(" - {}", url);
}
}
}
} else {
println!("❌ AI Model Enhancement failed");
if let Some(error) = &result.error {
println!(" Error: {}", error.message);
}
}
// Disconnect
println!("\n🔌 Disconnecting...");
client.disconnect().await?;
println!("👋 Disconnected!");
Ok(())
}
/// Template validation example
async fn template_validation_example() -> Result<(), Box<dyn std::error::Error>> {
println!("\n🔍 Template Validation Example");
println!("==============================");
let mut client = ComfyUIClient::new(ComfyUIClientConfig {
base_url: "http://192.168.0.193:8188".to_string(),
..Default::default()
})?;
// Register template
client.templates().register_from_data(AI_MODEL_FACE_HAIR_FIX_TEMPLATE.clone())?;
let template = client.templates_ref()
.get_by_id(&AI_MODEL_FACE_HAIR_FIX_TEMPLATE.metadata.id)
.ok_or("Failed to get template")?;
println!("📝 Testing parameter validation...");
// Test valid parameters
let mut valid_params = HashMap::new();
valid_params.insert("input_image".to_string(), json!("test.jpg"));
valid_params.insert("face_prompt".to_string(), json!("beautiful face"));
valid_params.insert("face_denoise".to_string(), json!("0.3"));
let validation_result = template.validate(&valid_params);
println!("✅ Valid parameters test: {}",
if validation_result.valid { "PASSED" } else { "FAILED" });
if !validation_result.valid {
println!("❌ Validation errors:");
for error in &validation_result.errors {
println!(" - {}: {}", error.path, error.message);
}
}
// Test invalid parameters
let mut invalid_params = HashMap::new();
invalid_params.insert("input_image".to_string(), json!("")); // Empty required field
invalid_params.insert("face_prompt".to_string(), json!("test"));
invalid_params.insert("unknown_param".to_string(), json!("value")); // Unknown parameter
let invalid_validation = template.validate(&invalid_params);
println!("🚫 Invalid parameters test: {}",
if !invalid_validation.valid { "PASSED" } else { "FAILED" });
if !invalid_validation.valid {
println!("📋 Expected validation errors:");
for error in &invalid_validation.errors {
println!(" - {}: {}", error.path, error.message);
}
}
// Test template instance creation
println!("\n🏗️ Testing template instance creation...");
match template.create_instance(valid_params) {
Ok(instance) => {
println!("✅ Template instance created successfully");
println!(" Template ID: {}", instance.template_id());
println!(" Template Name: {}", instance.template_name());
println!(" Node count: {}", instance.node_count());
}
Err(e) => {
println!("❌ Failed to create template instance: {}", e);
}
}
// Test template manager features
println!("\n📚 Testing template manager features...");
let templates = client.templates_ref();
println!(" Total templates: {}", templates.count());
println!(" Template IDs: {:?}", templates.list_ids());
let categories = templates.get_categories();
if !categories.is_empty() {
println!(" Categories: {:?}", categories);
}
let tags = templates.get_tags();
if !tags.is_empty() {
println!(" Tags: {:?}", tags);
}
Ok(())
}
/// Basic usage example (simpler version)
#[allow(dead_code)]
async fn basic_usage_example() -> Result<(), Box<dyn std::error::Error>> {
println!("\n📖 Basic Usage Example");
println!("======================");
// Create client
let mut client = ComfyUIClient::new(ComfyUIClientConfig {
base_url: "http://localhost:8188".to_string(),
..Default::default()
})?;
// Connect
client.connect().await?;
println!("✅ Connected to ComfyUI!");
// Get system stats
match client.get_system_stats().await {
Ok(stats) => {
println!("💻 System Info:");
println!(" OS: {}", stats.system.os);
println!(" Python: {}", stats.system.python_version);
println!(" Devices: {}", stats.devices.len());
}
Err(e) => {
println!("❌ Failed to get system stats: {}", e);
}
}
// Get queue status
match client.get_queue_status().await {
Ok(queue) => {
println!("📋 Queue Status:");
println!(" Running: {}", queue.queue_running.len());
println!(" Pending: {}", queue.queue_pending.len());
}
Err(e) => {
println!("❌ Failed to get queue status: {}", e);
}
}
client.disconnect().await?;
Ok(())
}

View File

@@ -0,0 +1,306 @@
//! Real Local Image Upload Test
//!
//! This example demonstrates how to upload your actual local image file
//! and use it with the AI Model Face & Hair Detail Fix template.
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use comfyui_sdk::{
ComfyUIClient, ComfyUIClientConfig, ExecutionOptions,
AI_MODEL_FACE_HAIR_FIX_TEMPLATE
};
use comfyui_sdk::utils::SimpleCallbacks;
use serde_json::json;
/// Test with real local image upload
async fn test_with_real_local_image() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 Starting Real Local Image Upload Test");
// Initialize client
let mut client = ComfyUIClient::new(ComfyUIClientConfig {
base_url: "http://192.168.0.193:8188".to_string(),
..Default::default()
})?;
// Connect to ComfyUI server
println!("📡 Connecting to ComfyUI server...");
client.connect().await?;
println!("✅ Connected successfully!");
// Register the template
println!("📝 Registering AI Model Face & Hair Fix template...");
client.templates().register_from_data(AI_MODEL_FACE_HAIR_FIX_TEMPLATE.clone())?;
let template = client.templates_ref()
.get_by_id(&AI_MODEL_FACE_HAIR_FIX_TEMPLATE.metadata.id)
.ok_or("Failed to register template")?;
println!("✅ Template registered!");
// Your actual local image path
let local_image_path = "20250808-111737.png";
println!("\n📤 Uploading your image: {}", local_image_path);
println!("⏳ This may take a moment depending on file size and network speed...");
match upload_and_execute(&client, template, local_image_path).await {
Ok(_) => println!("✅ Upload and execution completed successfully!"),
Err(e) => {
println!("\n💥 Upload/Execution failed: {}", e);
show_troubleshooting_tips(&e);
}
}
// Disconnect
println!("\n🔌 Disconnecting from ComfyUI server...");
client.disconnect().await?;
println!("👋 Disconnected successfully!");
Ok(())
}
/// Upload image and execute template
async fn upload_and_execute(
client: &ComfyUIClient,
template: &comfyui_sdk::WorkflowTemplate,
image_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Step 1: Upload image to ComfyUI server
println!("\n🔄 Step 1: Uploading image to ComfyUI server...");
if !Path::new(image_path).exists() {
return Err(format!("File not found: {}", image_path).into());
}
let upload_response = client.upload_image(image_path, false).await?;
let uploaded_filename = upload_response.name;
println!("✅ Upload successful! Server filename: {}", uploaded_filename);
// Step 2: Execute AI Model Face & Hair Enhancement
println!("\n🔄 Step 2: Executing AI Model Face & Hair Enhancement...");
// Create execution callbacks
let callbacks = Arc::new(
SimpleCallbacks::new()
.with_progress(|progress| {
let percentage = (progress.progress as f64 / progress.max as f64 * 100.0) as u32;
println!("⏳ Progress: {}% ({}/{}) - Node: {}",
percentage, progress.progress, progress.max, progress.node_id);
})
.with_executing(|node_id| {
println!("🔄 Executing node: {}", node_id);
})
.with_executed(|result| {
println!("✅ Node completed - Prompt ID: {}", result.prompt_id);
})
.with_error(|error| {
println!("❌ Execution error: {}", error.message);
})
);
// Prepare parameters
let mut parameters = HashMap::new();
parameters.insert("input_image".to_string(), json!(uploaded_filename));
parameters.insert("face_prompt".to_string(),
json!("beautiful woman, perfect skin, detailed facial features, professional photography"));
parameters.insert("face_denoise".to_string(), json!("0.25"));
let execution_options = ExecutionOptions {
timeout: Some(std::time::Duration::from_secs(180)), // 3 minutes timeout
priority: None,
};
let result = client.execute_template_with_callbacks(
template,
parameters,
execution_options,
callbacks,
).await?;
if result.success {
println!("\n🎉 AI Model Enhancement completed successfully!");
println!("📊 Total execution time: {}ms ({:.1}s)",
result.execution_time, result.execution_time as f64 / 1000.0);
println!("🆔 Prompt ID: {}", result.prompt_id);
if let Some(outputs) = &result.outputs {
println!("\n📁 Generated outputs:");
println!("{}", serde_json::to_string_pretty(outputs)?);
// Convert outputs to HTTP URLs
let image_urls = client.outputs_to_urls(outputs);
println!("\n🖼️ Enhanced Image URLs:");
for (index, url) in image_urls.iter().enumerate() {
println!(" {}. {}", index + 1, url);
}
println!("\n💡 How to use these URLs:");
println!(" - Copy the URL and paste in your browser to view");
println!(" - Right-click and \"Save As\" to download");
println!(" - Use in your application to display the enhanced image");
}
} else {
println!("\n❌ AI Model Enhancement failed!");
if let Some(error) = &result.error {
println!("Error details: {}", error.message);
}
}
Ok(())
}
/// Show troubleshooting tips based on error
fn show_troubleshooting_tips(error: &Box<dyn std::error::Error>) {
let error_msg = error.to_string();
if error_msg.contains("File not found") || error_msg.contains("No such file") {
println!("\n💡 Troubleshooting Tips:");
println!("1. 🔍 Check if the file path is correct");
println!("2. 📁 Make sure the file exists at the specified location");
println!("3. 🔐 Ensure you have read permissions for the file");
println!("4. 📝 Try using absolute path: /home/user/images/20250808-111737.png");
println!("5. 🖼️ Verify the file is a valid image format (PNG, JPG, etc.)");
} else if error_msg.contains("Failed to upload") || error_msg.contains("HTTP") {
println!("\n💡 Network/Server Issues:");
println!("1. 🌐 Check if ComfyUI server is running and accessible");
println!("2. 📡 Verify network connection to the server");
println!("3. 💾 Check if server has enough disk space");
println!("4. 🔒 Ensure server allows file uploads");
}
}
/// Alternative method using one-step upload and execute (conceptual)
async fn test_one_step_method() -> Result<(), Box<dyn std::error::Error>> {
println!("\n\n🚀 Testing One-Step Method (Upload + Execute)");
let mut client = ComfyUIClient::new(ComfyUIClientConfig {
base_url: "http://192.168.0.193:8188".to_string(),
..Default::default()
})?;
client.connect().await?;
client.templates().register_from_data(AI_MODEL_FACE_HAIR_FIX_TEMPLATE.clone())?;
let template = client.templates_ref()
.get_by_id(&AI_MODEL_FACE_HAIR_FIX_TEMPLATE.metadata.id)
.ok_or("Failed to register template")?;
let local_image_path = "20250808-111737.png";
println!("🔄 One-step upload and execute...");
// For now, we'll implement this as upload + execute since we don't have
// a dedicated one-step method yet
match upload_and_execute_one_step(&client, template, local_image_path).await {
Ok(_) => println!("🎉 One-step method completed successfully!"),
Err(e) => println!("⚠️ One-step method failed: {}", e),
}
client.disconnect().await?;
Ok(())
}
/// One-step upload and execute (simplified version)
async fn upload_and_execute_one_step(
client: &ComfyUIClient,
template: &comfyui_sdk::WorkflowTemplate,
image_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Upload image
let upload_response = client.upload_image(image_path, false).await?;
// Create simple callbacks
let callbacks = Arc::new(
SimpleCallbacks::new()
.with_progress(|progress| {
let percentage = (progress.progress as f64 / progress.max as f64 * 100.0) as u32;
println!("⏳ Progress: {}% - Node: {}", percentage, progress.node_id);
})
);
// Execute template
let mut parameters = HashMap::new();
parameters.insert("input_image".to_string(), json!(upload_response.name));
parameters.insert("face_prompt".to_string(),
json!("stunning model, flawless skin, professional photography, high quality"));
parameters.insert("face_denoise".to_string(), json!("0.3"));
let result = client.execute_template_with_callbacks(
template,
parameters,
ExecutionOptions {
timeout: Some(std::time::Duration::from_secs(180)),
priority: None,
},
callbacks,
).await?;
if result.success {
if let Some(outputs) = &result.outputs {
let image_urls = client.outputs_to_urls(outputs);
println!("🖼️ Result URLs: {:?}", image_urls);
}
}
Ok(())
}
/// Show usage examples
fn show_usage_examples() {
println!("\n📚 Complete Usage Guide for Local Images:");
println!("");
println!("🔧 Basic Setup:");
println!("```rust");
println!("use comfyui_sdk::{{ComfyUIClient, ComfyUIClientConfig, AI_MODEL_FACE_HAIR_FIX_TEMPLATE}};");
println!("");
println!("let mut client = ComfyUIClient::new(ComfyUIClientConfig {{");
println!(" base_url: \"http://your-comfyui-server:8188\".to_string(),");
println!(" ..Default::default()");
println!("}});");
println!("```");
println!("");
println!("📤 Method 1 - Manual Upload:");
println!("```rust");
println!("// Upload image first");
println!("let upload_response = client.upload_image(");
println!(" \"/path/to/your/image.png\", false");
println!(").await?;");
println!("");
println!("// Then execute template");
println!("let result = client.execute_template(template, parameters, options).await?;");
println!("```");
println!("");
println!("🚀 Method 2 - Combined Upload and Execute:");
println!("```rust");
println!("// Upload and execute in sequence");
println!("let upload_response = client.upload_image(image_path, false).await?;");
println!("let mut parameters = HashMap::new();");
println!("parameters.insert(\"input_image\".to_string(), json!(upload_response.name));");
println!("let result = client.execute_template_with_callbacks(");
println!(" template, parameters, options, callbacks");
println!(").await?;");
println!("```");
}
/// Run all tests
async fn run_all_tests() -> Result<(), Box<dyn std::error::Error>> {
show_usage_examples();
test_with_real_local_image().await?;
// Uncomment to test one-step method as well
// test_one_step_method().await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
run_all_tests().await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_show_usage_examples() {
show_usage_examples();
// This test just ensures the function runs without panicking
}
}

View File

@@ -0,0 +1,162 @@
//! Simple Local Image Upload and Enhancement
//!
//! A simplified version that focuses on the core functionality:
//! 1. Upload a local image to ComfyUI server
//! 2. Execute AI Model Face & Hair enhancement
//! 3. Get the enhanced image URLs
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use comfyui_sdk::{
ComfyUIClient, ComfyUIClientConfig, ExecutionOptions,
AI_MODEL_FACE_HAIR_FIX_TEMPLATE
};
use comfyui_sdk::utils::SimpleCallbacks;
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 Simple Local Image Enhancement Test");
// Configuration
let server_url = "http://192.168.0.193:8188";
let image_path = "20250808-111737.png";
// Step 1: Initialize client and connect
println!("📡 Connecting to ComfyUI server at {}...", server_url);
let mut client = ComfyUIClient::new(ComfyUIClientConfig {
base_url: server_url.to_string(),
..Default::default()
})?;
client.connect().await?;
println!("✅ Connected!");
// Step 2: Register template
println!("📝 Registering AI enhancement template...");
client.templates().register_from_data(AI_MODEL_FACE_HAIR_FIX_TEMPLATE.clone())?;
let template = client.templates_ref()
.get_by_id("ai-model-face-hair-fix")
.ok_or("Template not found")?;
println!("✅ Template ready!");
// Step 3: Check if image exists
if !Path::new(image_path).exists() {
println!("❌ Image file not found: {}", image_path);
println!("💡 Please make sure the file exists in the current directory");
return Ok(());
}
// Step 4: Upload image
println!("📤 Uploading image: {}...", image_path);
let upload_response = client.upload_image(image_path, false).await?;
println!("✅ Upload successful! Server filename: {}", upload_response.name);
// Step 5: Setup progress callbacks
let callbacks = Arc::new(
SimpleCallbacks::new()
.with_progress(|progress| {
let percentage = (progress.progress as f64 / progress.max as f64 * 100.0) as u32;
println!("⏳ Progress: {}%", percentage);
})
.with_executing(|node_id| {
println!("🔄 Processing node: {}", node_id);
})
.with_error(|error| {
println!("❌ Error: {}", error.message);
})
);
// Step 6: Execute enhancement
println!("🎨 Starting AI enhancement...");
let mut parameters = HashMap::new();
parameters.insert("input_image".to_string(), json!(upload_response.name));
parameters.insert("face_prompt".to_string(),
json!("beautiful woman, perfect skin, detailed facial features"));
parameters.insert("face_denoise".to_string(), json!("0.25"));
let result = client.execute_template_with_callbacks(
template,
parameters,
ExecutionOptions {
timeout: Some(std::time::Duration::from_secs(180)),
priority: None,
},
callbacks,
).await?;
// Step 7: Show results
if result.success {
println!("\n🎉 Enhancement completed successfully!");
println!("⏱️ Execution time: {:.1}s", result.execution_time as f64 / 1000.0);
if let Some(outputs) = &result.outputs {
let image_urls = client.outputs_to_urls(outputs);
if !image_urls.is_empty() {
println!("\n🖼️ Enhanced image URLs:");
for (i, url) in image_urls.iter().enumerate() {
println!(" {}. {}", i + 1, url);
}
println!("\n💡 To view your enhanced image:");
println!(" - Copy the URL above and paste it in your browser");
println!(" - Right-click and 'Save As' to download the image");
} else {
println!("⚠️ No image URLs found in outputs");
}
}
} else {
println!("❌ Enhancement failed!");
if let Some(error) = &result.error {
println!("Error: {}", error.message);
}
}
// Step 8: Cleanup
client.disconnect().await?;
println!("\n👋 Disconnected from server");
Ok(())
}
/// Helper function to validate image file
fn validate_image_file(path: &str) -> Result<(), String> {
let path = Path::new(path);
if !path.exists() {
return Err(format!("File does not exist: {}", path.display()));
}
if !path.is_file() {
return Err(format!("Path is not a file: {}", path.display()));
}
// Check file extension
if let Some(extension) = path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "bmp" | "tiff" | "webp" => Ok(()),
_ => Err(format!("Unsupported image format: {}", ext)),
}
} else {
Err("File has no extension".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_image_file() {
// Test with non-existent file
assert!(validate_image_file("non_existent.png").is_err());
// Test with valid extensions
// Note: These tests would pass if the files existed
// assert!(validate_image_file("test.png").is_ok());
// assert!(validate_image_file("test.jpg").is_ok());
}
}

View File

@@ -0,0 +1,113 @@
//! Test URL generation fix
//!
//! This example demonstrates that the double slash bug has been fixed
use std::collections::HashMap;
use serde_json::json;
use comfyui_sdk::{HTTPClient, ComfyUIClientConfig};
fn main() {
println!("🔧 Testing URL Generation Fix");
println!("==============================");
// Test with base URL ending with slash (the problematic case)
let config_with_slash = ComfyUIClientConfig {
base_url: "http://192.168.0.193:8188/".to_string(),
..Default::default()
};
let client_with_slash = HTTPClient::new(config_with_slash).unwrap();
// Test with base URL without ending slash
let config_without_slash = ComfyUIClientConfig {
base_url: "http://192.168.0.193:8188".to_string(),
..Default::default()
};
let client_without_slash = HTTPClient::new(config_without_slash).unwrap();
// Create test outputs (simulating ComfyUI response)
let mut outputs = HashMap::new();
outputs.insert("58".to_string(), json!({
"images": [
{
"filename": "ComfyUI_02046_.png",
"subfolder": "",
"type": "output"
}
]
}));
println!("\n📋 Test Case 1: Base URL with trailing slash");
println!("Base URL: http://192.168.0.193:8188/");
let urls_with_slash = client_with_slash.outputs_to_urls(&outputs);
for url in &urls_with_slash {
println!("Generated URL: {}", url);
if url.contains("//view") {
println!("❌ FAILED: URL contains double slash!");
} else {
println!("✅ PASSED: No double slash found");
}
}
println!("\n📋 Test Case 2: Base URL without trailing slash");
println!("Base URL: http://192.168.0.193:8188");
let urls_without_slash = client_without_slash.outputs_to_urls(&outputs);
for url in &urls_without_slash {
println!("Generated URL: {}", url);
if url.contains("//view") {
println!("❌ FAILED: URL contains double slash!");
} else {
println!("✅ PASSED: No double slash found");
}
}
println!("\n📋 Test Case 3: Multiple images");
let mut multi_outputs = HashMap::new();
multi_outputs.insert("58".to_string(), json!({
"images": [
{
"filename": "image1.png",
"subfolder": "temp",
"type": "output"
},
{
"filename": "image2.jpg",
"subfolder": "",
"type": "temp"
}
]
}));
let multi_urls = client_with_slash.outputs_to_urls(&multi_outputs);
println!("Generated {} URLs:", multi_urls.len());
for (i, url) in multi_urls.iter().enumerate() {
println!(" {}. {}", i + 1, url);
if url.contains("//view") {
println!(" ❌ FAILED: URL contains double slash!");
} else {
println!(" ✅ PASSED: No double slash found");
}
}
println!("\n🎯 Expected vs Actual URLs:");
println!("Expected: http://192.168.0.193:8188/view?filename=ComfyUI_02046_.png&subfolder=&type=output");
if !urls_with_slash.is_empty() {
println!("Actual: {}", urls_with_slash[0]);
if urls_with_slash[0] == "http://192.168.0.193:8188/view?filename=ComfyUI_02046_.png&subfolder=&type=output" {
println!("✅ URLs match perfectly!");
} else {
println!("❌ URLs don't match!");
}
}
println!("\n🔍 Consistency Check:");
if urls_with_slash == urls_without_slash {
println!("✅ URLs are consistent regardless of trailing slash in base URL");
} else {
println!("❌ URLs are inconsistent!");
println!("With slash: {:?}", urls_with_slash);
println!("Without slash: {:?}", urls_without_slash);
}
println!("\n🎉 URL Generation Fix Test Completed!");
println!("The double slash bug has been fixed. URLs now correctly use single slashes.");
}

48
cargos/comfyui-sdk/lib.rs Normal file
View File

@@ -0,0 +1,48 @@
//! # ComfyUI SDK for Rust
//!
//! A modern Rust SDK for ComfyUI with flexible template system and elegant callback mechanisms.
//!
//! ## Features
//!
//! - **HTTP Client**: Full-featured HTTP client for ComfyUI API
//! - **WebSocket Client**: Real-time event handling and progress tracking
//! - **Template System**: Parameterized workflow templates with validation
//! - **Built-in Templates**: Pre-built templates for common workflows
//! - **Type Safety**: Full type safety with comprehensive error handling
//! - **Async/Await**: Modern async/await support throughout
//!
//! ## Quick Start
//!
//! ```rust,no_run
//! use comfyui_sdk::{ComfyUIClient, ComfyUIClientConfig};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let mut client = ComfyUIClient::new(ComfyUIClientConfig {
//! base_url: "http://localhost:8188".to_string(),
//! ..Default::default()
//! })?;
//!
//! client.connect().await?;
//! println!("Connected to ComfyUI!");
//!
//! Ok(())
//! }
//! ```
pub mod client;
pub mod templates;
pub mod template_hub;
pub mod types;
pub mod utils;
pub mod error;
// Re-export main types and functions
pub use client::{ComfyUIClient, HTTPClient, WebSocketClient};
pub use templates::{WorkflowTemplate, WorkflowInstance, TemplateManager};
pub use template_hub::*;
pub use types::*;
pub use error::{ComfyUIError, Result};
/// SDK version
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

View File

@@ -0,0 +1,435 @@
//! AI Model Face & Hair Detail Fix Template
//!
//! This template performs detailed face and hair enhancement for AI model photos.
//! It uses segmentation masks and local inpainting to improve facial features and hair quality.
//!
//! Features:
//! - Automatic face and hair segmentation
//! - Two-stage local inpainting (hair first, then face)
//! - Configurable denoising strength
//! - High-quality detail enhancement
use std::collections::HashMap;
use serde_json::json;
use crate::types::{
WorkflowTemplateData, TemplateMetadata, ParameterSchema, ParameterType,
ComfyUIWorkflow, ComfyUINode, NodeMeta
};
/// AI Model Face & Hair Detail Fix Template
pub static AI_MODEL_FACE_HAIR_FIX_TEMPLATE: once_cell::sync::Lazy<WorkflowTemplateData> =
once_cell::sync::Lazy::new(|| {
create_ai_model_face_hair_fix_template()
});
fn create_ai_model_face_hair_fix_template() -> WorkflowTemplateData {
let metadata = TemplateMetadata {
id: "ai-model-face-hair-fix".to_string(),
name: "AI Model Face & Hair Detail Fix".to_string(),
description: Some("Professional face and hair detail enhancement for AI model photos using segmentation and local inpainting".to_string()),
category: Some("enhancement".to_string()),
tags: Some(vec![
"face-enhancement".to_string(),
"hair-enhancement".to_string(),
"portrait".to_string(),
"ai-model".to_string(),
"detail-fix".to_string(),
"inpainting".to_string(),
]),
version: Some("1.0.0".to_string()),
author: Some("ComfyUI SDK".to_string()),
created_at: None,
updated_at: None,
};
let mut parameters = HashMap::new();
parameters.insert("input_image".to_string(), ParameterSchema {
param_type: ParameterType::String,
required: Some(true),
default: Some(json!("20250806-190606.jpg")),
description: Some("Input image filename to enhance".to_string()),
r#enum: None,
min: None,
max: None,
pattern: None,
items: None,
properties: None,
});
parameters.insert("face_prompt".to_string(), ParameterSchema {
param_type: ParameterType::String,
required: Some(true),
default: Some(json!("A girl, slim figure, oval face, beauty, Pink and greasy lips")),
description: Some("Positive prompt for face enhancement".to_string()),
r#enum: None,
min: None,
max: None,
pattern: None,
items: None,
properties: None,
});
parameters.insert("face_denoise".to_string(), ParameterSchema {
param_type: ParameterType::String,
required: Some(true),
default: Some(json!("0.30")),
description: Some("Denoising strength for face enhancement".to_string()),
r#enum: None,
min: None,
max: None,
pattern: None,
items: None,
properties: None,
});
let workflow = create_workflow();
WorkflowTemplateData {
metadata,
workflow,
parameters,
}
}
fn create_workflow() -> ComfyUIWorkflow {
let mut workflow = HashMap::new();
// Node 6: 遮罩边缘滑条快速模糊
workflow.insert("6".to_string(), ComfyUINode {
class_type: "EG_ZZ_MHHT".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("模糊强度".to_string(), json!(3));
inputs.insert("mask".to_string(), json!(["53", 1]));
inputs
},
_meta: Some(NodeMeta {
title: Some("2🐕遮罩边缘滑条快速模糊".to_string()),
}),
});
// Node 8: Checkpoint加载器
workflow.insert("8".to_string(), ComfyUINode {
class_type: "CheckpointLoaderSimple".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("ckpt_name".to_string(), json!("juggernaut_reborn_sd1,5.safetensors"));
inputs
},
_meta: Some(NodeMeta {
title: Some("Checkpoint加载器简易".to_string()),
}),
});
// Node 9: CLIP文本编码 (头发)
workflow.insert("9".to_string(), ComfyUINode {
class_type: "CLIPTextEncode".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("text".to_string(), json!("Smooth long hair,Hair details, quality hair,Smooth hair"));
inputs.insert("clip".to_string(), json!(["8", 1]));
inputs
},
_meta: Some(NodeMeta {
title: Some("CLIP文本编码".to_string()),
}),
});
// Node 10: CLIP文本编码 (负面头发)
workflow.insert("10".to_string(), ComfyUINode {
class_type: "CLIPTextEncode".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("text".to_string(), json!("Frizzy hair, dry hair, split ends,bad quality, poor quality, doll, disfigured, jpg, toy, bad anatomy, missing limbs, missing fingers, 3d, cgi"));
inputs.insert("clip".to_string(), json!(["8", 1]));
inputs
},
_meta: Some(NodeMeta {
title: Some("CLIP文本编码".to_string()),
}),
});
// Node 13: CLIP文本编码 (脸部)
workflow.insert("13".to_string(), ComfyUINode {
class_type: "CLIPTextEncode".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("text".to_string(), json!(["59", 0]));
inputs.insert("clip".to_string(), json!(["8", 1]));
inputs
},
_meta: Some(NodeMeta {
title: Some("CLIP文本编码".to_string()),
}),
});
// Node 14: CLIP文本编码 (负面脸部)
workflow.insert("14".to_string(), ComfyUINode {
class_type: "CLIPTextEncode".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("text".to_string(), json!("(NSFW:1.2),(worst quality:1.2),(low quality:1.2),(normal quality:1.2),low resolution,watermark,"));
inputs.insert("clip".to_string(), json!(["8", 1]));
inputs
},
_meta: Some(NodeMeta {
title: Some("CLIP文本编码".to_string()),
}),
});
// Node 22: Segformer Ultra V2 (头发)
workflow.insert("22".to_string(), ComfyUINode {
class_type: "LayerMask: SegformerUltraV2".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("detail_method".to_string(), json!("VITMatte(local)"));
inputs.insert("detail_erode".to_string(), json!(44));
inputs.insert("detail_dilate".to_string(), json!(6));
inputs.insert("black_point".to_string(), json!(0.030000000000000006));
inputs.insert("white_point".to_string(), json!(0.99));
inputs.insert("process_detail".to_string(), json!(true));
inputs.insert("device".to_string(), json!("cuda"));
inputs.insert("max_megapixels".to_string(), json!(2));
inputs.insert("image".to_string(), json!(["30", 0]));
inputs.insert("segformer_pipeline".to_string(), json!(["28", 0]));
inputs
},
_meta: Some(NodeMeta {
title: Some("LayerMask: Segformer Ultra V2".to_string()),
}),
});
// Node 25: 局部重绘采样器 (头发)
workflow.insert("25".to_string(), ComfyUINode {
class_type: "EG_CYQ_JB".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("seed".to_string(), json!(646617836820462i64));
inputs.insert("steps".to_string(), json!(30));
inputs.insert("cfg".to_string(), json!(4.5200000000000005));
inputs.insert("sampler_name".to_string(), json!("dpmpp_2s_ancestral"));
inputs.insert("scheduler".to_string(), json!("karras"));
inputs.insert("denoise".to_string(), json!(0.4));
inputs.insert("重绘模式".to_string(), json!("原图"));
inputs.insert("遮罩延展".to_string(), json!(10));
inputs.insert("仅局部重绘".to_string(), json!(true));
inputs.insert("局部重绘大小".to_string(), json!(768));
inputs.insert("重绘区域扩展".to_string(), json!(50));
inputs.insert("遮罩羽化".to_string(), json!(5));
inputs.insert("TEXT".to_string(), json!("2🐕出品必出精品"));
inputs.insert("model".to_string(), json!(["8", 0]));
inputs.insert("image".to_string(), json!(["30", 0]));
inputs.insert("vae".to_string(), json!(["8", 2]));
inputs.insert("mask".to_string(), json!(["47", 0]));
inputs.insert("positive".to_string(), json!(["9", 0]));
inputs.insert("negative".to_string(), json!(["10", 0]));
inputs
},
_meta: Some(NodeMeta {
title: Some("2🐕局部重绘采样器".to_string()),
}),
});
// Node 26: 局部重绘采样器 (脸部)
workflow.insert("26".to_string(), ComfyUINode {
class_type: "EG_CYQ_JB".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("seed".to_string(), json!(969968724502727i64));
inputs.insert("steps".to_string(), json!(30));
inputs.insert("cfg".to_string(), json!(4.5200000000000005));
inputs.insert("sampler_name".to_string(), json!("dpmpp_2s_ancestral"));
inputs.insert("scheduler".to_string(), json!("karras"));
inputs.insert("denoise".to_string(), json!(["71", 0]));
inputs.insert("重绘模式".to_string(), json!("原图"));
inputs.insert("遮罩延展".to_string(), json!(10));
inputs.insert("仅局部重绘".to_string(), json!(true));
inputs.insert("局部重绘大小".to_string(), json!(768));
inputs.insert("重绘区域扩展".to_string(), json!(50));
inputs.insert("遮罩羽化".to_string(), json!(5));
inputs.insert("TEXT".to_string(), json!("2🐕出品必出精品"));
inputs.insert("model".to_string(), json!(["8", 0]));
inputs.insert("image".to_string(), json!(["25", 1]));
inputs.insert("vae".to_string(), json!(["8", 2]));
inputs.insert("mask".to_string(), json!(["6", 0]));
inputs.insert("positive".to_string(), json!(["13", 0]));
inputs.insert("negative".to_string(), json!(["14", 0]));
inputs
},
_meta: Some(NodeMeta {
title: Some("2🐕局部重绘采样器".to_string()),
}),
});
// Node 28: Segformer Clothes Pipeline (头发)
workflow.insert("28".to_string(), ComfyUINode {
class_type: "LayerMask: SegformerClothesPipelineLoader".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("model".to_string(), json!("segformer_b3_clothes"));
inputs.insert("face".to_string(), json!(false));
inputs.insert("hair".to_string(), json!(true));
inputs.insert("hat".to_string(), json!(false));
inputs.insert("sunglass".to_string(), json!(false));
inputs.insert("left_arm".to_string(), json!(false));
inputs.insert("right_arm".to_string(), json!(false));
inputs.insert("left_leg".to_string(), json!(false));
inputs.insert("right_leg".to_string(), json!(false));
inputs.insert("left_shoe".to_string(), json!(false));
inputs.insert("right_shoe".to_string(), json!(false));
inputs.insert("upper_clothes".to_string(), json!(false));
inputs.insert("skirt".to_string(), json!(false));
inputs.insert("pants".to_string(), json!(false));
inputs.insert("dress".to_string(), json!(false));
inputs.insert("belt".to_string(), json!(false));
inputs.insert("bag".to_string(), json!(false));
inputs.insert("scarf".to_string(), json!(false));
inputs
},
_meta: Some(NodeMeta {
title: Some("LayerMask: Segformer Clothes Pipeline".to_string()),
}),
});
// Node 30: 加载图像
workflow.insert("30".to_string(), ComfyUINode {
class_type: "LoadImage".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("image".to_string(), json!("{{input_image}}"));
inputs
},
_meta: Some(NodeMeta {
title: Some("加载图像".to_string()),
}),
});
// Node 44: Mask Fill Holes
workflow.insert("44".to_string(), ComfyUINode {
class_type: "Mask Fill Holes".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("masks".to_string(), json!(["22", 1]));
inputs
},
_meta: Some(NodeMeta {
title: Some("Mask Fill Holes".to_string()),
}),
});
// Node 47: Grow Mask With Blur
workflow.insert("47".to_string(), ComfyUINode {
class_type: "GrowMaskWithBlur".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("expand".to_string(), json!(5));
inputs.insert("incremental_expandrate".to_string(), json!(5));
inputs.insert("tapered_corners".to_string(), json!(true));
inputs.insert("flip_input".to_string(), json!(false));
inputs.insert("blur_radius".to_string(), json!(9.4));
inputs.insert("lerp_alpha".to_string(), json!(0.9900000000000002));
inputs.insert("decay_factor".to_string(), json!(1));
inputs.insert("fill_holes".to_string(), json!(false));
inputs.insert("mask".to_string(), json!(["44", 0]));
inputs
},
_meta: Some(NodeMeta {
title: Some("Grow Mask With Blur".to_string()),
}),
});
// Node 52: Segformer Clothes Pipeline (脸部)
workflow.insert("52".to_string(), ComfyUINode {
class_type: "LayerMask: SegformerClothesPipelineLoader".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("model".to_string(), json!("segformer_b3_clothes"));
inputs.insert("face".to_string(), json!(true));
inputs.insert("hair".to_string(), json!(false));
inputs.insert("hat".to_string(), json!(false));
inputs.insert("sunglass".to_string(), json!(true));
inputs.insert("left_arm".to_string(), json!(false));
inputs.insert("right_arm".to_string(), json!(false));
inputs.insert("left_leg".to_string(), json!(false));
inputs.insert("right_leg".to_string(), json!(false));
inputs.insert("left_shoe".to_string(), json!(false));
inputs.insert("right_shoe".to_string(), json!(false));
inputs.insert("upper_clothes".to_string(), json!(false));
inputs.insert("skirt".to_string(), json!(false));
inputs.insert("pants".to_string(), json!(false));
inputs.insert("dress".to_string(), json!(false));
inputs.insert("belt".to_string(), json!(false));
inputs.insert("bag".to_string(), json!(false));
inputs.insert("scarf".to_string(), json!(false));
inputs
},
_meta: Some(NodeMeta {
title: Some("LayerMask: Segformer Clothes Pipeline".to_string()),
}),
});
// Node 53: Segformer Ultra V2 (脸部)
workflow.insert("53".to_string(), ComfyUINode {
class_type: "LayerMask: SegformerUltraV2".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("detail_method".to_string(), json!("VITMatte(local)"));
inputs.insert("detail_erode".to_string(), json!(6));
inputs.insert("detail_dilate".to_string(), json!(4));
inputs.insert("black_point".to_string(), json!(0.01));
inputs.insert("white_point".to_string(), json!(0.99));
inputs.insert("process_detail".to_string(), json!(false));
inputs.insert("device".to_string(), json!("cuda"));
inputs.insert("max_megapixels".to_string(), json!(2));
inputs.insert("image".to_string(), json!(["30", 0]));
inputs.insert("segformer_pipeline".to_string(), json!(["52", 0]));
inputs
},
_meta: Some(NodeMeta {
title: Some("LayerMask: Segformer Ultra V2".to_string()),
}),
});
// Node 58: 保存图像
workflow.insert("58".to_string(), ComfyUINode {
class_type: "SaveImage".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("filename_prefix".to_string(), json!("ComfyUI"));
inputs.insert("images".to_string(), json!(["26", 1]));
inputs
},
_meta: Some(NodeMeta {
title: Some("保存图像".to_string()),
}),
});
// Node 59: String (脸部提示词)
workflow.insert("59".to_string(), ComfyUINode {
class_type: "String".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("String".to_string(), json!("A girl, slim figure, oval face, beauty, Pink and greasy lips{{face_prompt}}"));
inputs
},
_meta: Some(NodeMeta {
title: Some("String".to_string()),
}),
});
// Node 71: Float (脸部去噪强度)
workflow.insert("71".to_string(), ComfyUINode {
class_type: "Float".to_string(),
inputs: {
let mut inputs = HashMap::new();
inputs.insert("Number".to_string(), json!("{{face_denoise}}"));
inputs
},
_meta: Some(NodeMeta {
title: Some("Float".to_string()),
}),
});
workflow
}

View File

@@ -0,0 +1,6 @@
//! Built-in template hub
pub mod ai_model_face_hair_fix;
// Re-export templates
pub use ai_model_face_hair_fix::AI_MODEL_FACE_HAIR_FIX_TEMPLATE;

View File

@@ -0,0 +1,10 @@
//! Template system for ComfyUI SDK
pub mod workflow_template;
pub mod workflow_instance;
pub mod template_manager;
// Re-export main types
pub use workflow_template::WorkflowTemplate;
pub use workflow_instance::WorkflowInstance;
pub use template_manager::TemplateManager;

View File

@@ -0,0 +1,194 @@
//! TemplateManager for managing workflow templates
use std::collections::HashMap;
use crate::types::{WorkflowTemplateData, ParameterValues};
use crate::templates::{WorkflowTemplate, WorkflowInstance};
use crate::error::{ComfyUIError, Result};
/// Manages a collection of workflow templates
#[derive(Debug, Clone)]
pub struct TemplateManager {
templates: HashMap<String, WorkflowTemplate>,
}
impl TemplateManager {
/// Creates a new TemplateManager
pub fn new() -> Self {
Self {
templates: HashMap::new(),
}
}
/// Registers a template from template data
pub fn register_from_data(&mut self, data: WorkflowTemplateData) -> Result<()> {
let template = WorkflowTemplate::from_data(data)?;
let id = template.id().to_string();
self.templates.insert(id, template);
Ok(())
}
/// Registers a template
pub fn register(&mut self, template: WorkflowTemplate) {
let id = template.id().to_string();
self.templates.insert(id, template);
}
/// Gets a template by ID
pub fn get_by_id(&self, id: &str) -> Option<&WorkflowTemplate> {
self.templates.get(id)
}
/// Gets a mutable reference to a template by ID
pub fn get_by_id_mut(&mut self, id: &str) -> Option<&mut WorkflowTemplate> {
self.templates.get_mut(id)
}
/// Removes a template by ID
pub fn remove(&mut self, id: &str) -> Option<WorkflowTemplate> {
self.templates.remove(id)
}
/// Lists all template IDs
pub fn list_ids(&self) -> Vec<&str> {
self.templates.keys().map(|s| s.as_str()).collect()
}
/// Lists all templates
pub fn list_templates(&self) -> Vec<&WorkflowTemplate> {
self.templates.values().collect()
}
/// Gets templates by category
pub fn get_by_category(&self, category: &str) -> Vec<&WorkflowTemplate> {
self.templates
.values()
.filter(|template| {
template.category()
.map(|c| c == category)
.unwrap_or(false)
})
.collect()
}
/// Gets templates by tag
pub fn get_by_tag(&self, tag: &str) -> Vec<&WorkflowTemplate> {
self.templates
.values()
.filter(|template| template.tags().contains(&tag.to_string()))
.collect()
}
/// Searches templates by name (case-insensitive partial match)
pub fn search_by_name(&self, query: &str) -> Vec<&WorkflowTemplate> {
let query_lower = query.to_lowercase();
self.templates
.values()
.filter(|template| {
template.name().to_lowercase().contains(&query_lower)
})
.collect()
}
/// Searches templates by description (case-insensitive partial match)
pub fn search_by_description(&self, query: &str) -> Vec<&WorkflowTemplate> {
let query_lower = query.to_lowercase();
self.templates
.values()
.filter(|template| {
template.description()
.map(|desc| desc.to_lowercase().contains(&query_lower))
.unwrap_or(false)
})
.collect()
}
/// Creates a workflow instance from a template
pub fn create_instance(&self, template_id: &str, parameters: ParameterValues) -> Result<WorkflowInstance> {
let template = self.get_by_id(template_id)
.ok_or_else(|| ComfyUIError::template_validation(
format!("Template with ID '{}' not found", template_id)
))?;
template.create_instance(parameters)
}
/// Gets the number of registered templates
pub fn count(&self) -> usize {
self.templates.len()
}
/// Checks if a template exists
pub fn contains(&self, id: &str) -> bool {
self.templates.contains_key(id)
}
/// Clears all templates
pub fn clear(&mut self) {
self.templates.clear();
}
/// Gets all categories
pub fn get_categories(&self) -> Vec<String> {
let mut categories: Vec<String> = self.templates
.values()
.filter_map(|template| template.category().map(|c| c.to_string()))
.collect();
categories.sort();
categories.dedup();
categories
}
/// Gets all tags
pub fn get_tags(&self) -> Vec<String> {
let mut tags: Vec<String> = self.templates
.values()
.flat_map(|template| template.tags().iter().cloned())
.collect();
tags.sort();
tags.dedup();
tags
}
/// Validates all templates
pub fn validate_all(&self) -> Result<()> {
for (id, template) in &self.templates {
// Try to create an instance with default values to validate template
let default_values = template.get_default_values();
let validation = template.validate(&default_values);
if !validation.valid {
return Err(ComfyUIError::template_validation(
format!("Template '{}' validation failed", id)
));
}
}
Ok(())
}
/// Exports all templates as template data
pub fn export_all(&self) -> Vec<WorkflowTemplateData> {
self.templates
.values()
.map(|template| template.to_data())
.collect()
}
/// Imports templates from template data
pub fn import_all(&mut self, templates: Vec<WorkflowTemplateData>) -> Result<Vec<String>> {
let mut imported_ids = Vec::new();
for data in templates {
let id = data.metadata.id.clone();
self.register_from_data(data)?;
imported_ids.push(id);
}
Ok(imported_ids)
}
}
impl Default for TemplateManager {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,169 @@
//! WorkflowInstance implementation for executing parameterized workflows
use std::collections::HashMap;
use crate::types::{ComfyUIWorkflow, ParameterValues, ComfyUINode};
use crate::templates::WorkflowTemplate;
use crate::error::{ComfyUIError, Result};
use crate::utils::template_parser::apply_parameters;
/// Represents a workflow instance with applied parameters
#[derive(Debug, Clone)]
pub struct WorkflowInstance {
template: WorkflowTemplate,
parameters: ParameterValues,
resolved_workflow: ComfyUIWorkflow,
}
impl WorkflowInstance {
/// Creates a new WorkflowInstance
pub fn new(template: WorkflowTemplate, parameters: ParameterValues) -> Result<Self> {
// Apply parameters to the workflow
let resolved_workflow = apply_parameters(template.workflow(), &parameters)?;
Ok(Self {
template,
parameters,
resolved_workflow,
})
}
/// Gets the original template
pub fn template(&self) -> &WorkflowTemplate {
&self.template
}
/// Gets the applied parameters
pub fn parameters(&self) -> &ParameterValues {
&self.parameters
}
/// Gets the resolved workflow with parameters applied
pub fn workflow(&self) -> &ComfyUIWorkflow {
&self.resolved_workflow
}
/// Gets the template ID
pub fn template_id(&self) -> &str {
self.template.id()
}
/// Gets the template name
pub fn template_name(&self) -> &str {
self.template.name()
}
/// Gets a specific parameter value
pub fn get_parameter(&self, name: &str) -> Option<&serde_json::Value> {
self.parameters.get(name)
}
/// Updates a parameter value and re-resolves the workflow
pub fn set_parameter(&mut self, name: String, value: serde_json::Value) -> Result<()> {
// Check if parameter exists in template
if !self.template.has_parameter(&name) {
return Err(ComfyUIError::parameter_validation(
format!("Parameter '{}' does not exist in template", name)
));
}
// Update parameter
self.parameters.insert(name, value);
// Validate updated parameters
let validation = self.template.validate(&self.parameters);
if !validation.valid {
let error_messages: Vec<String> = validation.errors
.iter()
.map(|e| format!("{}: {}", e.path, e.message))
.collect();
return Err(ComfyUIError::parameter_validation(
format!("Invalid parameters after update: {}", error_messages.join(", "))
));
}
// Re-resolve workflow
self.resolved_workflow = apply_parameters(self.template.workflow(), &self.parameters)?;
Ok(())
}
/// Updates multiple parameters at once
pub fn set_parameters(&mut self, parameters: HashMap<String, serde_json::Value>) -> Result<()> {
// Check if all parameters exist in template
for name in parameters.keys() {
if !self.template.has_parameter(name) {
return Err(ComfyUIError::parameter_validation(
format!("Parameter '{}' does not exist in template", name)
));
}
}
// Update parameters
for (name, value) in parameters {
self.parameters.insert(name, value);
}
// Validate updated parameters
let validation = self.template.validate(&self.parameters);
if !validation.valid {
let error_messages: Vec<String> = validation.errors
.iter()
.map(|e| format!("{}: {}", e.path, e.message))
.collect();
return Err(ComfyUIError::parameter_validation(
format!("Invalid parameters after update: {}", error_messages.join(", "))
));
}
// Re-resolve workflow
self.resolved_workflow = apply_parameters(self.template.workflow(), &self.parameters)?;
Ok(())
}
/// Gets a specific node from the resolved workflow
pub fn get_node(&self, node_id: &str) -> Option<&ComfyUINode> {
self.resolved_workflow.get(node_id)
}
/// Gets all node IDs in the workflow
pub fn get_node_ids(&self) -> Vec<&str> {
self.resolved_workflow.keys().map(|s| s.as_str()).collect()
}
/// Checks if the workflow contains a specific node
pub fn has_node(&self, node_id: &str) -> bool {
self.resolved_workflow.contains_key(node_id)
}
/// Gets the number of nodes in the workflow
pub fn node_count(&self) -> usize {
self.resolved_workflow.len()
}
/// Converts the instance to a JSON-serializable workflow
pub fn to_workflow_json(&self) -> Result<serde_json::Value> {
serde_json::to_value(&self.resolved_workflow)
.map_err(ComfyUIError::from)
}
/// Creates a clone with different parameters
pub fn with_parameters(&self, parameters: ParameterValues) -> Result<Self> {
Self::new(self.template.clone(), parameters)
}
/// Validates the current instance
pub fn validate(&self) -> Result<()> {
let validation = self.template.validate(&self.parameters);
if !validation.valid {
let error_messages: Vec<String> = validation.errors
.iter()
.map(|e| format!("{}: {}", e.path, e.message))
.collect();
return Err(ComfyUIError::parameter_validation(
format!("Instance validation failed: {}", error_messages.join(", "))
));
}
Ok(())
}
}

View File

@@ -0,0 +1,193 @@
//! WorkflowTemplate implementation for managing parameterized ComfyUI workflows
use std::collections::HashMap;
use crate::types::{
WorkflowTemplateData, TemplateMetadata, ComfyUIWorkflow,
ParameterSchema, ParameterValues, ValidationResult, ParameterType
};
use crate::error::{ComfyUIError, Result};
use crate::templates::WorkflowInstance;
use crate::utils::validation::validate_parameters;
/// Represents a parameterized workflow template
#[derive(Debug, Clone)]
pub struct WorkflowTemplate {
metadata: TemplateMetadata,
workflow: ComfyUIWorkflow,
parameters: HashMap<String, ParameterSchema>,
}
impl WorkflowTemplate {
/// Creates a new WorkflowTemplate instance
pub fn new(data: WorkflowTemplateData) -> Result<Self> {
let template = Self {
metadata: data.metadata,
workflow: data.workflow,
parameters: data.parameters,
};
// Validate template structure
template.validate_template()?;
Ok(template)
}
/// Gets the template metadata
pub fn metadata(&self) -> &TemplateMetadata {
&self.metadata
}
/// Gets the workflow definition
pub fn workflow(&self) -> &ComfyUIWorkflow {
&self.workflow
}
/// Gets the parameter schemas
pub fn parameters(&self) -> &HashMap<String, ParameterSchema> {
&self.parameters
}
/// Gets the template ID
pub fn id(&self) -> &str {
&self.metadata.id
}
/// Gets the template name
pub fn name(&self) -> &str {
&self.metadata.name
}
/// Gets the template description
pub fn description(&self) -> Option<&str> {
self.metadata.description.as_deref()
}
/// Gets the template category
pub fn category(&self) -> Option<&str> {
self.metadata.category.as_deref()
}
/// Gets the template tags
pub fn tags(&self) -> &[String] {
self.metadata.tags.as_deref().unwrap_or(&[])
}
/// Validates the provided parameters against the template schema
pub fn validate(&self, parameters: &ParameterValues) -> ValidationResult {
validate_parameters(parameters, &self.parameters)
}
/// Creates a workflow instance with the provided parameters
pub fn create_instance(&self, parameters: ParameterValues) -> Result<WorkflowInstance> {
let validation = self.validate(&parameters);
if !validation.valid {
let error_messages: Vec<String> = validation.errors
.iter()
.map(|e| format!("{}: {}", e.path, e.message))
.collect();
return Err(ComfyUIError::parameter_validation(
format!("Invalid parameters: {}", error_messages.join(", "))
));
}
WorkflowInstance::new(self.clone(), parameters)
}
/// Gets the parameter schema for a specific parameter
pub fn get_parameter_schema(&self, parameter_name: &str) -> Option<&ParameterSchema> {
self.parameters.get(parameter_name)
}
/// Gets all required parameter names
pub fn get_required_parameters(&self) -> Vec<&str> {
self.parameters
.iter()
.filter(|(_, schema)| schema.required.unwrap_or(false))
.map(|(name, _)| name.as_str())
.collect()
}
/// Gets all optional parameter names
pub fn get_optional_parameters(&self) -> Vec<&str> {
self.parameters
.iter()
.filter(|(_, schema)| !schema.required.unwrap_or(false))
.map(|(name, _)| name.as_str())
.collect()
}
/// Checks if the template has a specific parameter
pub fn has_parameter(&self, parameter_name: &str) -> bool {
self.parameters.contains_key(parameter_name)
}
/// Gets default values for all parameters that have defaults
pub fn get_default_values(&self) -> ParameterValues {
let mut defaults = HashMap::new();
for (name, schema) in &self.parameters {
if let Some(default_value) = &schema.default {
defaults.insert(name.clone(), default_value.clone());
}
}
defaults
}
/// Creates a copy of the template with updated metadata
pub fn with_metadata(&self, metadata: TemplateMetadata) -> Self {
Self {
metadata,
workflow: self.workflow.clone(),
parameters: self.parameters.clone(),
}
}
/// Converts the template to a JSON-serializable object
pub fn to_data(&self) -> WorkflowTemplateData {
WorkflowTemplateData {
metadata: self.metadata.clone(),
workflow: self.workflow.clone(),
parameters: self.parameters.clone(),
}
}
/// Creates a WorkflowTemplate from template data
pub fn from_data(data: WorkflowTemplateData) -> Result<Self> {
Self::new(data)
}
/// Validates the template structure
fn validate_template(&self) -> Result<()> {
// Validate metadata
if self.metadata.id.is_empty() {
return Err(ComfyUIError::template_validation(
"Template metadata must have a valid id"
));
}
if self.metadata.name.is_empty() {
return Err(ComfyUIError::template_validation(
"Template metadata must have a valid name"
));
}
// Validate workflow
if self.workflow.is_empty() {
return Err(ComfyUIError::template_validation(
"Template must have a valid workflow object"
));
}
// Validate parameter schemas
for (name, schema) in &self.parameters {
if !matches!(
schema.param_type,
ParameterType::String | ParameterType::Number | ParameterType::Boolean |
ParameterType::Array | ParameterType::Object
) {
return Err(ComfyUIError::template_validation(
format!("Parameter '{}' has invalid type: {:?}", name, schema.param_type)
));
}
}
Ok(())
}
}

View File

@@ -0,0 +1,188 @@
//! URL generation tests
use std::collections::HashMap;
use serde_json::json;
use comfyui_sdk::{HTTPClient, ComfyUIClientConfig};
#[test]
fn test_outputs_to_urls_no_double_slash() {
// Test with base URL ending with slash
let config_with_slash = ComfyUIClientConfig {
base_url: "http://192.168.0.193:8188/".to_string(),
..Default::default()
};
let client_with_slash = HTTPClient::new(config_with_slash).unwrap();
// Test with base URL without ending slash
let config_without_slash = ComfyUIClientConfig {
base_url: "http://192.168.0.193:8188".to_string(),
..Default::default()
};
let client_without_slash = HTTPClient::new(config_without_slash).unwrap();
// Create test outputs
let mut outputs = HashMap::new();
outputs.insert("58".to_string(), json!({
"images": [
{
"filename": "ComfyUI_02046_.png",
"subfolder": "",
"type": "output"
}
]
}));
// Test with base URL ending with slash
let urls_with_slash = client_with_slash.outputs_to_urls(&outputs);
assert_eq!(urls_with_slash.len(), 1);
let url_with_slash = &urls_with_slash[0];
println!("URL with slash: {}", url_with_slash);
// Should not contain double slash
assert!(!url_with_slash.contains("//view"),
"URL should not contain double slash: {}", url_with_slash);
assert!(url_with_slash.contains("/view?"),
"URL should contain single slash before view: {}", url_with_slash);
// Test with base URL without ending slash
let urls_without_slash = client_without_slash.outputs_to_urls(&outputs);
assert_eq!(urls_without_slash.len(), 1);
let url_without_slash = &urls_without_slash[0];
println!("URL without slash: {}", url_without_slash);
// Should not contain double slash
assert!(!url_without_slash.contains("//view"),
"URL should not contain double slash: {}", url_without_slash);
assert!(url_without_slash.contains("/view?"),
"URL should contain single slash before view: {}", url_without_slash);
// Both URLs should be the same
assert_eq!(url_with_slash, url_without_slash,
"URLs should be identical regardless of trailing slash in base URL");
// Expected URL format
let expected_url = "http://192.168.0.193:8188/view?filename=ComfyUI_02046_.png&subfolder=&type=output";
assert_eq!(url_with_slash, expected_url);
assert_eq!(url_without_slash, expected_url);
}
#[test]
fn test_outputs_to_urls_multiple_images() {
let config = ComfyUIClientConfig {
base_url: "http://localhost:8188/".to_string(),
..Default::default()
};
let client = HTTPClient::new(config).unwrap();
let mut outputs = HashMap::new();
outputs.insert("58".to_string(), json!({
"images": [
{
"filename": "image1.png",
"subfolder": "temp",
"type": "output"
},
{
"filename": "image2.jpg",
"subfolder": "",
"type": "temp"
}
]
}));
let urls = client.outputs_to_urls(&outputs);
assert_eq!(urls.len(), 2);
for url in &urls {
println!("Generated URL: {}", url);
assert!(!url.contains("//view"), "URL should not contain double slash: {}", url);
assert!(url.contains("/view?"), "URL should contain single slash before view: {}", url);
assert!(url.starts_with("http://localhost:8188/view?"), "URL should have correct base");
}
// Check specific URLs
assert!(urls.contains(&"http://localhost:8188/view?filename=image1.png&subfolder=temp&type=output".to_string()));
assert!(urls.contains(&"http://localhost:8188/view?filename=image2.jpg&subfolder=&type=temp".to_string()));
}
#[test]
fn test_outputs_to_urls_empty_outputs() {
let config = ComfyUIClientConfig {
base_url: "http://localhost:8188".to_string(),
..Default::default()
};
let client = HTTPClient::new(config).unwrap();
let outputs = HashMap::new();
let urls = client.outputs_to_urls(&outputs);
assert_eq!(urls.len(), 0);
}
#[test]
fn test_outputs_to_urls_no_images() {
let config = ComfyUIClientConfig {
base_url: "http://localhost:8188".to_string(),
..Default::default()
};
let client = HTTPClient::new(config).unwrap();
let mut outputs = HashMap::new();
outputs.insert("58".to_string(), json!({
"text": "some text output"
}));
let urls = client.outputs_to_urls(&outputs);
assert_eq!(urls.len(), 0);
}
#[test]
fn test_different_base_url_formats() {
let test_cases = vec![
"http://192.168.0.193:8188",
"http://192.168.0.193:8188/",
"https://my-comfyui.com",
"https://my-comfyui.com/",
"http://localhost:8188",
"http://localhost:8188/",
];
let mut outputs = HashMap::new();
outputs.insert("test".to_string(), json!({
"images": [
{
"filename": "test.png",
"subfolder": "",
"type": "output"
}
]
}));
for base_url in test_cases {
println!("Testing base URL: {}", base_url);
let config = ComfyUIClientConfig {
base_url: base_url.to_string(),
..Default::default()
};
let client = HTTPClient::new(config).unwrap();
let urls = client.outputs_to_urls(&outputs);
assert_eq!(urls.len(), 1);
let url = &urls[0];
println!("Generated URL: {}", url);
// Should not contain double slash
assert!(!url.contains("//view"),
"URL should not contain double slash for base URL '{}': {}", base_url, url);
// Should contain single slash before view
assert!(url.contains("/view?"),
"URL should contain single slash before view for base URL '{}': {}", base_url, url);
// Should start with the base URL (without trailing slash) + /view
let expected_base = base_url.trim_end_matches('/');
assert!(url.starts_with(&format!("{}/view?", expected_base)),
"URL should start with correct base for '{}': {}", base_url, url);
}
}

View File

@@ -0,0 +1,133 @@
//! API types for ComfyUI SDK
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Queue status response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueStatus {
pub queue_running: Vec<QueueItem>,
pub queue_pending: Vec<QueueItem>,
}
/// Queue item
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueItem {
pub prompt_id: String,
pub number: u32,
pub outputs: HashMap<String, serde_json::Value>,
}
/// Prompt request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptRequest {
pub prompt: HashMap<String, serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_data: Option<HashMap<String, serde_json::Value>>,
}
/// Prompt response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptResponse {
pub prompt_id: String,
pub number: u32,
pub node_errors: HashMap<String, serde_json::Value>,
}
/// History item
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryItem {
pub prompt: Vec<serde_json::Value>,
pub outputs: HashMap<String, HashMap<String, Vec<HistoryOutput>>>,
pub status: HistoryStatus,
}
/// History output
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryOutput {
pub filename: String,
pub subfolder: String,
#[serde(rename = "type")]
pub output_type: String,
}
/// History status
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryStatus {
pub status_str: String,
pub completed: bool,
pub messages: Vec<Vec<serde_json::Value>>,
}
/// System stats
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemStats {
pub system: SystemInfo,
pub devices: Vec<DeviceInfo>,
}
/// System information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemInfo {
pub os: String,
pub python_version: String,
pub embedded_python: bool,
}
/// Device information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceInfo {
pub name: String,
pub device_type: String,
pub vram_total: u64,
pub vram_free: u64,
pub torch_vram_total: u64,
pub torch_vram_free: u64,
}
/// Object info response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectInfo {
#[serde(flatten)]
pub nodes: HashMap<String, NodeInfo>,
}
/// Node information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeInfo {
pub input: NodeInputInfo,
pub output: Vec<String>,
pub output_is_list: Vec<bool>,
pub output_name: Vec<String>,
pub name: String,
pub display_name: String,
pub description: String,
pub category: String,
pub python_module: String,
}
/// Node input information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeInputInfo {
pub required: HashMap<String, Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub optional: Option<HashMap<String, Vec<serde_json::Value>>>,
}
/// Upload image response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UploadImageResponse {
pub name: String,
pub subfolder: String,
#[serde(rename = "type")]
pub image_type: String,
}
/// View metadata response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewMetadata {
#[serde(flatten)]
pub metadata: HashMap<String, serde_json::Value>,
}

View File

@@ -0,0 +1,160 @@
//! Event types for ComfyUI SDK
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
/// Execution progress information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionProgress {
pub node_id: String,
pub progress: u32,
pub max: u32,
pub timestamp: DateTime<Utc>,
}
/// Execution result information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionResult {
pub prompt_id: String,
pub outputs: HashMap<String, serde_json::Value>,
pub execution_time: u64,
pub timestamp: DateTime<Utc>,
}
/// Execution error information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionError {
pub node_id: Option<String>,
pub message: String,
pub details: Option<serde_json::Value>,
pub timestamp: DateTime<Utc>,
}
/// Callback trait for execution events
pub trait ExecutionCallbacks: Send + Sync {
/// Called when progress is updated
fn on_progress(&self, _progress: ExecutionProgress) {}
/// Called when a node starts executing
fn on_executing(&self, _node_id: String) {}
/// Called when execution completes successfully
fn on_executed(&self, _result: ExecutionResult) {}
/// Called when an error occurs
fn on_error(&self, _error: ExecutionError) {}
}
/// WebSocket message types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WSMessage {
#[serde(rename = "progress")]
Progress {
data: WSProgressData,
},
#[serde(rename = "executing")]
Executing {
data: WSExecutingData,
},
#[serde(rename = "executed")]
Executed {
data: WSExecutedData,
},
#[serde(rename = "execution_error")]
ExecutionError {
data: WSErrorData,
},
#[serde(other)]
Unknown,
}
/// WebSocket progress message data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WSProgressData {
pub value: u32,
pub max: u32,
pub node: Option<String>,
}
/// WebSocket executing message data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WSExecutingData {
pub node: Option<String>,
pub prompt_id: String,
}
/// WebSocket executed message data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WSExecutedData {
pub node: String,
pub output: HashMap<String, serde_json::Value>,
pub prompt_id: String,
}
/// WebSocket error message data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WSErrorData {
pub node_id: Option<String>,
pub message: String,
pub details: Option<serde_json::Value>,
}
/// Execution options
#[derive(Debug, Clone)]
pub struct ExecutionOptions {
pub timeout: Option<std::time::Duration>,
pub priority: Option<i32>,
}
impl Default for ExecutionOptions {
fn default() -> Self {
Self {
timeout: Some(std::time::Duration::from_secs(300)), // 5 minutes default
priority: None,
}
}
}
/// Template execution result
#[derive(Debug, Clone)]
pub struct TemplateExecutionResult {
pub prompt_id: String,
pub success: bool,
pub outputs: Option<HashMap<String, serde_json::Value>>,
pub error: Option<ExecutionError>,
pub execution_time: u64,
}
impl TemplateExecutionResult {
/// Create a successful execution result
pub fn success(
prompt_id: String,
outputs: HashMap<String, serde_json::Value>,
execution_time: u64,
) -> Self {
Self {
prompt_id,
success: true,
outputs: Some(outputs),
error: None,
execution_time,
}
}
/// Create a failed execution result
pub fn failure(
prompt_id: String,
error: ExecutionError,
execution_time: u64,
) -> Self {
Self {
prompt_id,
success: false,
outputs: None,
error: Some(error),
execution_time,
}
}
}

View File

@@ -0,0 +1,192 @@
//! Core types for ComfyUI SDK
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
pub mod api;
pub mod events;
// Re-export all types
pub use api::*;
pub use events::*;
/// Base ComfyUI node definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComfyUINode {
pub class_type: String,
pub inputs: HashMap<String, serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub _meta: Option<NodeMeta>,
}
/// Node metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeMeta {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
/// ComfyUI workflow definition
pub type ComfyUIWorkflow = HashMap<String, ComfyUINode>;
/// Parameter schema types
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ParameterType {
String,
Number,
Boolean,
Array,
Object,
}
/// Parameter schema definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParameterSchema {
#[serde(rename = "type")]
pub param_type: ParameterType,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#enum: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<ParameterSchema>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<HashMap<String, ParameterSchema>>,
}
/// Template metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateMetadata {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
}
/// Workflow template data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowTemplateData {
pub metadata: TemplateMetadata,
pub workflow: ComfyUIWorkflow,
pub parameters: HashMap<String, ParameterSchema>,
}
/// Client configuration
#[derive(Debug, Clone)]
pub struct ComfyUIClientConfig {
pub base_url: String,
pub timeout: Option<std::time::Duration>,
pub retry_attempts: Option<u32>,
pub retry_delay: Option<std::time::Duration>,
pub headers: Option<HashMap<String, String>>,
}
impl Default for ComfyUIClientConfig {
fn default() -> Self {
Self {
base_url: "http://localhost:8188".to_string(),
timeout: Some(std::time::Duration::from_secs(30)),
retry_attempts: Some(3),
retry_delay: Some(std::time::Duration::from_millis(1000)),
headers: None,
}
}
}
/// Parameter values type
pub type ParameterValues = HashMap<String, serde_json::Value>;
/// Variable syntax options
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum VariableSyntax {
DoubleBrace, // {{}}
DollarBrace, // ${}
AtBrace, // @{}
}
/// Template validation result
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub valid: bool,
pub errors: Vec<ValidationError>,
}
/// Validation error
#[derive(Debug, Clone)]
pub struct ValidationError {
pub path: String,
pub message: String,
pub value: Option<serde_json::Value>,
}
impl ValidationResult {
/// Create a successful validation result
pub fn success() -> Self {
Self {
valid: true,
errors: Vec::new(),
}
}
/// Create a failed validation result with errors
pub fn failure(errors: Vec<ValidationError>) -> Self {
Self {
valid: false,
errors,
}
}
/// Add an error to the validation result
pub fn add_error(&mut self, error: ValidationError) {
self.valid = false;
self.errors.push(error);
}
}
impl ValidationError {
/// Create a new validation error
pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
Self {
path: path.into(),
message: message.into(),
value: None,
}
}
/// Create a new validation error with value
pub fn with_value(
path: impl Into<String>,
message: impl Into<String>,
value: serde_json::Value,
) -> Self {
Self {
path: path.into(),
message: message.into(),
value: Some(value),
}
}
}

View File

@@ -0,0 +1,216 @@
//! Error handling utilities
use crate::error::{ComfyUIError, Result};
use std::time::Duration;
use tokio::time::sleep;
/// Retry configuration
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_attempts: u32,
pub initial_delay: Duration,
pub max_delay: Duration,
pub backoff_multiplier: f64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 3,
initial_delay: Duration::from_millis(1000),
max_delay: Duration::from_secs(30),
backoff_multiplier: 2.0,
}
}
}
/// Retries an async operation with exponential backoff
pub async fn retry_with_backoff<F, Fut, T>(
operation: F,
config: RetryConfig,
) -> Result<T>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let mut last_error = None;
let mut delay = config.initial_delay;
for attempt in 1..=config.max_attempts {
match operation().await {
Ok(result) => return Ok(result),
Err(error) => {
last_error = Some(error);
if attempt < config.max_attempts {
log::warn!("Attempt {} failed, retrying in {:?}", attempt, delay);
sleep(delay).await;
// Exponential backoff
delay = std::cmp::min(
Duration::from_millis(
(delay.as_millis() as f64 * config.backoff_multiplier) as u64
),
config.max_delay,
);
}
}
}
}
Err(last_error.unwrap_or_else(|| ComfyUIError::new("Retry failed with no error")))
}
/// Checks if an error is retryable
pub fn is_retryable_error(error: &ComfyUIError) -> bool {
match error {
ComfyUIError::Http(reqwest_error) => {
// Retry on network errors, timeouts, and 5xx status codes
reqwest_error.is_timeout() ||
reqwest_error.is_connect() ||
reqwest_error.status().map_or(false, |status| status.is_server_error())
}
ComfyUIError::WebSocket(_) => true, // Most WebSocket errors are retryable
ComfyUIError::Connection(_) => true,
ComfyUIError::Timeout(_) => true,
ComfyUIError::Io(_) => true,
_ => false, // Don't retry validation errors, etc.
}
}
/// Retries an operation only if the error is retryable
pub async fn retry_if_retryable<F, Fut, T>(
operation: F,
config: RetryConfig,
) -> Result<T>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let mut last_error = None;
let mut delay = config.initial_delay;
for attempt in 1..=config.max_attempts {
match operation().await {
Ok(result) => return Ok(result),
Err(error) => {
if !is_retryable_error(&error) {
return Err(error);
}
last_error = Some(error);
if attempt < config.max_attempts {
log::warn!("Attempt {} failed with retryable error, retrying in {:?}", attempt, delay);
sleep(delay).await;
// Exponential backoff
delay = std::cmp::min(
Duration::from_millis(
(delay.as_millis() as f64 * config.backoff_multiplier) as u64
),
config.max_delay,
);
}
}
}
}
Err(last_error.unwrap_or_else(|| ComfyUIError::new("Retry failed with no error")))
}
/// Wraps an operation with timeout
pub async fn with_timeout<F, T>(
operation: F,
timeout: Duration,
) -> Result<T>
where
F: std::future::Future<Output = Result<T>>,
{
match tokio::time::timeout(timeout, operation).await {
Ok(result) => result,
Err(_) => Err(ComfyUIError::timeout(format!(
"Operation timed out after {:?}",
timeout
))),
}
}
/// Error context helper
pub trait ErrorContext<T> {
fn with_context(self, context: &str) -> Result<T>;
}
impl<T> ErrorContext<T> for Result<T> {
fn with_context(self, context: &str) -> Result<T> {
self.map_err(|error| {
ComfyUIError::new(format!("{}: {}", context, error))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
#[tokio::test]
async fn test_retry_success_on_second_attempt() {
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let config = RetryConfig {
max_attempts: 3,
initial_delay: Duration::from_millis(10),
max_delay: Duration::from_millis(100),
backoff_multiplier: 2.0,
};
let result = retry_with_backoff(
|| {
let counter = counter_clone.clone();
async move {
let count = counter.fetch_add(1, Ordering::SeqCst);
if count == 0 {
Err(ComfyUIError::connection("First attempt fails"))
} else {
Ok("Success")
}
}
},
config,
).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Success");
assert_eq!(counter.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_retry_exhausted() {
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let config = RetryConfig {
max_attempts: 2,
initial_delay: Duration::from_millis(10),
max_delay: Duration::from_millis(100),
backoff_multiplier: 2.0,
};
let result: Result<&str> = retry_with_backoff(
|| {
let counter = counter_clone.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
Err(ComfyUIError::connection("Always fails"))
}
},
config,
).await;
assert!(result.is_err());
assert_eq!(counter.load(Ordering::SeqCst), 2);
}
}

View File

@@ -0,0 +1,171 @@
//! Event emitter utilities
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
use crate::types::{ExecutionProgress, ExecutionResult, ExecutionError, ExecutionCallbacks};
/// Event emitter for execution events
#[derive(Clone)]
pub struct EventEmitter {
callbacks: Arc<RwLock<HashMap<String, Arc<dyn ExecutionCallbacks>>>>,
}
impl EventEmitter {
/// Creates a new event emitter
pub fn new() -> Self {
Self {
callbacks: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Registers callbacks with a unique ID
pub async fn register_callbacks(&self, id: String, callbacks: Arc<dyn ExecutionCallbacks>) {
let mut callbacks_map = self.callbacks.write().await;
callbacks_map.insert(id, callbacks);
}
/// Unregisters callbacks by ID
pub async fn unregister_callbacks(&self, id: &str) {
let mut callbacks_map = self.callbacks.write().await;
callbacks_map.remove(id);
}
/// Emits a progress event
pub async fn emit_progress(&self, progress: ExecutionProgress) {
let callbacks_map = self.callbacks.read().await;
for callback in callbacks_map.values() {
callback.on_progress(progress.clone());
}
}
/// Emits an executing event
pub async fn emit_executing(&self, node_id: String) {
let callbacks_map = self.callbacks.read().await;
for callback in callbacks_map.values() {
callback.on_executing(node_id.clone());
}
}
/// Emits an executed event
pub async fn emit_executed(&self, result: ExecutionResult) {
let callbacks_map = self.callbacks.read().await;
for callback in callbacks_map.values() {
callback.on_executed(result.clone());
}
}
/// Emits an error event
pub async fn emit_error(&self, error: ExecutionError) {
let callbacks_map = self.callbacks.read().await;
for callback in callbacks_map.values() {
callback.on_error(error.clone());
}
}
/// Gets the number of registered callbacks
pub async fn callback_count(&self) -> usize {
let callbacks_map = self.callbacks.read().await;
callbacks_map.len()
}
/// Clears all callbacks
pub async fn clear(&self) {
let mut callbacks_map = self.callbacks.write().await;
callbacks_map.clear();
}
}
impl Default for EventEmitter {
fn default() -> Self {
Self::new()
}
}
/// Simple callback implementation for testing
pub struct SimpleCallbacks {
pub on_progress_fn: Option<Box<dyn Fn(ExecutionProgress) + Send + Sync>>,
pub on_executing_fn: Option<Box<dyn Fn(String) + Send + Sync>>,
pub on_executed_fn: Option<Box<dyn Fn(ExecutionResult) + Send + Sync>>,
pub on_error_fn: Option<Box<dyn Fn(ExecutionError) + Send + Sync>>,
}
impl SimpleCallbacks {
/// Creates new simple callbacks
pub fn new() -> Self {
Self {
on_progress_fn: None,
on_executing_fn: None,
on_executed_fn: None,
on_error_fn: None,
}
}
/// Sets the progress callback
pub fn with_progress<F>(mut self, f: F) -> Self
where
F: Fn(ExecutionProgress) + Send + Sync + 'static,
{
self.on_progress_fn = Some(Box::new(f));
self
}
/// Sets the executing callback
pub fn with_executing<F>(mut self, f: F) -> Self
where
F: Fn(String) + Send + Sync + 'static,
{
self.on_executing_fn = Some(Box::new(f));
self
}
/// Sets the executed callback
pub fn with_executed<F>(mut self, f: F) -> Self
where
F: Fn(ExecutionResult) + Send + Sync + 'static,
{
self.on_executed_fn = Some(Box::new(f));
self
}
/// Sets the error callback
pub fn with_error<F>(mut self, f: F) -> Self
where
F: Fn(ExecutionError) + Send + Sync + 'static,
{
self.on_error_fn = Some(Box::new(f));
self
}
}
impl ExecutionCallbacks for SimpleCallbacks {
fn on_progress(&self, progress: ExecutionProgress) {
if let Some(ref f) = self.on_progress_fn {
f(progress);
}
}
fn on_executing(&self, node_id: String) {
if let Some(ref f) = self.on_executing_fn {
f(node_id);
}
}
fn on_executed(&self, result: ExecutionResult) {
if let Some(ref f) = self.on_executed_fn {
f(result);
}
}
fn on_error(&self, error: ExecutionError) {
if let Some(ref f) = self.on_error_fn {
f(error);
}
}
}
impl Default for SimpleCallbacks {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,12 @@
//! Utility functions for ComfyUI SDK
pub mod validation;
pub mod template_parser;
pub mod event_emitter;
pub mod error_handling;
// Re-export main utilities
pub use validation::*;
pub use template_parser::*;
pub use event_emitter::*;
pub use error_handling::*;

View File

@@ -0,0 +1,256 @@
//! Template parsing utilities for parameter substitution
use std::collections::HashMap;
use regex::Regex;
use crate::types::{ComfyUIWorkflow, ParameterValues, ComfyUINode, VariableSyntax};
use crate::error::{ComfyUIError, Result};
/// Applies parameters to a workflow template
pub fn apply_parameters(
workflow: &ComfyUIWorkflow,
parameters: &ParameterValues,
) -> Result<ComfyUIWorkflow> {
let mut resolved_workflow = HashMap::new();
for (node_id, node) in workflow {
let resolved_node = apply_parameters_to_node(node, parameters)?;
resolved_workflow.insert(node_id.clone(), resolved_node);
}
Ok(resolved_workflow)
}
/// Applies parameters to a single node
fn apply_parameters_to_node(
node: &ComfyUINode,
parameters: &ParameterValues,
) -> Result<ComfyUINode> {
let mut resolved_inputs = HashMap::new();
for (input_name, input_value) in &node.inputs {
let resolved_value = apply_parameters_to_value(input_value, parameters)?;
resolved_inputs.insert(input_name.clone(), resolved_value);
}
Ok(ComfyUINode {
class_type: node.class_type.clone(),
inputs: resolved_inputs,
_meta: node._meta.clone(),
})
}
/// Applies parameters to a JSON value
fn apply_parameters_to_value(
value: &serde_json::Value,
parameters: &ParameterValues,
) -> Result<serde_json::Value> {
match value {
serde_json::Value::String(s) => {
let resolved_string = substitute_variables(s, parameters, VariableSyntax::DoubleBrace)?;
Ok(serde_json::Value::String(resolved_string))
}
serde_json::Value::Array(arr) => {
let mut resolved_array = Vec::new();
for item in arr {
resolved_array.push(apply_parameters_to_value(item, parameters)?);
}
Ok(serde_json::Value::Array(resolved_array))
}
serde_json::Value::Object(obj) => {
let mut resolved_object = serde_json::Map::new();
for (key, val) in obj {
let resolved_key = substitute_variables(key, parameters, VariableSyntax::DoubleBrace)?;
let resolved_val = apply_parameters_to_value(val, parameters)?;
resolved_object.insert(resolved_key, resolved_val);
}
Ok(serde_json::Value::Object(resolved_object))
}
_ => Ok(value.clone()),
}
}
/// Substitutes variables in a string using the specified syntax
pub fn substitute_variables(
template: &str,
parameters: &ParameterValues,
syntax: VariableSyntax,
) -> Result<String> {
let pattern = match syntax {
VariableSyntax::DoubleBrace => r"\{\{([^}]+)\}\}",
VariableSyntax::DollarBrace => r"\$\{([^}]+)\}",
VariableSyntax::AtBrace => r"@\{([^}]+)\}",
};
let regex = Regex::new(pattern)
.map_err(|e| ComfyUIError::new(format!("Invalid regex pattern: {}", e)))?;
let mut result = template.to_string();
let mut offset = 0i32;
for captures in regex.captures_iter(template) {
let full_match = captures.get(0).unwrap();
let var_name = captures.get(1).unwrap().as_str().trim();
// Get parameter value
let replacement = match parameters.get(var_name) {
Some(value) => value_to_string(value)?,
None => {
return Err(ComfyUIError::template_validation(
format!("Parameter '{}' not found", var_name)
));
}
};
// Calculate positions with offset
let start = (full_match.start() as i32 + offset) as usize;
let end = (full_match.end() as i32 + offset) as usize;
// Replace the variable
result.replace_range(start..end, &replacement);
// Update offset
offset += replacement.len() as i32 - full_match.len() as i32;
}
Ok(result)
}
/// Converts a JSON value to string for substitution
fn value_to_string(value: &serde_json::Value) -> Result<String> {
match value {
serde_json::Value::String(s) => Ok(s.clone()),
serde_json::Value::Number(n) => Ok(n.to_string()),
serde_json::Value::Bool(b) => Ok(b.to_string()),
serde_json::Value::Null => Ok("null".to_string()),
_ => {
// For complex types, serialize to JSON
serde_json::to_string(value)
.map_err(ComfyUIError::from)
}
}
}
/// Extracts variable names from a template string
pub fn extract_variables(template: &str, syntax: VariableSyntax) -> Result<Vec<String>> {
let pattern = match syntax {
VariableSyntax::DoubleBrace => r"\{\{([^}]+)\}\}",
VariableSyntax::DollarBrace => r"\$\{([^}]+)\}",
VariableSyntax::AtBrace => r"@\{([^}]+)\}",
};
let regex = Regex::new(pattern)
.map_err(|e| ComfyUIError::new(format!("Invalid regex pattern: {}", e)))?;
let mut variables = Vec::new();
for captures in regex.captures_iter(template) {
if let Some(var_match) = captures.get(1) {
let var_name = var_match.as_str().trim().to_string();
if !variables.contains(&var_name) {
variables.push(var_name);
}
}
}
Ok(variables)
}
/// Extracts all variables from a workflow
pub fn extract_workflow_variables(workflow: &ComfyUIWorkflow) -> Result<Vec<String>> {
let mut all_variables = Vec::new();
for node in workflow.values() {
let node_variables = extract_node_variables(node)?;
for var in node_variables {
if !all_variables.contains(&var) {
all_variables.push(var);
}
}
}
Ok(all_variables)
}
/// Extracts variables from a single node
fn extract_node_variables(node: &ComfyUINode) -> Result<Vec<String>> {
let mut variables = Vec::new();
for input_value in node.inputs.values() {
let value_variables = extract_value_variables(input_value)?;
for var in value_variables {
if !variables.contains(&var) {
variables.push(var);
}
}
}
Ok(variables)
}
/// Extracts variables from a JSON value
fn extract_value_variables(value: &serde_json::Value) -> Result<Vec<String>> {
let mut variables = Vec::new();
match value {
serde_json::Value::String(s) => {
let string_vars = extract_variables(s, VariableSyntax::DoubleBrace)?;
variables.extend(string_vars);
}
serde_json::Value::Array(arr) => {
for item in arr {
let item_vars = extract_value_variables(item)?;
variables.extend(item_vars);
}
}
serde_json::Value::Object(obj) => {
for (key, val) in obj {
let key_vars = extract_variables(key, VariableSyntax::DoubleBrace)?;
variables.extend(key_vars);
let val_vars = extract_value_variables(val)?;
variables.extend(val_vars);
}
}
_ => {}
}
// Remove duplicates
variables.sort();
variables.dedup();
Ok(variables)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_substitute_variables() {
let mut parameters = HashMap::new();
parameters.insert("name".to_string(), json!("test"));
parameters.insert("value".to_string(), json!(42));
let template = "Hello {{name}}, value is {{value}}";
let result = substitute_variables(template, &parameters, VariableSyntax::DoubleBrace).unwrap();
assert_eq!(result, "Hello test, value is 42");
}
#[test]
fn test_extract_variables() {
let template = "{{var1}} and {{var2}} and {{var1}} again";
let variables = extract_variables(template, VariableSyntax::DoubleBrace).unwrap();
assert_eq!(variables, vec!["var1", "var2"]);
}
#[test]
fn test_missing_parameter() {
let parameters = HashMap::new();
let template = "Hello {{missing}}";
let result = substitute_variables(template, &parameters, VariableSyntax::DoubleBrace);
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,276 @@
//! Parameter validation utilities
use std::collections::HashMap;
use crate::types::{ParameterSchema, ParameterValues, ValidationResult, ValidationError, ParameterType};
use regex::Regex;
/// Validates parameters against their schemas
pub fn validate_parameters(
parameters: &ParameterValues,
schemas: &HashMap<String, ParameterSchema>,
) -> ValidationResult {
let mut errors = Vec::new();
// Check required parameters
for (name, schema) in schemas {
if schema.required.unwrap_or(false) {
if !parameters.contains_key(name) {
errors.push(ValidationError::new(
name,
"Required parameter is missing",
));
continue;
}
}
// Validate parameter if present
if let Some(value) = parameters.get(name) {
if let Err(error) = validate_parameter_value(name, value, schema) {
errors.push(error);
}
}
}
// Check for unknown parameters
for name in parameters.keys() {
if !schemas.contains_key(name) {
errors.push(ValidationError::new(
name,
"Unknown parameter",
));
}
}
if errors.is_empty() {
ValidationResult::success()
} else {
ValidationResult::failure(errors)
}
}
/// Validates a single parameter value against its schema
pub fn validate_parameter_value(
name: &str,
value: &serde_json::Value,
schema: &ParameterSchema,
) -> Result<(), ValidationError> {
// Check type
match (&schema.param_type, value) {
(ParameterType::String, serde_json::Value::String(s)) => {
validate_string_constraints(name, s, schema)?;
}
(ParameterType::Number, serde_json::Value::Number(n)) => {
validate_number_constraints(name, n, schema)?;
}
(ParameterType::Boolean, serde_json::Value::Bool(_)) => {
// Boolean values are always valid
}
(ParameterType::Array, serde_json::Value::Array(arr)) => {
validate_array_constraints(name, arr, schema)?;
}
(ParameterType::Object, serde_json::Value::Object(_)) => {
// Object validation could be more complex, but for now just check type
}
_ => {
return Err(ValidationError::with_value(
name,
format!("Expected type {:?}, got {:?}", schema.param_type, get_value_type(value)),
value.clone(),
));
}
}
// Check enum constraints
if let Some(enum_values) = &schema.r#enum {
if !enum_values.contains(value) {
return Err(ValidationError::with_value(
name,
format!("Value must be one of: {:?}", enum_values),
value.clone(),
));
}
}
Ok(())
}
/// Validates string constraints
fn validate_string_constraints(
name: &str,
value: &str,
schema: &ParameterSchema,
) -> Result<(), ValidationError> {
// Check pattern
if let Some(pattern) = &schema.pattern {
let regex = Regex::new(pattern).map_err(|_| {
ValidationError::new(name, "Invalid regex pattern in schema")
})?;
if !regex.is_match(value) {
return Err(ValidationError::with_value(
name,
format!("String does not match pattern: {}", pattern),
serde_json::Value::String(value.to_string()),
));
}
}
// Check length constraints (using min/max as length bounds for strings)
if let Some(min) = schema.min {
if (value.len() as f64) < min {
return Err(ValidationError::with_value(
name,
format!("String length must be at least {}", min),
serde_json::Value::String(value.to_string()),
));
}
}
if let Some(max) = schema.max {
if (value.len() as f64) > max {
return Err(ValidationError::with_value(
name,
format!("String length must be at most {}", max),
serde_json::Value::String(value.to_string()),
));
}
}
Ok(())
}
/// Validates number constraints
fn validate_number_constraints(
name: &str,
value: &serde_json::Number,
schema: &ParameterSchema,
) -> Result<(), ValidationError> {
let num_value = value.as_f64().unwrap_or(0.0);
if let Some(min) = schema.min {
if num_value < min {
return Err(ValidationError::with_value(
name,
format!("Number must be at least {}", min),
serde_json::Value::Number(value.clone()),
));
}
}
if let Some(max) = schema.max {
if num_value > max {
return Err(ValidationError::with_value(
name,
format!("Number must be at most {}", max),
serde_json::Value::Number(value.clone()),
));
}
}
Ok(())
}
/// Validates array constraints
fn validate_array_constraints(
name: &str,
value: &[serde_json::Value],
schema: &ParameterSchema,
) -> Result<(), ValidationError> {
// Check length constraints
if let Some(min) = schema.min {
if (value.len() as f64) < min {
return Err(ValidationError::with_value(
name,
format!("Array length must be at least {}", min),
serde_json::Value::Array(value.to_vec()),
));
}
}
if let Some(max) = schema.max {
if (value.len() as f64) > max {
return Err(ValidationError::with_value(
name,
format!("Array length must be at most {}", max),
serde_json::Value::Array(value.to_vec()),
));
}
}
// Validate items if schema is provided
if let Some(items_schema) = &schema.items {
for (index, item) in value.iter().enumerate() {
let item_name = format!("{}[{}]", name, index);
if let Err(error) = validate_parameter_value(&item_name, item, items_schema) {
return Err(error);
}
}
}
Ok(())
}
/// Gets the type of a JSON value
fn get_value_type(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_validate_required_parameter() {
let mut schemas = HashMap::new();
schemas.insert("test".to_string(), ParameterSchema {
param_type: ParameterType::String,
required: Some(true),
default: None,
description: None,
r#enum: None,
min: None,
max: None,
pattern: None,
items: None,
properties: None,
});
let parameters = HashMap::new();
let result = validate_parameters(&parameters, &schemas);
assert!(!result.valid);
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].path, "test");
}
#[test]
fn test_validate_string_parameter() {
let mut schemas = HashMap::new();
schemas.insert("test".to_string(), ParameterSchema {
param_type: ParameterType::String,
required: Some(true),
default: None,
description: None,
r#enum: None,
min: Some(3.0),
max: Some(10.0),
pattern: None,
items: None,
properties: None,
});
let mut parameters = HashMap::new();
parameters.insert("test".to_string(), json!("hello"));
let result = validate_parameters(&parameters, &schemas);
assert!(result.valid);
}
}