// 工作流字段取值:把用户在画布上的选择(尺寸、比例、时长、分辨率、提示词、种子) // 解析成 RunningHub 节点可接受的具体值。 // // 规则:显式值优先;其次按字段声明的选项/范围就近取值;都没有才用配置默认值。 // 尺寸类字段会按上游要求的步长取整,避免上游拒绝非法分辨率。 package app import ( cryptorand "crypto/rand" "encoding/json" "errors" "fmt" "math" "math/big" "sort" "strconv" "strings" ) func workflowFieldsFromManagement(workflow map[string]interface{}, mode string) ([]WorkflowField, error) { rawFields := collectManagementWorkflowFields(workflow, mode) encoded, err := json.Marshal(rawFields) if err != nil { return nil, fmt.Errorf("工作流字段映射编码失败:%w", err) } var fields []WorkflowField if err := json.Unmarshal(encoded, &fields); err != nil { return nil, fmt.Errorf("工作流字段映射解析失败:%w", err) } return fields, nil } func workflowFieldsForMode(fields []WorkflowField, mode string) []WorkflowField { if len(fields) == 0 { return fields } normalized := make([]WorkflowField, len(fields)) copy(normalized, fields) for index := range normalized { field := &normalized[index] if canonical := workflowNamedDynamicSource(field.Source, mode); canonical != "" { field.Source = canonical } inferred := workflowDynamicSourceForMode(field.FieldName, field.FieldType, mode) if strings.TrimSpace(field.Source) == "" && !field.sourceConfigured && !field.BindPrompt { field.Source = inferred if inferred != "" { field.SourceAutomatic = ptr(true) } continue } if shouldRepairLegacyWorkflowSource(field.FieldName, field.Source, inferred, mode) { field.Source = inferred } } return normalized } func shouldRepairLegacyWorkflowSource(fieldName string, source string, inferred string, mode string) bool { sourceKey := normalizeManagementFieldName(source) inferredKey := normalizeManagementFieldName(inferred) if sourceKey == "" || inferredKey == "" || sourceKey == inferredKey { return false } fieldKey := normalizeManagementFieldName(fieldName) wasMistakenForMedia := sourceKey == "referenceimage" || sourceKey == "referencevideo" || sourceKey == "referenceaudio" if wasMistakenForMedia && (workflowDimensionNameSource(fieldKey) != "" || inferredKey == "aspectratio" || inferredKey == "vquality") { return true } mode = strings.ToLower(strings.TrimSpace(mode)) return (fieldKey == "resolution" && (mode == "video" && sourceKey == "size" || mode == "image" && sourceKey == "vquality")) || (fieldKey == "quality" && sourceKey == "vquality") } func validateWorkflowMediaInputs(fields []WorkflowField, input canvasGenerationInput) error { capacities := map[string]int{"referenceimage": 0, "referencevideo": 0, "referenceaudio": 0} hasMaskMapping := false for _, field := range fields { if field.Enabled != nil && !*field.Enabled { continue } source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") if source == "mask" { hasMaskMapping = true continue } key := "" switch source { case "referenceimage", "image", "referenceimages": key = "referenceimage" case "referencevideo", "video", "referencevideos": key = "referencevideo" case "referenceaudio", "audio", "referenceaudios": key = "referenceaudio" } if key == "" { continue } index := field.SourceIndex if field.ImageOrder > 0 { index = field.ImageOrder - 1 } if index < 0 { index = 0 } if index+1 > capacities[key] { capacities[key] = index + 1 } } checks := []struct { label string count int capacity int }{ {label: "参考图片", count: len(input.ReferenceImages), capacity: capacities["referenceimage"]}, {label: "参考视频", count: len(input.ReferenceVideos), capacity: capacities["referencevideo"]}, {label: "参考音频", count: len(input.ReferenceAudios), capacity: capacities["referenceaudio"]}, } for _, check := range checks { if check.count > check.capacity { return fmt.Errorf("工作流只配置了 %d 个%s槽位,但画布传入了 %d 个;请在工作流字段映射中补齐槽位", check.capacity, check.label, check.count) } } if input.Mask != nil && !hasMaskMapping { return errors.New("画布传入了蒙版,但工作流没有配置蒙版字段映射") } return nil } func runningHubNodeInfo(fields []WorkflowField, files map[string]string, input canvasGenerationInput) ([]map[string]any, error) { return runningHubNodeInfoWithWorkflow(fields, files, input, nil) } func runningHubNodeInfoWithWorkflow(fields []WorkflowField, files map[string]string, input canvasGenerationInput, workflow map[string]interface{}) ([]map[string]any, error) { items := make([]map[string]any, 0, len(fields)+3) for _, field := range fields { if field.Enabled != nil && !*field.Enabled || strings.TrimSpace(field.NodeID) == "" || strings.TrimSpace(field.FieldName) == "" { continue } // RunningHub 的 nodeInfoList 只接受字符串 fieldValue。部分内部节点不会把 // 字符串恢复为数值,因此安全边界必须由服务端强制执行,不能依赖前端隐藏。 if isRunningHubUnsafeInternalField(workflow, field) { continue } value, present, err := resolveWorkflowFieldValue(field, files, input) if err != nil { return nil, err } if !present { if field.Required { return nil, fmt.Errorf("工作流字段 %s 缺少值", firstNonEmptyString(field.ID, field.FieldName)) } continue } if err := validateRunningHubWorkflowFieldValue(field, value); err != nil { return nil, err } encoded := workflowScalarString(value) if !shouldSendRunningHubWorkflowField(workflow, field, encoded) { continue } items = append(items, map[string]any{"nodeId": field.NodeID, "fieldName": field.FieldName, "fieldValue": encoded}) } return items, nil } func shouldSendRunningHubWorkflowField(workflow map[string]interface{}, field WorkflowField, encoded string) bool { if len(workflow) == 0 { // AI App 只有公开参数列表,没有可用于比较的工作流 JSON。 return true } node, ok := workflow[strings.TrimSpace(field.NodeID)].(map[string]interface{}) if !ok { return true } inputs, ok := node["inputs"].(map[string]interface{}) if !ok { return true } original, exists := inputs[strings.TrimSpace(field.FieldName)] if exists && isWorkflowLinkValue(original) { // 已连接输入属于工作流拓扑,不能被旧字段映射拆成字符串覆盖。 return false } if normalizeWorkflowFieldSource(field) != "" || field.RandomEnabled { return !isRunningHubAutomaticInternalBinding(node, field) } if !exists { return true } // 拉取参数时会保存所有 widget 的默认值;未修改项无需重复覆盖工作流。 return encoded != workflowScalarString(original) } func isRunningHubAutomaticInternalBinding(node map[string]interface{}, field WorkflowField) bool { if field.SourceAutomatic != nil && !*field.SourceAutomatic { return false } classType := strings.ToLower(strings.TrimSpace(stringValue(node["class_type"]))) if classType != "imageresize+" { return false } // 旧配置没有 sourceAutomatic,但 ImageResize+ 的宽高来源是按同名字段自动误判的。 // 保留节点自身尺寸链;用户仍可将来源设为默认并明确修改静态值。 source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") return source == "size" || source == "imagesize" || source == "sizewidth" || source == "width" || source == "imagewidth" || source == "videowidth" || source == "sizeheight" || source == "height" || source == "imageheight" || source == "videoheight" } func isRunningHubUnsafeInternalField(workflow map[string]interface{}, field WorkflowField) bool { if field.SafeToOverride != nil && !*field.SafeToOverride { return true } classType := strings.ToLower(strings.TrimSpace(field.ClassType)) if node, ok := workflow[strings.TrimSpace(field.NodeID)].(map[string]interface{}); ok { classType = strings.ToLower(strings.TrimSpace(stringValue(node["class_type"]))) } fieldName := strings.ToLower(strings.TrimSpace(field.FieldName)) if classType == "int" && fieldName == "value" { return true } return classType == "imageresize+" && (fieldName == "width" || fieldName == "height" || fieldName == "multiple_of") } func validateRunningHubWorkflowFieldValue(field WorkflowField, value interface{}) error { fieldID := firstNonEmptyString(strings.TrimSpace(field.ID), strings.TrimSpace(field.NodeID)+"."+strings.TrimSpace(field.FieldName)) fieldType := strings.ToUpper(strings.TrimSpace(field.FieldType)) if fieldType == "NUMBER" || fieldType == "FLOAT" || fieldType == "INTEGER" || fieldType == "INT" || fieldType == "SLIDER" { numeric, ok := workflowNumericBound(value) if !ok { return fmt.Errorf("工作流字段 %s 不是有效数字", fieldID) } minValue, hasMin := workflowNumericBound(field.Min) maxValue, hasMax := workflowNumericBound(field.Max) stepValue, hasStep := workflowNumericBound(field.Step) if hasMin && hasMax && minValue > maxValue { return fmt.Errorf("工作流字段 %s 的最小值不能大于最大值", fieldID) } if hasStep && stepValue <= 0 { return fmt.Errorf("工作流字段 %s 的步长必须大于 0", fieldID) } if (hasMin && numeric < minValue) || (hasMax && numeric > maxValue) { return fmt.Errorf("工作流字段 %s 的值超出允许范围", fieldID) } if hasStep { start := float64(0) if hasMin { start = minValue } steps := (numeric - start) / stepValue if math.Abs(steps-math.Round(steps)) > 1e-7 { return fmt.Errorf("工作流字段 %s 的值不符合步长", fieldID) } } } if fieldType == "BOOLEAN" || fieldType == "BOOL" { switch item := value.(type) { case bool: case string: normalized := strings.ToLower(strings.TrimSpace(item)) if normalized != "true" && normalized != "false" { return fmt.Errorf("工作流字段 %s 不是有效开关值", fieldID) } default: return fmt.Errorf("工作流字段 %s 不是有效开关值", fieldID) } } if options := workflowFieldAllowedOptions(field); len(options) > 0 { encoded := strings.TrimSpace(workflowScalarString(value)) matched := false hasScalarOption := false for _, option := range options { if workflowOptionIsRange(option) { continue } candidate := strings.TrimSpace(workflowOptionString(option)) if candidate == "" || candidate == "" { continue } hasScalarOption = true if candidate == encoded { matched = true break } } if hasScalarOption && !matched { return fmt.Errorf("工作流字段 %s 的值不在允许选项中", fieldID) } } return nil } func workflowOptionIsRange(value interface{}) bool { object, ok := value.(map[string]interface{}) if !ok { return false } for _, key := range []string{"min", "max", "step", "minValue", "maxValue", "stepValue"} { if _, exists := object[key]; exists { return true } } if nested, ok := object["range"].(map[string]interface{}); ok { return workflowOptionIsRange(nested) } return false } func resolveWorkflowFieldValue(field WorkflowField, files map[string]string, input canvasGenerationInput) (interface{}, bool, error) { source := normalizeWorkflowFieldSource(field) // 画布参数按 nodeId + fieldName 独立保存时 source 可能为空;枚举字段仍必须 // 使用工作流原始 options 的完整值,不能把用户界面上的比例简称直接发给 ComfyUI。 if source == "" && strings.EqualFold(strings.TrimSpace(field.FieldName), "aspect_ratio") { // 旧版前端只把比例写入 Config.Size,没有同步改写字段映射。此时不能 // 直接使用工作流保存的默认值,否则用户选择 9:16 会在提交前静默回到 1:1。 // 仅对非 1:1 的显式比例做回填,避免没有动态参数时覆盖工作流自己的 1:1 默认值。 requested := strings.TrimSpace(input.Config.Size) if requested != "" && !strings.EqualFold(workflowAspectRatio(requested), "1:1") { value := workflowAspectRatioValue(field, requested) if strings.TrimSpace(fmt.Sprint(value)) != "" { return value, true, nil } } raw := strings.TrimSpace(workflowOptionString(firstNonNilWorkflowValue(field.FieldValue, field.Value))) if raw != "" { value := workflowAspectRatioValue(field, raw) return value, true, nil } } if source != "" { switch strings.ReplaceAll(strings.ReplaceAll(source, "_", ""), "-", "") { case "prompt", "text", "positiveprompt", "positive": return input.Prompt, strings.TrimSpace(input.Prompt) != "", nil case "size", "imagesize": return input.Config.Size, strings.TrimSpace(input.Config.Size) != "", nil case "resolution": if strings.EqualFold(strings.TrimSpace(input.Mode), "video") { value := workflowVideoResolutionValue(field, input.Config.VQuality) return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil } return input.Config.Size, strings.TrimSpace(input.Config.Size) != "", nil case "aspectratio", "ratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio": value := workflowAspectRatioValue(field, input.Config.Size) return value, value != "", nil case "sizewidth", "width", "imagewidth", "videowidth": value := workflowDimensionPart(input.Mode, input.Config.Size, input.Config.VQuality, 0) return value, value != "", nil case "sizeheight", "height", "imageheight", "videoheight": value := workflowDimensionPart(input.Mode, input.Config.Size, input.Config.VQuality, 1) return value, value != "", nil case "quality": return input.Config.Quality, strings.TrimSpace(input.Config.Quality) != "", nil case "count", "batch", "batchsize": return input.Config.Count, strings.TrimSpace(input.Config.Count) != "", nil case "videoseconds", "video_seconds", "duration": value := workflowVideoDurationValue(field, input.Config.VideoSeconds) return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil case "vquality", "videoquality", "video_quality": value := workflowVideoResolutionValue(field, input.Config.VQuality) return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil case "videogenerateaudio", "video_generate_audio", "generateaudio": return input.Config.VideoGenerateAudio, strings.TrimSpace(input.Config.VideoGenerateAudio) != "", nil case "videowatermark", "video_watermark", "watermark": return input.Config.VideoWatermark, strings.TrimSpace(input.Config.VideoWatermark) != "", nil case "audioformat", "audio_format": return input.Config.AudioFormat, strings.TrimSpace(input.Config.AudioFormat) != "", nil case "systemprompt", "system_prompt": return input.Config.SystemPrompt, strings.TrimSpace(input.Config.SystemPrompt) != "", nil case "transparentbackground", "transparent_background": return input.Config.TransparentBackground, strings.TrimSpace(input.Config.TransparentBackground) != "", nil case "audiovoice", "audio_voice", "voice": return input.Config.AudioVoice, strings.TrimSpace(input.Config.AudioVoice) != "", nil case "audiospeed", "audio_speed": return input.Config.AudioSpeed, strings.TrimSpace(input.Config.AudioSpeed) != "", nil case "audioinstructions", "audio_instructions": return input.Config.AudioInstructions, strings.TrimSpace(input.Config.AudioInstructions) != "", nil } index := field.SourceIndex if field.ImageOrder > 0 { index = field.ImageOrder - 1 } var media providerMedia switch strings.ReplaceAll(strings.ReplaceAll(source, "_", ""), "-", "") { case "referenceimage", "image", "referenceimages": if index < 0 || index >= len(input.ReferenceImages) { return nil, false, nil } media = input.ReferenceImages[index] case "referencevideo", "video", "referencevideos": if index < 0 || index >= len(input.ReferenceVideos) { return nil, false, nil } media = input.ReferenceVideos[index] case "referenceaudio", "audio", "referenceaudios": if index < 0 || index >= len(input.ReferenceAudios) { return nil, false, nil } media = input.ReferenceAudios[index] case "mask": if input.Mask == nil { return nil, false, nil } media = *input.Mask default: return nil, false, fmt.Errorf("不支持的工作流字段来源:%s", field.Source) } name := files[media.ID] if name == "" { return nil, false, errors.New("工作流参考素材尚未上传") } return name, true, nil } if field.RandomEnabled { randomMax := field.Max if isRunningHubInterface(input.Config.InterfaceType) && isWorkflowSeedField(field.FieldName) { // RunningHub 的随机 Seed 按 uint32 传输,不能沿用 JavaScript 安全整数上限。 const runningHubSeedMax int64 = 1<<32 - 1 maxValue, err := workflowIntegerBound(field.Max, runningHubSeedMax) if err != nil { return nil, false, err } if maxValue > runningHubSeedMax { maxValue = runningHubSeedMax } randomMax = maxValue } value, err := randomWorkflowInteger(field.Min, randomMax) return value, err == nil, err } if field.FieldValue != nil { return field.FieldValue, true, nil } if field.Value != nil { return field.Value, true, nil } return nil, false, nil } func firstNonNilWorkflowValue(values ...interface{}) interface{} { for _, value := range values { if value != nil { return value } } return nil } func normalizeWorkflowFieldSource(field WorkflowField) string { source := strings.ToLower(strings.TrimSpace(field.Source)) if source == "" && field.BindPrompt { return "prompt" } if source != "" { return source } if !field.SourceFromUpstream { return "" } fieldName := strings.ToLower(strings.TrimSpace(field.FieldName)) if fieldName == "prompt" || fieldName == "text" || fieldName == "positive_prompt" || fieldName == "positiveprompt" { return "prompt" } switch strings.ToLower(strings.TrimSpace(field.FieldType)) { case "image", "img", "photo", "picture": return "referenceimage" case "video", "movie": return "referencevideo" case "audio", "sound", "music", "voice": return "referenceaudio" default: return "" } } func workflowDimensionPart(mode string, size string, videoQuality string, index int) string { if index < 0 || index > 1 { return "" } if dimensions, ok := workflowPixelDimensions(size); ok { return strconv.Itoa(dimensions[index]) } if strings.EqualFold(strings.TrimSpace(mode), "video") { dimensions, ok := workflowVideoDimensions(size, videoQuality) if !ok { return "" } return strconv.Itoa(dimensions[index]) } dimensions, ok := workflowImageDimensions(size) if !ok { return "" } return strconv.Itoa(dimensions[index]) } func workflowPixelDimensions(value string) ([2]int, bool) { var result [2]int normalized := strings.ToLower(strings.TrimSpace(value)) if !strings.Contains(normalized, "x") { return result, false } parts := strings.FieldsFunc(normalized, func(r rune) bool { return r == 'x' || r == ' ' || r == ',' }) if len(parts) != 2 { return result, false } for index, part := range parts { parsed, err := strconv.Atoi(strings.TrimSpace(part)) if err != nil || parsed <= 0 { return result, false } result[index] = parsed } return result, true } func workflowAspectRatio(value string) string { normalized := strings.ToLower(strings.TrimSpace(value)) // RunningHub 的 ResolutionSelector 选项带有展示文案(例如 // "9:16 (Portrait Widescreen)"),协议比较只需要比例前缀。 if cut := strings.IndexAny(normalized, " ("); cut >= 0 { normalized = strings.TrimSpace(normalized[:cut]) } for _, suffix := range []string{"-1k", "-2k", "-4k"} { if strings.HasSuffix(normalized, suffix) { normalized = strings.TrimSuffix(normalized, suffix) break } } if width, height, ok := workflowRatioParts(normalized); ok { divisor := workflowGreatestCommonDivisor(width, height) return fmt.Sprintf("%d:%d", width/divisor, height/divisor) } dimensions, ok := workflowPixelDimensions(normalized) if !ok { return "" } ratio := 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}} bestDifference := math.MaxFloat64 best := [2]int{} for _, candidate := range known { expected := float64(candidate[0]) / float64(candidate[1]) difference := math.Abs(ratio-expected) / expected if difference < bestDifference { bestDifference = difference best = candidate } } if bestDifference <= 0.03 { return fmt.Sprintf("%d:%d", best[0], best[1]) } divisor := workflowGreatestCommonDivisor(dimensions[0], dimensions[1]) return fmt.Sprintf("%d:%d", dimensions[0]/divisor, dimensions[1]/divisor) } func workflowAspectRatioValue(field WorkflowField, value string) string { raw := strings.TrimSpace(value) if options := workflowFieldAllowedOptions(field); len(options) > 0 { requested := workflowAspectRatio(raw) for _, option := range options { candidate := strings.TrimSpace(workflowOptionString(option)) if candidate != "" && strings.EqualFold(workflowAspectRatio(candidate), requested) { return candidate } } if fallback := workflowFieldConfiguredDefault(field); fallback != "" { return fallback } return raw } // “auto/adaptive” 是工作流的真实模式,不应被画布当前像素尺寸反推成 // 一个并不存在的比例;宽高字段仍按工作流自身的默认值或显式尺寸处理。 if fallback := strings.TrimSpace(workflowFieldConfiguredDefault(field)); strings.EqualFold(fallback, "auto") || strings.EqualFold(fallback, "adaptive") { return fallback } return workflowAspectRatio(raw) } func workflowFieldAllowedOptions(field WorkflowField) []interface{} { classType := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(field.ClassType), "_", ""), "-", "")) fieldName := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(field.FieldName), "_", ""), "-", "")) if classType == "resolutionselector" && fieldName == "aspectratio" { // RunningHub 的工作流 API 不返回 object_info;ResolutionSelector 的完整枚举 // 是节点协议的一部分,通用比例预设(9:16 等)不能直接提交给该节点。 return []interface{}{ "1:1 (Square)", "2:3 (Portrait Photo)", "3:2 (Photo)", "3:4 (Portrait Standard)", "4:3 (Standard)", "9:16 (Portrait Widescreen)", "16:9 (Widescreen)", "21:9 (Ultrawide)", } } return field.Options } func workflowRatioParts(value string) (int, int, bool) { parts := strings.Split(strings.TrimSpace(value), ":") if len(parts) != 2 { return 0, 0, false } width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) return width, height, widthErr == nil && heightErr == nil && width > 0 && height > 0 } func workflowGreatestCommonDivisor(left int, right int) int { for right != 0 { left, right = right, left%right } if left <= 0 { return 1 } return left } func workflowImageDimensions(value string) ([2]int, bool) { normalized := strings.ToLower(strings.TrimSpace(value)) preset := 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 dimensions, ok := preset[normalized]; ok { return dimensions, true } ratio := workflowAspectRatio(normalized) widthRatio, heightRatio, ok := workflowRatioParts(ratio) if !ok { return [2]int{}, false } tier := "1k" if strings.HasSuffix(normalized, "-2k") { tier = "2k" } else if strings.HasSuffix(normalized, "-4k") { tier = "4k" } if tier == "1k" { if widthRatio >= heightRatio { return [2]int{workflowRoundToStep(1024*float64(widthRatio)/float64(heightRatio), 16), 1024}, true } return [2]int{1024, workflowRoundToStep(1024*float64(heightRatio)/float64(widthRatio), 16)}, true } longEdge := 2048 if tier == "4k" { longEdge = 3840 } if widthRatio >= heightRatio { return [2]int{longEdge, workflowRoundToStep(float64(longEdge)*float64(heightRatio)/float64(widthRatio), 16)}, true } return [2]int{workflowRoundToStep(float64(longEdge)*float64(widthRatio)/float64(heightRatio), 16), longEdge}, true } func workflowVideoDimensions(size string, quality string) ([2]int, bool) { ratio := workflowAspectRatio(size) widthRatio, heightRatio, ok := workflowRatioParts(ratio) shortEdge := workflowVideoResolutionPixels(quality) if !ok || shortEdge <= 0 { return [2]int{}, false } if widthRatio >= heightRatio { return [2]int{workflowRoundToStep(float64(shortEdge)*float64(widthRatio)/float64(heightRatio), 2), shortEdge}, true } return [2]int{shortEdge, workflowRoundToStep(float64(shortEdge)*float64(heightRatio)/float64(widthRatio), 2)}, true } func workflowRoundToStep(value float64, step int) int { if step <= 1 { return int(math.Round(value)) } return int(math.Round(value/float64(step))) * step } func workflowVideoResolutionPixels(value string) int { normalized := strings.ToLower(strings.TrimSpace(value)) switch normalized { case "low": return 480 case "auto", "default", "medium", "high": return 720 case "2k": return 1440 case "4k": return 2160 } parsed, err := strconv.Atoi(strings.TrimSuffix(normalized, "p")) if err != nil || parsed <= 0 { return 0 } return parsed } func workflowVideoResolutionValue(field WorkflowField, value string) interface{} { raw := strings.TrimSpace(value) if raw == "" { return "" } if len(field.Options) > 0 { requested := normalizeWorkflowResolutionToken(raw) for _, option := range field.Options { candidate := strings.TrimSpace(workflowOptionString(option)) if candidate != "" && normalizeWorkflowResolutionToken(candidate) == requested { // nodeInfoList 的 fieldValue 是字符串;对象选项只取其 value/id 等标量。 return candidate } } // 工作流明确声明了可选项但画布值不在其中时,保留工作流默认值, // 不再把 2K/4K/720 等普通模型别名硬塞给工作流。 if fallback := workflowFieldConfiguredDefault(field); fallback != "" { for _, option := range field.Options { candidate := strings.TrimSpace(workflowOptionString(option)) if candidate != "" && normalizeWorkflowResolutionToken(candidate) == normalizeWorkflowResolutionToken(fallback) { return candidate } } return fallback } return raw } if numeric := workflowNumericResolutionValue(field, raw); numeric != nil { return numeric } if fallback := workflowFieldConfiguredDefault(field); fallback != "" && !workflowResolutionShapeCompatible(fallback, raw) { return fallback } return raw } func workflowVideoDurationValue(field WorkflowField, value string) interface{} { raw := strings.TrimSpace(value) if raw == "" { return "" } if len(field.Options) > 0 { requested := normalizeWorkflowDurationToken(raw) for _, option := range field.Options { candidate := strings.TrimSpace(workflowOptionString(option)) if candidate != "" && normalizeWorkflowDurationToken(candidate) == requested { return candidate } } if fallback := workflowFieldConfiguredDefault(field); fallback != "" { for _, option := range field.Options { candidate := strings.TrimSpace(workflowOptionString(option)) if candidate != "" && normalizeWorkflowDurationToken(candidate) == normalizeWorkflowDurationToken(fallback) { return candidate } } return fallback } return raw } if numeric := workflowNumericResolutionValue(field, raw); numeric != nil { return numeric } if fallback := workflowFieldConfiguredDefault(field); fallback != "" && !workflowResolutionShapeCompatible(fallback, raw) { return fallback } return raw } func normalizeWorkflowDurationToken(value string) string { return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "s"), "秒"))) } func workflowFieldConfiguredDefault(field WorkflowField) string { value := field.FieldValue if value == nil { value = field.Value } if value == nil { return "" } return strings.TrimSpace(workflowOptionString(value)) } func normalizeWorkflowResolutionToken(value string) string { return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "p"), "P"))) } func workflowNumericValue(value string) *float64 { parsed, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "p"), "P")), 64) if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) { return nil } return &parsed } func workflowResolutionShapeCompatible(defaultValue string, requested string) bool { defaultNumeric := workflowNumericValue(defaultValue) requestedNumeric := workflowNumericValue(requested) if defaultNumeric != nil && requestedNumeric != nil { return true } return strings.EqualFold(strings.TrimSpace(defaultValue), strings.TrimSpace(requested)) } func workflowNumericResolutionValue(field WorkflowField, raw string) interface{} { parsed := workflowNumericValue(raw) if parsed == nil { return nil } min, minOK := workflowNumericBound(field.Min) max, maxOK := workflowNumericBound(field.Max) step, stepOK := workflowNumericBound(field.Step) if !minOK && !maxOK && !stepOK && !strings.EqualFold(strings.TrimSpace(field.FieldType), "NUMBER") { return nil } value := *parsed if minOK && value < min { value = min } if maxOK && value > max { value = max } if stepOK && step > 0 { anchor := min if !minOK { anchor = 0 } value = anchor + math.Round((value-anchor)/step)*step } if strings.EqualFold(strings.TrimSpace(field.FieldType), "NUMBER") && math.Trunc(value) == value { return int64(value) } return value } func workflowNumericBound(value interface{}) (float64, bool) { parsed, err := strconv.ParseFloat(strings.TrimSpace(fmt.Sprint(value)), 64) return parsed, err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0) } func workflowOptionString(value interface{}) string { if object, ok := value.(map[string]interface{}); ok { for _, key := range []string{"value", "id", "key", "label", "name"} { if candidate := strings.TrimSpace(fmt.Sprint(object[key])); candidate != "" && candidate != "" { return candidate } } } return fmt.Sprint(value) } func runningHubPromptFallback(workflow map[string]interface{}, prompt string) []map[string]any { if strings.TrimSpace(prompt) == "" || len(workflow) == 0 { return nil } bestNodeID := "" bestFieldName := "" bestScore := -1000 nodeIDs := make([]string, 0, len(workflow)) for nodeID := range workflow { nodeIDs = append(nodeIDs, nodeID) } sort.Slice(nodeIDs, func(i, j int) bool { return managementNodeIDLess(nodeIDs[i], nodeIDs[j]) }) for _, nodeID := range nodeIDs { node, ok := workflow[nodeID].(map[string]interface{}) if !ok { continue } inputs, ok := node["inputs"].(map[string]interface{}) if !ok { continue } fieldNames := make([]string, 0, len(inputs)) for fieldName := range inputs { fieldNames = append(fieldNames, fieldName) } sort.Strings(fieldNames) classType := strings.ToLower(strings.TrimSpace(fmt.Sprint(node["class_type"]))) metaTitle := strings.ToLower(strings.TrimSpace(fmt.Sprint(workflowNodeMetaTitle(node)))) for _, fieldName := range fieldNames { if !isWorkflowPromptCandidate(fieldName, classType, metaTitle) { continue } if isWorkflowLinkValue(inputs[fieldName]) { continue } score := workflowPromptCandidateScore(fieldName, classType, metaTitle) if bestNodeID == "" || score > bestScore { bestNodeID = nodeID bestFieldName = fieldName bestScore = score } } } if bestNodeID != "" { return []map[string]any{{"nodeId": bestNodeID, "fieldName": bestFieldName, "fieldValue": prompt}} } return nil } func isWorkflowMediaSource(source string) bool { normalized := strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(strings.TrimSpace(source)), "_", ""), "-", "") switch normalized { case "referenceimage", "referenceimages", "image", "referencevideo", "referencevideos", "video", "referenceaudio", "referenceaudios", "audio", "mask": return true default: return false } } func workflowPromptCandidateScore(fieldName string, classType string, metaTitle string) int { descriptor := strings.ToLower(strings.TrimSpace(fieldName + " " + metaTitle)) score := 0 for _, marker := range []string{"negative", "neg prompt", "负面", "反向", "负向"} { if strings.Contains(descriptor, marker) { score -= 100 break } } for _, marker := range []string{"positive", "正面", "正向"} { if strings.Contains(descriptor, marker) { score += 20 break } } if strings.Contains(classType, "cliptextencode") { score += 5 } if isWorkflowPromptFieldName(fieldName) { score += 2 } return score } func randomWorkflowInteger(rawMin interface{}, rawMax interface{}) (int64, error) { const defaultMax int64 = 9007199254740991 minValue, err := workflowIntegerBound(rawMin, 0) if err != nil { return 0, err } maxValue, err := workflowIntegerBound(rawMax, defaultMax) if err != nil { return 0, err } if maxValue < minValue { return 0, errors.New("工作流随机值最大值不能小于最小值") } rangeSize := new(big.Int).Sub(big.NewInt(maxValue), big.NewInt(minValue)) rangeSize.Add(rangeSize, big.NewInt(1)) offset, err := cryptorand.Int(cryptorand.Reader, rangeSize) if err != nil { return 0, fmt.Errorf("生成工作流随机值失败:%w", err) } return new(big.Int).Add(offset, big.NewInt(minValue)).Int64(), nil } func workflowIntegerBound(value interface{}, fallback int64) (int64, error) { if value == nil || strings.TrimSpace(fmt.Sprint(value)) == "" { return fallback, nil } parsed, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64) if err != nil { return 0, fmt.Errorf("工作流随机值范围不是有效整数:%v", value) } return parsed, nil } func workflowFieldsBindPrompt(fields []WorkflowField) bool { for _, field := range fields { if field.Enabled != nil && !*field.Enabled || strings.TrimSpace(field.NodeID) == "" || strings.TrimSpace(field.FieldName) == "" { continue } source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") switch source { case "prompt", "text", "positiveprompt", "positive": return true } } return false } func upsertRunningHubNodeInfo(items []map[string]any, overrides []map[string]any) []map[string]any { for _, override := range overrides { nodeID := strings.TrimSpace(stringValue(override["nodeId"])) fieldName := strings.TrimSpace(stringValue(override["fieldName"])) replaced := false for index, item := range items { if strings.TrimSpace(stringValue(item["nodeId"])) == nodeID && strings.TrimSpace(stringValue(item["fieldName"])) == fieldName { items[index] = override replaced = true break } } if !replaced { items = append(items, override) } } return items } func isWorkflowLinkValue(value interface{}) bool { items, ok := value.([]interface{}) return ok && len(items) == 2 && fmt.Sprint(items[0]) != "" && isIntegerLike(items[1]) } func isIntegerLike(value interface{}) bool { switch item := value.(type) { case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: return true case float64: return item == float64(int(item)) case json.Number: _, err := strconv.Atoi(string(item)) return err == nil default: return false } } func isWorkflowPromptFieldName(value string) bool { normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.TrimSpace(value))) switch normalized { case "text", "prompt", "positiveprompt", "positive", "caption", "description": return true default: return false } } func isWorkflowPromptCandidate(fieldName string, classType string, metaTitle string) bool { if isWorkflowPromptFieldName(fieldName) { return true } normalizedField := strings.ToLower(strings.TrimSpace(fieldName)) if normalizedField != "value" { return false } return strings.Contains(classType, "text") || strings.Contains(classType, "string") || strings.Contains(classType, "prompt") || strings.Contains(metaTitle, "text") || strings.Contains(metaTitle, "prompt") || strings.Contains(metaTitle, "提示词") || strings.Contains(metaTitle, "文本") } func workflowNodeMetaTitle(node map[string]interface{}) string { meta, _ := node["_meta"].(map[string]interface{}) return fmt.Sprint(meta["title"]) } func workflowScalarString(value interface{}) string { switch item := value.(type) { case string: return item case nil: return "" default: encoded, err := json.Marshal(item) if err == nil { return string(encoded) } return fmt.Sprint(item) } }