Files
ttstd-infinite-canvas/backend/internal/app/workflow_provider_values.go

1056 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 工作流字段取值:把用户在画布上的选择(尺寸、比例、时长、分辨率、提示词、种子)
// 解析成 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 == "<nil>" {
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 != "<nil>" {
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)
}
}