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 }