feat: add comfyui sdk
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
274
cargos/comfyui-sdk/client/comfyui_client.rs
Normal file
274
cargos/comfyui-sdk/client/comfyui_client.rs
Normal 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
|
||||
}
|
||||
}
|
||||
324
cargos/comfyui-sdk/client/http_client.rs
Normal file
324
cargos/comfyui-sdk/client/http_client.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
10
cargos/comfyui-sdk/client/mod.rs
Normal file
10
cargos/comfyui-sdk/client/mod.rs
Normal 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;
|
||||
226
cargos/comfyui-sdk/client/websocket_client.rs
Normal file
226
cargos/comfyui-sdk/client/websocket_client.rs
Normal 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(());
|
||||
}
|
||||
}
|
||||
}
|
||||
86
cargos/comfyui-sdk/error.rs
Normal file
86
cargos/comfyui-sdk/error.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
271
cargos/comfyui-sdk/examples/main.rs
Normal file
271
cargos/comfyui-sdk/examples/main.rs
Normal 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(())
|
||||
}
|
||||
306
cargos/comfyui-sdk/examples/real_local_image_test.rs
Normal file
306
cargos/comfyui-sdk/examples/real_local_image_test.rs
Normal 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
|
||||
}
|
||||
}
|
||||
162
cargos/comfyui-sdk/examples/simple_local_image.rs
Normal file
162
cargos/comfyui-sdk/examples/simple_local_image.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
113
cargos/comfyui-sdk/examples/test_url_fix.rs
Normal file
113
cargos/comfyui-sdk/examples/test_url_fix.rs
Normal 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
48
cargos/comfyui-sdk/lib.rs
Normal 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");
|
||||
435
cargos/comfyui-sdk/template_hub/ai_model_face_hair_fix.rs
Normal file
435
cargos/comfyui-sdk/template_hub/ai_model_face_hair_fix.rs
Normal 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
|
||||
}
|
||||
6
cargos/comfyui-sdk/template_hub/mod.rs
Normal file
6
cargos/comfyui-sdk/template_hub/mod.rs
Normal 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;
|
||||
10
cargos/comfyui-sdk/templates/mod.rs
Normal file
10
cargos/comfyui-sdk/templates/mod.rs
Normal 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;
|
||||
194
cargos/comfyui-sdk/templates/template_manager.rs
Normal file
194
cargos/comfyui-sdk/templates/template_manager.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
169
cargos/comfyui-sdk/templates/workflow_instance.rs
Normal file
169
cargos/comfyui-sdk/templates/workflow_instance.rs
Normal 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(), ¶meters)?;
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
193
cargos/comfyui-sdk/templates/workflow_template.rs
Normal file
193
cargos/comfyui-sdk/templates/workflow_template.rs
Normal 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(¶meters);
|
||||
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(())
|
||||
}
|
||||
}
|
||||
188
cargos/comfyui-sdk/tests/url_generation_test.rs
Normal file
188
cargos/comfyui-sdk/tests/url_generation_test.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
133
cargos/comfyui-sdk/types/api.rs
Normal file
133
cargos/comfyui-sdk/types/api.rs
Normal 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>,
|
||||
}
|
||||
160
cargos/comfyui-sdk/types/events.rs
Normal file
160
cargos/comfyui-sdk/types/events.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
192
cargos/comfyui-sdk/types/mod.rs
Normal file
192
cargos/comfyui-sdk/types/mod.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
216
cargos/comfyui-sdk/utils/error_handling.rs
Normal file
216
cargos/comfyui-sdk/utils/error_handling.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
171
cargos/comfyui-sdk/utils/event_emitter.rs
Normal file
171
cargos/comfyui-sdk/utils/event_emitter.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
12
cargos/comfyui-sdk/utils/mod.rs
Normal file
12
cargos/comfyui-sdk/utils/mod.rs
Normal 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::*;
|
||||
256
cargos/comfyui-sdk/utils/template_parser.rs
Normal file
256
cargos/comfyui-sdk/utils/template_parser.rs
Normal 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, ¶meters, 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, ¶meters, VariableSyntax::DoubleBrace);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
276
cargos/comfyui-sdk/utils/validation.rs
Normal file
276
cargos/comfyui-sdk/utils/validation.rs
Normal 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(¶meters, &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(¶meters, &schemas);
|
||||
assert!(result.valid);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user