feat(*): 模型能力配置 - 支持模型级视频限制校验与协议管理

This commit is contained in:
ddCat
2026-08-06 15:34:45 +08:00
parent a18673a101
commit 9ca9dd9df6
36 changed files with 1692 additions and 401 deletions

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -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")
}

View 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
}

View File

@@ -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 {

View File

@@ -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("任务输入不能包含内嵌媒体,请先上传到资源存储")
}