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},
|
||||
services::workspace::{self, WorkspaceAssetInput},
|
||||
workflow::{
|
||||
graph,
|
||||
models::{NodeTypeDefinition, RunContext, WorkflowGraph, WorkflowNode},
|
||||
models::{RunContext, WorkflowGraph},
|
||||
registry, storage,
|
||||
},
|
||||
AppState,
|
||||
@@ -122,6 +121,12 @@ struct WorkflowValidateQuery {
|
||||
|
||||
mod auth;
|
||||
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> {
|
||||
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 {
|
||||
let normalized = value.trim();
|
||||
!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