refactor(workflow_ui): 抽出 node_defs 子模块

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
boos
2026-06-18 14:13:27 +08:00
parent 8a55efbfb6
commit 7b1167bd75
2 changed files with 524 additions and 512 deletions
+7 -512
View File
@@ -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()
+517
View File
@@ -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()],
}
}