feat(workflow): 工作流提供方 - 集成 RunningHub 与 ComfyUI Bridge
This commit is contained in:
@@ -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`。
|
||||
|
||||
3
canvas-agent/native/comfy-bridge/go.mod
Normal file
3
canvas-agent/native/comfy-bridge/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module open-ai-canvas/comfy-bridge
|
||||
|
||||
go 1.24
|
||||
668
canvas-agent/native/comfy-bridge/main.go
Normal file
668
canvas-agent/native/comfy-bridge/main.go
Normal 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 ""
|
||||
}
|
||||
381
canvas-agent/native/comfy-bridge/media.go
Normal file
381
canvas-agent/native/comfy-bridge/media.go
Normal 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"
|
||||
}
|
||||
1055
canvas-agent/native/comfy-bridge/workflow.go
Normal file
1055
canvas-agent/native/comfy-bridge/workflow.go
Normal file
File diff suppressed because it is too large
Load Diff
71
canvas-agent/scripts/build-comfy-bridge.ts
Normal file
71
canvas-agent/scripts/build-comfy-bridge.ts
Normal 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");
|
||||
Reference in New Issue
Block a user