1048 lines
43 KiB
Go
1048 lines
43 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
)
|
||
|
||
// ModelCapabilityConfig 是模型能力声明,不包含供应商字段名;协议适配器负责把统一参数映射到上游请求。
|
||
type ModelCapabilityConfig struct {
|
||
Version int `json:"version"`
|
||
Text *TextCapabilityConfig `json:"text,omitempty"`
|
||
Image *ImageCapabilityConfig `json:"image,omitempty"`
|
||
Video *VideoCapabilityConfig `json:"video,omitempty"`
|
||
}
|
||
|
||
type TextCapabilityConfig struct {
|
||
References TextReferenceConfig `json:"references"`
|
||
}
|
||
|
||
type TextReferenceConfig struct {
|
||
PromptMaxChars int `json:"promptMaxChars"`
|
||
MaxImages int `json:"maxImages"`
|
||
MaxImageBytes int64 `json:"maxImageBytes"`
|
||
MaxVideos int `json:"maxVideos"`
|
||
MaxVideoBytes int64 `json:"maxVideoBytes"`
|
||
}
|
||
|
||
type ImageCapabilityConfig struct {
|
||
References ImageReferenceConfig `json:"references"`
|
||
Size ImageSizeConfig `json:"size"`
|
||
Quality ImageQualityConfig `json:"quality"`
|
||
TransparentBackground VideoBooleanConfig `json:"transparentBackground"`
|
||
ResponseFormat ParameterSupport `json:"responseFormat"`
|
||
OutputFormat ParameterSupport `json:"outputFormat"`
|
||
MaxOutputs int `json:"maxOutputs"`
|
||
}
|
||
|
||
type ImageReferenceConfig struct {
|
||
PromptMaxChars int `json:"promptMaxChars"`
|
||
MaxImages int `json:"maxImages"`
|
||
MaxImageBytes int64 `json:"maxImageBytes"`
|
||
MaskSupported bool `json:"maskSupported"`
|
||
}
|
||
|
||
type ImageSizeConfig struct {
|
||
Parameter string `json:"parameter"`
|
||
Values []string `json:"values"`
|
||
Default string `json:"default"`
|
||
AllowCustom bool `json:"allowCustom"`
|
||
Presets []ImageSizePreset `json:"presets,omitempty"`
|
||
}
|
||
|
||
type ImageSizePreset struct {
|
||
Tier string `json:"tier"`
|
||
Ratio string `json:"ratio"`
|
||
Size string `json:"size"`
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
}
|
||
|
||
type ImageQualityConfig struct {
|
||
Supported bool `json:"supported"`
|
||
Values []string `json:"values"`
|
||
Default string `json:"default"`
|
||
}
|
||
|
||
type ParameterSupport struct {
|
||
Supported bool `json:"supported"`
|
||
}
|
||
|
||
type VideoCapabilityConfig struct {
|
||
References VideoReferenceConfig `json:"references"`
|
||
Duration VideoDurationConfig `json:"duration"`
|
||
DurationSupported *bool `json:"durationSupported,omitempty"`
|
||
Ratios []string `json:"ratios"`
|
||
DefaultRatio string `json:"defaultRatio"`
|
||
Resolutions []string `json:"resolutions"`
|
||
DefaultResolution string `json:"defaultResolution"`
|
||
GenerateAudio VideoBooleanConfig `json:"generateAudio"`
|
||
Watermark VideoBooleanConfig `json:"watermark"`
|
||
Operations []string `json:"operations"`
|
||
DefaultOperation string `json:"defaultOperation"`
|
||
}
|
||
|
||
type VideoReferenceConfig struct {
|
||
PromptMaxChars int `json:"promptMaxChars"`
|
||
MinImages int `json:"minImages"`
|
||
MaxImages int `json:"maxImages"`
|
||
MaxImageBytes int64 `json:"maxImageBytes"`
|
||
MaxVideos int `json:"maxVideos"`
|
||
MaxVideoBytes int64 `json:"maxVideoBytes"`
|
||
MaxVideoDuration int `json:"maxVideoDurationSeconds"`
|
||
MaxAudios int `json:"maxAudios"`
|
||
MaxAudioBytes int64 `json:"maxAudioBytes"`
|
||
MaxAudioDuration int `json:"maxAudioDurationSeconds"`
|
||
}
|
||
|
||
type VideoDurationConfig struct {
|
||
Selection string `json:"selection"`
|
||
Min int `json:"min,omitempty"`
|
||
Max int `json:"max,omitempty"`
|
||
Step int `json:"step,omitempty"`
|
||
Values []int `json:"values,omitempty"`
|
||
Default int `json:"default"`
|
||
}
|
||
|
||
type VideoBooleanConfig struct {
|
||
Supported bool `json:"supported"`
|
||
Default bool `json:"default"`
|
||
}
|
||
|
||
func DefaultModelCapabilityConfig(protocol string) *ModelCapabilityConfig {
|
||
return DefaultModelCapabilityConfigForModel(protocol, "")
|
||
}
|
||
|
||
func videoDurationSupported(value *VideoCapabilityConfig) bool {
|
||
return value == nil || value.DurationSupported == nil || *value.DurationSupported
|
||
}
|
||
|
||
func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapabilityConfig {
|
||
image := &ImageCapabilityConfig{
|
||
References: ImageReferenceConfig{PromptMaxChars: 32000, MaxImages: 16, MaxImageBytes: 30 * 1024 * 1024, MaskSupported: true},
|
||
Size: ImageSizeConfig{Parameter: "size", Values: defaultImageSizeValues(), Default: "1:1", AllowCustom: true},
|
||
Quality: ImageQualityConfig{Supported: true, Values: []string{"auto", "low", "medium", "high"}, Default: "auto"},
|
||
TransparentBackground: VideoBooleanConfig{Supported: true, Default: false},
|
||
ResponseFormat: ParameterSupport{Supported: true},
|
||
OutputFormat: ParameterSupport{Supported: true},
|
||
MaxOutputs: 15,
|
||
}
|
||
switch model.ChannelInterfaceType(protocol) {
|
||
case model.ChannelInterfaceGrokImage:
|
||
image.References.MaxImages = 1
|
||
image.References.MaskSupported = false
|
||
// grok2api / xAI Imagine:size→aspect_ratio,quality→resolution(1k/2k)。
|
||
image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false}
|
||
image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"}
|
||
image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false}
|
||
image.ResponseFormat = ParameterSupport{Supported: true}
|
||
image.OutputFormat = ParameterSupport{Supported: false}
|
||
image.MaxOutputs = 1
|
||
case model.ChannelInterfaceVolcengineArkImage:
|
||
image.References.MaskSupported = false
|
||
image.Quality.Supported = false
|
||
image.TransparentBackground.Supported = false
|
||
image.ResponseFormat.Supported = false
|
||
image.OutputFormat.Supported = false
|
||
case model.ChannelInterfaceVolcengineJiMengImage:
|
||
image.References.MaxImages = 14
|
||
image.References.MaskSupported = false
|
||
image.Quality.Supported = false
|
||
image.TransparentBackground.Supported = false
|
||
image.ResponseFormat.Supported = false
|
||
image.OutputFormat.Supported = false
|
||
case model.ChannelInterfaceGeminiImage:
|
||
image.References.MaskSupported = false
|
||
// Gemini Images uses imageConfig.aspectRatio, not the OpenAI-style pixel size field.
|
||
image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"auto", "1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"}, Default: "1:1", AllowCustom: false}
|
||
image.TransparentBackground.Supported = false
|
||
image.ResponseFormat.Supported = false
|
||
image.OutputFormat.Supported = false
|
||
image.MaxOutputs = 4
|
||
}
|
||
if model.ChannelInterfaceType(protocol) != model.ChannelInterfaceGrokImage && strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelName)), "grok-imagine-image") {
|
||
image.References.MaxImages = 0
|
||
image.References.MaskSupported = false
|
||
image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false}
|
||
image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"}
|
||
image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false}
|
||
image.ResponseFormat = ParameterSupport{Supported: true}
|
||
image.OutputFormat = ParameterSupport{Supported: false}
|
||
image.MaxOutputs = 1
|
||
}
|
||
return image
|
||
}
|
||
|
||
func defaultImageSizeValues() []string {
|
||
return []string{
|
||
"auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16",
|
||
"1024x1024", "1360x1024", "1024x1360", "1536x1024", "1024x1536", "1024x1280", "1280x1024", "2048x878", "1824x1024", "1024x1824",
|
||
"2048x2048", "2304x1728", "1728x2304", "2496x1664", "1664x2496", "1792x2240", "2240x1792", "3136x1344", "2752x1536", "1536x2752",
|
||
"2880x2880", "3264x2448", "2448x3264", "3504x2336", "2336x3504", "2560x3200", "3200x2560", "3808x1632", "3840x2160", "2160x3840",
|
||
}
|
||
}
|
||
|
||
// legacyImageSizeValues 用于修复旧数据中仅保存了 "*" 的图片尺寸能力。
|
||
// 这组值是前后台共同展示的基础预设,不能让历史通配符配置继续污染用户生成参数。
|
||
func legacyImageSizeValues() []string {
|
||
return []string{
|
||
"1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16",
|
||
"1024x1024", "1536x1024", "1024x1536",
|
||
}
|
||
}
|
||
|
||
func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *ModelCapabilityConfig {
|
||
// 文本模型是否支持视觉输入不能从协议或模型名可靠推断,默认关闭,由管理员按真实上游能力开启。
|
||
text := &TextCapabilityConfig{References: TextReferenceConfig{PromptMaxChars: 32000}}
|
||
video := &VideoCapabilityConfig{
|
||
References: VideoReferenceConfig{PromptMaxChars: 1000, MinImages: 0, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0},
|
||
Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6},
|
||
Ratios: []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||
DefaultRatio: "16:9",
|
||
Resolutions: []string{"480p", "720p", "1080p", "1440p", "2160p"},
|
||
DefaultResolution: "720p",
|
||
GenerateAudio: VideoBooleanConfig{Supported: false, Default: false},
|
||
Watermark: VideoBooleanConfig{Supported: false, Default: false},
|
||
Operations: []string{"text_to_video", "image_to_video"},
|
||
DefaultOperation: "text_to_video",
|
||
}
|
||
switch model.ChannelInterfaceType(protocol) {
|
||
case model.ChannelInterfaceVolcengineJiMengVideo:
|
||
video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{5, 10}, Default: 5}
|
||
video.Resolutions = []string{"720p"}
|
||
case model.ChannelInterfaceGeminiVeo:
|
||
video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{4, 6, 8}, Default: 6}
|
||
video.Resolutions = []string{"720p", "1080p"}
|
||
case model.ChannelInterfaceVolcengineArkVideo:
|
||
video.Operations = append(video.Operations, "reference_to_video", "audio_to_video")
|
||
video.References.MaxVideos, video.References.MaxAudios = 3, 3
|
||
video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024
|
||
video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15
|
||
video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true}
|
||
video.Watermark = VideoBooleanConfig{Supported: true, Default: false}
|
||
video.Resolutions = []string{"480p", "720p", "1080p"}
|
||
case model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2:
|
||
video.References.MaxVideos, video.References.MaxAudios = 3, 3
|
||
video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024
|
||
video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15
|
||
video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true}
|
||
if model.ChannelInterfaceType(protocol) == model.ChannelInterfaceNewAPIChannel1 {
|
||
video.Resolutions = []string{"480p", "720p", "1080p"}
|
||
}
|
||
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceXAIVideo:
|
||
video.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false}
|
||
case model.ChannelInterfaceNovitaVideo:
|
||
video.References.MaxImages, video.References.MaxImageBytes = 1, 10*1024*1024
|
||
video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{5, 10}, Default: 5}
|
||
video.Ratios = []string{"16:9", "9:16", "1:1"}
|
||
video.Resolutions = []string{"1080p"}
|
||
video.DefaultResolution = "1080p"
|
||
case model.ChannelInterfaceMiniMaxVideo:
|
||
video.Operations = append(video.Operations, "reference_to_video")
|
||
video.References.MaxImages = 9
|
||
video.References.MaxImageBytes = 30 * 1024 * 1024
|
||
video.References.MaxVideos = 3
|
||
video.References.MaxVideoBytes = 50 * 1024 * 1024
|
||
video.References.MaxVideoDuration = 15
|
||
video.References.MaxAudios = 3
|
||
video.References.MaxAudioBytes = 15 * 1024 * 1024
|
||
video.References.MaxAudioDuration = 15
|
||
video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, Default: 5}
|
||
video.Ratios = []string{"adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
|
||
video.DefaultRatio = "16:9"
|
||
video.Resolutions = []string{"768P", "2K"}
|
||
video.DefaultResolution = "768P"
|
||
video.Watermark = VideoBooleanConfig{Supported: true, Default: false}
|
||
case model.ChannelInterfaceAgnesVideo:
|
||
video = applyModelSpecificVideoCapability(video, protocol, modelName)
|
||
}
|
||
return &ModelCapabilityConfig{Version: 1, Text: text, Image: DefaultImageCapabilityConfig(protocol, modelName), Video: video}
|
||
}
|
||
|
||
func DecodeModelCapabilityConfig(raw string) (*ModelCapabilityConfig, error) {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return nil, nil
|
||
}
|
||
var value ModelCapabilityConfig
|
||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||
return nil, err
|
||
}
|
||
return &value, nil
|
||
}
|
||
|
||
// normalizedChannelModelCapability 从持久化记录恢复渠道模型的权威能力合同。
|
||
// 目录读取可以选择隔离损坏记录;任务创建等写路径必须把错误向上返回并失败关闭。
|
||
func normalizedChannelModelCapability(channelModel *model.ChannelModel) (*ModelCapabilityConfig, error) {
|
||
if channelModel == nil {
|
||
return nil, errors.New("渠道模型为空")
|
||
}
|
||
capability := normalizeCapability(channelModel.Capability)
|
||
if capability == "audio" {
|
||
return nil, nil
|
||
}
|
||
if capability != "text" && capability != "image" && capability != "video" {
|
||
return nil, fmt.Errorf("不支持的渠道模型能力:%s", channelModel.Capability)
|
||
}
|
||
config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析渠道模型能力配置失败:%w", err)
|
||
}
|
||
normalized, err := NormalizeModelCapabilityConfigForModel(capability, string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return normalized, nil
|
||
}
|
||
|
||
func NormalizeModelCapabilityConfig(capability string, protocol string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) {
|
||
return NormalizeModelCapabilityConfigForModel(capability, protocol, "", input)
|
||
}
|
||
|
||
func NormalizeModelCapabilityConfigForModel(capability string, protocol string, modelName string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) {
|
||
if capability != "text" && capability != "image" && capability != "video" {
|
||
return nil, nil
|
||
}
|
||
if capability == "text" {
|
||
if input == nil || input.Text == nil {
|
||
return nil, BadAuthRequest("请配置文本模型能力参数")
|
||
}
|
||
value := &ModelCapabilityConfig{Version: 1, Text: input.Text}
|
||
if err := validateTextCapabilityConfig(value.Text); err != nil {
|
||
return nil, err
|
||
}
|
||
return value, nil
|
||
}
|
||
if capability == "image" {
|
||
if input == nil || input.Image == nil {
|
||
return nil, BadAuthRequest("请配置图片模型能力参数")
|
||
}
|
||
value := &ModelCapabilityConfig{Version: 1, Image: input.Image}
|
||
if err := validateImageCapabilityConfig(value.Image); err != nil {
|
||
return nil, err
|
||
}
|
||
return value, nil
|
||
}
|
||
if input == nil || input.Video == nil {
|
||
return nil, BadAuthRequest("请配置视频模型能力参数")
|
||
}
|
||
value := &ModelCapabilityConfig{Version: 1, Video: applyModelSpecificVideoCapability(input.Video, protocol, modelName)}
|
||
if err := validateVideoCapabilityConfig(value.Video); err != nil {
|
||
return nil, err
|
||
}
|
||
return value, nil
|
||
}
|
||
|
||
func applyModelSpecificVideoCapability(profile *VideoCapabilityConfig, protocol string, modelName string) *VideoCapabilityConfig {
|
||
if profile == nil || model.ChannelInterfaceType(strings.TrimSpace(protocol)) != model.ChannelInterfaceAgnesVideo {
|
||
return profile
|
||
}
|
||
normalizedModel := strings.ToLower(strings.TrimSpace(modelName))
|
||
if normalizedModel != "agnes-video-2.5" && normalizedModel != "agnes-video-2.5-flash" {
|
||
return profile
|
||
}
|
||
value := *profile
|
||
value.References = profile.References
|
||
flash := normalizedModel == "agnes-video-2.5-flash"
|
||
value.References.MaxImages = 9
|
||
value.References.MaxVideos = 3
|
||
value.References.MaxAudios = 3
|
||
value.References.MaxVideoBytes = 200 * 1024 * 1024
|
||
value.References.MaxVideoDuration = 15
|
||
value.References.MaxAudioBytes = 15 * 1024 * 1024
|
||
value.References.MaxAudioDuration = 15
|
||
if flash {
|
||
value.References.MaxImages = 5
|
||
value.References.MaxVideos = 0
|
||
value.References.MaxVideoBytes = 0
|
||
value.References.MaxVideoDuration = 0
|
||
}
|
||
value.Duration = VideoDurationConfig{Selection: "range", Min: 4, Max: 12, Step: 1, Default: 5}
|
||
value.Ratios = []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
|
||
value.DefaultRatio = "16:9"
|
||
value.Resolutions = []string{"720P", "960P", "2K"}
|
||
if flash {
|
||
value.Resolutions = []string{"720P"}
|
||
}
|
||
value.DefaultResolution = "720P"
|
||
value.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false}
|
||
value.Watermark = VideoBooleanConfig{Supported: false, Default: false}
|
||
value.Operations = []string{"text_to_video", "image_to_video", "reference_to_video", "audio_to_video"}
|
||
value.DefaultOperation = "text_to_video"
|
||
return &value
|
||
}
|
||
|
||
// CapabilitySpecFromModelCapabilityConfig 将渠道模型的真实供应能力投影为路由能力规格。
|
||
// 渠道模型能力参数是唯一事实来源,前台模型供应线路直接引用该规格。
|
||
func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capability string) (CapabilitySpec, error) {
|
||
spec := CapabilitySpec{Version: 1, Capability: capability, Inputs: map[string]InputConstraint{}, Options: map[string]OptionConstraint{}}
|
||
// 音频模型当前没有可编辑的渠道能力 JSON,使用空能力规格表示“无额外路由约束”。
|
||
if capability == "audio" {
|
||
return spec, nil
|
||
}
|
||
if config == nil {
|
||
switch capability {
|
||
case "text":
|
||
return spec, BadAuthRequest("渠道文本模型尚未配置能力参数")
|
||
case "image":
|
||
return spec, BadAuthRequest("渠道图片模型尚未配置能力参数")
|
||
case "video":
|
||
return spec, BadAuthRequest("渠道视频模型尚未配置能力参数")
|
||
default:
|
||
return spec, BadAuthRequest("渠道模型尚未配置能力参数")
|
||
}
|
||
}
|
||
switch capability {
|
||
case "text":
|
||
if config.Text == nil {
|
||
return spec, BadAuthRequest("渠道文本模型尚未配置能力参数")
|
||
}
|
||
addInputConstraint(spec.Inputs, "image", 0, config.Text.References.MaxImages)
|
||
addInputConstraint(spec.Inputs, "video", 0, config.Text.References.MaxVideos)
|
||
case "image":
|
||
if config.Image == nil {
|
||
return spec, BadAuthRequest("渠道图片模型尚未配置能力参数")
|
||
}
|
||
image := config.Image
|
||
addInputConstraint(spec.Inputs, "image", 0, image.References.MaxImages)
|
||
if image.References.MaskSupported {
|
||
addInputConstraint(spec.Inputs, "mask", 0, 1)
|
||
}
|
||
if image.Size.Parameter != "none" {
|
||
spec.Options["size"] = imageSizeOptionConstraint(image.Size)
|
||
spec.ImageSize = capabilityImageSizeFromConfig(image.Size)
|
||
}
|
||
if image.Quality.Supported {
|
||
spec.Options["quality"] = anyValues(image.Quality.Values)
|
||
}
|
||
if image.TransparentBackground.Supported {
|
||
spec.Options["transparentBackground"] = boolValues(true)
|
||
} else {
|
||
spec.Options["transparentBackground"] = boolValues(false)
|
||
}
|
||
spec.Options["count"] = numericRange(1, float64(image.MaxOutputs), 1)
|
||
case "video":
|
||
if config.Video == nil {
|
||
return spec, BadAuthRequest("渠道视频模型尚未配置能力参数")
|
||
}
|
||
video := config.Video
|
||
spec.Operations = append([]string(nil), video.Operations...)
|
||
addInputConstraint(spec.Inputs, "image", video.References.MinImages, video.References.MaxImages)
|
||
addInputConstraint(spec.Inputs, "video", 0, video.References.MaxVideos)
|
||
addInputConstraint(spec.Inputs, "audio", 0, video.References.MaxAudios)
|
||
if video.Duration.Selection == "enum" {
|
||
values := make([]any, 0, len(video.Duration.Values))
|
||
for _, value := range video.Duration.Values {
|
||
values = append(values, value)
|
||
}
|
||
spec.Options["videoSeconds"] = OptionConstraint{Values: values}
|
||
} else {
|
||
spec.Options["videoSeconds"] = numericRange(float64(video.Duration.Min), float64(video.Duration.Max), float64(video.Duration.Step))
|
||
}
|
||
spec.Options["size"] = anyValues(video.Ratios)
|
||
if len(video.Resolutions) > 0 {
|
||
spec.Options["vquality"] = anyValues(video.Resolutions)
|
||
}
|
||
if video.GenerateAudio.Supported {
|
||
spec.Options["videoGenerateAudio"] = boolValues(true)
|
||
} else {
|
||
spec.Options["videoGenerateAudio"] = boolValues(false)
|
||
}
|
||
if video.Watermark.Supported {
|
||
spec.Options["videoWatermark"] = boolValues(true)
|
||
} else {
|
||
spec.Options["videoWatermark"] = boolValues(false)
|
||
}
|
||
default:
|
||
return spec, BadAuthRequest("未知模型能力类型")
|
||
}
|
||
return spec, nil
|
||
}
|
||
|
||
// imageSizeOptionConstraint 保留可见的标准尺寸/比例,同时用 * 表示允许自定义。
|
||
// * 不能替代标准值,否则管理端只能看到一个没有业务含义的通配符。
|
||
func imageSizeOptionConstraint(size ImageSizeConfig) OptionConstraint {
|
||
values := make([]string, 0, len(size.Values)+1)
|
||
seen := make(map[string]struct{}, len(size.Values)+1)
|
||
for _, value := range size.Values {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" || value == "*" {
|
||
continue
|
||
}
|
||
if _, exists := seen[value]; exists {
|
||
continue
|
||
}
|
||
seen[value] = struct{}{}
|
||
values = append(values, value)
|
||
}
|
||
if size.AllowCustom {
|
||
if len(values) == 0 {
|
||
for _, value := range legacyImageSizeValues() {
|
||
seen[value] = struct{}{}
|
||
values = append(values, value)
|
||
}
|
||
}
|
||
values = append(values, "*")
|
||
}
|
||
return anyValues(values)
|
||
}
|
||
|
||
func capabilityImageSizeFromConfig(size ImageSizeConfig) *CapabilityImageSize {
|
||
if size.Parameter == "" || size.Parameter == "none" {
|
||
return nil
|
||
}
|
||
result := &CapabilityImageSize{Parameter: size.Parameter, AllowCustom: size.AllowCustom}
|
||
for _, preset := range size.Presets {
|
||
tier := strings.ToLower(strings.TrimSpace(preset.Tier))
|
||
ratio := strings.TrimSpace(preset.Ratio)
|
||
value := strings.TrimSpace(preset.Size)
|
||
if tier == "" || ratio == "" || value == "" {
|
||
continue
|
||
}
|
||
result.Presets = append(result.Presets, CapabilityImageSizePreset{
|
||
Size: value, Tier: tier, Ratio: ratio, Width: preset.Width, Height: preset.Height,
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
func addInputConstraint(inputs map[string]InputConstraint, name string, min int, max int) {
|
||
if min <= 0 && max <= 0 {
|
||
return
|
||
}
|
||
inputs[name] = InputConstraint{Min: min, Max: max}
|
||
}
|
||
|
||
func validateTextCapabilityConfig(value *TextCapabilityConfig) error {
|
||
if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 {
|
||
return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间")
|
||
}
|
||
for name, number := range map[string]int{"最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos} {
|
||
if number < 0 || number > 100 {
|
||
return BadAuthRequest(name + "必须在 0-100 之间")
|
||
}
|
||
}
|
||
if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 {
|
||
return BadAuthRequest("引用素材大小限制不能小于 0")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateImageCapabilityConfig(value *ImageCapabilityConfig) error {
|
||
if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 {
|
||
return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间")
|
||
}
|
||
if value.References.MaxImages < 0 || value.References.MaxImages > 100 || value.References.MaxImageBytes < 0 {
|
||
return BadAuthRequest("图片引用限制无效")
|
||
}
|
||
if value.MaxOutputs < 1 || value.MaxOutputs > 100 {
|
||
return BadAuthRequest("单次图片数量必须在 1-100 之间")
|
||
}
|
||
switch value.Size.Parameter {
|
||
case "none":
|
||
value.Size.Values = []string{}
|
||
value.Size.Presets = nil
|
||
value.Size.Default = "auto"
|
||
value.Size.AllowCustom = false
|
||
case "size", "aspect_ratio":
|
||
if strings.TrimSpace(value.Size.Default) == "" {
|
||
return BadAuthRequest("请配置默认图片尺寸或比例")
|
||
}
|
||
if !value.Size.AllowCustom && !containsCapabilityString(value.Size.Values, value.Size.Default) {
|
||
return BadAuthRequest("默认图片尺寸必须属于支持值")
|
||
}
|
||
default:
|
||
return BadAuthRequest("尺寸参数仅支持不发送、size 或 aspect_ratio")
|
||
}
|
||
seenPresets := make(map[string]bool)
|
||
for _, preset := range value.Size.Presets {
|
||
if preset.Tier != "1k" && preset.Tier != "2k" && preset.Tier != "4k" {
|
||
return BadAuthRequest("图片分辨率档位仅支持 1K、2K、4K")
|
||
}
|
||
parts := strings.Split(preset.Ratio, ":")
|
||
if len(parts) != 2 {
|
||
return BadAuthRequest("图片预设比例格式无效")
|
||
}
|
||
w, ew := strconv.Atoi(parts[0])
|
||
h, eh := strconv.Atoi(parts[1])
|
||
if ew != nil || eh != nil || w <= 0 || h <= 0 || w > 100000 || h > 100000 || max(w, h) > min(w, h)*3 {
|
||
return BadAuthRequest("图片预设比例无效")
|
||
}
|
||
if preset.Width <= 0 || preset.Height <= 0 || max(preset.Width, preset.Height) > 3840 || max(preset.Width, preset.Height) > min(preset.Width, preset.Height)*3 || preset.Width*preset.Height < 655360 || preset.Width*preset.Height > 8294400 || preset.Size != fmt.Sprintf("%dx%d", preset.Width, preset.Height) {
|
||
return BadAuthRequest("图片预设像素尺寸无效")
|
||
}
|
||
// 容许像素取整误差,但不能将横屏尺寸标记成竖屏或其他比例。
|
||
difference := preset.Width*h - preset.Height*w
|
||
if difference < 0 {
|
||
difference = -difference
|
||
}
|
||
if difference*1000 > preset.Height*w*25 {
|
||
return BadAuthRequest("图片预设像素尺寸与宽高比不一致")
|
||
}
|
||
a, b := w, h
|
||
for b != 0 {
|
||
a, b = b, a%b
|
||
}
|
||
key := fmt.Sprintf("%s:%d:%d", preset.Tier, w/a, h/a)
|
||
if seenPresets[key] {
|
||
return BadAuthRequest("图片尺寸预设重复")
|
||
}
|
||
seenPresets[key] = true
|
||
requestValue := preset.Size
|
||
if value.Size.Parameter == "aspect_ratio" {
|
||
requestValue = preset.Ratio
|
||
}
|
||
if !containsCapabilityString(value.Size.Values, requestValue) {
|
||
return BadAuthRequest("图片预设必须包含在尺寸支持值中")
|
||
}
|
||
}
|
||
if value.Quality.Supported {
|
||
if len(value.Quality.Values) == 0 || strings.TrimSpace(value.Quality.Default) == "" || !containsCapabilityString(value.Quality.Values, value.Quality.Default) {
|
||
return BadAuthRequest("请配置图片质量支持值和默认值")
|
||
}
|
||
} else {
|
||
value.Quality.Values = []string{}
|
||
value.Quality.Default = "auto"
|
||
}
|
||
if err := validateImagePresetSelection(value, value.Quality.Default, value.Size.Default); err != nil {
|
||
return BadAuthRequest("默认图片分辨率与宽高比不在已配置的组合中")
|
||
}
|
||
if !value.TransparentBackground.Supported {
|
||
value.TransparentBackground.Default = false
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error {
|
||
if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 {
|
||
return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间")
|
||
}
|
||
for name, number := range map[string]int{"最少图片引用数": value.References.MinImages, "最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} {
|
||
if number < 0 || number > 100 {
|
||
return BadAuthRequest(name + "必须在 0-100 之间")
|
||
}
|
||
}
|
||
if value.References.MinImages > value.References.MaxImages {
|
||
return BadAuthRequest("最少图片引用数不能超过最大图片引用数")
|
||
}
|
||
if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 || value.References.MaxAudioBytes < 0 || value.References.MaxVideoDuration < 0 || value.References.MaxAudioDuration < 0 {
|
||
return BadAuthRequest("引用素材限制不能小于 0")
|
||
}
|
||
if err := validateVideoDuration(value.Duration); err != nil {
|
||
return err
|
||
}
|
||
if len(value.Ratios) == 0 {
|
||
if strings.TrimSpace(value.DefaultRatio) != "" {
|
||
return BadAuthRequest("未配置画面比例时不能设置默认比例")
|
||
}
|
||
} else if strings.TrimSpace(value.DefaultRatio) == "" || !containsCapabilityString(value.Ratios, value.DefaultRatio) {
|
||
return BadAuthRequest("默认画面比例必须属于支持值")
|
||
}
|
||
if len(value.Resolutions) == 0 {
|
||
if strings.TrimSpace(value.DefaultResolution) != "" {
|
||
return BadAuthRequest("未配置输出分辨率时不能设置默认分辨率")
|
||
}
|
||
} else if strings.TrimSpace(value.DefaultResolution) == "" || !containsCapabilityString(value.Resolutions, value.DefaultResolution) {
|
||
return BadAuthRequest("默认输出分辨率必须属于支持值")
|
||
}
|
||
if len(value.Operations) == 0 || strings.TrimSpace(value.DefaultOperation) == "" || !containsCapabilityString(value.Operations, value.DefaultOperation) {
|
||
return BadAuthRequest("请至少配置一个生成模式,并选择默认模式")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateVideoDuration(value VideoDurationConfig) error {
|
||
switch value.Selection {
|
||
case "range":
|
||
if value.Min < 1 || value.Max < value.Min || value.Max > 3600 || value.Step < 1 || value.Default < value.Min || value.Default > value.Max || (value.Default-value.Min)%value.Step != 0 {
|
||
return BadAuthRequest("视频时长范围或默认值无效")
|
||
}
|
||
case "enum":
|
||
if len(value.Values) == 0 || len(value.Values) > 100 {
|
||
return BadAuthRequest("视频固定时长至少需要一个选项")
|
||
}
|
||
values := append([]int(nil), value.Values...)
|
||
sort.Ints(values)
|
||
for index, item := range values {
|
||
if item < 1 || item > 3600 || (index > 0 && values[index-1] == item) {
|
||
return BadAuthRequest("视频固定时长选项无效或重复")
|
||
}
|
||
}
|
||
if !containsInt(values, value.Default) {
|
||
return BadAuthRequest("视频默认时长必须属于固定时长选项")
|
||
}
|
||
default:
|
||
return BadAuthRequest("视频时长选择方式仅支持范围或固定值")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) ValidateTaskCapability(input map[string]any) error {
|
||
encoded, err := json.Marshal(input)
|
||
if err != nil {
|
||
return BadAuthRequest("任务输入格式无效")
|
||
}
|
||
var taskInput canvasGenerationInput
|
||
if err := json.Unmarshal(encoded, &taskInput); err != nil || (taskInput.Mode != "image" && taskInput.Mode != "video" && taskInput.Mode != "audio") {
|
||
return nil
|
||
}
|
||
if isWorkflowProviderInterface(taskInput.Config.InterfaceType) {
|
||
if err := validateWorkflowProviderPromptLength(taskInput); err != nil {
|
||
return err
|
||
}
|
||
return validateWorkflowProviderConfig(taskInput.Mode, taskInput.Config)
|
||
}
|
||
// 普通音频模型沿用主线的能力校验路径;当前专用能力表只覆盖图片和视频。
|
||
if taskInput.Mode == "audio" {
|
||
return nil
|
||
}
|
||
channelID := strings.TrimSpace(taskInput.Config.ChannelID)
|
||
if channelID == "" {
|
||
channelID = systemChannelIDFromBaseURL(taskInput.Config.BaseURL)
|
||
}
|
||
if channelID == "" {
|
||
if taskInput.Mode == "image" {
|
||
profile := DefaultImageCapabilityConfig(taskInput.Config.InterfaceType, taskInput.Config.Model)
|
||
if taskInput.Config.CapabilityConfig != nil && taskInput.Config.CapabilityConfig.Image != nil {
|
||
profile = taskInput.Config.CapabilityConfig.Image
|
||
}
|
||
return validateImageTask(profile, taskInput)
|
||
}
|
||
profile := taskInput.Config.CapabilityConfig
|
||
if profile == nil || profile.Video == nil {
|
||
if taskInput.Config.InterfaceType != string(model.ChannelInterfaceAgnesVideo) {
|
||
return nil
|
||
}
|
||
profile = DefaultModelCapabilityConfigForModel(taskInput.Config.InterfaceType, taskInput.Config.Model)
|
||
}
|
||
normalized, normalizeErr := NormalizeModelCapabilityConfigForModel("video", taskInput.Config.InterfaceType, taskInput.Config.Model, profile)
|
||
if normalizeErr != nil || normalized == nil || normalized.Video == nil {
|
||
return BadAuthRequest("当前视频模型能力参数无效")
|
||
}
|
||
return validateVideoTask(normalized.Video, taskInput)
|
||
}
|
||
item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(taskInput.Config))
|
||
if err != nil {
|
||
return BadAuthRequest("当前系统渠道模型未配置或已停用")
|
||
}
|
||
profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
|
||
if taskInput.Mode == "image" {
|
||
if err != nil {
|
||
return BadAuthRequest("当前图片模型能力参数无效")
|
||
}
|
||
imageProfile := DefaultImageCapabilityConfig(string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey))
|
||
if profile != nil && profile.Image != nil {
|
||
imageProfile = profile.Image
|
||
}
|
||
return validateImageTask(applyModelSpecificImageCapability(imageProfile, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), taskInput.Config.APIFormat), taskInput)
|
||
}
|
||
if err != nil || profile == nil || profile.Video == nil {
|
||
return BadAuthRequest("当前视频模型尚未配置能力参数")
|
||
}
|
||
normalized, normalizeErr := NormalizeModelCapabilityConfigForModel("video", string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), profile)
|
||
if normalizeErr != nil || normalized == nil || normalized.Video == nil {
|
||
return BadAuthRequest("当前视频模型能力参数无效")
|
||
}
|
||
applyFixedVideoResolution(&taskInput, normalized.Video)
|
||
if config, ok := input["config"].(map[string]any); ok {
|
||
config["vquality"] = taskInput.Config.VQuality
|
||
}
|
||
return validateVideoTask(normalized.Video, taskInput)
|
||
}
|
||
|
||
// applyModelSpecificImageCapability is retained as a narrow normalization hook
|
||
// for provider-specific image validation. The stored capability profile is
|
||
// already normalized when the channel model is saved, so no second override is
|
||
// needed here.
|
||
func applyModelSpecificImageCapability(profile *ImageCapabilityConfig, _ string, _ string, _ string) *ImageCapabilityConfig {
|
||
return profile
|
||
}
|
||
|
||
// applyFixedVideoResolution 让单档位 SKU 的预扣、恢复和上游请求保持同一分辨率。
|
||
func applyFixedVideoResolution(input *canvasGenerationInput, profile *VideoCapabilityConfig) {
|
||
if input == nil || profile == nil || len(profile.Resolutions) != 1 {
|
||
return
|
||
}
|
||
if resolution := videoResolutionNameRequest(profile, profile.Resolutions[0]); resolution != "" {
|
||
input.Config.VQuality = resolution
|
||
}
|
||
}
|
||
|
||
func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInput) error {
|
||
if profile == nil {
|
||
return BadAuthRequest("当前视频模型能力参数无效")
|
||
}
|
||
if err := validateModelPromptLength("视频", input.Prompt, profile.References.PromptMaxChars); err != nil {
|
||
return err
|
||
}
|
||
if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios {
|
||
return BadAuthRequest("参考素材数量超过当前模型限制")
|
||
}
|
||
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) && len(input.ReferenceAudios) > 0 && len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 {
|
||
return BadAuthRequest("火山方舟全模态参考不支持纯音频或文本+音频,请同时添加参考图片或参考视频")
|
||
}
|
||
if len(input.ReferenceImages) < profile.References.MinImages {
|
||
return BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages))
|
||
}
|
||
for _, media := range input.ReferenceImages {
|
||
if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes {
|
||
return BadAuthRequest("参考图片文件超过当前模型大小限制")
|
||
}
|
||
}
|
||
for _, media := range input.ReferenceVideos {
|
||
if profile.References.MaxVideoBytes > 0 && media.Bytes > profile.References.MaxVideoBytes {
|
||
return BadAuthRequest("参考视频文件超过当前模型大小限制")
|
||
}
|
||
if profile.References.MaxVideoDuration > 0 && media.DurationMs > int64(profile.References.MaxVideoDuration)*1000 {
|
||
return BadAuthRequest("参考视频时长超过当前模型限制")
|
||
}
|
||
}
|
||
for _, media := range input.ReferenceAudios {
|
||
if profile.References.MaxAudioBytes > 0 && media.Bytes > profile.References.MaxAudioBytes {
|
||
return BadAuthRequest("参考音频文件超过当前模型大小限制")
|
||
}
|
||
if profile.References.MaxAudioDuration > 0 && media.DurationMs > int64(profile.References.MaxAudioDuration)*1000 {
|
||
return BadAuthRequest("参考音频时长超过当前模型限制")
|
||
}
|
||
}
|
||
seconds, err := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds))
|
||
if err != nil || !videoDurationAllowed(profile.Duration, seconds) {
|
||
return BadAuthRequest("视频时长不在当前模型支持范围内")
|
||
}
|
||
if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) {
|
||
return BadAuthRequest("画面比例不在当前模型支持范围内")
|
||
}
|
||
if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" {
|
||
return BadAuthRequest("输出分辨率不在当前模型支持范围内")
|
||
}
|
||
operation := metadataString(input.Metadata, "videoEditOperation")
|
||
if operation == "" {
|
||
if len(input.ReferenceImages) > 0 {
|
||
operation = "image_to_video"
|
||
} else {
|
||
operation = profile.DefaultOperation
|
||
}
|
||
}
|
||
if !containsCapabilityString(profile.Operations, operation) {
|
||
return BadAuthRequest("当前视频模型不支持该生成模式")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInput) error {
|
||
if profile == nil {
|
||
return nil
|
||
}
|
||
modelName := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(input.Config.Model)), "models/")
|
||
if input.Config.InterfaceType == string(model.ChannelInterfaceGrokImage) && modelName == "grok-imagine-image-quality" {
|
||
const maxPromptBytes = 8000
|
||
promptBytes := len(withSystemPrompt(input.Config, input.Prompt))
|
||
if promptBytes > maxPromptBytes {
|
||
return BadAuthRequest(fmt.Sprintf("Grok 图片完整提示词为 %d UTF-8 字节,超过上游 %d 字节限制。系统不会自动删改;请精简当前输入、连线文本、角色卡、画风或模板内容后重试", promptBytes, maxPromptBytes))
|
||
}
|
||
}
|
||
if len(input.ReferenceImages) > profile.References.MaxImages {
|
||
return BadAuthRequest(fmt.Sprintf("当前图片模型最多支持 %d 张参考图", profile.References.MaxImages))
|
||
}
|
||
for _, media := range input.ReferenceImages {
|
||
if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes {
|
||
return BadAuthRequest("参考图片文件超过当前模型大小限制")
|
||
}
|
||
}
|
||
if input.Mask != nil && !profile.References.MaskSupported {
|
||
return BadAuthRequest("当前图片模型不支持蒙版编辑")
|
||
}
|
||
if profile.Size.Parameter != "none" && !profile.Size.AllowCustom && strings.TrimSpace(input.Config.Size) != "" && !containsCapabilityString(profile.Size.Values, input.Config.Size) {
|
||
return BadAuthRequest("图片尺寸不在当前模型支持范围内")
|
||
}
|
||
if profile.Size.Parameter == "size" && profile.Size.AllowCustom && strings.HasPrefix(modelName, "gpt-image-2") && !containsCapabilityString(profile.Size.Values, input.Config.Size) {
|
||
if err := validateGPTImage2CustomSize(input.Config.Size); err != nil {
|
||
return BadAuthRequest(err.Error())
|
||
}
|
||
}
|
||
quality := strings.TrimSpace(input.Config.Quality)
|
||
if profile.Quality.Supported && quality != "" && !strings.EqualFold(quality, "auto") && !strings.EqualFold(quality, "any") && !containsCapabilityString(profile.Quality.Values, quality) {
|
||
return BadAuthRequest("图片质量不在当前模型支持范围内")
|
||
}
|
||
if err := validateImagePresetSelection(profile, qualityForImageValidation(quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil {
|
||
return err
|
||
}
|
||
count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count))
|
||
if err == nil && count > profile.MaxOutputs {
|
||
return BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func qualityForImageValidation(quality string, fallback string) string {
|
||
if strings.EqualFold(quality, "auto") || strings.EqualFold(quality, "any") {
|
||
return fallback
|
||
}
|
||
return firstNonEmpty(quality, fallback)
|
||
}
|
||
|
||
func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio string) error {
|
||
if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" {
|
||
return nil
|
||
}
|
||
tier := imageResolutionTier(quality)
|
||
if tier == "" {
|
||
return nil
|
||
}
|
||
for _, preset := range profile.Size.Presets {
|
||
if preset.Tier == tier && preset.Ratio == ratio {
|
||
return nil
|
||
}
|
||
}
|
||
return BadAuthRequest("当前分辨率不支持所选图片宽高比")
|
||
}
|
||
|
||
func imageResolutionTier(quality string) string {
|
||
switch strings.ToLower(strings.TrimSpace(quality)) {
|
||
case "1k", "low":
|
||
return "1k"
|
||
case "2k", "medium":
|
||
return "2k"
|
||
case "4k", "high":
|
||
return "4k"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func validateWorkflowProviderPromptLength(input canvasGenerationInput) error {
|
||
profile := input.Config.CapabilityConfig
|
||
if input.Mode != "video" || profile == nil || profile.Video == nil {
|
||
return nil
|
||
}
|
||
return validateModelPromptLength("视频", input.Prompt, profile.Video.References.PromptMaxChars)
|
||
}
|
||
|
||
func validateModelPromptLength(label string, prompt string, maxChars int) error {
|
||
if maxChars <= 0 {
|
||
return nil
|
||
}
|
||
actualChars := utf8.RuneCountInString(prompt)
|
||
if actualChars <= maxChars {
|
||
return nil
|
||
}
|
||
return BadAuthRequest(fmt.Sprintf("当前%s模型提示词最多 %d 个字符,完整提示词为 %d 个字符。系统不会自动截断,请精简当前输入、连线内容或技能上下文后重试", label, maxChars, actualChars))
|
||
}
|
||
|
||
func validateGPTImage2CustomSize(value string) error {
|
||
value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x")))
|
||
if value == "" || value == "auto" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(value, "x")
|
||
if len(parts) != 2 {
|
||
return errors.New("自定义图片尺寸请使用宽x高,例如 3840x1920")
|
||
}
|
||
width, widthErr := strconv.Atoi(parts[0])
|
||
height, heightErr := strconv.Atoi(parts[1])
|
||
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
|
||
return errors.New("图片尺寸必须是正整数")
|
||
}
|
||
if width%16 != 0 || height%16 != 0 {
|
||
return errors.New("图片尺寸宽高必须是 16 的倍数")
|
||
}
|
||
if max(width, height) > 3840 {
|
||
return errors.New("图片尺寸最长边不能超过 3840px")
|
||
}
|
||
if max(width, height) > min(width, height)*3 {
|
||
return errors.New("图片宽高比不能超过 3:1")
|
||
}
|
||
pixels := int64(width) * int64(height)
|
||
if pixels < 655360 || pixels > 8294400 {
|
||
return errors.New("图片总像素需在 655360 到 8294400 之间")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func videoDurationAllowed(value VideoDurationConfig, seconds int) bool {
|
||
if value.Selection == "enum" {
|
||
return containsInt(value.Values, seconds)
|
||
}
|
||
return seconds >= value.Min && seconds <= value.Max && value.Step > 0 && (seconds-value.Min)%value.Step == 0
|
||
}
|
||
|
||
func videoRatioAllowed(options []string, value string) bool {
|
||
value = strings.TrimSpace(strings.ToLower(strings.ReplaceAll(value, "×", "x")))
|
||
if containsCapabilityString(options, value) {
|
||
return true
|
||
}
|
||
parts := strings.Split(value, "x")
|
||
if len(parts) != 2 {
|
||
return false
|
||
}
|
||
width, widthErr := strconv.ParseFloat(parts[0], 64)
|
||
height, heightErr := strconv.ParseFloat(parts[1], 64)
|
||
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
|
||
return false
|
||
}
|
||
actual := width / height
|
||
for _, option := range options {
|
||
candidate := ratioValue(option)
|
||
if candidate > 0 && absFloat(candidate-actual)/candidate < 0.01 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func ratioValue(value string) float64 {
|
||
parts := strings.Split(strings.TrimSpace(value), ":")
|
||
if len(parts) != 2 {
|
||
return 0
|
||
}
|
||
width, widthErr := strconv.ParseFloat(parts[0], 64)
|
||
height, heightErr := strconv.ParseFloat(parts[1], 64)
|
||
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
|
||
return 0
|
||
}
|
||
return width / height
|
||
}
|
||
|
||
func normalizeResolution(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
value = strings.TrimSuffix(value, "p")
|
||
if value == "2k" {
|
||
return "1440p"
|
||
}
|
||
if value == "4k" {
|
||
return "2160p"
|
||
}
|
||
return value + "p"
|
||
}
|
||
|
||
func containsCapabilityString(values []string, target string) bool {
|
||
for _, value := range values {
|
||
if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func containsInt(values []int, target int) bool {
|
||
for _, value := range values {
|
||
if value == target {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func absFloat(value float64) float64 {
|
||
if value < 0 {
|
||
return -value
|
||
}
|
||
return value
|
||
}
|