feat(*): 模型能力配置 - 支持模型级视频限制校验与协议管理
This commit is contained in:
@@ -237,6 +237,9 @@ type ChannelModel struct {
|
||||
PriceConfigured bool `json:"priceConfigured" gorm:"index"`
|
||||
Enabled bool `json:"enabled" gorm:"index"`
|
||||
PriceVersion int64 `json:"priceVersion"`
|
||||
CapabilityConfigJSON string `json:"-" gorm:"type:text"`
|
||||
CapabilityVersion int64 `json:"capabilityVersion"`
|
||||
CapabilityConfig map[string]any `json:"capabilityConfig,omitempty" gorm:"-"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
@@ -123,6 +123,7 @@ type PublicChannelModelPrice struct {
|
||||
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
|
||||
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
|
||||
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
|
||||
CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) RequireAdmin(user *model.User) error {
|
||||
@@ -749,7 +750,8 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
}
|
||||
models = append(models, item.ModelKey)
|
||||
if item.Enabled && item.PriceConfigured {
|
||||
modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits})
|
||||
capabilityConfig, _ := DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
|
||||
modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig})
|
||||
}
|
||||
}
|
||||
if len(models) == 0 {
|
||||
|
||||
@@ -14,17 +14,18 @@ import (
|
||||
)
|
||||
|
||||
type ChannelModelRequest struct {
|
||||
ModelKey string `json:"modelKey"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Capability string `json:"capability"`
|
||||
Protocol string `json:"protocol"`
|
||||
BillingMode string `json:"billingMode"`
|
||||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||||
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
|
||||
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
|
||||
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
|
||||
PriceConfigured bool `json:"priceConfigured"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ModelKey string `json:"modelKey"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Capability string `json:"capability"`
|
||||
Protocol string `json:"protocol"`
|
||||
BillingMode string `json:"billingMode"`
|
||||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||||
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
|
||||
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
|
||||
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
|
||||
PriceConfigured bool `json:"priceConfigured"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"`
|
||||
}
|
||||
|
||||
// AdminChannelModelFetchResult 是管理员从上游拉目录后的汇总:models 为去重后的标识,added 为本次新建条数。
|
||||
@@ -63,7 +64,20 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod
|
||||
if _, err := s.repo.AdminSystemChannel(channelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ensureChannelModels(channelID, true)
|
||||
items, err := s.ensureChannelModels(channelID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range items {
|
||||
if strings.TrimSpace(items[index].CapabilityConfigJSON) == "" {
|
||||
continue
|
||||
}
|
||||
var config map[string]any
|
||||
if json.Unmarshal([]byte(items[index].CapabilityConfigJSON), &config) == nil {
|
||||
items[index].CapabilityConfig = config
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) SystemChannelModel(channelID string, modelKey string) (*model.ChannelModel, error) {
|
||||
@@ -123,6 +137,11 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if capability == "video" {
|
||||
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
billingMode := strings.TrimSpace(req.BillingMode)
|
||||
if billingMode == "" {
|
||||
billingMode = "fixed_request"
|
||||
@@ -174,6 +193,23 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
item.OutputTokenPriceMicrocredits = req.OutputTokenPriceMicrocredits
|
||||
item.CachedTokenPriceMicrocredits = req.CachedTokenPriceMicrocredits
|
||||
item.PriceConfigured = req.PriceConfigured
|
||||
if capability == "video" {
|
||||
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig)
|
||||
if normalizeErr != nil {
|
||||
return nil, normalizeErr
|
||||
}
|
||||
encoded, encodeErr := json.Marshal(capabilityConfig)
|
||||
if encodeErr != nil {
|
||||
return nil, encodeErr
|
||||
}
|
||||
if item.CapabilityConfigJSON != string(encoded) {
|
||||
item.CapabilityVersion++
|
||||
}
|
||||
item.CapabilityConfigJSON = string(encoded)
|
||||
} else {
|
||||
item.CapabilityConfigJSON = ""
|
||||
item.CapabilityVersion = 0
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
item.Enabled = *req.Enabled
|
||||
}
|
||||
@@ -198,6 +234,11 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if capability == "video" {
|
||||
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(channel.BaseURL) == "" || strings.TrimSpace(channel.APIKey) == "" {
|
||||
return nil, BadAuthRequest("请先在渠道中配置 Base URL 和 API Key")
|
||||
}
|
||||
|
||||
332
backend/internal/service/model_capability.go
Normal file
332
backend/internal/service/model_capability.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
// ModelCapabilityConfig 是模型能力声明,不包含供应商字段名;协议适配器负责把统一参数映射到上游请求。
|
||||
type ModelCapabilityConfig struct {
|
||||
Version int `json:"version"`
|
||||
Video *VideoCapabilityConfig `json:"video,omitempty"`
|
||||
}
|
||||
|
||||
type VideoCapabilityConfig struct {
|
||||
References VideoReferenceConfig `json:"references"`
|
||||
Duration VideoDurationConfig `json:"duration"`
|
||||
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"`
|
||||
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 {
|
||||
video := &VideoCapabilityConfig{
|
||||
References: VideoReferenceConfig{PromptMaxChars: 1000, 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", "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.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}
|
||||
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}
|
||||
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceXAIVideo:
|
||||
video.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false}
|
||||
}
|
||||
return &ModelCapabilityConfig{Version: 1, 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
|
||||
}
|
||||
|
||||
func NormalizeModelCapabilityConfig(capability string, protocol string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) {
|
||||
if capability != "video" {
|
||||
return nil, nil
|
||||
}
|
||||
if input == nil || input.Video == nil {
|
||||
return nil, BadAuthRequest("请配置视频模型能力参数")
|
||||
}
|
||||
value := &ModelCapabilityConfig{Version: 1, Video: input.Video}
|
||||
if err := validateVideoCapabilityConfig(value.Video); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, 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.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} {
|
||||
if number < 0 || number > 100 {
|
||||
return BadAuthRequest(name + "必须在 0-100 之间")
|
||||
}
|
||||
}
|
||||
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 || strings.TrimSpace(value.DefaultRatio) == "" || !containsCapabilityString(value.Ratios, value.DefaultRatio) {
|
||||
return BadAuthRequest("请至少配置一个画面比例,并选择默认比例")
|
||||
}
|
||||
if len(value.Resolutions) == 0 || 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 != "video" {
|
||||
return nil
|
||||
}
|
||||
channelID := strings.TrimSpace(taskInput.Config.ChannelID)
|
||||
if channelID == "" {
|
||||
channelID = systemChannelIDFromBaseURL(taskInput.Config.BaseURL)
|
||||
}
|
||||
if channelID == "" {
|
||||
if taskInput.Config.CapabilityConfig == nil || taskInput.Config.CapabilityConfig.Video == nil {
|
||||
return nil
|
||||
}
|
||||
return validateVideoTask(taskInput.Config.CapabilityConfig.Video, taskInput)
|
||||
}
|
||||
item, err := s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(taskInput.Config.Model), "models/"))
|
||||
if err != nil {
|
||||
return BadAuthRequest("当前系统渠道模型未配置或已停用")
|
||||
}
|
||||
profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
|
||||
if err != nil || profile == nil || profile.Video == nil {
|
||||
return BadAuthRequest("当前视频模型尚未配置能力参数")
|
||||
}
|
||||
return validateVideoTask(profile.Video, taskInput)
|
||||
}
|
||||
|
||||
func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInput) error {
|
||||
if utf8.RuneCountInString(input.Prompt) > profile.References.PromptMaxChars {
|
||||
return BadAuthRequest(fmt.Sprintf("提示词超过当前模型限制(最多 %d 字)", profile.References.PromptMaxChars))
|
||||
}
|
||||
if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios {
|
||||
return BadAuthRequest("参考素材数量超过当前模型限制")
|
||||
}
|
||||
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 input.Config.VQuality != "" && !containsCapabilityString(profile.Resolutions, normalizeResolution(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 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 == "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
|
||||
}
|
||||
@@ -31,30 +31,32 @@ type canvasGenerationInput struct {
|
||||
ReferenceAudios []providerMedia `json:"referenceAudios"`
|
||||
Mask *providerMedia `json:"mask"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
VideoCapability *VideoCapabilityConfig `json:"-"`
|
||||
}
|
||||
|
||||
type providerConfig struct {
|
||||
ChannelID string `json:"channelId"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
InterfaceType string `json:"interfaceType"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
Headers []OutboundHeader `json:"headers"`
|
||||
Model string `json:"model"`
|
||||
Size string `json:"size"`
|
||||
Quality string `json:"quality"`
|
||||
TransparentBackground string `json:"transparentBackground"`
|
||||
Count string `json:"count"`
|
||||
VideoSeconds string `json:"videoSeconds"`
|
||||
VQuality string `json:"vquality"`
|
||||
VideoGenerateAudio string `json:"videoGenerateAudio"`
|
||||
VideoWatermark string `json:"videoWatermark"`
|
||||
AudioVoice string `json:"audioVoice"`
|
||||
AudioFormat string `json:"audioFormat"`
|
||||
AudioSpeed string `json:"audioSpeed"`
|
||||
AudioInstructions string `json:"audioInstructions"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
ChannelID string `json:"channelId"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
InterfaceType string `json:"interfaceType"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
Headers []OutboundHeader `json:"headers"`
|
||||
Model string `json:"model"`
|
||||
Size string `json:"size"`
|
||||
Quality string `json:"quality"`
|
||||
TransparentBackground string `json:"transparentBackground"`
|
||||
Count string `json:"count"`
|
||||
VideoSeconds string `json:"videoSeconds"`
|
||||
VQuality string `json:"vquality"`
|
||||
VideoGenerateAudio string `json:"videoGenerateAudio"`
|
||||
VideoWatermark string `json:"videoWatermark"`
|
||||
AudioVoice string `json:"audioVoice"`
|
||||
AudioFormat string `json:"audioFormat"`
|
||||
AudioSpeed string `json:"audioSpeed"`
|
||||
AudioInstructions string `json:"audioInstructions"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"`
|
||||
}
|
||||
|
||||
const providerHTTPTimeout = 5 * time.Minute
|
||||
@@ -69,6 +71,10 @@ type providerMedia struct {
|
||||
URL string `json:"url"`
|
||||
StorageKey string `json:"storageKey"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
type imageResponse struct {
|
||||
@@ -183,12 +189,22 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
|
||||
if isVolcengineJiMengProtocol(input.Config.InterfaceType) && strings.TrimSpace(input.Config.SecretKey) == "" {
|
||||
return nil, errors.New("即梦官方 API 缺少 Secret Key")
|
||||
}
|
||||
if input.Mode == "video" {
|
||||
if err := s.validateResolvedVideoCapability(&input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if resumedProviderRequestID(ctx) == "" {
|
||||
requirePublicURL := input.Config.InterfaceType == "newapi-channel-1" || input.Config.InterfaceType == "newapi-channel-2" || input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo)
|
||||
if err := s.hydrateGenerationMedia(userID, &input, requirePublicURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if input.Mode == "video" && input.VideoCapability != nil {
|
||||
if err := validateVideoTask(input.VideoCapability, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
switch input.Mode {
|
||||
case "image":
|
||||
return runImageTask(ctx, input)
|
||||
@@ -207,6 +223,27 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) validateResolvedVideoCapability(input *canvasGenerationInput) error {
|
||||
channelID := strings.TrimSpace(input.Config.ChannelID)
|
||||
if channelID == "" {
|
||||
if input.Config.CapabilityConfig == nil || input.Config.CapabilityConfig.Video == nil {
|
||||
return nil
|
||||
}
|
||||
input.VideoCapability = input.Config.CapabilityConfig.Video
|
||||
return validateVideoTask(input.VideoCapability, *input)
|
||||
}
|
||||
item, err := s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(input.Config.Model), "models/"))
|
||||
if err != nil {
|
||||
return errors.New("当前系统渠道模型未配置或已停用")
|
||||
}
|
||||
profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
|
||||
if err != nil || profile == nil || profile.Video == nil {
|
||||
return errors.New("当前视频模型尚未配置能力参数")
|
||||
}
|
||||
input.VideoCapability = profile.Video
|
||||
return validateVideoTask(profile.Video, *input)
|
||||
}
|
||||
|
||||
func metadataStringValues(value any) map[string]string {
|
||||
values := map[string]string{}
|
||||
raw, ok := value.(map[string]interface{})
|
||||
@@ -257,6 +294,10 @@ func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requ
|
||||
media.URL = signedURL
|
||||
media.DataURL = ""
|
||||
media.MimeType = firstNonEmpty(media.MimeType, resource.MimeType)
|
||||
media.Bytes = resource.Size
|
||||
media.Width = resource.Width
|
||||
media.Height = resource.Height
|
||||
media.DurationMs = resource.DurationMs
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(media.DataURL), "data:") {
|
||||
@@ -282,6 +323,10 @@ func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requ
|
||||
mimeType := normalizedMediaMimeType(firstNonEmpty(media.MimeType, resource.MimeType), data)
|
||||
media.DataURL = dataURL(mimeType, data)
|
||||
media.MimeType = mimeType
|
||||
media.Bytes = int64(len(data))
|
||||
media.Width = resource.Width
|
||||
media.Height = resource.Height
|
||||
media.DurationMs = resource.DurationMs
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1385,12 +1430,14 @@ func newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{
|
||||
ratio := normalizeNewAPIChannel2Ratio(input.Config.Size, modelName)
|
||||
resolution := normalizeNewAPIChannel2Resolution(input.Config.VQuality, modelName)
|
||||
body := map[string]interface{}{
|
||||
"model": input.Config.Model,
|
||||
"prompt": strings.TrimSpace(input.Prompt),
|
||||
"seconds": strconv.Itoa(seconds),
|
||||
"aspect_ratio": ratio,
|
||||
"resolution": resolution,
|
||||
"generate_audio": parseBool(input.Config.VideoGenerateAudio, true),
|
||||
"model": input.Config.Model,
|
||||
"prompt": strings.TrimSpace(input.Prompt),
|
||||
"seconds": strconv.Itoa(seconds),
|
||||
"aspect_ratio": ratio,
|
||||
"resolution": resolution,
|
||||
}
|
||||
if videoCapabilitySupportsAudio(input) {
|
||||
body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true)
|
||||
}
|
||||
if len(images) > 0 {
|
||||
body["image_urls"] = images
|
||||
@@ -1557,16 +1604,19 @@ func newAPIChannel1VideoBody(input canvasGenerationInput) (map[string]interface{
|
||||
}
|
||||
media = append(media, map[string]string{"type": "reference_voice", "url": url})
|
||||
}
|
||||
parameters := map[string]interface{}{
|
||||
"resolution": normalizeNewAPIChannel1Resolution(input.Config.VQuality),
|
||||
"ratio": normalizeNewAPIChannel1Ratio(input.Config.Size),
|
||||
"prompt_extend": false,
|
||||
"duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds),
|
||||
}
|
||||
if videoCapabilitySupportsWatermark(input) {
|
||||
parameters["watermark"] = parseBool(input.Config.VideoWatermark, false)
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"model": input.Config.Model,
|
||||
"input": map[string]interface{}{"prompt": strings.TrimSpace(input.Prompt)},
|
||||
"parameters": map[string]interface{}{
|
||||
"resolution": normalizeNewAPIChannel1Resolution(input.Config.VQuality),
|
||||
"ratio": normalizeNewAPIChannel1Ratio(input.Config.Size),
|
||||
"prompt_extend": false,
|
||||
"watermark": parseBool(input.Config.VideoWatermark, false),
|
||||
"duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds),
|
||||
},
|
||||
"model": input.Config.Model,
|
||||
"input": map[string]interface{}{"prompt": strings.TrimSpace(input.Prompt)},
|
||||
"parameters": parameters,
|
||||
}
|
||||
if len(media) > 0 {
|
||||
body["input"].(map[string]interface{})["media"] = media
|
||||
@@ -1758,13 +1808,17 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn
|
||||
}
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"model": input.Config.Model,
|
||||
"content": content,
|
||||
"ratio": normalizeSeedanceRatio(input.Config.Size),
|
||||
"resolution": normalizeSeedanceResolution(input.Config.VQuality, input.Config.Model),
|
||||
"duration": normalizeSeedanceDuration(input.Config.VideoSeconds),
|
||||
"generate_audio": parseBool(input.Config.VideoGenerateAudio, true),
|
||||
"watermark": parseBool(input.Config.VideoWatermark, false),
|
||||
"model": input.Config.Model,
|
||||
"content": content,
|
||||
"ratio": normalizeSeedanceRatio(input.Config.Size),
|
||||
"resolution": normalizeSeedanceResolution(input.Config.VQuality, input.Config.Model),
|
||||
"duration": normalizeSeedanceDuration(input.Config.VideoSeconds),
|
||||
}
|
||||
if videoCapabilitySupportsAudio(input) {
|
||||
body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true)
|
||||
}
|
||||
if videoCapabilitySupportsWatermark(input) {
|
||||
body["watermark"] = parseBool(input.Config.VideoWatermark, false)
|
||||
}
|
||||
if err := postJSON(ctx, input.Config, "/contents/generations/tasks", body, &created); err != nil {
|
||||
return nil, err
|
||||
@@ -2297,6 +2351,15 @@ func shouldSendNewAPIVideoImages(input canvasGenerationInput) bool {
|
||||
return strings.TrimSpace(operation) != "text_to_video"
|
||||
}
|
||||
|
||||
// 本地测试 helper 没有能力配置时保留历史协议字段;真实系统任务会携带已解析的模型能力。
|
||||
func videoCapabilitySupportsAudio(input canvasGenerationInput) bool {
|
||||
return input.VideoCapability == nil || input.VideoCapability.GenerateAudio.Supported
|
||||
}
|
||||
|
||||
func videoCapabilitySupportsWatermark(input canvasGenerationInput) bool {
|
||||
return input.VideoCapability == nil || input.VideoCapability.Watermark.Supported
|
||||
}
|
||||
|
||||
func newAPIVideoPromptText(input canvasGenerationInput) string {
|
||||
return strings.TrimSpace(input.Prompt)
|
||||
}
|
||||
@@ -2306,11 +2369,13 @@ func seedanceVideosBody(input canvasGenerationInput) (map[string]interface{}, er
|
||||
return nil, errors.New("Seedance 参考视频或参考音频需要同时连接至少 1 张主参考图")
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"model": input.Config.Model,
|
||||
"prompt": seedanceVideosPromptText(input),
|
||||
"aspect_ratio": normalizeSeedanceVideosRatio(input.Config.Size),
|
||||
"duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds),
|
||||
"generate_audio": parseBool(input.Config.VideoGenerateAudio, true),
|
||||
"model": input.Config.Model,
|
||||
"prompt": seedanceVideosPromptText(input),
|
||||
"aspect_ratio": normalizeSeedanceVideosRatio(input.Config.Size),
|
||||
"duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds),
|
||||
}
|
||||
if videoCapabilitySupportsAudio(input) {
|
||||
body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true)
|
||||
}
|
||||
imageURLs := make([]string, 0, len(input.ReferenceImages))
|
||||
for _, image := range input.ReferenceImages {
|
||||
|
||||
@@ -320,6 +320,9 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ValidateTaskCapability(normalizedInput); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if containsInlineMediaDataURL(normalizedInput) {
|
||||
return nil, BadAuthRequest("任务输入不能包含内嵌媒体,请先上传到资源存储")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user