Files
ttstd-infinite-canvas/canvas-agent/native/comfy-bridge/workflow.go

1056 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"errors"
"fmt"
"math"
"math/rand/v2"
"regexp"
"sort"
"strconv"
"strings"
)
var numberPattern = regexp.MustCompile(`^-?\d+(\.\d+)?$`)
func sortWorkflowList(items []jsonMap) {
sort.Slice(items, func(i, j int) bool {
return strings.ToLower(stringValue(items[i]["title"])) < strings.ToLower(stringValue(items[j]["title"]))
})
}
func discoverWorkflowFields(workflow jsonMap, capability string) []any {
fields := make([]any, 0)
nodeIDs := sortedNodeIDs(workflow)
promptNode, promptField := findWorkflowPromptTarget(workflow, nodeIDs)
imageOrder, videoOrder, audioOrder := 0, 0, 0
for _, nodeID := range nodeIDs {
node, ok := mapValue(workflow[nodeID])
if !ok {
continue
}
inputs, ok := mapValue(node["inputs"])
if !ok {
continue
}
title := ""
if meta, ok := mapValue(node["_meta"]); ok {
title = stringValue(meta["title"])
}
classType := stringValue(node["class_type"])
if isCanvasAnnotationNode(classType) {
continue
}
fieldNames := make([]string, 0, len(inputs))
for fieldName := range inputs {
fieldNames = append(fieldNames, fieldName)
}
sort.Strings(fieldNames)
for _, fieldName := range fieldNames {
fieldValue := inputs[fieldName]
if isWorkflowLink(fieldValue) {
continue
}
fieldType := discoveredFieldType(fieldName, fieldValue, classType)
label := fieldName
if title != "" {
label = title + " · " + fieldName
}
field := jsonMap{"id": nodeID + "::" + fieldName, "nodeId": nodeID, "fieldName": fieldName, "fieldValue": fieldValue, "fieldType": fieldType, "label": label, "enabled": true}
if nodeID == promptNode && fieldName == promptField {
field["source"] = "prompt"
field["required"] = true
} else if source := discoveredDynamicSource(fieldName, fieldType, capability); source != "" {
field["source"] = source
}
switch normalizeSourceName(stringValue(field["source"])) {
case "referenceimage":
imageOrder++
field["imageOrder"] = imageOrder
field["sourceIndex"] = imageOrder - 1
field["required"] = imageOrder == 1
case "referencevideo":
field["sourceIndex"] = videoOrder
field["required"] = videoOrder == 0
videoOrder++
case "referenceaudio":
field["sourceIndex"] = audioOrder
field["required"] = audioOrder == 0
audioOrder++
case "mask":
field["required"] = false
}
if isSeedField(fieldName) {
field["randomEnabled"] = true
}
fields = append(fields, field)
}
}
return fields
}
// discoverWorkflowGraph 将 ComfyUI 画布 JSON 压缩成网页只读预览所需的节点和连线。
// 画布 JSON 不能直接提交给 /prompt,因此预览数据必须和可执行 API JSON 分开传输。
func discoverWorkflowGraph(workflow jsonMap) jsonMap {
rawNodes := sliceValue(workflow["nodes"])
if len(rawNodes) == 0 {
return nil
}
nodes := make([]any, 0, len(rawNodes))
nodeIDs := make(map[string]bool, len(rawNodes))
for _, raw := range rawNodes {
node, ok := mapValue(raw)
if !ok {
continue
}
nodeID := strings.TrimSpace(stringValue(node["id"]))
if nodeID == "" || nodeIDs[nodeID] {
continue
}
classType := firstNonEmpty(stringValue(node["type"]), stringValue(node["class_type"]), "Unknown")
title := stringValue(node["title"])
if title == "" {
if properties, ok := mapValue(node["properties"]); ok {
title = stringValue(properties["Node name for S&R"])
}
}
if title == "" {
title = classType
}
nodes = append(nodes, jsonMap{"id": nodeID, "title": title, "classType": classType})
nodeIDs[nodeID] = true
}
if len(nodes) == 0 {
return nil
}
edges := make([]any, 0)
edgeKeys := make(map[string]bool)
for _, raw := range sliceValue(workflow["links"]) {
items := sliceValue(raw)
if len(items) < 4 {
continue
}
from := strings.TrimSpace(stringValue(items[1]))
to := strings.TrimSpace(stringValue(items[3]))
if from == "" || to == "" || from == to || !nodeIDs[from] || !nodeIDs[to] {
continue
}
key := from + ":" + to
if edgeKeys[key] {
continue
}
edgeKeys[key] = true
edges = append(edges, jsonMap{"from": from, "to": to})
}
return jsonMap{"nodes": nodes, "edges": edges}
}
// convertComfyCanvasWorkflow 将 ComfyUI 保存的画布 JSON 转成 /prompt 接受的 API JSON。
// 画布节点的 link 使用全局 link ID,API 节点则直接保存 [来源节点, 输出槽位]。
// 只转换画布中明确声明的输入和 widget 默认值,避免把布局字段带入执行请求。
func convertComfyCanvasWorkflow(workflow jsonMap) jsonMap {
rawNodes := sliceValue(workflow["nodes"])
if len(rawNodes) == 0 {
return nil
}
links := make(map[string][]any)
for _, raw := range sliceValue(workflow["links"]) {
items := sliceValue(raw)
if len(items) < 4 {
continue
}
links[workflowNumberKey(items[0])] = items
}
converted := make(jsonMap, len(rawNodes))
for _, raw := range rawNodes {
node, ok := mapValue(raw)
if !ok {
continue
}
nodeID := strings.TrimSpace(stringValue(node["id"]))
classType := strings.TrimSpace(firstNonEmpty(stringValue(node["type"]), stringValue(node["class_type"])))
// 画布备注只用于编辑器展示,不是可执行节点。若将其转换进 /prompt,
// ComfyUI 会按自定义节点校验,缺少 MarkdownNote 时直接让整个任务失败。
if nodeID == "" || classType == "" || isCanvasAnnotationNode(classType) {
continue
}
inputs := make(jsonMap)
namedWidgets := make(jsonMap)
if rawNamed, ok := mapValue(node["widgets_values_named"]); ok {
namedWidgets = rawNamed
}
widgetValues := sliceValue(node["widgets_values"])
widgetIndex := 0
for _, rawInput := range sliceValue(node["inputs"]) {
input, ok := mapValue(rawInput)
if !ok {
continue
}
fieldName := strings.TrimSpace(stringValue(input["name"]))
if fieldName == "" {
continue
}
if linkKey := workflowNumberKey(input["link"]); linkKey != "" {
if link, exists := links[linkKey]; exists {
fromNode := strings.TrimSpace(stringValue(link[1]))
fromSlot := int(numberValue(link[2]))
if fromNode != "" {
inputs[fieldName] = []any{fromNode, fromSlot}
}
}
continue
}
widgetName := fieldName
if widget, ok := mapValue(input["widget"]); ok {
widgetName = firstNonEmpty(stringValue(widget["name"]), fieldName)
}
if value, exists := namedWidgets[widgetName]; exists {
inputs[fieldName] = value
widgetIndex++
continue
}
if _, hasWidget := input["widget"]; hasWidget && widgetIndex < len(widgetValues) {
inputs[fieldName] = widgetValues[widgetIndex]
widgetIndex++
}
}
apiNode := jsonMap{"class_type": classType, "inputs": inputs}
if title := firstNonEmpty(stringValue(node["title"]), nodeTitle(node)); title != "" {
apiNode["_meta"] = jsonMap{"title": title}
}
converted[nodeID] = apiNode
}
if len(converted) == 0 {
return nil
}
return converted
}
func isCanvasAnnotationNode(classType string) bool {
normalized := strings.ToLower(strings.TrimSpace(classType))
return normalized == "note" ||
strings.HasPrefix(normalized, "note:") ||
normalized == "markdownnote" ||
strings.HasPrefix(normalized, "markdownnote:") ||
// rgthree labels are canvas-only headings and must not be sent to /prompt.
normalized == "label (rgthree)" ||
normalized == "label" ||
normalized == "addlabel" ||
normalized == "fast groups bypasser (rgthree)" ||
normalized == "fast groups bypasser"
}
func stripCanvasAnnotationNodes(workflow jsonMap) {
for nodeID, raw := range workflow {
node, ok := mapValue(raw)
if !ok || !isCanvasAnnotationNode(firstNonEmpty(stringValue(node["class_type"]), stringValue(node["type"]))) {
continue
}
delete(workflow, nodeID)
}
}
func nodeTitle(node jsonMap) string {
if properties, ok := mapValue(node["properties"]); ok {
return stringValue(properties["Node name for S&R"])
}
return ""
}
func workflowNumberKey(value any) string {
if value == nil || strings.TrimSpace(stringValue(value)) == "" {
return ""
}
return strconv.FormatInt(int64(numberValue(value)), 10)
}
func sortedNodeIDs(workflow jsonMap) []string {
items := make([]string, 0, len(workflow))
for nodeID := range workflow {
items = append(items, nodeID)
}
sort.Slice(items, func(i, j int) bool {
left, leftErr := strconv.ParseInt(items[i], 10, 64)
right, rightErr := strconv.ParseInt(items[j], 10, 64)
if leftErr == nil && rightErr == nil {
return left < right
}
return items[i] < items[j]
})
return items
}
func findWorkflowPromptTarget(workflow jsonMap, nodeIDs []string) (string, string) {
bestNode, bestField, bestScore := "", "", math.MinInt
for _, nodeID := range nodeIDs {
node, ok := mapValue(workflow[nodeID])
if !ok {
continue
}
inputs, ok := mapValue(node["inputs"])
if !ok {
continue
}
classType := strings.ToLower(stringValue(node["class_type"]))
title := ""
if meta, ok := mapValue(node["_meta"]); ok {
title = strings.ToLower(stringValue(meta["title"]))
}
for fieldName, value := range inputs {
if _, ok := value.(string); !ok || !isPromptCandidate(fieldName, classType, title) {
continue
}
descriptor := strings.ToLower(fieldName + " " + title)
score := 0
if strings.Contains(descriptor, "negative") || strings.Contains(descriptor, "负面") || strings.Contains(descriptor, "反向") || strings.Contains(descriptor, "负向") {
score -= 100
}
if strings.Contains(descriptor, "positive") || strings.Contains(descriptor, "正面") || strings.Contains(descriptor, "正向") {
score += 20
}
if strings.Contains(classType, "cliptextencode") {
score += 5
}
if isPromptWorkflowField(jsonMap{"fieldName": fieldName}) {
score += 2
}
if bestNode == "" || score > bestScore {
bestNode, bestField, bestScore = nodeID, fieldName, score
}
}
}
return bestNode, bestField
}
func isPromptCandidate(fieldName, classType, title string) bool {
key := normalizeSourceName(fieldName)
if stringIn(key, "text", "prompt", "positiveprompt", "positive", "caption", "description") {
return true
}
if strings.ToLower(fieldName) != "value" {
return false
}
descriptor := classType + " " + title
return strings.Contains(descriptor, "cliptextencode") || strings.Contains(descriptor, "text") || strings.Contains(descriptor, "string") || strings.Contains(descriptor, "prompt") || strings.Contains(descriptor, "提示词") || strings.Contains(descriptor, "文本")
}
func discoveredFieldType(fieldName string, value any, classType string) string {
key, classKey := normalizeSourceName(fieldName), normalizeSourceName(classType)
if strings.Contains(key, "mask") {
return "IMAGE"
}
if strings.Contains(classKey, "loadimage") && stringIn(key, "image", "file", "path") {
return "IMAGE"
}
if strings.Contains(classKey, "loadvideo") && stringIn(key, "video", "file", "path") {
return "VIDEO"
}
if strings.Contains(classKey, "loadaudio") && stringIn(key, "audio", "file", "path") {
return "AUDIO"
}
if discoveredNamedDynamicSource(fieldName, "") != "" {
return scalarFieldType(value)
}
switch value.(type) {
case bool:
return "BOOLEAN"
case float64, float32, int, int64:
return "NUMBER"
}
if mediaFieldName(key, "image") || mediaValueType(value) == "IMAGE" {
return "IMAGE"
}
if mediaFieldName(key, "video") || mediaValueType(value) == "VIDEO" {
return "VIDEO"
}
if mediaFieldName(key, "audio") || mediaValueType(value) == "AUDIO" {
return "AUDIO"
}
return "TEXT"
}
func mediaValueType(value any) string {
if items := sliceValue(value); len(items) > 0 {
value = items[0]
}
text := strings.ToLower(stringValue(value))
if index := strings.IndexAny(text, "?#"); index >= 0 {
text = text[:index]
}
if hasAnySuffix(text, ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".avif") {
return "IMAGE"
}
if hasAnySuffix(text, ".mp4", ".webm", ".mov", ".m4v", ".mkv") {
return "VIDEO"
}
if hasAnySuffix(text, ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac") {
return "AUDIO"
}
return ""
}
func discoveredDynamicSource(fieldName, fieldType, capability string) string {
if source := discoveredNamedDynamicSource(fieldName, capability); source != "" {
return source
}
switch strings.ToUpper(strings.TrimSpace(fieldType)) {
case "IMAGE":
return "referenceImage"
case "VIDEO":
return "referenceVideo"
case "AUDIO":
return "referenceAudio"
default:
return ""
}
}
func discoveredNamedDynamicSource(fieldName, capability string) string {
key := normalizeSourceName(fieldName)
if strings.Contains(key, "mask") {
return "mask"
}
if source := dimensionFieldSource(key); source != "" {
return source
}
if stringIn(key, "ratio", "aspectratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio") {
return "aspectRatio"
}
if stringIn(key, "videoresolution", "videoquality", "vquality") {
return "vquality"
}
if stringIn(key, "size", "imagesize", "imageresolution") {
return "size"
}
if key == "resolution" {
if capability == "video" {
return "vquality"
}
if capability == "image" {
return "size"
}
}
aliases := map[string]string{
"batch": "count", "batchsize": "count", "count": "count", "numimages": "count", "numberofimages": "count", "imagecount": "count", "imagescount": "count",
"quality": "quality", "duration": "videoSeconds", "seconds": "videoSeconds", "durationseconds": "videoSeconds", "videoseconds": "videoSeconds", "videoduration": "videoSeconds", "videodurationseconds": "videoSeconds", "videolength": "videoSeconds", "clipduration": "videoSeconds",
"generateaudio": "videoGenerateAudio", "videogenerateaudio": "videoGenerateAudio", "watermark": "videoWatermark", "videowatermark": "videoWatermark",
"audioformat": "audioFormat", "voice": "audioVoice", "audiovoice": "audioVoice", "audiospeed": "audioSpeed", "audioinstructions": "audioInstructions",
"transparent": "transparentBackground", "transparentbackground": "transparentBackground",
}
return aliases[key]
}
var dimensionPrefixes = []string{"", "image", "video", "size", "output", "target", "latent", "frame", "canvas", "source", "resolution", "final"}
func dimensionFieldSource(key string) string {
for _, prefix := range dimensionPrefixes {
if key == prefix+"width" {
return "width"
}
if key == prefix+"height" {
return "height"
}
}
if key == "pixelwidth" || key == "widthpixels" {
return "width"
}
if key == "pixelheight" || key == "heightpixels" {
return "height"
}
return ""
}
func scalarFieldType(value any) string {
switch value.(type) {
case bool:
return "BOOLEAN"
case float64, float32, int, int64:
return "NUMBER"
default:
return "TEXT"
}
}
func mediaFieldName(key, mediaType string) bool {
if key == mediaType {
return true
}
for _, prefix := range []string{"input", "reference", "ref", "source", "init", "start", "end", "first", "last", "control", "controlnet", "style", "subject"} {
if key == prefix+mediaType {
return true
}
}
for _, suffix := range []string{"file", "path", "filename", "upload"} {
if key == mediaType+suffix {
return true
}
}
return mediaType == "image" && stringIn(key, "firstframe", "lastframe", "startframe", "endframe")
}
func isWorkflowLink(value any) bool {
items := sliceValue(value)
if len(items) != 2 || stringValue(items[0]) == "" {
return false
}
index := numberValue(items[1])
return index == math.Trunc(index)
}
func isSeedField(value string) bool {
key := normalizeSourceName(value)
return key == "seed" || strings.HasSuffix(key, "seed") || strings.Contains(key, "noiseseed")
}
func normalizeSourceName(value string) string {
replacer := strings.NewReplacer(" ", "", "_", "", "-", "")
return strings.ToLower(replacer.Replace(strings.TrimSpace(value)))
}
func validateWorkflowMediaInputs(fields []any, payload jsonMap) error {
capacities := map[string]int{"referenceimage": 0, "referencevideo": 0, "referenceaudio": 0}
hasMask := false
for _, raw := range fields {
field, ok := mapValue(raw)
if !ok || field["enabled"] == false {
continue
}
source := normalizedWorkflowFieldSource(field)
if source == "mask" {
hasMask = true
continue
}
key := canonicalMediaSource(source)
if _, ok := capacities[key]; !ok {
continue
}
index := workflowSourceIndex(field)
if index+1 > capacities[key] {
capacities[key] = index + 1
}
}
checks := []struct {
label string
key string
value any
}{{"参考图片", "referenceimage", payload["referenceImages"]}, {"参考视频", "referencevideo", payload["referenceVideos"]}, {"参考音频", "referenceaudio", payload["referenceAudios"]}}
for _, check := range checks {
count := len(sliceValue(check.value))
if count > capacities[check.key] {
return fmt.Errorf("工作流只配置了 %d 个%s槽位,但画布传入了 %d 个;请在字段映射中补齐槽位", capacities[check.key], check.label, count)
}
}
if payload["mask"] != nil && !hasMask {
return errors.New("画布传入了蒙版,但工作流没有配置蒙版字段映射")
}
return nil
}
func applyWorkflowFields(workflow jsonMap, payload jsonMap, files map[string]string) error {
fields := sliceValue(payload["workflowFields"])
removed := map[string]bool{}
for _, raw := range fields {
field, ok := mapValue(raw)
if !ok || field["enabled"] == false {
continue
}
nodeID := firstNonEmpty(stringValue(field["nodeId"]), stringValue(field["node"]))
fieldName := firstNonEmpty(stringValue(field["fieldName"]), stringValue(field["input"]))
if nodeID == "" || fieldName == "" {
continue
}
node, ok := mapValue(workflow[nodeID])
if !ok {
if boolValue(field["required"]) {
return fmt.Errorf("工作流缺少必填映射节点 %s", nodeID)
}
continue
}
inputs, ok := mapValue(node["inputs"])
if !ok {
inputs = jsonMap{}
}
source := normalizedWorkflowFieldSource(field)
value := firstValue(field["value"], field["fieldValue"], field["default"])
if source != "" {
value = resolveSource(source, field, payload, files)
} else if boolValue(field["randomEnabled"]) || boolValue(field["random_enabled"]) {
var err error
value, err = randomWorkflowValue(field)
if err != nil {
return err
}
}
if !emptyWorkflowValue(value) {
inputs[fieldName] = normalizeComfyValue(value)
} else if boolValue(field["required"]) {
return fmt.Errorf("工作流必填字段 %s.%s 缺少值", nodeID, fieldName)
} else if isMediaSource(source) {
delete(inputs, fieldName)
}
node["inputs"] = inputs
if len(inputs) == 0 && isMediaSource(source) {
removed[nodeID] = true
}
}
for nodeID := range removed {
delete(workflow, nodeID)
}
if len(removed) == 0 {
return nil
}
for _, rawNode := range workflow {
node, ok := mapValue(rawNode)
if !ok {
continue
}
inputs, ok := mapValue(node["inputs"])
if !ok {
continue
}
for fieldName, value := range inputs {
link := sliceValue(value)
if len(link) > 0 && removed[stringValue(link[0])] {
delete(inputs, fieldName)
}
}
}
return nil
}
func normalizedWorkflowFieldSource(field jsonMap) string {
source := stringValue(field["source"])
if source == "" && (boolValue(field["bindPrompt"]) || boolValue(field["bind_prompt"])) {
source = "prompt"
}
if source == "" && (boolValue(field["sourceFromUpstream"]) || boolValue(field["source_from_upstream"])) {
fieldType := strings.ToLower(firstNonEmpty(stringValue(field["fieldType"]), stringValue(field["type"])))
switch fieldType {
case "image":
source = "referenceImage"
case "video":
source = "referenceVideo"
case "audio":
source = "referenceAudio"
default:
if isPromptWorkflowField(field) {
source = "prompt"
}
}
}
return normalizeSourceName(source)
}
func randomWorkflowValue(field jsonMap) (int64, error) {
minimum, err := integerFieldBound(field["min"], 0)
if err != nil {
return 0, err
}
maximum, err := integerFieldBound(field["max"], math.MaxInt32)
if err != nil {
return 0, err
}
if maximum < minimum {
return 0, errors.New("工作流随机值最大值不能小于最小值")
}
return minimum + rand.Int64N(maximum-minimum+1), nil
}
func integerFieldBound(value any, fallback int64) (int64, error) {
if value == nil || stringValue(value) == "" {
return fallback, nil
}
parsed, err := strconv.ParseInt(strings.Trim(stringValue(value), `"`), 10, 64)
if err != nil {
return 0, fmt.Errorf("工作流随机值范围不是有效整数:%s", stringValue(value))
}
return parsed, nil
}
func resolveSource(source string, field, payload jsonMap, files map[string]string) any {
normalized := normalizeSourceName(source)
if stringIn(normalized, "prompt", "text", "positiveprompt", "positive") {
return stringValue(payload["prompt"])
}
params, _ := mapValue(payload["params"])
switch {
case stringIn(normalized, "size", "imagesize", "imageresolution"):
return params["size"]
case normalized == "resolution":
if strings.ToLower(stringValue(payload["mode"])) == "video" {
return videoResolutionValue(field, params["vquality"])
}
return params["size"]
case stringIn(normalized, "aspectratio", "ratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio"):
return aspectRatioValue(field, params["size"])
case stringIn(normalized, "sizewidth", "width", "imagewidth", "videowidth"):
return dimensionPart(stringValue(payload["mode"]), stringValue(params["size"]), stringValue(params["vquality"]), 0)
case stringIn(normalized, "sizeheight", "height", "imageheight", "videoheight"):
return dimensionPart(stringValue(payload["mode"]), stringValue(params["size"]), stringValue(params["vquality"]), 1)
}
aliases := map[string]string{
"quality": "quality", "count": "count", "batch": "count", "batchsize": "count", "videoseconds": "videoSeconds", "duration": "videoSeconds",
"videogenerateaudio": "videoGenerateAudio", "generateaudio": "videoGenerateAudio", "videowatermark": "videoWatermark", "watermark": "videoWatermark",
"audioformat": "audioFormat", "systemprompt": "systemPrompt", "transparentbackground": "transparentBackground", "audiovoice": "audioVoice", "voice": "audioVoice", "audiospeed": "audioSpeed", "audioinstructions": "audioInstructions",
}
if stringIn(normalized, "vquality", "videoquality", "videoresolution") {
return videoResolutionValue(field, params["vquality"])
}
if key := aliases[normalized]; key != "" {
return params[key]
}
if normalized == "mask" {
media, _ := mapValue(payload["mask"])
return files[stringValue(media["id"])]
}
values := payload["referenceImages"]
if strings.Contains(normalized, "video") {
values = payload["referenceVideos"]
} else if strings.Contains(normalized, "audio") {
values = payload["referenceAudios"]
}
items := sliceValue(values)
index := workflowSourceIndex(field)
if index < 0 || index >= len(items) {
return nil
}
media, _ := mapValue(items[index])
return files[stringValue(media["id"])]
}
func applyPromptFallback(workflow jsonMap, prompt string, fields []any) {
if prompt == "" {
return
}
for _, raw := range fields {
field, ok := mapValue(raw)
if !ok {
continue
}
source := normalizedWorkflowFieldSource(field)
if boolValue(field["bind_prompt"]) || stringIn(source, "prompt", "text", "positiveprompt", "positive") {
return
}
}
nodeID, fieldName := findWorkflowPromptTarget(workflow, sortedNodeIDs(workflow))
node, ok := mapValue(workflow[nodeID])
if !ok {
return
}
inputs, ok := mapValue(node["inputs"])
if ok {
inputs[fieldName] = prompt
}
}
func isPromptWorkflowField(field jsonMap) bool {
for _, key := range []string{"fieldName", "input", "label", "name"} {
if stringIn(normalizeSourceName(stringValue(field[key])), "prompt", "text", "positiveprompt", "caption", "description", "提示词", "正向提示词") {
return true
}
}
return false
}
func normalizeComfyValue(value any) any {
text, ok := value.(string)
if !ok {
return value
}
trimmed := strings.TrimSpace(text)
if strings.EqualFold(trimmed, "true") {
return true
}
if strings.EqualFold(trimmed, "false") {
return false
}
if numberPattern.MatchString(trimmed) {
if number, err := strconv.ParseFloat(trimmed, 64); err == nil {
return number
}
}
return value
}
func emptyWorkflowValue(value any) bool {
return value == nil || (func() bool { text, ok := value.(string); return ok && strings.TrimSpace(text) == "" })()
}
func workflowSourceIndex(field jsonMap) int {
if order := int(numberValue(field["imageOrder"])); order > 0 {
return order - 1
}
index := int(numberValue(field["sourceIndex"]))
if index < 0 {
return 0
}
return index
}
func canonicalMediaSource(source string) string {
switch source {
case "image", "referenceimages":
return "referenceimage"
case "video", "referencevideos":
return "referencevideo"
case "audio", "referenceaudios":
return "referenceaudio"
default:
return source
}
}
func isMediaSource(source string) bool {
return stringIn(source, "referenceimage", "referenceimages", "image", "referencevideo", "referencevideos", "video", "referenceaudio", "referenceaudios", "audio", "mask")
}
func dimensionPart(mode, size, quality string, index int) any {
if pixels, ok := pixelDimensions(size); ok {
return pixels[index]
}
var dimensions [2]int
var ok bool
if strings.EqualFold(strings.TrimSpace(mode), "video") {
dimensions, ok = videoDimensions(size, quality)
} else {
dimensions, ok = imageDimensions(size)
}
if !ok {
return nil
}
return dimensions[index]
}
func pixelDimensions(value string) ([2]int, bool) {
parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "x")
if len(parts) != 2 {
return [2]int{}, false
}
width, errWidth := strconv.Atoi(strings.TrimSpace(parts[0]))
height, errHeight := strconv.Atoi(strings.TrimSpace(parts[1]))
return [2]int{width, height}, errWidth == nil && errHeight == nil && width > 0 && height > 0
}
func aspectRatio(value string) any {
normalized := strings.ToLower(strings.TrimSpace(value))
if match := regexp.MustCompile(`^(\d+)\s*:\s*(\d+)`).FindStringSubmatch(normalized); len(match) == 3 {
width, widthErr := strconv.Atoi(match[1])
height, heightErr := strconv.Atoi(match[2])
if widthErr == nil && heightErr == nil && width > 0 && height > 0 {
divisor := gcd(width, height)
return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
}
}
normalized = regexp.MustCompile(`-(1k|2k|4k)$`).ReplaceAllString(normalized, "")
if parts, ok := ratioParts(normalized); ok {
divisor := gcd(parts[0], parts[1])
return fmt.Sprintf("%d:%d", parts[0]/divisor, parts[1]/divisor)
}
dimensions, ok := pixelDimensions(normalized)
if !ok {
return nil
}
actual := float64(dimensions[0]) / float64(dimensions[1])
known := [][2]int{{1, 1}, {3, 2}, {2, 3}, {4, 3}, {3, 4}, {4, 5}, {5, 4}, {16, 9}, {9, 16}, {2, 1}, {1, 2}, {21, 9}}
best, difference := [2]int{}, math.Inf(1)
for _, candidate := range known {
expected := float64(candidate[0]) / float64(candidate[1])
current := math.Abs(actual-expected) / expected
if current < difference {
best, difference = candidate, current
}
}
if difference <= 0.03 {
return fmt.Sprintf("%d:%d", best[0], best[1])
}
divisor := gcd(dimensions[0], dimensions[1])
return fmt.Sprintf("%d:%d", dimensions[0]/divisor, dimensions[1]/divisor)
}
// aspectRatioValue keeps the canonical canvas ratio while restoring the exact
// enum value expected by ComfyUI nodes such as ResolutionSelector.
func aspectRatioValue(field jsonMap, value any) any {
canonical, _ := aspectRatio(stringValue(value)).(string)
if canonical == "" {
return value
}
candidates := sliceValue(field["options"])
for _, candidate := range []any{field["value"], field["fieldValue"], field["default"]} {
if !emptyWorkflowValue(candidate) {
candidates = append(candidates, candidate)
}
}
for _, candidate := range candidates {
option := optionString(candidate)
if option == "" {
continue
}
optionCanonical, _ := aspectRatio(option).(string)
if optionCanonical == canonical {
return option
}
}
return canonical
}
func ratioParts(value string) ([2]int, bool) {
parts := strings.Split(strings.TrimSpace(value), ":")
if len(parts) != 2 {
return [2]int{}, false
}
width, errWidth := strconv.Atoi(parts[0])
height, errHeight := strconv.Atoi(parts[1])
return [2]int{width, height}, errWidth == nil && errHeight == nil && width > 0 && height > 0
}
func imageDimensions(value string) ([2]int, bool) {
normalized := strings.ToLower(strings.TrimSpace(value))
presets := map[string][2]int{"1:1": {1024, 1024}, "3:2": {1536, 1024}, "2:3": {1024, 1536}, "4:3": {1360, 1024}, "3:4": {1024, 1360}, "4:5": {1024, 1280}, "5:4": {1280, 1024}, "16:9": {1824, 1024}, "9:16": {1024, 1824}, "2:1": {2048, 1024}, "1:2": {1024, 2048}, "21:9": {2352, 1008}, "1:1-2k": {2048, 2048}, "16:9-2k": {2048, 1152}, "9:16-2k": {1152, 2048}, "16:9-4k": {3840, 2160}, "9:16-4k": {2160, 3840}}
if result, ok := presets[normalized]; ok {
return result, true
}
ratio, ok := aspectRatio(normalized).(string)
if !ok {
return [2]int{}, false
}
parts, ok := ratioParts(ratio)
if !ok {
return [2]int{}, false
}
longEdge := 1024
if strings.HasSuffix(normalized, "-4k") {
longEdge = 3840
} else if strings.HasSuffix(normalized, "-2k") {
longEdge = 2048
}
if parts[0] >= parts[1] {
if longEdge == 1024 {
return [2]int{roundStep(1024*parts[0]/parts[1], 16), 1024}, true
}
return [2]int{longEdge, roundStep(longEdge*parts[1]/parts[0], 16)}, true
}
if longEdge == 1024 {
return [2]int{1024, roundStep(1024*parts[1]/parts[0], 16)}, true
}
return [2]int{roundStep(longEdge*parts[0]/parts[1], 16), longEdge}, true
}
func videoDimensions(size, quality string) ([2]int, bool) {
ratio, ok := aspectRatio(size).(string)
if !ok {
return [2]int{}, false
}
parts, ok := ratioParts(ratio)
shortEdge := videoResolutionPixels(quality)
if !ok || shortEdge == 0 {
return [2]int{}, false
}
if parts[0] >= parts[1] {
return [2]int{roundStep(shortEdge*parts[0]/parts[1], 2), shortEdge}, true
}
return [2]int{shortEdge, roundStep(shortEdge*parts[1]/parts[0], 2)}, true
}
func videoResolutionPixels(value any) int {
normalized := strings.ToLower(strings.Trim(strings.TrimSpace(stringValue(value)), `"`))
switch normalized {
case "low":
return 480
case "auto", "default", "medium", "high":
return 720
case "2k":
return 1440
case "4k":
return 2160
}
parsed, _ := strconv.Atoi(strings.TrimSuffix(normalized, "p"))
return parsed
}
func videoResolutionValue(field jsonMap, value any) any {
raw := strings.Trim(stringValue(value), `"`)
if raw == "" {
return nil
}
pixels := videoResolutionPixels(raw)
if pixels == 0 {
return value
}
candidates := []string{raw, strconv.Itoa(pixels), strconv.Itoa(pixels) + "p"}
if pixels == 1440 {
candidates = append(candidates, "2k")
} else if pixels == 2160 {
candidates = append(candidates, "4k")
}
for _, option := range sliceValue(field["options"]) {
for _, candidate := range candidates {
if strings.EqualFold(optionString(option), candidate) {
return option
}
}
}
defaultValue := firstValue(field["value"], field["fieldValue"], field["default"])
if _, ok := defaultValue.(float64); ok {
return pixels
}
normalizedDefault := strings.ToLower(strings.Trim(stringValue(defaultValue), `"`))
if strings.HasSuffix(normalizedDefault, "p") {
return strconv.Itoa(pixels) + "p"
}
if normalizedDefault == "2k" || normalizedDefault == "4k" {
if pixels == 1440 {
return "2k"
}
if pixels == 2160 {
return "4k"
}
}
return value
}
func optionString(value any) string {
if item, ok := mapValue(value); ok {
for _, key := range []string{"value", "id", "key", "label", "name"} {
if candidate := strings.Trim(stringValue(item[key]), `"`); candidate != "" {
return candidate
}
}
}
return strings.Trim(stringValue(value), `"`)
}
func gcd(left, right int) int {
for right != 0 {
left, right = right, left%right
}
if left <= 0 {
return 1
}
return left
}
func roundStep(value, step int) int {
return int(math.Round(float64(value)/float64(step))) * step
}
func stringIn(value string, candidates ...string) bool {
for _, candidate := range candidates {
if value == candidate {
return true
}
}
return false
}
func hasAnySuffix(value string, suffixes ...string) bool {
for _, suffix := range suffixes {
if strings.HasSuffix(value, suffix) {
return true
}
}
return false
}