feat(workflow): 工作流提供方 - 集成 RunningHub 与 ComfyUI Bridge

This commit is contained in:
yepwait
2026-08-26 15:02:24 +08:00
committed by ddCat
parent 6141bd688c
commit 86fc3fda2f
82 changed files with 11405 additions and 248 deletions

View File

@@ -26,6 +26,41 @@ Connect token: xxxxxx
在画布右上角点击 `Agent`,填入地址和 token 后连接。
## ComfyUI Bridge
Bridge 让云端后端把工作流请求投递到运行 Bridge 的机器,再由该进程访问 ComfyUI。`--comfy` 可填写本机 `127.0.0.1:8188`、局域网地址或公网 HTTP/HTTPS 地址,只要运行 Bridge 的机器能够访问即可;网页和云端不直接访问该地址。
部署镜像会用 Go 标准库把 Bridge 交叉编译成站点根目录下的 Windows x64、Linux x64 和 Linux ARM64 原生程序。它们不捆绑 Node.js/Bun 运行时,运行 Bridge 的机器不需要安装 Node.js、npm 或项目源码;画布“设置 → ComfyUI Bridge”会按平台生成带当前地址、令牌和工作流目录的完整命令。
```powershell
$bridgeDir = Join-Path $env:LOCALAPPDATA "OpenAICanvas"
New-Item -ItemType Directory -Force -Path $bridgeDir | Out-Null
$bridgeFile = Join-Path $bridgeDir "OpenAICanvas-ComfyBridge.exe"
Invoke-WebRequest "https://你的画布服务地址/OpenAICanvas-ComfyBridge.exe" -OutFile $bridgeFile
$bridgeStream = [System.IO.File]::OpenRead($bridgeFile)
try { $bridgeHeader0 = $bridgeStream.ReadByte(); $bridgeHeader1 = $bridgeStream.ReadByte() } finally { $bridgeStream.Dispose() }
if ($bridgeHeader0 -ne 0x4D -or $bridgeHeader1 -ne 0x5A) { throw "Bridge 下载失败:服务器未返回 Windows 可执行程序,请联系管理员重新部署 Bridge" }
& $bridgeFile --server "https://你的画布服务地址" --token "你的 Bridge Token" --comfy "http://127.0.0.1:8188" --workflow-dir "D:\\ComfyUI\\workflows"
```
Linux x64 云服务器(ARM64 服务器请把文件名中的 `amd64` 改为 `arm64`):
```bash
bridge_dir="./openai-canvas-bridge"
mkdir -p "$bridge_dir"
curl --fail --location "https://你的画布服务地址/OpenAICanvas-ComfyBridge-linux-amd64" --output "$bridge_dir/OpenAICanvas-ComfyBridge-linux-amd64"
chmod +x "$bridge_dir/OpenAICanvas-ComfyBridge-linux-amd64"
"$bridge_dir/OpenAICanvas-ComfyBridge-linux-amd64" --server "https://你的画布服务地址" --token "你的 Bridge Token" --comfy "http://127.0.0.1:8188" --workflow-dir "/opt/ComfyUI/user/default/workflows"
```
如果 ComfyUI 和 Bridge 都在云端 Linux,`--comfy` 建议使用 `http://127.0.0.1:8188`,不要把 ComfyUI 的 8188 端口暴露给公网。生产环境可将上面的 Bridge 命令配置为 systemd 服务,确保云服务器重启后自动恢复;Bridge Token 应通过受限的环境文件或服务管理器注入,不要写进公开脚本。
工作流可以在“设置 → ComfyUI Bridge”中选择 Bridge 发现的 API JSON、粘贴 ComfyUI API 格式 JSON,也可以只填写 `workflowId`,让 Bridge 从 `--workflow-dir` 下读取同名 `.json` 文件。Bridge 会分析本机工作流,并把可配置字段回传给可视化映射面板;面板可设置任务提示词、固定文本、参考图片/视频/音频顺序、蒙版、尺寸、宽高、数量、质量、视频时长、音频参数、随机 Seed 以及必选/可选规则。未保存字段映射时,Bridge 也会在执行前按同一规则分析工作流;正向 Prompt 优先于负向文本节点。参考素材会先上传到本机 ComfyUI,多余素材或缺失的必选槽位会直接报错,可选媒体缺失时不会继续使用工作流模板中的旧文件名。映射同时兼容原项目配置里的 `node`、`input`、`default` 和 `bind_prompt` 字段。RunningHub 工作流在独立的“设置 → RunningHub 工作流”中管理,不属于模型渠道。
Bridge Token 只用于主动轮询和回传结果,不要提交到 Git 或写入公开日志。Bridge 执行长任务时每 30 秒发送心跳;远程参考素材只允许解析到公网地址,`127.0.0.1`、localhost、私网和链路本地地址会被拒绝。请求、领取状态和完成结果由后端持久化:后端重启后未领取请求会继续投递,已领取请求的迟到结果也能由恢复后的任务读取;Bridge 进程自身在执行中退出时,本机 ComfyUI 任务仍不能自动接管。工作流请求 JSON 上限为 16MB,结果 JSON(含 base64 媒体)上限为 64MB,超大视频建议改为资源上传链路。
Bridge 首次使用网页生成的命令启动后,会把服务器地址、Token、ComfyUI 地址和工作流目录保存到本机用户配置目录(权限为仅当前用户可读)。之后重启或断线恢复可以直接再次启动 Bridge 程序,不需要重新填写这些参数;如需更换连接目标,再使用带参数的启动命令覆盖旧配置。
Codex app 插件会读取启动输出里的 Local URL 和 Connect token,并直接打开画布网页地址;Canvas Agent 不负责生成画布打开 URL。
Canvas Agent 默认只监听 `127.0.0.1`。网页第一次带正确 token 连接后,Canvas Agent 会记录该网页 Origin;之后其他 Origin 不能复用这个本地 Agent,除非用户清理 `~/.infinite-canvas/canvas-agent.json` 里的 `origins`。

View File

@@ -0,0 +1,3 @@
module open-ai-canvas/comfy-bridge
go 1.24

View File

@@ -0,0 +1,668 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
type jsonMap map[string]any
type bridgeOptions struct {
Server string `json:"server"`
Token string `json:"token"`
Comfy string `json:"comfy"`
WorkflowDir string `json:"workflowDir"`
PollSeconds int `json:"pollSeconds"`
}
type bridgeRequest struct {
ID string `json:"id"`
TaskID string `json:"taskId"`
BridgeID string `json:"bridgeId"`
Payload jsonMap `json:"payload"`
}
var bridgeHTTP = &http.Client{Timeout: 70 * time.Second}
var comfyHTTP = &http.Client{Timeout: 10 * time.Minute}
func main() {
options, err := parseOptions(os.Args[1:])
if err != nil {
fatal(err)
}
fmt.Printf("ComfyUI Bridge connecting to %s\n", options.Server)
go heartbeatLoop(options)
for {
request, err := pollRequest(options)
if err != nil {
fmt.Fprintf(os.Stderr, "ComfyUI Bridge: %v\n", err)
time.Sleep(2500 * time.Millisecond)
continue
}
if request != nil {
executeRequest(options, *request)
}
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func parseOptions(args []string) (bridgeOptions, error) {
value := func(name string) string {
for index, item := range args {
if item == name && index+1 < len(args) {
return strings.TrimSpace(args[index+1])
}
}
return ""
}
options := bridgeOptions{PollSeconds: 20}
if persisted, err := loadBridgeOptions(); err != nil {
return options, err
} else if persisted != nil {
options = *persisted
}
if raw := value("--server"); raw != "" {
options.Server = strings.TrimRight(raw, "/")
}
if raw := value("--token"); raw != "" {
options.Token = raw
}
if raw := value("--comfy"); raw != "" {
options.Comfy = strings.TrimRight(raw, "/")
}
if raw := value("--workflow-dir"); raw != "" {
options.WorkflowDir = raw
}
if raw := value("--poll-seconds"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
options.PollSeconds = parsed
}
}
if options.Comfy == "" {
options.Comfy = "http://127.0.0.1:8188"
}
if options.Server == "" || options.Token == "" {
return options, errors.New("需要 --server 和 --token;首次启动请使用网页生成的启动命令")
}
if options.PollSeconds < 0 {
options.PollSeconds = 0
}
if options.PollSeconds > 60 {
options.PollSeconds = 60
}
if err := validateHTTPURL(options.Comfy, "--comfy"); err != nil {
return options, err
}
if err := validateHTTPURL(options.Server, "--server"); err != nil {
return options, err
}
if err := saveBridgeOptions(options); err != nil {
return options, fmt.Errorf("保存 Bridge 本机配置失败:%w", err)
}
return options, nil
}
func bridgeOptionsPath() (string, error) {
if runtime.GOOS == "windows" {
base := strings.TrimSpace(os.Getenv("LOCALAPPDATA"))
if base == "" {
return "", errors.New("LOCALAPPDATA 未设置,无法保存 Bridge 本机配置")
}
return filepath.Join(base, "OpenAICanvas", "comfy-bridge.json"), nil
}
base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("无法确定用户配置目录:%w", err)
}
base = filepath.Join(home, ".config")
}
return filepath.Join(base, "open-ai-canvas", "comfy-bridge.json"), nil
}
func loadBridgeOptions() (*bridgeOptions, error) {
path, err := bridgeOptionsPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("读取 Bridge 本机配置失败:%w", err)
}
var options bridgeOptions
if err := json.Unmarshal(data, &options); err != nil {
return nil, fmt.Errorf("Bridge 本机配置格式无效:%w", err)
}
return &options, nil
}
func saveBridgeOptions(options bridgeOptions) error {
path, err := bridgeOptionsPath()
if err != nil {
return err
}
data, err := json.MarshalIndent(options, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
temporary := path + ".tmp"
if err := os.WriteFile(temporary, data, 0600); err != nil {
return err
}
if err := os.Rename(temporary, path); err != nil {
_ = os.Remove(temporary)
return err
}
return nil
}
func validateHTTPURL(value, label string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil {
return fmt.Errorf("%s 只支持不含账号密码的完整 HTTP/HTTPS 地址", label)
}
return nil
}
func pollRequest(options bridgeOptions) (*bridgeRequest, error) {
var data struct {
Request *bridgeRequest `json:"request"`
}
endpoint := fmt.Sprintf("%s/api/comfy-bridge/poll?wait=%d", options.Server, options.PollSeconds)
if err := requestBridgeJSON(options, http.MethodGet, endpoint, nil, &data); err != nil {
return nil, err
}
return data.Request, nil
}
func executeRequest(options bridgeOptions, request bridgeRequest) {
completion := jsonMap{"requestId": request.ID, "status": "succeeded"}
result, err := runComfyRequest(options, request.Payload)
if err != nil {
completion["status"] = "failed"
completion["error"] = err.Error()
} else {
completion["result"] = result
}
if err := submitResultWithRetry(options, completion); err != nil {
fmt.Fprintf(os.Stderr, "ComfyUI Bridge 结果回传失败(请求 %s):%v\n", request.ID, err)
}
}
func runComfyRequest(options bridgeOptions, payload jsonMap) (jsonMap, error) {
mode := stringValue(payload["mode"])
if mode == "" {
mode = "image"
}
workflow, err := loadWorkflow(options, payload)
if err != nil {
return nil, err
}
fields := sliceValue(payload["workflowFields"])
if len(fields) == 0 {
fields = discoverWorkflowFields(workflow, mode)
payload["workflowFields"] = fields
}
if err := validateWorkflowMediaInputs(fields, payload); err != nil {
return nil, err
}
files, err := uploadReferences(options.Comfy, payload)
if err != nil {
return nil, err
}
if err := applyWorkflowFields(workflow, payload, files); err != nil {
return nil, err
}
applyPromptFallback(workflow, stringValue(payload["prompt"]), fields)
stripCanvasAnnotationNodes(workflow)
promptID, err := submitPrompt(options.Comfy, workflow)
if err != nil {
return nil, err
}
history, err := waitForHistory(options.Comfy, promptID)
if err != nil {
return nil, err
}
return collectResult(options.Comfy, mode, history)
}
func heartbeatLoop(options bridgeOptions) {
heartbeat := func() {
capabilities, err := bridgeCapabilities(options)
if err != nil {
fmt.Fprintf(os.Stderr, "ComfyUI Bridge 能力扫描失败:%v\n", err)
return
}
if err := requestBridgeJSON(options, http.MethodPost, options.Server+"/api/comfy-bridge/heartbeat", jsonMap{"capabilities": capabilities}, nil); err != nil {
fmt.Fprintf(os.Stderr, "ComfyUI Bridge 心跳失败:%v\n", err)
}
}
heartbeat()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
heartbeat()
}
}
func bridgeCapabilities(options bridgeOptions) (jsonMap, error) {
workflows, err := listWorkflowFiles(options)
if err != nil {
return nil, err
}
return jsonMap{
"comfyUrl": options.Comfy,
"workflowDir": workflowRoot(options),
"comfyOnline": checkComfyUI(options.Comfy),
"workflows": workflows,
}, nil
}
func workflowRoot(options bridgeOptions) string {
if options.WorkflowDir != "" {
absolute, _ := filepath.Abs(options.WorkflowDir)
return absolute
}
current, _ := os.Getwd()
return filepath.Join(current, "workflows")
}
func listWorkflowFiles(options bridgeOptions) ([]jsonMap, error) {
root := workflowRoot(options)
output := make([]jsonMap, 0)
err := filepath.WalkDir(root, func(filePath string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
if errors.Is(walkErr, os.ErrNotExist) {
return nil
}
return walkErr
}
if len(output) >= 500 || entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".json") || strings.HasSuffix(strings.ToLower(entry.Name()), ".config.json") {
return nil
}
relative, _ := filepath.Rel(root, filePath)
workflowID := filepath.ToSlash(relative)
item := jsonMap{"workflowId": workflowID, "title": strings.TrimSuffix(workflowID, filepath.Ext(workflowID)), "fields": []any{}, "format": "ui"}
if workflow, err := readWorkflowFile(filePath); err == nil {
item["fields"] = discoverWorkflowFields(workflow, "")
if graph := discoverWorkflowGraph(workflow); len(graph) > 0 {
item["workflowGraph"] = graph
}
if converted := convertComfyCanvasWorkflow(workflow); len(converted) > 0 {
item["workflowJson"] = converted
item["fields"] = discoverWorkflowFields(converted, "")
item["format"] = "api"
}
// UI JSON 只有画布布局,不能当作 API prompt 提交;API JSON 才回传
// 完整拓扑,网页才能绘制节点和连线。画布 JSON 会先转换为 API JSON,
// 同时保留 workflowGraph 作为轻量预览,避免提交布局字段。
if isComfyAPIWorkflow(workflow) {
item["workflowJson"] = workflow
item["format"] = "api"
}
}
output = append(output, item)
return nil
})
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
sortWorkflowList(output)
return output, nil
}
func isComfyAPIWorkflow(workflow jsonMap) bool {
if len(workflow) == 0 {
return false
}
for _, rawNode := range workflow {
node, ok := mapValue(rawNode)
if !ok || strings.TrimSpace(stringValue(node["class_type"])) == "" {
continue
}
if _, ok := mapValue(node["inputs"]); ok {
return true
}
}
return false
}
func loadWorkflow(options bridgeOptions, payload jsonMap) (jsonMap, error) {
if raw, ok := mapValue(payload["workflowJson"]); ok && len(raw) > 0 {
return cloneMap(unwrapWorkflow(raw))
}
workflowID := strings.TrimSpace(stringValue(payload["workflowId"]))
if workflowID == "" {
return nil, errors.New("任务没有 workflowJson 或 workflowId")
}
if !strings.HasSuffix(strings.ToLower(workflowID), ".json") {
workflowID += ".json"
}
root := workflowRoot(options)
filePath, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(workflowID)))
if err != nil {
return nil, err
}
rootAbs, _ := filepath.Abs(root)
relative, err := filepath.Rel(rootAbs, filePath)
if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return nil, errors.New("workflowId 路径不合法")
}
realRoot, err := filepath.EvalSymlinks(rootAbs)
if err != nil {
return nil, err
}
realFile, err := filepath.EvalSymlinks(filePath)
if err != nil {
return nil, err
}
realRelative, err := filepath.Rel(realRoot, realFile)
if err != nil || realRelative == "." || realRelative == ".." || strings.HasPrefix(realRelative, ".."+string(filepath.Separator)) || filepath.IsAbs(realRelative) {
return nil, errors.New("workflowId 不能指向 workflows 目录之外")
}
return readWorkflowFile(realFile)
}
func readWorkflowFile(filePath string) (jsonMap, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
var value jsonMap
if err := json.Unmarshal(data, &value); err != nil {
return nil, err
}
return unwrapWorkflow(value), nil
}
func unwrapWorkflow(value jsonMap) jsonMap {
current := value
for index := 0; index < 3; index++ {
if nested, ok := mapValue(firstValue(current["prompt"], current["workflow"])); ok {
current = nested
continue
}
if text := stringValue(firstValue(current["prompt"], current["workflow"])); text != "" {
var nested jsonMap
if json.Unmarshal([]byte(text), &nested) == nil {
current = nested
continue
}
}
if raw, ok := mapValue(current["raw"]); ok {
current = raw
continue
}
break
}
return current
}
func submitPrompt(comfy string, workflow jsonMap) (string, error) {
body := jsonMap{"prompt": workflow, "client_id": randomID()}
var response jsonMap
if err := requestComfyJSON(http.MethodPost, comfy+"/prompt", body, &response); err != nil {
return "", fmt.Errorf("ComfyUI 提交工作流失败:%w", err)
}
promptID := stringValue(response["prompt_id"])
if promptID == "" {
return "", errors.New("ComfyUI 未返回 prompt_id")
}
return promptID, nil
}
func waitForHistory(comfy, promptID string) (jsonMap, error) {
deadline := time.Now().Add(30 * time.Minute)
emptyOutputPolls := 0
for time.Now().Before(deadline) {
var payload jsonMap
if err := requestComfyJSON(http.MethodGet, comfy+"/history/"+url.PathEscape(promptID), nil, &payload); err == nil {
if history, ok := mapValue(payload[promptID]); ok {
completed, err := completedHistory(history)
if err != nil {
return nil, err
}
if completed {
return history, nil
}
if historyCompletedWithoutOutputs(history) {
emptyOutputPolls++
if emptyOutputPolls >= 10 {
return nil, errors.New("ComfyUI 工作流已完成,但没有返回可下载产物;请检查启用的输出节点")
}
} else {
emptyOutputPolls = 0
}
}
}
time.Sleep(1500 * time.Millisecond)
}
return nil, errors.New("ComfyUI 任务超时")
}
func historyCompletedWithoutOutputs(history jsonMap) bool {
status, _ := mapValue(history["status"])
statusText := strings.ToLower(firstNonEmpty(stringValue(status["status_str"]), stringValue(status["status"])))
completed := boolValue(status["completed"]) || statusText == "success"
outputs, _ := mapValue(history["outputs"])
return completed && statusText == "success" && len(outputs) == 0
}
func completedHistory(history jsonMap) (bool, error) {
status, _ := mapValue(history["status"])
statusText := strings.ToLower(firstNonEmpty(stringValue(status["status_str"]), stringValue(status["status"])))
completed := boolValue(status["completed"]) || statusText == "success" || statusText == "error" || statusText == "failed"
outputs, _ := mapValue(history["outputs"])
if len(outputs) > 0 && statusText == "" {
return true, nil
}
if !completed {
return false, nil
}
if statusText == "error" || statusText == "failed" {
return false, fmt.Errorf("ComfyUI 工作流执行失败:%.500s", stringValue(firstValue(status["messages"], statusText)))
}
// ComfyUI can publish status=success before persisting the output map.
// Keep polling so a transient empty output is not reported as a failed task.
if len(outputs) == 0 {
return false, nil
}
return true, nil
}
func submitResultWithRetry(options bridgeOptions, body jsonMap) error {
var last error
for attempt := 0; attempt < 3; attempt++ {
last = requestBridgeJSON(options, http.MethodPost, options.Server+"/api/comfy-bridge/result", body, nil)
if last == nil {
return nil
}
if attempt < 2 {
time.Sleep(time.Duration(attempt+1) * time.Second)
}
}
return last
}
func requestBridgeJSON(options bridgeOptions, method, endpoint string, body any, target any) error {
return requestJSON(bridgeHTTP, method, endpoint, body, target, func(request *http.Request) {
request.Header.Set("Authorization", "Bearer "+options.Token)
})
}
func requestComfyJSON(method, endpoint string, body any, target any) error {
return requestJSON(comfyHTTP, method, endpoint, body, target, nil)
}
func requestJSON(client *http.Client, method, endpoint string, body any, target any, headers func(*http.Request)) error {
var reader io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(context.Background(), method, endpoint, reader)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
if headers != nil {
headers(request)
}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
data, err := io.ReadAll(io.LimitReader(response.Body, 64<<20))
if err != nil {
return err
}
if client == bridgeHTTP {
var envelope struct {
Code any `json:"code"`
Data json.RawMessage `json:"data"`
Msg string `json:"msg"`
Error string `json:"error"`
}
if json.Unmarshal(data, &envelope) != nil {
return fmt.Errorf("Bridge 服务返回非 JSON:HTTP %d", response.StatusCode)
}
if response.StatusCode < 200 || response.StatusCode >= 300 || numberValue(envelope.Code) != 0 {
return errors.New(firstNonEmpty(envelope.Msg, envelope.Error, fmt.Sprintf("Bridge 服务 HTTP %d", response.StatusCode)))
}
if target != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
return json.Unmarshal(envelope.Data, target)
}
return nil
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("HTTP %d: %.500s", response.StatusCode, string(data))
}
if target != nil {
return json.Unmarshal(data, target)
}
return nil
}
func checkComfyUI(comfy string) bool {
client := &http.Client{Timeout: 5 * time.Second}
response, err := client.Get(comfy + "/system_stats")
if err != nil {
return false
}
response.Body.Close()
return response.StatusCode >= 200 && response.StatusCode < 300
}
func randomID() string {
data := make([]byte, 16)
if _, err := rand.Read(data); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(data)
}
func cloneMap(value jsonMap) (jsonMap, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
var cloned jsonMap
if err := json.Unmarshal(data, &cloned); err != nil {
return nil, err
}
return cloned, nil
}
func mapValue(value any) (jsonMap, bool) {
if item, ok := value.(jsonMap); ok {
return item, true
}
item, ok := value.(map[string]any)
return jsonMap(item), ok
}
func sliceValue(value any) []any {
items, _ := value.([]any)
return items
}
func stringValue(value any) string {
if value == nil {
return ""
}
if text, ok := value.(string); ok {
return strings.TrimSpace(text)
}
data, _ := json.Marshal(value)
return strings.TrimSpace(string(data))
}
func boolValue(value any) bool {
result, _ := value.(bool)
return result
}
func numberValue(value any) float64 {
switch item := value.(type) {
case float64:
return item
case json.Number:
result, _ := item.Float64()
return result
case string:
result, _ := strconv.ParseFloat(item, 64)
return result
default:
return 0
}
}
func firstValue(values ...any) any {
for _, value := range values {
if value != nil && stringValue(value) != "" {
return value
}
}
return nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@@ -0,0 +1,381 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/http"
"net/textproto"
"net/url"
"path/filepath"
"strings"
"time"
)
const maxReferenceBytes int64 = 64 << 20
type outputItem struct {
Filename string
Subfolder string
Type string
Kind string
}
func uploadReferences(comfy string, payload jsonMap) (map[string]string, error) {
files := map[string]string{}
for _, group := range []any{payload["referenceImages"], payload["referenceVideos"], payload["referenceAudios"], payload["mask"]} {
values := sliceValue(group)
if len(values) == 0 && group != nil {
values = []any{group}
}
for _, raw := range values {
media, ok := mapValue(raw)
if !ok {
continue
}
data, mimeType, err := mediaBytes(media)
if err != nil {
return nil, err
}
if len(data) == 0 {
continue
}
name := safeName(firstNonEmpty(stringValue(media["id"]), randomID())) + extensionForMime(mimeType)
uploadedName, err := uploadComfyMedia(comfy, name, mimeType, data)
if err != nil {
return nil, err
}
if id := stringValue(media["id"]); id != "" {
files[id] = uploadedName
}
}
}
return files, nil
}
func mediaBytes(media jsonMap) ([]byte, string, error) {
dataURL := stringValue(media["dataUrl"])
mimeType := firstNonEmpty(stringValue(media["mimeType"]), stringValue(media["type"]))
if strings.HasPrefix(dataURL, "data:") {
comma := strings.IndexByte(dataURL, ',')
if comma < 0 {
return nil, "", errors.New("参考素材 data URL 格式无效")
}
header := dataURL[5:comma]
if mimeType == "" {
mimeType = strings.Split(header, ";")[0]
}
data, err := base64.StdEncoding.DecodeString(dataURL[comma+1:])
if err != nil {
return nil, "", errors.New("参考素材 data URL 编码无效")
}
if int64(len(data)) > maxReferenceBytes {
return nil, "", errors.New("参考素材超过 64MB")
}
return data, firstNonEmpty(mimeType, "application/octet-stream"), nil
}
remoteURL := stringValue(media["url"])
if remoteURL == "" {
return nil, "", nil
}
if err := assertPublicRemoteURL(remoteURL); err != nil {
return nil, "", err
}
client := publicMediaClient()
response, err := client.Get(remoteURL)
if err != nil {
return nil, "", fmt.Errorf("下载远程参考素材失败:%w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, "", fmt.Errorf("下载远程参考素材失败 HTTP %d", response.StatusCode)
}
if response.ContentLength > maxReferenceBytes {
return nil, "", errors.New("远程参考素材超过 64MB")
}
data, err := io.ReadAll(io.LimitReader(response.Body, maxReferenceBytes+1))
if err != nil {
return nil, "", err
}
if int64(len(data)) > maxReferenceBytes {
return nil, "", errors.New("远程参考素材超过 64MB")
}
if mimeType == "" {
mimeType = strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0])
}
return data, firstNonEmpty(mimeType, "application/octet-stream"), nil
}
func uploadComfyMedia(comfy, name, mimeType string, data []byte) (string, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "image", "filename": name}))
header.Set("Content-Type", mimeType)
part, err := writer.CreatePart(header)
if err != nil {
return "", err
}
if _, err := part.Write(data); err != nil {
return "", err
}
if err := writer.Close(); err != nil {
return "", err
}
request, err := http.NewRequest(http.MethodPost, comfy+"/upload/image", body)
if err != nil {
return "", err
}
request.Header.Set("Content-Type", writer.FormDataContentType())
response, err := comfyHTTP.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
dataResponse, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", fmt.Errorf("ComfyUI 上传参考素材失败 HTTP %d: %.500s", response.StatusCode, dataResponse)
}
var payload jsonMap
if json.Unmarshal(dataResponse, &payload) != nil {
return "", errors.New("ComfyUI 上传响应不是有效 JSON")
}
uploadedName := stringValue(payload["name"])
if uploadedName == "" {
return "", errors.New("ComfyUI 上传响应缺少文件名")
}
return uploadedName, nil
}
func assertPublicRemoteURL(raw string) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil {
return errors.New("远程参考素材只允许无凭证的 HTTP/HTTPS 公网地址")
}
hostname := strings.ToLower(strings.Trim(parsed.Hostname(), "[]"))
if hostname == "localhost" || strings.HasSuffix(hostname, ".localhost") || strings.HasSuffix(hostname, ".local") {
return errors.New("拒绝访问本机或私网远程参考素材地址")
}
addresses, err := net.DefaultResolver.LookupIPAddr(context.Background(), hostname)
if err != nil || len(addresses) == 0 {
return errors.New("远程参考素材域名无法解析")
}
for _, address := range addresses {
if isPrivateIP(address.IP) {
return errors.New("拒绝访问解析到本机或私网的远程参考素材地址")
}
}
return nil
}
func publicMediaClient() *http.Client {
transport := &http.Transport{DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil || len(addresses) == 0 {
return nil, errors.New("远程参考素材域名无法解析")
}
for _, candidate := range addresses {
if isPrivateIP(candidate.IP) {
return nil, errors.New("拒绝访问解析到本机或私网的远程参考素材地址")
}
}
dialer := &net.Dialer{Timeout: 15 * time.Second}
return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port))
}}
return &http.Client{
Timeout: 2 * time.Minute,
Transport: transport,
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("远程参考素材重定向次数过多")
}
return assertPublicRemoteURL(request.URL.String())
},
}
}
func isPrivateIP(ip net.IP) bool {
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() {
return true
}
if ipv4 := ip.To4(); ipv4 != nil {
return ipv4[0] == 0 || (ipv4[0] == 100 && ipv4[1] >= 64 && ipv4[1] <= 127) || (ipv4[0] == 192 && ipv4[1] == 0) || (ipv4[0] == 198 && (ipv4[1] == 18 || ipv4[1] == 19)) || ipv4[0] >= 224
}
return false
}
func collectResult(comfy, mode string, history jsonMap) (jsonMap, error) {
outputs := make([]outputItem, 0)
root, _ := mapValue(history["outputs"])
for _, nodeOutput := range root {
collectOutputItems(nodeOutput, "", &outputs)
}
if len(outputs) == 0 {
return nil, errors.New("ComfyUI 没有返回可下载产物")
}
images := make([]any, 0)
var video, audio jsonMap
for _, item := range outputs {
query := url.Values{"filename": {item.Filename}, "subfolder": {item.Subfolder}, "type": {firstNonEmpty(item.Type, "output")}}
response, err := comfyHTTP.Get(comfy + "/view?" + query.Encode())
if err != nil {
continue
}
data, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<20))
response.Body.Close()
if readErr != nil || response.StatusCode < 200 || response.StatusCode >= 300 {
continue
}
mimeType := strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0])
if mimeType == "" || mimeType == "application/octet-stream" {
mimeType = mimeForName(item.Filename)
}
value := jsonMap{"dataUrl": "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), "mimeType": mimeType, "bytes": len(data), "name": item.Filename}
switch item.Kind {
case "video":
video = value
case "audio":
audio = value
case "image":
images = append(images, value)
}
}
if mode == "video" && video != nil {
return jsonMap{"mode": "video", "video": video}, nil
}
if mode == "audio" && audio != nil {
return jsonMap{"mode": "audio", "audio": audio}, nil
}
if len(images) > 0 {
return jsonMap{"mode": "image", "images": images}, nil
}
if video != nil {
return jsonMap{"mode": "video", "video": video}, nil
}
if audio != nil {
return jsonMap{"mode": "audio", "audio": audio}, nil
}
return nil, errors.New("ComfyUI 产物下载失败")
}
func collectOutputItems(value any, forcedKind string, output *[]outputItem) {
if items := sliceValue(value); len(items) > 0 {
for _, child := range items {
collectOutputItems(child, forcedKind, output)
}
return
}
item, ok := mapValue(value)
if !ok {
return
}
if filename := stringValue(item["filename"]); filename != "" {
kind := forcedKind
if kind == "" {
kind = outputKind(filename)
}
if kind != "other" {
*output = append(*output, outputItem{Filename: filename, Subfolder: stringValue(item["subfolder"]), Type: firstNonEmpty(stringValue(item["type"]), "output"), Kind: kind})
}
return
}
for key, child := range item {
kind := ""
switch key {
case "videos":
kind = "video"
case "audio", "audios":
kind = "audio"
case "images", "gifs", "files":
kind = forcedKind
}
collectOutputItems(child, kind, output)
}
}
func safeName(value string) string {
var result strings.Builder
for _, character := range value {
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '_' || character == '-' {
result.WriteRune(character)
}
if result.Len() >= 64 {
break
}
}
if result.Len() == 0 {
return "reference"
}
return result.String()
}
func extensionForMime(value string) string {
lower := strings.ToLower(value)
switch {
case strings.Contains(lower, "jpeg"):
return ".jpg"
case strings.Contains(lower, "webp"):
return ".webp"
case strings.Contains(lower, "video"):
return ".mp4"
case strings.Contains(lower, "audio"):
return ".wav"
default:
return ".png"
}
}
func mimeForName(value string) string {
extension := strings.ToLower(filepath.Ext(value))
switch extension {
case ".webm":
return "video/webm"
case ".mov":
return "video/quicktime"
case ".mp4", ".m4v", ".mkv":
return "video/mp4"
case ".wav":
return "audio/wav"
case ".ogg":
return "audio/ogg"
case ".flac":
return "audio/flac"
case ".m4a", ".aac":
return "audio/mp4"
case ".mp3":
return "audio/mpeg"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
default:
return "image/png"
}
}
func outputKind(value string) string {
extension := strings.ToLower(filepath.Ext(value))
if stringIn(extension, ".mp4", ".webm", ".mov", ".m4v", ".mkv") {
return "video"
}
if stringIn(extension, ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac") {
return "audio"
}
if stringIn(extension, ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp") {
return "image"
}
return "other"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
import { existsSync, openSync, closeSync, readSync } from "node:fs";
import path from "node:path";
const outputDir = path.resolve(import.meta.dir, "../../web/public");
const outputs = {
windows: path.join(outputDir, "OpenAICanvas-ComfyBridge.exe"),
linuxAmd64: path.join(outputDir, "OpenAICanvas-ComfyBridge-linux-amd64"),
linuxArm64: path.join(outputDir, "OpenAICanvas-ComfyBridge-linux-arm64"),
} as const;
function isWindowsExecutable(file: string) {
if (!existsSync(file)) return false;
const descriptor = openSync(file, "r");
try {
const header = Buffer.alloc(2);
return readSync(descriptor, header, 0, header.length, 0) === 2 && header[0] === 0x4d && header[1] === 0x5a;
} finally {
closeSync(descriptor);
}
}
function isLinuxExecutable(file: string) {
if (!existsSync(file)) return false;
const descriptor = openSync(file, "r");
try {
const header = Buffer.alloc(4);
return readSync(descriptor, header, 0, header.length, 0) === 4
&& header[0] === 0x7f
&& header[1] === 0x45
&& header[2] === 0x4c
&& header[3] === 0x46;
} finally {
closeSync(descriptor);
}
}
function assertBuildOutput(target: keyof typeof outputs) {
const valid = target === "windows"
? isWindowsExecutable(outputs[target])
: isLinuxExecutable(outputs[target]);
if (!valid) throw new Error(`Bridge 构建产物缺失或格式无效:${path.basename(outputs[target])}`);
}
function build(target: keyof typeof outputs, goos: string, goarch: string) {
const command = Bun.spawnSync({
cmd: ["go", "build", "-trimpath", "-ldflags=-s -w", "-o", outputs[target], "."],
cwd: path.resolve(import.meta.dir, "../native/comfy-bridge"),
env: {
...process.env,
GOOS: goos,
GOARCH: goarch,
CGO_ENABLED: "0",
GOTELEMETRY: "off",
},
stdout: "inherit",
stderr: "inherit",
});
if (command.exitCode !== 0) process.exit(command.exitCode);
assertBuildOutput(target);
}
if (process.env.CANVAS_PREBUILT_BRIDGE === "1") {
assertBuildOutput("windows");
assertBuildOutput("linuxAmd64");
assertBuildOutput("linuxArm64");
process.exit(0);
}
build("windows", "windows", "amd64");
build("linuxAmd64", "linux", "amd64");
build("linuxArm64", "linux", "arm64");