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

1056 lines
32 KiB
Go

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
}