refactor(workflow_ui): 抽出 node_defs 子模块
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,7 @@ use crate::{
|
|||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
services::workspace::{self, WorkspaceAssetInput},
|
services::workspace::{self, WorkspaceAssetInput},
|
||||||
workflow::{
|
workflow::{
|
||||||
graph,
|
models::{RunContext, WorkflowGraph},
|
||||||
models::{NodeTypeDefinition, RunContext, WorkflowGraph, WorkflowNode},
|
|
||||||
registry, storage,
|
registry, storage,
|
||||||
},
|
},
|
||||||
AppState,
|
AppState,
|
||||||
@@ -122,6 +121,12 @@ struct WorkflowValidateQuery {
|
|||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
use auth::{auth_caller, caller_from_headers, caller_is_admin, caller_user_id};
|
use auth::{auth_caller, caller_from_headers, caller_is_admin, caller_user_id};
|
||||||
|
mod node_defs;
|
||||||
|
use node_defs::{
|
||||||
|
core_node_definitions, core_workflow_template_index, default_boosapi_workflow_graph,
|
||||||
|
inject_boosapi_workflow_template_payload, node_type_def_to_comfy, normalize_prompt_payload,
|
||||||
|
text_to_video_workflow_graph, workflow_template_payload,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -378,516 +383,6 @@ fn parse_boolish(input: Option<&str>) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn known_output_ports(node_type: &str) -> &'static [&'static str] {
|
|
||||||
match node_type {
|
|
||||||
"CheckpointLoaderSimple" => &["MODEL", "CLIP", "VAE"],
|
|
||||||
"CLIPTextEncode" => &["CONDITIONING"],
|
|
||||||
"EmptyLatentImage" => &["LATENT"],
|
|
||||||
"KSampler" => &["LATENT"],
|
|
||||||
"VAEDecode" => &["IMAGE"],
|
|
||||||
"SaveImage" => &["IMAGE"],
|
|
||||||
"PreviewImage" => &["IMAGE"],
|
|
||||||
"input.text" => &["text"],
|
|
||||||
"boosapi.chat_completion" => &["text", "raw"],
|
|
||||||
"boosapi.prompt_rewrite" => &["prompt", "raw"],
|
|
||||||
"boosapi.text_to_image" => &["image", "images", "raw"],
|
|
||||||
"boosapi.text_to_video" | "boosapi.image_to_video" => &["task_id", "video", "raw"],
|
|
||||||
"boosapi.video_status" => &["video", "raw"],
|
|
||||||
"boosapi.comfyui_workflow" => &["workflow", "text", "raw"],
|
|
||||||
"comfyui.submit_workflow" => &["prompt_id", "response", "workflow"],
|
|
||||||
"output.preview_image" => &["image"],
|
|
||||||
"output.preview_video" => &["video"],
|
|
||||||
"output.save_file" => &["file"],
|
|
||||||
"util.text_join" => &["text"],
|
|
||||||
"util.json_extract" => &["value", "text"],
|
|
||||||
_ => &["output"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn core_node_definitions() -> Vec<NodeTypeDefinition> {
|
|
||||||
vec![
|
|
||||||
NodeTypeDefinition {
|
|
||||||
node_type: "CheckpointLoaderSimple",
|
|
||||||
title: "CheckpointLoaderSimple",
|
|
||||||
category: "loaders",
|
|
||||||
description: "Loads a checkpoint model and provides model, clip and vae outputs.",
|
|
||||||
inputs: json!({}),
|
|
||||||
params: json!({
|
|
||||||
"ckpt_name": { "ui": "combo", "type": "COMBO", "default": "gpt-image-2" }
|
|
||||||
}),
|
|
||||||
outputs: json!({
|
|
||||||
"MODEL": { "type": "MODEL" },
|
|
||||||
"CLIP": { "type": "CLIP" },
|
|
||||||
"VAE": { "type": "VAE" }
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
NodeTypeDefinition {
|
|
||||||
node_type: "CLIPTextEncode",
|
|
||||||
title: "CLIPTextEncode",
|
|
||||||
category: "conditioning",
|
|
||||||
description: "Encodes text into conditioning.",
|
|
||||||
inputs: json!({
|
|
||||||
"clip": { "type": "CLIP", "required": false }
|
|
||||||
}),
|
|
||||||
params: json!({
|
|
||||||
"text": { "ui": "textarea", "type": "STRING", "default": "" }
|
|
||||||
}),
|
|
||||||
outputs: json!({ "CONDITIONING": { "type": "CONDITIONING" } }),
|
|
||||||
},
|
|
||||||
NodeTypeDefinition {
|
|
||||||
node_type: "EmptyLatentImage",
|
|
||||||
title: "EmptyLatentImage",
|
|
||||||
category: "latent",
|
|
||||||
description: "Creates an empty latent image.",
|
|
||||||
inputs: json!({}),
|
|
||||||
params: json!({
|
|
||||||
"width": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
|
|
||||||
"height": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
|
|
||||||
"batch_size": { "ui": "number", "type": "INT", "default": 1, "min": 1, "max": 64 }
|
|
||||||
}),
|
|
||||||
outputs: json!({ "LATENT": { "type": "LATENT" } }),
|
|
||||||
},
|
|
||||||
NodeTypeDefinition {
|
|
||||||
node_type: "KSampler",
|
|
||||||
title: "KSampler",
|
|
||||||
category: "sampling",
|
|
||||||
description: "Samples latent images with a diffusion model.",
|
|
||||||
inputs: json!({
|
|
||||||
"model": { "type": "MODEL", "required": true },
|
|
||||||
"positive": { "type": "CONDITIONING", "required": true },
|
|
||||||
"negative": { "type": "CONDITIONING", "required": true },
|
|
||||||
"latent_image": { "type": "LATENT", "required": true }
|
|
||||||
}),
|
|
||||||
params: json!({
|
|
||||||
"seed": { "ui": "number", "type": "INT", "default": 0 },
|
|
||||||
"control_after_generate": { "ui": "boolean", "type": "BOOLEAN", "default": true },
|
|
||||||
"steps": { "ui": "number", "type": "INT", "default": 20, "min": 1, "max": 100 },
|
|
||||||
"cfg": { "ui": "number", "type": "FLOAT", "default": 8, "min": 0, "max": 50 },
|
|
||||||
"sampler_name": { "ui": "text", "type": "STRING", "default": "euler" },
|
|
||||||
"scheduler": { "ui": "text", "type": "STRING", "default": "normal" },
|
|
||||||
"denoise": { "ui": "number", "type": "FLOAT", "default": 1, "min": 0, "max": 1 }
|
|
||||||
}),
|
|
||||||
outputs: json!({ "LATENT": { "type": "LATENT" } }),
|
|
||||||
},
|
|
||||||
NodeTypeDefinition {
|
|
||||||
node_type: "VAEDecode",
|
|
||||||
title: "VAEDecode",
|
|
||||||
category: "latent",
|
|
||||||
description: "Decodes latent samples to images.",
|
|
||||||
inputs: json!({
|
|
||||||
"samples": { "type": "LATENT", "required": true },
|
|
||||||
"vae": { "type": "VAE", "required": true }
|
|
||||||
}),
|
|
||||||
params: json!({}),
|
|
||||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
|
||||||
},
|
|
||||||
NodeTypeDefinition {
|
|
||||||
node_type: "SaveImage",
|
|
||||||
title: "SaveImage",
|
|
||||||
category: "image",
|
|
||||||
description: "Saves images to disk.",
|
|
||||||
inputs: json!({
|
|
||||||
"images": { "type": "IMAGE", "required": true }
|
|
||||||
}),
|
|
||||||
params: json!({
|
|
||||||
"filename_prefix": { "ui": "text", "type": "STRING", "default": "ComfyUI" }
|
|
||||||
}),
|
|
||||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
|
||||||
},
|
|
||||||
NodeTypeDefinition {
|
|
||||||
node_type: "PreviewImage",
|
|
||||||
title: "PreviewImage",
|
|
||||||
category: "image",
|
|
||||||
description: "Previews generated images.",
|
|
||||||
inputs: json!({
|
|
||||||
"images": { "type": "IMAGE", "required": true }
|
|
||||||
}),
|
|
||||||
params: json!({}),
|
|
||||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workflow_template_payload() -> Value {
|
|
||||||
json!({
|
|
||||||
"boosapi": ["文生图示例", "文生视频示例"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn core_workflow_template_index() -> Value {
|
|
||||||
json!([
|
|
||||||
{
|
|
||||||
"moduleName": "default",
|
|
||||||
"title": "BoosAPI Examples",
|
|
||||||
"category": "basics",
|
|
||||||
"type": "core",
|
|
||||||
"isEssential": true,
|
|
||||||
"templates": [
|
|
||||||
{
|
|
||||||
"name": "boosapi-text-to-image",
|
|
||||||
"title": "BoosAPI 文生图",
|
|
||||||
"description": "使用 BoosAPI 模型路由生成图片并预览输出。",
|
|
||||||
"mediaType": "image",
|
|
||||||
"mediaSubtype": "svg",
|
|
||||||
"tags": ["boosapi", "image"],
|
|
||||||
"models": ["BoosAPI"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "boosapi-text-to-video",
|
|
||||||
"title": "BoosAPI 文生视频",
|
|
||||||
"description": "使用 BoosAPI 视频节点生成视频并保存输出。",
|
|
||||||
"mediaType": "image",
|
|
||||||
"mediaSubtype": "svg",
|
|
||||||
"tags": ["boosapi", "video"],
|
|
||||||
"models": ["BoosAPI"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inject_boosapi_workflow_template_payload(value: &mut Value) {
|
|
||||||
let Some(object) = value.as_object_mut() else {
|
|
||||||
*value = workflow_template_payload();
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
object
|
|
||||||
.entry("boosapi".to_string())
|
|
||||||
.or_insert_with(|| json!(["文生图示例", "文生视频示例"]));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_boosapi_workflow_graph() -> Value {
|
|
||||||
json!({
|
|
||||||
"last_node_id": 3,
|
|
||||||
"last_link_id": 2,
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"type": "input.text",
|
|
||||||
"pos": [80, 140],
|
|
||||||
"size": [460, 260],
|
|
||||||
"flags": {},
|
|
||||||
"order": 0,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [],
|
|
||||||
"outputs": [{ "name": "text", "type": "STRING", "links": [1], "slot_index": 0 }],
|
|
||||||
"properties": { "Node name for S&R": "input.text" },
|
|
||||||
"widgets_values": ["cinematic golden hour portrait of an ancient Chinese woman in jade-green hanfu, delicate light, ultra detailed"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"type": "boosapi.text_to_image",
|
|
||||||
"pos": [520, 120],
|
|
||||||
"size": [380, 180],
|
|
||||||
"flags": {},
|
|
||||||
"order": 1,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [{ "name": "prompt", "type": "STRING", "link": 1 }],
|
|
||||||
"outputs": [
|
|
||||||
{ "name": "image", "type": "IMAGE", "links": [2], "slot_index": 0 },
|
|
||||||
{ "name": "images", "type": "IMAGE", "links": null, "slot_index": 1 },
|
|
||||||
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 2 }
|
|
||||||
],
|
|
||||||
"properties": { "Node name for S&R": "boosapi.text_to_image" },
|
|
||||||
"widgets_values": ["gpt-image-2", 1024, 1024, ""]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"type": "output.preview_image",
|
|
||||||
"pos": [980, 140],
|
|
||||||
"size": [320, 120],
|
|
||||||
"flags": {},
|
|
||||||
"order": 2,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [{ "name": "image", "type": "IMAGE", "link": 2 }],
|
|
||||||
"outputs": [{ "name": "image", "type": "IMAGE", "links": null, "slot_index": 0 }],
|
|
||||||
"properties": { "Node name for S&R": "output.preview_image" },
|
|
||||||
"widgets_values": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"links": [
|
|
||||||
[1, 1, 0, 2, 0, "STRING"],
|
|
||||||
[2, 2, 0, 3, 0, "IMAGE"]
|
|
||||||
],
|
|
||||||
"groups": [],
|
|
||||||
"config": {},
|
|
||||||
"extra": { "ds": { "scale": 0.9, "offset": [80, 80] } },
|
|
||||||
"version": 0.4
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn text_to_video_workflow_graph() -> Value {
|
|
||||||
json!({
|
|
||||||
"last_node_id": 4,
|
|
||||||
"last_link_id": 3,
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"type": "input.text",
|
|
||||||
"pos": [80, 150],
|
|
||||||
"size": [480, 260],
|
|
||||||
"flags": {},
|
|
||||||
"order": 0,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [],
|
|
||||||
"outputs": [{ "name": "text", "type": "STRING", "links": [1], "slot_index": 0 }],
|
|
||||||
"properties": { "Node name for S&R": "input.text" },
|
|
||||||
"widgets_values": ["cinematic aerial shot of a glass bottle floating through a purple galaxy nebula, elegant camera motion, dreamy lighting"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"type": "boosapi.text_to_video",
|
|
||||||
"pos": [600, 120],
|
|
||||||
"size": [420, 300],
|
|
||||||
"flags": {},
|
|
||||||
"order": 1,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [{ "name": "prompt", "type": "STRING", "link": 1 }],
|
|
||||||
"outputs": [
|
|
||||||
{ "name": "task_id", "type": "STRING", "links": [2], "slot_index": 0 },
|
|
||||||
{ "name": "video", "type": "VIDEO", "links": [3], "slot_index": 1 },
|
|
||||||
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 2 }
|
|
||||||
],
|
|
||||||
"properties": { "Node name for S&R": "boosapi.text_to_video" },
|
|
||||||
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL, "16:9", 5, "720p", 24, true, 5, 180]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"type": "boosapi.video_status",
|
|
||||||
"pos": [1080, 95],
|
|
||||||
"size": [360, 140],
|
|
||||||
"flags": {},
|
|
||||||
"order": 2,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [{ "name": "task_id", "type": "STRING", "link": 2 }],
|
|
||||||
"outputs": [
|
|
||||||
{ "name": "video", "type": "VIDEO", "links": null, "slot_index": 0 },
|
|
||||||
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 1 }
|
|
||||||
],
|
|
||||||
"properties": { "Node name for S&R": "boosapi.video_status" },
|
|
||||||
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"type": "output.preview_video",
|
|
||||||
"pos": [1080, 285],
|
|
||||||
"size": [340, 120],
|
|
||||||
"flags": {},
|
|
||||||
"order": 3,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [{ "name": "video", "type": "VIDEO", "link": 3 }],
|
|
||||||
"outputs": [{ "name": "video", "type": "VIDEO", "links": null, "slot_index": 0 }],
|
|
||||||
"properties": { "Node name for S&R": "output.preview_video" },
|
|
||||||
"widgets_values": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"links": [
|
|
||||||
[1, 1, 0, 2, 0, "STRING"],
|
|
||||||
[2, 2, 0, 3, 0, "STRING"],
|
|
||||||
[3, 2, 1, 4, 0, "VIDEO"]
|
|
||||||
],
|
|
||||||
"groups": [],
|
|
||||||
"config": {},
|
|
||||||
"extra": { "ds": { "scale": 0.85, "offset": [80, 80] } },
|
|
||||||
"version": 0.4
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn port_name(node_type: &str, index: usize) -> &'static str {
|
|
||||||
known_output_ports(node_type)
|
|
||||||
.get(index)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or_else(|| known_output_ports(node_type)[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn source_node_type(prompt: &Value, node_id: &str) -> Option<String> {
|
|
||||||
prompt
|
|
||||||
.as_object()?
|
|
||||||
.get(node_id)?
|
|
||||||
.get("class_type")?
|
|
||||||
.as_str()
|
|
||||||
.map(str::to_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_prompt_payload(prompt: &Value) -> AppResult<WorkflowGraph> {
|
|
||||||
let nodes = prompt
|
|
||||||
.as_object()
|
|
||||||
.ok_or_else(|| AppError::BadRequest("prompt must be an object".into()))?;
|
|
||||||
|
|
||||||
let mut workflow_nodes = Vec::with_capacity(nodes.len());
|
|
||||||
let mut edges = Vec::new();
|
|
||||||
|
|
||||||
for (node_id, raw_node) in nodes {
|
|
||||||
let node_type = raw_node
|
|
||||||
.get("class_type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| AppError::BadRequest(format!("node {node_id} missing class_type")))?;
|
|
||||||
let inputs = raw_node.get("inputs").and_then(Value::as_object);
|
|
||||||
let mut params = Map::new();
|
|
||||||
|
|
||||||
if let Some(map) = inputs {
|
|
||||||
for (input_key, input_value) in map {
|
|
||||||
if let Some((source_id, output_index)) = prompt_link(input_value) {
|
|
||||||
let source_type = source_node_type(prompt, &source_id)
|
|
||||||
.unwrap_or_else(|| "input.text".to_string());
|
|
||||||
edges.push(crate::workflow::models::WorkflowEdge {
|
|
||||||
from: format!("{source_id}.{}", port_name(&source_type, output_index)),
|
|
||||||
to: format!("{node_id}.{input_key}"),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
params.insert(input_key.clone(), input_value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
workflow_nodes.push(WorkflowNode {
|
|
||||||
id: node_id.clone(),
|
|
||||||
node_type: node_type.to_string(),
|
|
||||||
position: raw_node
|
|
||||||
.get("_meta")
|
|
||||||
.and_then(|meta| meta.get("pos"))
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| json!({ "x": 80, "y": 120 })),
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let graph = WorkflowGraph {
|
|
||||||
version: "1.0".to_string(),
|
|
||||||
name: Some("ComfyUI workflow".to_string()),
|
|
||||||
nodes: workflow_nodes,
|
|
||||||
edges,
|
|
||||||
};
|
|
||||||
graph::validate_and_sort(&graph)?;
|
|
||||||
Ok(graph)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prompt_link(value: &Value) -> Option<(String, usize)> {
|
|
||||||
let array = value.as_array()?;
|
|
||||||
let source = array.first()?.as_str()?.to_string();
|
|
||||||
let index = array.get(1).and_then(Value::as_u64).unwrap_or_default() as usize;
|
|
||||||
Some((source, index))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn node_type_def_to_comfy(def: &NodeTypeDefinition) -> Value {
|
|
||||||
let mut required = Map::new();
|
|
||||||
let mut optional = Map::new();
|
|
||||||
|
|
||||||
if let Some(inputs) = def.inputs.as_object() {
|
|
||||||
for (name, spec) in inputs {
|
|
||||||
required.insert(name.clone(), input_spec_to_comfy(spec, true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(params) = def.params.as_object() {
|
|
||||||
for (name, spec) in params {
|
|
||||||
optional.insert(name.clone(), input_spec_to_comfy(spec, false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
json!({
|
|
||||||
"name": def.node_type,
|
|
||||||
"display_name": def.title,
|
|
||||||
"description": def.description,
|
|
||||||
"category": def.category,
|
|
||||||
"output_node": def.category.eq_ignore_ascii_case("output"),
|
|
||||||
"python_module": "boosapi.workflow",
|
|
||||||
"input": {
|
|
||||||
"required": required,
|
|
||||||
"optional": optional,
|
|
||||||
},
|
|
||||||
"output": output_specs(def),
|
|
||||||
"output_name": known_output_ports(def.node_type),
|
|
||||||
"output_is_list": output_is_list_specs(def),
|
|
||||||
"output_tooltips": vec![""; known_output_ports(def.node_type).len()],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn input_spec_to_comfy(value: &Value, required: bool) -> Value {
|
|
||||||
let ty = value
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("STRING");
|
|
||||||
let default = value.get("default").cloned();
|
|
||||||
let options = value
|
|
||||||
.get("options")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut props = Map::new();
|
|
||||||
if let Some(default) = default {
|
|
||||||
props.insert("default".into(), default);
|
|
||||||
}
|
|
||||||
if value.get("ui").and_then(Value::as_str) == Some("textarea") {
|
|
||||||
props.insert("multiline".into(), Value::Bool(true));
|
|
||||||
props.insert("dynamicPrompts".into(), Value::Bool(false));
|
|
||||||
}
|
|
||||||
if required {
|
|
||||||
props.insert("forceInput".into(), Value::Bool(true));
|
|
||||||
}
|
|
||||||
if ty == "CONDITIONING" {
|
|
||||||
return json!(["CONDITIONING", props]);
|
|
||||||
}
|
|
||||||
match ty {
|
|
||||||
"COMBO" => {
|
|
||||||
let choices = if options.is_empty() {
|
|
||||||
props
|
|
||||||
.get("default")
|
|
||||||
.cloned()
|
|
||||||
.map(|value| vec![value])
|
|
||||||
.unwrap_or_default()
|
|
||||||
} else {
|
|
||||||
options
|
|
||||||
};
|
|
||||||
json!([choices, props])
|
|
||||||
}
|
|
||||||
"BOOLEAN" => json!(["BOOLEAN", props]),
|
|
||||||
"NUMBER" | "FLOAT" => json!(["FLOAT", props]),
|
|
||||||
"INT" => json!(["INT", props]),
|
|
||||||
"JSON" => json!(["STRING", props]),
|
|
||||||
"FILE" => json!(["STRING", props]),
|
|
||||||
"STRING" => json!(["STRING", props]),
|
|
||||||
_ => json!([ty, props]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn output_specs(def: &NodeTypeDefinition) -> Vec<Value> {
|
|
||||||
let mut outputs = Vec::new();
|
|
||||||
match def.node_type {
|
|
||||||
"input.text" => outputs.push(json!("STRING")),
|
|
||||||
"boosapi.chat_completion" => outputs.extend([json!("STRING"), json!("JSON")]),
|
|
||||||
"boosapi.prompt_rewrite" => outputs.extend([json!("STRING"), json!("JSON")]),
|
|
||||||
"boosapi.text_to_image" => outputs.extend([json!("IMAGE"), json!("IMAGE"), json!("JSON")]),
|
|
||||||
"boosapi.text_to_video" | "boosapi.image_to_video" => {
|
|
||||||
outputs.extend([json!("STRING"), json!("VIDEO"), json!("JSON")])
|
|
||||||
}
|
|
||||||
"boosapi.video_status" => outputs.extend([json!("VIDEO"), json!("JSON")]),
|
|
||||||
"boosapi.comfyui_workflow" => {
|
|
||||||
outputs.extend([json!("JSON"), json!("STRING"), json!("JSON")])
|
|
||||||
}
|
|
||||||
"comfyui.submit_workflow" => {
|
|
||||||
outputs.extend([json!("STRING"), json!("JSON"), json!("JSON")])
|
|
||||||
}
|
|
||||||
"CheckpointLoaderSimple" => outputs.extend([json!("MODEL"), json!("CLIP"), json!("VAE")]),
|
|
||||||
"CLIPTextEncode" => outputs.push(json!("CONDITIONING")),
|
|
||||||
"EmptyLatentImage" | "KSampler" => outputs.push(json!("LATENT")),
|
|
||||||
"VAEDecode" | "SaveImage" | "PreviewImage" => outputs.push(json!("IMAGE")),
|
|
||||||
"output.preview_image" => outputs.push(json!("IMAGE")),
|
|
||||||
"output.preview_video" => outputs.push(json!("VIDEO")),
|
|
||||||
"output.save_file" => outputs.push(json!("FILE")),
|
|
||||||
"util.text_join" => outputs.push(json!("STRING")),
|
|
||||||
"util.json_extract" => outputs.extend([json!("JSON"), json!("STRING")]),
|
|
||||||
_ => outputs.push(json!("STRING")),
|
|
||||||
}
|
|
||||||
outputs
|
|
||||||
}
|
|
||||||
|
|
||||||
fn output_is_list_specs(def: &NodeTypeDefinition) -> Vec<bool> {
|
|
||||||
match def.node_type {
|
|
||||||
"boosapi.text_to_image" => vec![false, true, false],
|
|
||||||
_ => vec![false; known_output_ports(def.node_type).len()],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_exact_workflow_model(value: &str) -> bool {
|
fn is_exact_workflow_model(value: &str) -> bool {
|
||||||
let normalized = value.trim();
|
let normalized = value.trim();
|
||||||
!normalized.is_empty()
|
!normalized.is_empty()
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
use super::DEFAULT_TEXT_TO_VIDEO_MODEL;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::workflow::{
|
||||||
|
graph,
|
||||||
|
models::{NodeTypeDefinition, WorkflowGraph, WorkflowNode},
|
||||||
|
};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
|
pub(super) fn known_output_ports(node_type: &str) -> &'static [&'static str] {
|
||||||
|
match node_type {
|
||||||
|
"CheckpointLoaderSimple" => &["MODEL", "CLIP", "VAE"],
|
||||||
|
"CLIPTextEncode" => &["CONDITIONING"],
|
||||||
|
"EmptyLatentImage" => &["LATENT"],
|
||||||
|
"KSampler" => &["LATENT"],
|
||||||
|
"VAEDecode" => &["IMAGE"],
|
||||||
|
"SaveImage" => &["IMAGE"],
|
||||||
|
"PreviewImage" => &["IMAGE"],
|
||||||
|
"input.text" => &["text"],
|
||||||
|
"boosapi.chat_completion" => &["text", "raw"],
|
||||||
|
"boosapi.prompt_rewrite" => &["prompt", "raw"],
|
||||||
|
"boosapi.text_to_image" => &["image", "images", "raw"],
|
||||||
|
"boosapi.text_to_video" | "boosapi.image_to_video" => &["task_id", "video", "raw"],
|
||||||
|
"boosapi.video_status" => &["video", "raw"],
|
||||||
|
"boosapi.comfyui_workflow" => &["workflow", "text", "raw"],
|
||||||
|
"comfyui.submit_workflow" => &["prompt_id", "response", "workflow"],
|
||||||
|
"output.preview_image" => &["image"],
|
||||||
|
"output.preview_video" => &["video"],
|
||||||
|
"output.save_file" => &["file"],
|
||||||
|
"util.text_join" => &["text"],
|
||||||
|
"util.json_extract" => &["value", "text"],
|
||||||
|
_ => &["output"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn core_node_definitions() -> Vec<NodeTypeDefinition> {
|
||||||
|
vec![
|
||||||
|
NodeTypeDefinition {
|
||||||
|
node_type: "CheckpointLoaderSimple",
|
||||||
|
title: "CheckpointLoaderSimple",
|
||||||
|
category: "loaders",
|
||||||
|
description: "Loads a checkpoint model and provides model, clip and vae outputs.",
|
||||||
|
inputs: json!({}),
|
||||||
|
params: json!({
|
||||||
|
"ckpt_name": { "ui": "combo", "type": "COMBO", "default": "gpt-image-2" }
|
||||||
|
}),
|
||||||
|
outputs: json!({
|
||||||
|
"MODEL": { "type": "MODEL" },
|
||||||
|
"CLIP": { "type": "CLIP" },
|
||||||
|
"VAE": { "type": "VAE" }
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
NodeTypeDefinition {
|
||||||
|
node_type: "CLIPTextEncode",
|
||||||
|
title: "CLIPTextEncode",
|
||||||
|
category: "conditioning",
|
||||||
|
description: "Encodes text into conditioning.",
|
||||||
|
inputs: json!({
|
||||||
|
"clip": { "type": "CLIP", "required": false }
|
||||||
|
}),
|
||||||
|
params: json!({
|
||||||
|
"text": { "ui": "textarea", "type": "STRING", "default": "" }
|
||||||
|
}),
|
||||||
|
outputs: json!({ "CONDITIONING": { "type": "CONDITIONING" } }),
|
||||||
|
},
|
||||||
|
NodeTypeDefinition {
|
||||||
|
node_type: "EmptyLatentImage",
|
||||||
|
title: "EmptyLatentImage",
|
||||||
|
category: "latent",
|
||||||
|
description: "Creates an empty latent image.",
|
||||||
|
inputs: json!({}),
|
||||||
|
params: json!({
|
||||||
|
"width": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
|
||||||
|
"height": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
|
||||||
|
"batch_size": { "ui": "number", "type": "INT", "default": 1, "min": 1, "max": 64 }
|
||||||
|
}),
|
||||||
|
outputs: json!({ "LATENT": { "type": "LATENT" } }),
|
||||||
|
},
|
||||||
|
NodeTypeDefinition {
|
||||||
|
node_type: "KSampler",
|
||||||
|
title: "KSampler",
|
||||||
|
category: "sampling",
|
||||||
|
description: "Samples latent images with a diffusion model.",
|
||||||
|
inputs: json!({
|
||||||
|
"model": { "type": "MODEL", "required": true },
|
||||||
|
"positive": { "type": "CONDITIONING", "required": true },
|
||||||
|
"negative": { "type": "CONDITIONING", "required": true },
|
||||||
|
"latent_image": { "type": "LATENT", "required": true }
|
||||||
|
}),
|
||||||
|
params: json!({
|
||||||
|
"seed": { "ui": "number", "type": "INT", "default": 0 },
|
||||||
|
"control_after_generate": { "ui": "boolean", "type": "BOOLEAN", "default": true },
|
||||||
|
"steps": { "ui": "number", "type": "INT", "default": 20, "min": 1, "max": 100 },
|
||||||
|
"cfg": { "ui": "number", "type": "FLOAT", "default": 8, "min": 0, "max": 50 },
|
||||||
|
"sampler_name": { "ui": "text", "type": "STRING", "default": "euler" },
|
||||||
|
"scheduler": { "ui": "text", "type": "STRING", "default": "normal" },
|
||||||
|
"denoise": { "ui": "number", "type": "FLOAT", "default": 1, "min": 0, "max": 1 }
|
||||||
|
}),
|
||||||
|
outputs: json!({ "LATENT": { "type": "LATENT" } }),
|
||||||
|
},
|
||||||
|
NodeTypeDefinition {
|
||||||
|
node_type: "VAEDecode",
|
||||||
|
title: "VAEDecode",
|
||||||
|
category: "latent",
|
||||||
|
description: "Decodes latent samples to images.",
|
||||||
|
inputs: json!({
|
||||||
|
"samples": { "type": "LATENT", "required": true },
|
||||||
|
"vae": { "type": "VAE", "required": true }
|
||||||
|
}),
|
||||||
|
params: json!({}),
|
||||||
|
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||||
|
},
|
||||||
|
NodeTypeDefinition {
|
||||||
|
node_type: "SaveImage",
|
||||||
|
title: "SaveImage",
|
||||||
|
category: "image",
|
||||||
|
description: "Saves images to disk.",
|
||||||
|
inputs: json!({
|
||||||
|
"images": { "type": "IMAGE", "required": true }
|
||||||
|
}),
|
||||||
|
params: json!({
|
||||||
|
"filename_prefix": { "ui": "text", "type": "STRING", "default": "ComfyUI" }
|
||||||
|
}),
|
||||||
|
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||||
|
},
|
||||||
|
NodeTypeDefinition {
|
||||||
|
node_type: "PreviewImage",
|
||||||
|
title: "PreviewImage",
|
||||||
|
category: "image",
|
||||||
|
description: "Previews generated images.",
|
||||||
|
inputs: json!({
|
||||||
|
"images": { "type": "IMAGE", "required": true }
|
||||||
|
}),
|
||||||
|
params: json!({}),
|
||||||
|
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn workflow_template_payload() -> Value {
|
||||||
|
json!({
|
||||||
|
"boosapi": ["文生图示例", "文生视频示例"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn core_workflow_template_index() -> Value {
|
||||||
|
json!([
|
||||||
|
{
|
||||||
|
"moduleName": "default",
|
||||||
|
"title": "BoosAPI Examples",
|
||||||
|
"category": "basics",
|
||||||
|
"type": "core",
|
||||||
|
"isEssential": true,
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"name": "boosapi-text-to-image",
|
||||||
|
"title": "BoosAPI 文生图",
|
||||||
|
"description": "使用 BoosAPI 模型路由生成图片并预览输出。",
|
||||||
|
"mediaType": "image",
|
||||||
|
"mediaSubtype": "svg",
|
||||||
|
"tags": ["boosapi", "image"],
|
||||||
|
"models": ["BoosAPI"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "boosapi-text-to-video",
|
||||||
|
"title": "BoosAPI 文生视频",
|
||||||
|
"description": "使用 BoosAPI 视频节点生成视频并保存输出。",
|
||||||
|
"mediaType": "image",
|
||||||
|
"mediaSubtype": "svg",
|
||||||
|
"tags": ["boosapi", "video"],
|
||||||
|
"models": ["BoosAPI"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn inject_boosapi_workflow_template_payload(value: &mut Value) {
|
||||||
|
let Some(object) = value.as_object_mut() else {
|
||||||
|
*value = workflow_template_payload();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
object
|
||||||
|
.entry("boosapi".to_string())
|
||||||
|
.or_insert_with(|| json!(["文生图示例", "文生视频示例"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn default_boosapi_workflow_graph() -> Value {
|
||||||
|
json!({
|
||||||
|
"last_node_id": 3,
|
||||||
|
"last_link_id": 2,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "input.text",
|
||||||
|
"pos": [80, 140],
|
||||||
|
"size": [460, 260],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [{ "name": "text", "type": "STRING", "links": [1], "slot_index": 0 }],
|
||||||
|
"properties": { "Node name for S&R": "input.text" },
|
||||||
|
"widgets_values": ["cinematic golden hour portrait of an ancient Chinese woman in jade-green hanfu, delicate light, ultra detailed"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "boosapi.text_to_image",
|
||||||
|
"pos": [520, 120],
|
||||||
|
"size": [380, 180],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "prompt", "type": "STRING", "link": 1 }],
|
||||||
|
"outputs": [
|
||||||
|
{ "name": "image", "type": "IMAGE", "links": [2], "slot_index": 0 },
|
||||||
|
{ "name": "images", "type": "IMAGE", "links": null, "slot_index": 1 },
|
||||||
|
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 2 }
|
||||||
|
],
|
||||||
|
"properties": { "Node name for S&R": "boosapi.text_to_image" },
|
||||||
|
"widgets_values": ["gpt-image-2", 1024, 1024, ""]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "output.preview_image",
|
||||||
|
"pos": [980, 140],
|
||||||
|
"size": [320, 120],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "image", "type": "IMAGE", "link": 2 }],
|
||||||
|
"outputs": [{ "name": "image", "type": "IMAGE", "links": null, "slot_index": 0 }],
|
||||||
|
"properties": { "Node name for S&R": "output.preview_image" },
|
||||||
|
"widgets_values": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[1, 1, 0, 2, 0, "STRING"],
|
||||||
|
[2, 2, 0, 3, 0, "IMAGE"]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": { "ds": { "scale": 0.9, "offset": [80, 80] } },
|
||||||
|
"version": 0.4
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn text_to_video_workflow_graph() -> Value {
|
||||||
|
json!({
|
||||||
|
"last_node_id": 4,
|
||||||
|
"last_link_id": 3,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "input.text",
|
||||||
|
"pos": [80, 150],
|
||||||
|
"size": [480, 260],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [{ "name": "text", "type": "STRING", "links": [1], "slot_index": 0 }],
|
||||||
|
"properties": { "Node name for S&R": "input.text" },
|
||||||
|
"widgets_values": ["cinematic aerial shot of a glass bottle floating through a purple galaxy nebula, elegant camera motion, dreamy lighting"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "boosapi.text_to_video",
|
||||||
|
"pos": [600, 120],
|
||||||
|
"size": [420, 300],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "prompt", "type": "STRING", "link": 1 }],
|
||||||
|
"outputs": [
|
||||||
|
{ "name": "task_id", "type": "STRING", "links": [2], "slot_index": 0 },
|
||||||
|
{ "name": "video", "type": "VIDEO", "links": [3], "slot_index": 1 },
|
||||||
|
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 2 }
|
||||||
|
],
|
||||||
|
"properties": { "Node name for S&R": "boosapi.text_to_video" },
|
||||||
|
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL, "16:9", 5, "720p", 24, true, 5, 180]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "boosapi.video_status",
|
||||||
|
"pos": [1080, 95],
|
||||||
|
"size": [360, 140],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "task_id", "type": "STRING", "link": 2 }],
|
||||||
|
"outputs": [
|
||||||
|
{ "name": "video", "type": "VIDEO", "links": null, "slot_index": 0 },
|
||||||
|
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 1 }
|
||||||
|
],
|
||||||
|
"properties": { "Node name for S&R": "boosapi.video_status" },
|
||||||
|
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "output.preview_video",
|
||||||
|
"pos": [1080, 285],
|
||||||
|
"size": [340, 120],
|
||||||
|
"flags": {},
|
||||||
|
"order": 3,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "video", "type": "VIDEO", "link": 3 }],
|
||||||
|
"outputs": [{ "name": "video", "type": "VIDEO", "links": null, "slot_index": 0 }],
|
||||||
|
"properties": { "Node name for S&R": "output.preview_video" },
|
||||||
|
"widgets_values": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[1, 1, 0, 2, 0, "STRING"],
|
||||||
|
[2, 2, 0, 3, 0, "STRING"],
|
||||||
|
[3, 2, 1, 4, 0, "VIDEO"]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": { "ds": { "scale": 0.85, "offset": [80, 80] } },
|
||||||
|
"version": 0.4
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn port_name(node_type: &str, index: usize) -> &'static str {
|
||||||
|
known_output_ports(node_type)
|
||||||
|
.get(index)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_else(|| known_output_ports(node_type)[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn source_node_type(prompt: &Value, node_id: &str) -> Option<String> {
|
||||||
|
prompt
|
||||||
|
.as_object()?
|
||||||
|
.get(node_id)?
|
||||||
|
.get("class_type")?
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_prompt_payload(prompt: &Value) -> AppResult<WorkflowGraph> {
|
||||||
|
let nodes = prompt
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::BadRequest("prompt must be an object".into()))?;
|
||||||
|
|
||||||
|
let mut workflow_nodes = Vec::with_capacity(nodes.len());
|
||||||
|
let mut edges = Vec::new();
|
||||||
|
|
||||||
|
for (node_id, raw_node) in nodes {
|
||||||
|
let node_type = raw_node
|
||||||
|
.get("class_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| AppError::BadRequest(format!("node {node_id} missing class_type")))?;
|
||||||
|
let inputs = raw_node.get("inputs").and_then(Value::as_object);
|
||||||
|
let mut params = Map::new();
|
||||||
|
|
||||||
|
if let Some(map) = inputs {
|
||||||
|
for (input_key, input_value) in map {
|
||||||
|
if let Some((source_id, output_index)) = prompt_link(input_value) {
|
||||||
|
let source_type = source_node_type(prompt, &source_id)
|
||||||
|
.unwrap_or_else(|| "input.text".to_string());
|
||||||
|
edges.push(crate::workflow::models::WorkflowEdge {
|
||||||
|
from: format!("{source_id}.{}", port_name(&source_type, output_index)),
|
||||||
|
to: format!("{node_id}.{input_key}"),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
params.insert(input_key.clone(), input_value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow_nodes.push(WorkflowNode {
|
||||||
|
id: node_id.clone(),
|
||||||
|
node_type: node_type.to_string(),
|
||||||
|
position: raw_node
|
||||||
|
.get("_meta")
|
||||||
|
.and_then(|meta| meta.get("pos"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!({ "x": 80, "y": 120 })),
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let graph = WorkflowGraph {
|
||||||
|
version: "1.0".to_string(),
|
||||||
|
name: Some("ComfyUI workflow".to_string()),
|
||||||
|
nodes: workflow_nodes,
|
||||||
|
edges,
|
||||||
|
};
|
||||||
|
graph::validate_and_sort(&graph)?;
|
||||||
|
Ok(graph)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn prompt_link(value: &Value) -> Option<(String, usize)> {
|
||||||
|
let array = value.as_array()?;
|
||||||
|
let source = array.first()?.as_str()?.to_string();
|
||||||
|
let index = array.get(1).and_then(Value::as_u64).unwrap_or_default() as usize;
|
||||||
|
Some((source, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn node_type_def_to_comfy(def: &NodeTypeDefinition) -> Value {
|
||||||
|
let mut required = Map::new();
|
||||||
|
let mut optional = Map::new();
|
||||||
|
|
||||||
|
if let Some(inputs) = def.inputs.as_object() {
|
||||||
|
for (name, spec) in inputs {
|
||||||
|
required.insert(name.clone(), input_spec_to_comfy(spec, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(params) = def.params.as_object() {
|
||||||
|
for (name, spec) in params {
|
||||||
|
optional.insert(name.clone(), input_spec_to_comfy(spec, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"name": def.node_type,
|
||||||
|
"display_name": def.title,
|
||||||
|
"description": def.description,
|
||||||
|
"category": def.category,
|
||||||
|
"output_node": def.category.eq_ignore_ascii_case("output"),
|
||||||
|
"python_module": "boosapi.workflow",
|
||||||
|
"input": {
|
||||||
|
"required": required,
|
||||||
|
"optional": optional,
|
||||||
|
},
|
||||||
|
"output": output_specs(def),
|
||||||
|
"output_name": known_output_ports(def.node_type),
|
||||||
|
"output_is_list": output_is_list_specs(def),
|
||||||
|
"output_tooltips": vec![""; known_output_ports(def.node_type).len()],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn input_spec_to_comfy(value: &Value, required: bool) -> Value {
|
||||||
|
let ty = value
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("STRING");
|
||||||
|
let default = value.get("default").cloned();
|
||||||
|
let options = value
|
||||||
|
.get("options")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut props = Map::new();
|
||||||
|
if let Some(default) = default {
|
||||||
|
props.insert("default".into(), default);
|
||||||
|
}
|
||||||
|
if value.get("ui").and_then(Value::as_str) == Some("textarea") {
|
||||||
|
props.insert("multiline".into(), Value::Bool(true));
|
||||||
|
props.insert("dynamicPrompts".into(), Value::Bool(false));
|
||||||
|
}
|
||||||
|
if required {
|
||||||
|
props.insert("forceInput".into(), Value::Bool(true));
|
||||||
|
}
|
||||||
|
if ty == "CONDITIONING" {
|
||||||
|
return json!(["CONDITIONING", props]);
|
||||||
|
}
|
||||||
|
match ty {
|
||||||
|
"COMBO" => {
|
||||||
|
let choices = if options.is_empty() {
|
||||||
|
props
|
||||||
|
.get("default")
|
||||||
|
.cloned()
|
||||||
|
.map(|value| vec![value])
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
options
|
||||||
|
};
|
||||||
|
json!([choices, props])
|
||||||
|
}
|
||||||
|
"BOOLEAN" => json!(["BOOLEAN", props]),
|
||||||
|
"NUMBER" | "FLOAT" => json!(["FLOAT", props]),
|
||||||
|
"INT" => json!(["INT", props]),
|
||||||
|
"JSON" => json!(["STRING", props]),
|
||||||
|
"FILE" => json!(["STRING", props]),
|
||||||
|
"STRING" => json!(["STRING", props]),
|
||||||
|
_ => json!([ty, props]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn output_specs(def: &NodeTypeDefinition) -> Vec<Value> {
|
||||||
|
let mut outputs = Vec::new();
|
||||||
|
match def.node_type {
|
||||||
|
"input.text" => outputs.push(json!("STRING")),
|
||||||
|
"boosapi.chat_completion" => outputs.extend([json!("STRING"), json!("JSON")]),
|
||||||
|
"boosapi.prompt_rewrite" => outputs.extend([json!("STRING"), json!("JSON")]),
|
||||||
|
"boosapi.text_to_image" => outputs.extend([json!("IMAGE"), json!("IMAGE"), json!("JSON")]),
|
||||||
|
"boosapi.text_to_video" | "boosapi.image_to_video" => {
|
||||||
|
outputs.extend([json!("STRING"), json!("VIDEO"), json!("JSON")])
|
||||||
|
}
|
||||||
|
"boosapi.video_status" => outputs.extend([json!("VIDEO"), json!("JSON")]),
|
||||||
|
"boosapi.comfyui_workflow" => {
|
||||||
|
outputs.extend([json!("JSON"), json!("STRING"), json!("JSON")])
|
||||||
|
}
|
||||||
|
"comfyui.submit_workflow" => {
|
||||||
|
outputs.extend([json!("STRING"), json!("JSON"), json!("JSON")])
|
||||||
|
}
|
||||||
|
"CheckpointLoaderSimple" => outputs.extend([json!("MODEL"), json!("CLIP"), json!("VAE")]),
|
||||||
|
"CLIPTextEncode" => outputs.push(json!("CONDITIONING")),
|
||||||
|
"EmptyLatentImage" | "KSampler" => outputs.push(json!("LATENT")),
|
||||||
|
"VAEDecode" | "SaveImage" | "PreviewImage" => outputs.push(json!("IMAGE")),
|
||||||
|
"output.preview_image" => outputs.push(json!("IMAGE")),
|
||||||
|
"output.preview_video" => outputs.push(json!("VIDEO")),
|
||||||
|
"output.save_file" => outputs.push(json!("FILE")),
|
||||||
|
"util.text_join" => outputs.push(json!("STRING")),
|
||||||
|
"util.json_extract" => outputs.extend([json!("JSON"), json!("STRING")]),
|
||||||
|
_ => outputs.push(json!("STRING")),
|
||||||
|
}
|
||||||
|
outputs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn output_is_list_specs(def: &NodeTypeDefinition) -> Vec<bool> {
|
||||||
|
match def.node_type {
|
||||||
|
"boosapi.text_to_image" => vec![false, true, false],
|
||||||
|
_ => vec![false; known_output_ports(def.node_type).len()],
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user