Files
ttstd-infinite-canvas/backend/internal/service/provider.go

3059 lines
108 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"mime"
"mime/multipart"
"net"
"net/http"
"net/textproto"
"net/url"
"strconv"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
)
type canvasGenerationInput struct {
Mode string `json:"mode"`
Prompt string `json:"prompt"`
Config providerConfig `json:"config"`
ReferenceImages []providerMedia `json:"referenceImages"`
ReferenceVideos []providerMedia `json:"referenceVideos"`
ReferenceAudios []providerMedia `json:"referenceAudios"`
Mask *providerMedia `json:"mask"`
Metadata map[string]interface{} `json:"metadata"`
ImageCapability *ImageCapabilityConfig `json:"-"`
StreamText bool `json:"-"` // 分镜请求使用上游 SSE 保活;最终结构仍在流结束后统一校验。
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"`
CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"`
}
const providerHTTPTimeout = 5 * time.Minute
const videoPollTimeout = 30 * time.Minute
const maxProviderResponseBytes int64 = 64 << 20
type providerMedia struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
DataURL string `json:"dataUrl"`
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 {
Data []map[string]interface{} `json:"data"`
Error *providerError `json:"error"`
Code *int `json:"code"`
Msg string `json:"msg"`
}
type providerError struct {
Message string `json:"message"`
}
type providerHTTPError struct {
StatusCode int
Status string
Body string
}
type providerAnalyticsKey struct{}
type providerAnalyticsContext struct {
Service *Service
UserID string
TaskID string
BillingOrderID string
Capability string
Operation string
ChannelID string
Model string
VideoSeconds int
RequestKind string
ProviderRequestID string
ConcurrencyLimit int
}
func withProviderAnalytics(ctx context.Context, service *Service, task model.Task) context.Context {
metadata := providerAnalyticsContext{Service: service, UserID: task.UserID, TaskID: task.ID, BillingOrderID: task.BillingOrderID, Capability: capabilityFromTaskType(task.Type), Operation: task.Operation, Model: task.Model, ProviderRequestID: task.ProviderRequestID}
var input struct {
Mode string `json:"mode"`
Config providerConfig `json:"config"`
}
if json.Unmarshal([]byte(task.InputJSON), &input) == nil {
metadata.ChannelID = firstNonEmpty(input.Config.ChannelID, systemChannelIDFromBaseURL(input.Config.BaseURL))
metadata.Model = firstNonEmpty(input.Config.Model, metadata.Model)
metadata.VideoSeconds, _ = strconv.Atoi(input.Config.VideoSeconds)
if normalized := normalizeCapability(input.Mode); normalized != "" {
metadata.Capability = normalized
}
}
return context.WithValue(ctx, providerAnalyticsKey{}, metadata)
}
func resumedProviderRequestID(ctx context.Context) string {
metadata, _ := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
return strings.TrimSpace(metadata.ProviderRequestID)
}
func withProviderRequestKind(ctx context.Context, requestKind string) context.Context {
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
if !ok {
return ctx
}
metadata.RequestKind = requestKind
return context.WithValue(ctx, providerAnalyticsKey{}, metadata)
}
func (e providerHTTPError) Error() string {
if e.StatusCode == 524 {
return "上游网关超时(524):模型请求可能仍在服务端执行并产生费用,请勿立即重试,请先到供应商后台核对任务或账单"
}
return fmt.Sprintf("接口请求失败:%s %s", e.Status, e.Body)
}
func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string, taskProjectID string, taskType string, fallbackPrompt string, rawInput string) (map[string]interface{}, error) {
var input canvasGenerationInput
if err := json.Unmarshal([]byte(rawInput), &input); err != nil {
return nil, fmt.Errorf("任务输入解析失败:%w", err)
}
if strings.TrimSpace(input.Prompt) == "" {
input.Prompt = fallbackPrompt
}
if input.Mode == "" && strings.HasPrefix(taskType, "video_") {
input.Mode = "video"
}
promptTemplateOperation := metadataString(input.Metadata, "promptTemplateOperation")
// 视频节点的最终 Prompt 只取输入框内容,不能被分镜模板替换;图片和文本仍沿用模板能力。
if input.Mode != "video" && promptTemplateOperation != "" {
values := metadataStringValues(input.Metadata["promptTemplateVariables"])
compiled, compileErr := s.compilePrompt(userID, promptTemplateOperation, values)
if compileErr != nil {
return nil, fmt.Errorf("编译用户提示词失败:%w", compileErr)
}
input.Prompt = compiled.Content
}
if strings.TrimSpace(input.Prompt) == "" {
return nil, errors.New("prompt is required")
}
config, err := s.resolveProviderConfig(input.Config)
if err != nil {
return nil, err
}
input.Config = config
if input.Mode == "image" && input.Metadata != nil {
if err := s.applyGenerationStyleProfile(userID, taskProjectID, &input); err != nil {
return nil, err
}
}
if input.Config.APIFormat == "gemini" && input.Config.InterfaceType != string(model.ChannelInterfaceGeminiVeo) {
return nil, errors.New("后端任务队列暂不支持 Gemini 调用格式,请使用 OpenAI 兼容渠道")
}
if strings.TrimSpace(input.Config.BaseURL) == "" || strings.TrimSpace(input.Config.APIKey) == "" || strings.TrimSpace(input.Config.Model) == "" {
return nil, errors.New("后端生成任务缺少 Base URL、API Key 或模型名")
}
if err := validateGenerationInterface(input.Mode, input.Config.InterfaceType); err != nil {
return nil, err
}
if isVolcengineJiMengProtocol(input.Config.InterfaceType) && strings.TrimSpace(input.Config.SecretKey) == "" {
return nil, errors.New("即梦官方 API 缺少 Secret Key")
}
if input.Mode == "image" {
if err := s.validateResolvedImageCapability(&input); err != nil {
return nil, err
}
}
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)
case "text":
result, taskErr := runTextTask(ctx, input)
if taskErr == nil && promptTemplateOperation != "" {
taskErr = validatePromptTemplateResult(promptTemplateOperation, result)
}
return result, taskErr
case "video":
return runVideoTask(ctx, input)
case "audio":
return runAudioTask(ctx, input)
default:
return nil, fmt.Errorf("不支持的生成模式:%s", input.Mode)
}
}
type styleExecutionPlanDocument struct {
SchemaVersion int `json:"schemaVersion"`
ProfilePresetID string `json:"profilePresetId"`
ProfileRevision int `json:"profileRevision"`
Mode string `json:"mode"`
Model string `json:"model"`
InterfaceType string `json:"interfaceType"`
Status string `json:"status"`
Prompt string `json:"prompt"`
}
func (s *Service) applyGenerationStyleProfile(userID string, taskProjectID string, input *canvasGenerationInput) error {
styleProfileJSON := metadataString(input.Metadata, "styleProfileJson")
if strings.TrimSpace(styleProfileJSON) == "" {
return nil
}
if _, err := validateStyleProfileJSON(styleProfileJSON); err != nil {
return fmt.Errorf("项目画风执行配置无效:%w", err)
}
var profile styleProfileDocument
if err := json.Unmarshal([]byte(styleProfileJSON), &profile); err != nil {
return fmt.Errorf("项目画风执行配置解析失败:%w", err)
}
storedProfileJSON, storedPresetID, belongsToProject, err := s.taskProjectStyleProfile(userID, taskProjectID)
if err != nil {
return fmt.Errorf("读取项目画风失败:%w", err)
}
if belongsToProject {
if strings.TrimSpace(storedProfileJSON) == "" {
// 旧项目只有 preset ID,允许画布把该预设编译为结构化快照;仍需锁定同一预设,不能借降级路径换画风。
if strings.TrimSpace(storedPresetID) == "" || strings.TrimSpace(profile.PresetID) != strings.TrimSpace(storedPresetID) {
return errors.New("任务画风预设与项目当前设置不一致,请刷新画布后重试")
}
} else {
matches, compareErr := equivalentStyleProfileJSON(styleProfileJSON, storedProfileJSON)
if compareErr != nil || !matches {
return errors.New("任务画风快照与项目当前设置不一致,请刷新画布后重试")
}
}
}
plan, err := decodeStyleExecutionPlan(input.Metadata["styleExecutionPlan"])
if err != nil {
return err
}
stylePrompt, expectedStatus, warnings := resolveGenerationStyleExecution(profile, input.Config.Model, firstNonEmpty(input.Config.InterfaceType, input.Config.APIFormat))
if plan.SchemaVersion != 1 || plan.ProfilePresetID != profile.PresetID || plan.ProfileRevision != profile.Revision || plan.Mode != input.Mode || !strings.EqualFold(plan.Model, input.Config.Model) || plan.InterfaceType != firstNonEmpty(input.Config.InterfaceType, input.Config.APIFormat) {
return errors.New("项目画风执行计划与当前模型或快照不一致,请刷新配置后重试")
}
if plan.Status != expectedStatus || strings.TrimSpace(plan.Prompt) != stylePrompt {
return errors.New("项目画风执行计划已失效,请重新生成执行计划")
}
if expectedStatus == "blocked" {
return fmt.Errorf("项目画风与当前生成模型不兼容:%s", strings.Join(warnings, ";"))
}
if stylePrompt != "" && !strings.Contains(input.Prompt, stylePrompt) {
input.Prompt = strings.TrimSpace(input.Prompt) + "\n\n【项目画风执行规范】\n" + stylePrompt
}
return nil
}
func (s *Service) taskProjectStyleProfile(userID string, canvasOrProjectID string) (string, string, bool, error) {
id := strings.TrimSpace(canvasOrProjectID)
if id == "" {
return "", "", false, nil
}
if canvas, err := s.repo.CanvasProjectForUser(userID, id); err == nil {
if strings.TrimSpace(canvas.ProjectID) == "" {
return "", "", false, nil
}
project, projectErr := s.repo.ProjectForUser(userID, canvas.ProjectID)
if projectErr != nil {
return "", "", true, projectErr
}
return project.StyleProfileJSON, project.StylePresetID, true, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return "", "", false, err
}
project, err := s.repo.ProjectForUser(userID, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", "", false, nil
}
return "", "", false, err
}
return project.StyleProfileJSON, project.StylePresetID, true, nil
}
func equivalentStyleProfileJSON(left string, right string) (bool, error) {
var leftValue interface{}
if err := json.Unmarshal([]byte(left), &leftValue); err != nil {
return false, err
}
var rightValue interface{}
if err := json.Unmarshal([]byte(right), &rightValue); err != nil {
return false, err
}
leftCanonical, err := json.Marshal(leftValue)
if err != nil {
return false, err
}
rightCanonical, err := json.Marshal(rightValue)
if err != nil {
return false, err
}
return bytes.Equal(leftCanonical, rightCanonical), nil
}
func decodeStyleExecutionPlan(value interface{}) (styleExecutionPlanDocument, error) {
if value == nil {
return styleExecutionPlanDocument{}, errors.New("项目画风执行计划缺失")
}
raw, err := json.Marshal(value)
if err != nil {
return styleExecutionPlanDocument{}, errors.New("项目画风执行计划格式无效")
}
var plan styleExecutionPlanDocument
if err := json.Unmarshal(raw, &plan); err != nil {
return styleExecutionPlanDocument{}, errors.New("项目画风执行计划格式无效")
}
return plan, nil
}
func resolveGenerationStyleExecution(profile styleProfileDocument, generationModel string, interfaceType string) (string, string, []string) {
fragments := []string{strings.TrimSpace(profile.Prompt)}
if negative := strings.TrimSpace(profile.NegativePrompt); negative != "" {
fragments = append(fragments, "【全局负面 Prompt】\n"+negative)
}
warnings := make([]string, 0)
for _, asset := range profile.Assets {
if asset.Enabled != nil && !*asset.Enabled {
continue
}
if asset.Status != "validated" {
reason := "资产尚未验证"
if asset.Status == "unavailable" {
reason = "资产当前不可用"
}
warnings = append(warnings, asset.Title+":"+reason)
continue
}
if len(asset.BaseModels) > 0 && !styleAssetSupportsModel(asset.BaseModels, generationModel) {
warnings = append(warnings, asset.Title+":仅兼容 "+strings.Join(asset.BaseModels, "、"))
continue
}
switch asset.Kind {
case "prompt", "template":
fragments = append(fragments, strings.TrimSpace(asset.PromptFragment))
fragments = append(fragments, nonEmptyStyleProfileStrings(asset.TriggerWords)...)
case "reference":
warnings = append(warnings, asset.Title+":项目参考图自动注入适配器尚未启用")
case "lora":
warnings = append(warnings, asset.Title+":当前 "+firstNonEmpty(interfaceType, "图片")+" 协议未启用 LoRA 适配器")
}
}
normalizedFragments := nonEmptyStyleProfileStrings(fragments)
status := "ready"
if len(warnings) > 0 {
status = "degraded"
if profile.ExecutionPolicy == "strict-assets" {
status = "blocked"
}
}
return strings.Join(normalizedFragments, "\n"), status, warnings
}
func styleAssetSupportsModel(baseModels []string, generationModel string) bool {
for _, baseModel := range baseModels {
if strings.EqualFold(strings.TrimSpace(baseModel), strings.TrimSpace(generationModel)) {
return true
}
}
return false
}
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 (s *Service) validateResolvedImageCapability(input *canvasGenerationInput) error {
fallback := DefaultImageCapabilityConfig(input.Config.InterfaceType, input.Config.Model)
channelID := strings.TrimSpace(input.Config.ChannelID)
if channelID == "" {
if input.Config.CapabilityConfig != nil && input.Config.CapabilityConfig.Image != nil {
input.ImageCapability = input.Config.CapabilityConfig.Image
} else {
input.ImageCapability = fallback
}
return validateImageTask(input.ImageCapability, *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 {
return errors.New("当前图片模型能力参数无效")
}
if profile != nil && profile.Image != nil {
input.ImageCapability = profile.Image
} else {
input.ImageCapability = fallback
}
return validateImageTask(input.ImageCapability, *input)
}
func metadataStringValues(value any) map[string]string {
values := map[string]string{}
raw, ok := value.(map[string]interface{})
if !ok {
return values
}
for key, item := range raw {
values[key] = strings.TrimSpace(fmt.Sprint(item))
}
return values
}
func (s *Service) hydrateGenerationMedia(userID string, input *canvasGenerationInput, requirePublicURL bool) error {
groups := [][]providerMedia{input.ReferenceImages, input.ReferenceVideos, input.ReferenceAudios}
for _, group := range groups {
for index := range group {
if err := s.hydrateProviderMedia(userID, &group[index], requirePublicURL); err != nil {
return err
}
}
}
if input.Mask != nil {
return s.hydrateProviderMedia(userID, input.Mask, requirePublicURL)
}
return nil
}
func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requirePublicURL bool) error {
if !strings.HasPrefix(media.StorageKey, "resource:") {
if requirePublicURL && strings.HasPrefix(strings.TrimSpace(media.DataURL), "data:") {
return errors.New("当前 JSON 视频协议的参考素材不能使用内嵌数据,请先上传到 OSS 或提供公网素材地址")
}
return nil
}
resourceID := strings.TrimPrefix(media.StorageKey, "resource:")
if requirePublicURL {
resource, err := s.repo.ResourceForUser(userID, resourceID)
if err != nil {
return fmt.Errorf("读取任务参考资源失败:%w", err)
}
if resource.Status != "ready" {
return errors.New("任务参考资源尚未上传完成")
}
signedURL, err := s.directResourceURL(resource, time.Now().Add(providerResourceURLTTL))
if err != nil {
return fmt.Errorf("生成 JSON 视频协议参考素材地址失败:%w", err)
}
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:") {
return nil
}
resource, body, err := s.OpenResource(userID, resourceID)
if err != nil {
return fmt.Errorf("读取任务参考资源失败:%w", err)
}
defer body.Close()
policy, err := s.RuntimePolicy()
if err != nil {
return err
}
resourceLimit := megabytes(policy.Resource.ResourceUploadMB)
data, err := io.ReadAll(io.LimitReader(body, resourceLimit+1))
if err != nil {
return err
}
if int64(len(data)) > resourceLimit {
return fmt.Errorf("任务参考资源超过 %dMB", policy.Resource.ResourceUploadMB)
}
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
}
func normalizedMediaMimeType(declared string, data []byte) string {
declared = strings.TrimSpace(strings.Split(declared, ";")[0])
if declared != "" && declared != "application/octet-stream" {
return declared
}
detected := strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])
return defaultString(detected, "application/octet-stream")
}
func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig, error) {
headers, err := NormalizeOutboundHeaders(config.Headers)
if err != nil {
return providerConfig{}, err
}
config.Headers = headers
channelID := strings.TrimSpace(config.ChannelID)
if channelID == "" {
channelID = systemChannelIDFromBaseURL(config.BaseURL)
}
if channelID == "" {
if _, err := ValidateOutboundURL(config.BaseURL); err != nil {
return providerConfig{}, err
}
return config, nil
}
channel, err := s.repo.SystemChannel(channelID)
if err != nil {
return providerConfig{}, errors.New("系统渠道不存在或已停用")
}
modelName := strings.TrimSpace(config.Model)
if modelName == "" {
models := channelModelNames(*channel)
if len(models) == 0 {
return providerConfig{}, errors.New("系统渠道未配置可用模型")
}
modelName = models[0]
}
if !stringInSlice(modelName, channelModelNames(*channel)) {
return providerConfig{}, errors.New("当前系统渠道未授权该模型")
}
config.ChannelID = channel.ID
config.APIFormat = channel.APIFormat
channelModel, modelErr := s.repo.ChannelModelByKey(channel.ID, modelName)
if modelErr != nil || channelModel.Protocol == "" {
return providerConfig{}, errors.New("当前模型尚未配置请求协议")
}
config.InterfaceType = string(channelModel.Protocol)
// 模型协议是实际请求契约;混合渠道中鉴权格式也必须随模型协议切换。
if config.InterfaceType == string(model.ChannelInterfaceGeminiVeo) {
config.APIFormat = "gemini"
} else if config.InterfaceType != "" {
config.APIFormat = "openai"
}
config.BaseURL = channel.BaseURL
config.APIKey = channel.APIKey
config.SecretKey = channel.SecretKey
config.Headers, err = ParseOutboundHeadersJSON(channel.HeadersJSON)
if err != nil {
return providerConfig{}, err
}
config.Model = modelName
return config, nil
}
func stringInSlice(value string, values []string) bool {
value = strings.TrimPrefix(strings.TrimSpace(value), "models/")
for _, candidate := range values {
if strings.TrimPrefix(strings.TrimSpace(candidate), "models/") == value {
return true
}
}
return false
}
func systemChannelIDFromBaseURL(baseURL string) string {
value := strings.TrimSpace(baseURL)
for _, marker := range []string{"/api/ai/system/", "api/ai/system/"} {
index := strings.Index(value, marker)
if index < 0 {
continue
}
id := strings.Trim(value[index+len(marker):], "/")
if slash := strings.Index(id, "/"); slash >= 0 {
id = id[:slash]
}
return strings.TrimSpace(id)
}
return ""
}
func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if input.Config.InterfaceType == string(model.ChannelInterfaceGrokImage) {
return runGrokImageTask(ctx, input)
}
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineJiMengImage) {
return runVolcengineJiMengImageTask(ctx, input)
}
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkImage) {
return runVolcengineArkImageTask(ctx, input)
}
var payload imageResponse
if input.Mask != nil {
// 蒙版编辑是强校验写路径:协议能力不明确时必须失败,不能静默退化为整图重绘。
if strings.TrimSpace(input.Config.InterfaceType) != string(model.ChannelInterfaceOpenAIImage) {
return nil, errors.New("当前渠道未声明 OpenAI Images 编辑协议,已拒绝可能忽略蒙版的整图重绘")
}
if len(input.ReferenceImages) == 0 {
return nil, errors.New("蒙版编辑必须提供与蒙版同尺寸的源图片")
}
}
if len(input.ReferenceImages) > 0 || input.Mask != nil {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writeField(writer, "model", input.Config.Model)
writeField(writer, "prompt", withSystemPrompt(input.Config, input.Prompt))
writeField(writer, "n", "1")
if imageParameterSupported(input.ImageCapability, "response_format") {
writeField(writer, "response_format", "b64_json")
}
if imageParameterSupported(input.ImageCapability, "output_format") {
writeField(writer, "output_format", "png")
}
if imageTransparentBackgroundSupported(input.ImageCapability) && input.Config.TransparentBackground == "true" {
writeField(writer, "background", "transparent")
}
if imageQualitySupported(input.ImageCapability) && input.Config.Quality != "" {
writeField(writer, "quality", normalizeImageQuality(input.Config.Quality))
}
if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" {
writeField(writer, key, value)
}
for _, image := range input.ReferenceImages {
if err := writeMediaPart(writer, "image", image); err != nil {
return nil, err
}
}
if input.Mask != nil {
if err := writeMediaPart(writer, "mask", *input.Mask); err != nil {
return nil, err
}
}
if err := writer.Close(); err != nil {
return nil, err
}
if err := postForm(ctx, input.Config, "/images/edits", writer.FormDataContentType(), body, &payload); err != nil {
return nil, err
}
} else {
body := map[string]interface{}{
"model": input.Config.Model,
"prompt": withSystemPrompt(input.Config, input.Prompt),
"n": 1,
}
if imageParameterSupported(input.ImageCapability, "response_format") {
body["response_format"] = "b64_json"
}
if imageParameterSupported(input.ImageCapability, "output_format") {
body["output_format"] = "png"
}
if imageTransparentBackgroundSupported(input.ImageCapability) && input.Config.TransparentBackground == "true" {
body["background"] = "transparent"
}
if imageQualitySupported(input.ImageCapability) && input.Config.Quality != "" {
body["quality"] = normalizeImageQuality(input.Config.Quality)
}
if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" {
body[key] = value
}
if err := postJSON(ctx, input.Config, "/images/generations", body, &payload); err != nil {
return nil, err
}
}
images, err := imageDataURLs(payload)
if err != nil {
return nil, err
}
return map[string]interface{}{"mode": "image", "images": images}, nil
}
func runGrokImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
body, path, err := grokImageRequestBody(input)
if err != nil {
return nil, err
}
var payload imageResponse
if err := postJSON(ctx, input.Config, path, body, &payload); err != nil {
return nil, err
}
images, err := imageDataURLs(payload)
if err != nil {
return nil, err
}
return map[string]interface{}{"mode": "image", "images": images}, nil
}
func grokImageRequestBody(input canvasGenerationInput) (grokImageRequest, string, error) {
if input.Mask != nil {
return grokImageRequest{}, "", errors.New("Grok 图片协议不支持蒙版编辑,请移除蒙版后重试")
}
body := grokImageRequest{
Model: input.Config.Model,
Prompt: withSystemPrompt(input.Config, input.Prompt),
N: 1,
ResponseFormat: "url",
Size: strings.TrimSpace(input.Config.Size),
AspectRatio: normalizeGrokImageAspectRatio(input.Config.Size),
Resolution: normalizeGrokImageResolution(input.Config.Quality),
}
if len(input.ReferenceImages) == 0 {
return body, "/images/generations", nil
}
if len(input.ReferenceImages) != 1 {
return grokImageRequest{}, "", fmt.Errorf("Grok 图片编辑只支持 1 张参考图,当前连接了 %d 张", len(input.ReferenceImages))
}
imageURL, err := grokImageInputURL(input.ReferenceImages[0])
if err != nil {
return grokImageRequest{}, "", err
}
body.Image = &grokImageInput{URL: imageURL}
return body, "/images/edits", nil
}
// normalizeGrokImageResolution 把画布 quality(1k/2k/high…)映射为 grok2api / xAI 的 resolution。
func normalizeGrokImageResolution(quality string) string {
raw := strings.ToLower(strings.TrimSpace(quality))
switch raw {
case "", "auto":
return ""
case "1k", "low", "standard":
return "1k"
case "2k", "medium", "hd", "high", "4k":
// xAI Imagine 图片通常最高 2k;超出则夹到 2k,避免上游拒参。
return "2k"
default:
return ""
}
}
// normalizeGrokImageAspectRatio 把画布 size(如 1280x720 / 9:16)转成 grok2api / xAI 接受的 aspect_ratio。
func normalizeGrokImageAspectRatio(size string) string {
raw := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(size, "×", "x")))
if raw == "" || raw == "auto" {
return ""
}
if strings.Contains(raw, ":") {
switch raw {
case "1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2", "9:19.5", "19.5:9", "1:2", "2:1":
return raw
}
}
parts := strings.Split(raw, "x")
if len(parts) != 2 {
return ""
}
w, wErr := strconv.Atoi(parts[0])
h, hErr := strconv.Atoi(parts[1])
if wErr != nil || hErr != nil || w <= 0 || h <= 0 {
return ""
}
if w == h {
return "1:1"
}
ratio := float64(w) / float64(h)
switch {
case w*9 == h*16 || (ratio >= 1.7 && ratio <= 1.8):
return "16:9"
case h*9 == w*16 || (ratio > 0 && ratio <= 1.0/1.7 && ratio >= 1.0/1.8):
return "9:16"
case w*3 == h*4 || (ratio > 1.2 && ratio < 1.4):
return "4:3"
case h*3 == w*4 || (ratio > 0.7 && ratio < 0.85):
return "3:4"
case w > h:
return "16:9"
default:
return "9:16"
}
}
func grokImageInputURL(media providerMedia) (string, error) {
if isPublicMediaURL(strings.TrimSpace(media.URL)) {
return strings.TrimSpace(media.URL), nil
}
return openAIImageInputURL(media)
}
const volcengineArkImageMaxPixels = 4624220
func runVolcengineArkImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if input.Mask != nil {
return nil, errors.New("火山方舟图片协议不支持蒙版编辑,请移除蒙版后重试")
}
body, err := volcengineArkImageBody(input)
if err != nil {
return nil, err
}
var payload imageResponse
if err := postJSON(ctx, input.Config, "/images/generations", body, &payload); err != nil {
return nil, err
}
images, err := imageDataURLs(payload)
if err != nil {
return nil, err
}
return map[string]interface{}{"mode": "image", "images": images}, nil
}
func volcengineArkImageBody(input canvasGenerationInput) (map[string]interface{}, error) {
body := map[string]interface{}{
"model": input.Config.Model,
"prompt": withSystemPrompt(input.Config, input.Prompt),
"n": 1,
}
if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" {
if key == "size" {
value = normalizeVolcengineArkImageSize(value)
}
body[key] = value
}
if len(input.ReferenceImages) == 0 {
return body, nil
}
images := make([]string, 0, len(input.ReferenceImages))
for _, image := range input.ReferenceImages {
url, err := openAIImageInputURL(image)
if err != nil {
return nil, err
}
images = append(images, url)
}
if len(images) == 1 {
body["image"] = images[0]
} else {
body["image"] = images
}
return body, nil
}
func normalizeVolcengineArkImageSize(value string) string {
size := normalizePixelSize(value)
parts := strings.Split(strings.ToLower(size), "x")
if len(parts) != 2 {
return size
}
width, widthErr := strconv.Atoi(parts[0])
height, heightErr := strconv.Atoi(parts[1])
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
return size
}
if int64(width)*int64(height) <= volcengineArkImageMaxPixels {
return size
}
scale := math.Sqrt(float64(volcengineArkImageMaxPixels) / (float64(width) * float64(height)))
width = int(math.Floor(float64(width)*scale/2)) * 2
height = int(math.Floor(float64(height)*scale/2)) * 2
for width > 2 && height > 2 && int64(width)*int64(height) > volcengineArkImageMaxPixels {
if width >= height {
width -= 2
} else {
height -= 2
}
}
return strconv.Itoa(width) + "x" + strconv.Itoa(height)
}
func runTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
switch input.Config.InterfaceType {
case "chat-completion":
return runChatCompletionsTextTask(ctx, input)
case "openai-response":
return runResponsesTextTask(ctx, input)
}
return runLegacyTextTask(ctx, input)
}
func runLegacyTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
responseInput, err := textResponseInput(input)
if err != nil {
return nil, err
}
body := map[string]interface{}{"model": input.Config.Model, "input": responseInput}
text, err := requestTextProvider(ctx, input.Config, "/responses", body, "responses", input.StreamText)
if err != nil {
if !shouldFallbackTextToChat(err) {
return nil, err
}
result, chatErr := runChatCompletionsTextTask(ctx, input)
if chatErr == nil {
return result, nil
}
return nil, fmt.Errorf("文本接口请求失败:Responses API %v;Chat Completions %v", err, chatErr)
}
return map[string]interface{}{"mode": "text", "text": text}, nil
}
func runResponsesTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
responseInput, err := textResponseInput(input)
if err != nil {
return nil, err
}
body := map[string]interface{}{"model": input.Config.Model, "input": responseInput}
text, err := requestTextProvider(ctx, input.Config, "/responses", body, "responses", input.StreamText)
if err != nil {
return nil, err
}
return map[string]interface{}{"mode": "text", "text": text}, nil
}
func runChatCompletionsTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
messages := []map[string]interface{}{}
if systemPrompt := strings.TrimSpace(input.Config.SystemPrompt); systemPrompt != "" {
messages = append(messages, map[string]interface{}{"role": "system", "content": systemPrompt})
}
userContent, err := textChatContent(input)
if err != nil {
return nil, err
}
messages = append(messages, map[string]interface{}{"role": "user", "content": userContent})
body := map[string]interface{}{"model": input.Config.Model, "messages": messages}
text, err := requestTextProvider(ctx, input.Config, "/chat/completions", body, "chat-completion", input.StreamText)
if err != nil {
return nil, err
}
return map[string]interface{}{"mode": "text", "text": text}, nil
}
func textResponseInput(input canvasGenerationInput) (interface{}, error) {
systemPrompt := strings.TrimSpace(input.Config.SystemPrompt)
if len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 {
return withSystemPrompt(input.Config, input.Prompt), nil
}
messages := make([]map[string]interface{}, 0, 2)
if systemPrompt != "" {
messages = append(messages, map[string]interface{}{"role": "system", "content": systemPrompt})
}
content, err := textResponseContent(input)
if err != nil {
return nil, err
}
messages = append(messages, map[string]interface{}{"role": "user", "content": content})
return messages, nil
}
func textResponseContent(input canvasGenerationInput) ([]map[string]interface{}, error) {
content := []map[string]interface{}{{"type": "input_text", "text": input.Prompt}}
for _, image := range input.ReferenceImages {
url, err := openAIImageInputURL(image)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "input_image", "image_url": url})
}
for _, video := range input.ReferenceVideos {
url, err := openAIVideoInputURL(video)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "input_video", "video_url": url})
}
return content, nil
}
func textChatContent(input canvasGenerationInput) (interface{}, error) {
if len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 {
return input.Prompt, nil
}
content := []map[string]interface{}{{"type": "text", "text": input.Prompt}}
for _, image := range input.ReferenceImages {
url, err := openAIImageInputURL(image)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": url}})
}
for _, video := range input.ReferenceVideos {
url, err := openAIVideoInputURL(video)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "video_url", "video_url": map[string]interface{}{"url": url}})
}
return content, nil
}
func openAIImageInputURL(media providerMedia) (string, error) {
value := strings.TrimSpace(media.DataURL)
if strings.HasPrefix(value, "data:image/") {
return value, nil
}
if strings.HasPrefix(value, "data:") {
return "", errors.New("参考图片 MIME 类型无效,请重新读取或上传图片")
}
value = strings.TrimSpace(media.URL)
if strings.HasPrefix(value, "data:image/") || isPublicMediaURL(value) {
return value, nil
}
if strings.HasPrefix(value, "data:") {
return "", errors.New("参考图片 MIME 类型无效,请重新读取或上传图片")
}
return "", errors.New("OpenAI 文本多模态参考图片需要公网 URL 或 base64 data URL")
}
func openAIVideoInputURL(media providerMedia) (string, error) {
value := strings.TrimSpace(media.DataURL)
if strings.HasPrefix(value, "data:video/") {
return value, nil
}
if strings.HasPrefix(value, "data:") {
return "", errors.New("参考视频 MIME 类型无效,请重新读取或上传视频")
}
value = strings.TrimSpace(media.URL)
if strings.HasPrefix(value, "data:video/") || isPublicMediaURL(value) {
return value, nil
}
if strings.HasPrefix(value, "data:") {
return "", errors.New("参考视频 MIME 类型无效,请重新读取或上传视频")
}
return "", errors.New("文本多模态参考视频需要公网 URL 或 base64 data URL")
}
func shouldFallbackTextToChat(err error) bool {
var httpErr providerHTTPError
if !errors.As(err, &httpErr) {
return false
}
switch httpErr.StatusCode {
case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
default:
return false
}
}
func runAudioTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if resolved, ok := input.Metadata["resolvedCharacterVersions"].([]interface{}); ok && len(resolved) > 0 {
voiceKey := metadataString(input.Metadata, "resolvedCharacterVoiceKey")
if voiceKey == "" || strings.TrimSpace(input.Config.AudioVoice) != voiceKey {
return nil, errors.New("角色配音缺少已解析的声音绑定")
}
}
format := defaultString(input.Config.AudioFormat, "mp3")
body := map[string]interface{}{
"model": input.Config.Model,
"input": input.Prompt,
"voice": defaultString(input.Config.AudioVoice, "alloy"),
"response_format": format,
"speed": 1,
}
if input.Config.AudioSpeed != "" {
body["speed"] = parseFloat(input.Config.AudioSpeed, 1)
}
if input.Config.AudioInstructions != "" {
body["instructions"] = input.Config.AudioInstructions
}
if input.Config.InterfaceType == string(model.ChannelInterfaceAsyncAudio) {
return runAsyncAudioTask(ctx, input, body, format)
}
data, mimeType, err := postBinary(ctx, input.Config, "/audio/speech", body)
if err != nil {
return nil, err
}
mimeType, err = validateGeneratedAudio(mimeType, data, format)
if err != nil {
return nil, err
}
return map[string]interface{}{"mode": "audio", "audio": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType, "format": format}}, nil
}
func runAsyncAudioTask(ctx context.Context, input canvasGenerationInput, body map[string]interface{}, format string) (map[string]interface{}, error) {
id := resumedProviderRequestID(ctx)
var state map[string]interface{}
if id == "" {
if err := postJSON(ctx, input.Config, "/audio/tasks", body, &state); err != nil {
return nil, err
}
state = asyncAudioPayload(state)
id = firstNonEmptyString(stringField(state, "id"), stringField(state, "task_id"), stringField(state, "request_id"))
if id == "" {
return nil, errors.New("异步音频接口没有返回任务 ID")
}
if asyncAudioSucceeded(state) {
return asyncAudioResult(ctx, input.Config, id, state, format)
}
}
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
state = map[string]interface{}{}
pollCtx := withProviderRequestKind(ctx, "poll")
if err := getJSON(pollCtx, input.Config, "/audio/tasks/"+url.PathEscape(id), &state); err != nil {
return nil, err
}
state = asyncAudioPayload(state)
if asyncAudioSucceeded(state) {
return asyncAudioResult(ctx, input.Config, id, state, format)
}
status := strings.ToLower(strings.TrimSpace(stringField(state, "status")))
if status == "failed" || status == "cancelled" || status == "canceled" || status == "expired" || status == "error" {
return nil, fmt.Errorf("异步音频生成失败(任务 %s):%s", id, asyncAudioErrorMessage(state))
}
if err := sleepContext(ctx, 2500*time.Millisecond); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("异步音频生成超时(任务 %s)", id)
}
func asyncAudioPayload(payload map[string]interface{}) map[string]interface{} {
for _, key := range []string{"data", "result", "output"} {
if nested, ok := payload[key].(map[string]interface{}); ok {
for parentKey, parentValue := range payload {
if parentKey == "data" || parentKey == "result" || parentKey == "output" {
continue
}
if _, exists := nested[parentKey]; !exists {
nested[parentKey] = parentValue
}
}
return nested
}
}
return payload
}
func asyncAudioSucceeded(state map[string]interface{}) bool {
status := strings.ToLower(strings.TrimSpace(stringField(state, "status")))
done, _ := state["done"].(bool)
return done || status == "completed" || status == "succeeded" || status == "success" || status == "done" || (status == "" && asyncAudioResultURL(state) != "")
}
func asyncAudioResult(ctx context.Context, config providerConfig, id string, state map[string]interface{}, format string) (map[string]interface{}, error) {
resultURL := asyncAudioResultURL(state)
var data []byte
var mimeType string
var err error
if strings.HasPrefix(resultURL, "data:") {
mimeType, data, err = decodeProviderDataURL(resultURL)
if err == nil {
limit, limitErr := providerGeneratedFileLimit(ctx)
if limitErr != nil {
err = limitErr
} else if int64(len(data)) > limit {
err = fmt.Errorf("异步音频结果超过 %s 限制", formatStorageLimit(limit))
}
}
} else if isPublicMediaURL(resultURL) {
data, mimeType, err = getExternalBinary(withProviderRequestKind(ctx, "download"), resultURL)
} else {
data, mimeType, err = getBinary(withProviderRequestKind(ctx, "download"), config, "/audio/tasks/"+url.PathEscape(id)+"/content")
}
if err != nil {
return nil, fmt.Errorf("异步音频结果下载失败(任务 %s):%w", id, err)
}
mimeType, err = validateGeneratedAudio(mimeType, data, format)
if err != nil {
return nil, fmt.Errorf("异步音频结果无效(任务 %s):%w", id, err)
}
return map[string]interface{}{"mode": "audio", "audio": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType, "format": format}}, nil
}
func asyncAudioResultURL(state map[string]interface{}) string {
for _, key := range []string{"audio_url", "audioUrl", "result_url", "resultUrl", "output_url", "outputUrl", "url", "data"} {
if value := strings.TrimSpace(stringField(state, key)); strings.HasPrefix(value, "data:") || isPublicMediaURL(value) {
return value
}
}
for _, key := range []string{"audio", "data", "result", "output"} {
if nested, ok := state[key].(map[string]interface{}); ok {
if value := asyncAudioResultURL(nested); value != "" {
return value
}
}
}
return ""
}
func asyncAudioErrorMessage(state map[string]interface{}) string {
_, message := providerFailureDetails(state)
return defaultString(message, firstNonEmptyString(stringField(state, "message"), "上游返回失败状态"))
}
func decodeProviderDataURL(value string) (string, []byte, error) {
header, encoded, ok := strings.Cut(value, ",")
if !ok || !strings.HasPrefix(header, "data:") || !strings.HasSuffix(strings.ToLower(header), ";base64") {
return "", nil, errors.New("音频 data URL 格式无效")
}
mimeType := strings.TrimSuffix(strings.TrimPrefix(header, "data:"), ";base64")
data, err := base64.StdEncoding.DecodeString(encoded)
return mimeType, data, err
}
func providerGeneratedFileLimit(ctx context.Context) (int64, error) {
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
if !ok || metadata.Service == nil {
return maxProviderResponseBytes, nil
}
policy, err := metadata.Service.RuntimePolicy()
if err != nil {
return 0, fmt.Errorf("读取生成资源限制失败:%w", err)
}
return megabytes(policy.Resource.GeneratedFileMB), nil
}
func validateGeneratedAudio(declared string, data []byte, format string) (string, error) {
if len(data) == 0 {
return "", errors.New("音频内容为空")
}
detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]))
if strings.Contains(detected, "json") || strings.HasPrefix(detected, "text/") || strings.HasPrefix(detected, "image/") || strings.HasPrefix(detected, "video/") {
return "", fmt.Errorf("上游返回了非音频内容:%s", detected)
}
mimeType := strings.ToLower(strings.TrimSpace(strings.Split(declared, ";")[0]))
resolved := ""
if strings.HasPrefix(mimeType, "audio/") {
resolved = mimeType
} else if strings.HasPrefix(detected, "audio/") {
resolved = detected
} else if fallback := audioFormatMimeType(format); fallback != "" && (mimeType == "" || mimeType == "application/octet-stream") {
resolved = fallback
}
if resolved == "" {
return "", fmt.Errorf("上游响应类型不是音频:%s", defaultString(mimeType, detected))
}
if !audioSignatureMatches(resolved, data) {
return "", fmt.Errorf("音频内容与格式不匹配:%s", resolved)
}
return resolved, nil
}
func audioSignatureMatches(mimeType string, data []byte) bool {
if strings.Contains(mimeType, "pcm") || mimeType == "audio/l16" {
return len(data) > 0
}
if strings.Contains(mimeType, "mpeg") || strings.Contains(mimeType, "mp3") {
return bytes.HasPrefix(data, []byte("ID3")) || (len(data) >= 2 && data[0] == 0xff && data[1]&0xe0 == 0xe0)
}
if strings.Contains(mimeType, "wav") || strings.Contains(mimeType, "wave") {
return len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WAVE"))
}
if strings.Contains(mimeType, "opus") || strings.Contains(mimeType, "ogg") {
return bytes.HasPrefix(data, []byte("OggS"))
}
if strings.Contains(mimeType, "flac") {
return bytes.HasPrefix(data, []byte("fLaC"))
}
if strings.Contains(mimeType, "aac") {
return bytes.HasPrefix(data, []byte("ADIF")) || (len(data) >= 2 && data[0] == 0xff && data[1]&0xf0 == 0xf0)
}
return false
}
func audioFormatMimeType(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "wav":
return "audio/wav"
case "opus":
return "audio/opus"
case "aac":
return "audio/aac"
case "flac":
return "audio/flac"
case "pcm":
return "audio/pcm"
case "mp3":
return "audio/mpeg"
default:
return ""
}
}
func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineJiMengVideo) {
return runVolcengineJiMengVideoTask(ctx, input)
}
if input.Config.InterfaceType == "gemini-veo" {
return runGeminiVeoVideoTask(ctx, input)
}
if input.Config.InterfaceType == "newapi-channel-2" {
return runNewAPIChannel2VideoTask(ctx, input)
}
if input.Config.InterfaceType == "newapi-channel-1" {
return runNewAPIChannel1VideoTask(ctx, input)
}
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) {
return runSeedanceAgentPlanVideoTask(ctx, input)
}
if isArkPlanVideoConfig(input.Config) {
return runSeedanceAgentPlanVideoTask(ctx, input)
}
if isSeedanceVideoConfig(input.Config) {
return runSeedanceVideosTask(ctx, input)
}
if len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0 {
return nil, errors.New("OpenAI 风格视频接口不支持参考视频或参考音频,请切换到 Seedance / Agent Plan 渠道")
}
id := resumedProviderRequestID(ctx)
var created map[string]interface{}
if id == "" && (input.Config.InterfaceType == "xai-video" || isGrokVideoConfig(input.Config)) {
var requestBody interface{}
var err error
if input.Config.InterfaceType == "xai-video" {
requestBody, err = xaiVideoRequestBody(input)
} else {
requestBody, err = grokVideoBody(input)
}
if err != nil {
return nil, err
}
createPath := "/videos"
if input.Config.InterfaceType == "xai-video" {
createPath = "/videos/generations"
}
if err := postJSON(ctx, input.Config, createPath, requestBody, &created); err != nil {
return nil, err
}
} else if id == "" {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writeField(writer, "model", input.Config.Model)
writeField(writer, "prompt", newAPIVideoPromptText(input))
writeField(writer, "seconds", defaultString(input.Config.VideoSeconds, "6"))
if size := normalizeVideoSize(input.Config.Size); size != "" {
writeField(writer, "size", size)
}
writeField(writer, "resolution_name", normalizeVideoResolution(input.Config.VQuality))
writeField(writer, "preset", "normal")
if shouldSendNewAPIVideoImages(input) {
for _, image := range input.ReferenceImages {
if err := writeMediaPart(writer, "input_reference[]", image); err != nil {
return nil, err
}
}
}
if err := writer.Close(); err != nil {
return nil, err
}
if err := postForm(ctx, input.Config, "/videos", writer.FormDataContentType(), body, &created); err != nil {
return nil, err
}
}
if id == "" {
id = firstNonEmptyString(stringField(created, "id"), stringField(created, "request_id"), stringField(created, "task_id"))
}
if id == "" {
if data, ok := created["data"].(map[string]interface{}); ok {
id = firstNonEmptyString(stringField(data, "id"), stringField(data, "request_id"), stringField(data, "task_id"))
}
}
if id == "" {
return nil, errors.New("视频接口没有返回任务 ID")
}
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
var state map[string]interface{}
if err := getJSON(ctx, input.Config, "/videos/"+id, &state); err != nil {
return nil, err
}
if data, ok := state["data"].(map[string]interface{}); ok {
state = data
}
status := strings.ToLower(stringField(state, "status"))
if status == "completed" || status == "succeeded" || status == "success" || status == "done" {
if videoURL := newAPIVideoResultURL(state); videoURL != "" {
data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), input.Config, videoURL)
if err != nil {
return nil, fmt.Errorf("视频结果下载失败(任务 %s):%w", id, err)
}
mimeType = normalizedMediaMimeType(mimeType, data)
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil
}
data, mimeType, err := getBinary(ctx, input.Config, "/videos/"+id+"/content")
if err != nil {
return nil, err
}
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil
}
if status == "failed" || status == "cancelled" {
return nil, errors.New("视频生成失败")
}
if err := sleepContext(ctx, 2500*time.Millisecond); err != nil {
return nil, err
}
}
return nil, errors.New("视频生成超时")
}
func runGeminiVeoVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if len(input.ReferenceImages) > 1 || len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0 {
return nil, errors.New("Gemini Veo 当前只支持 1 张起始图,不支持参考视频或音频")
}
id := resumedProviderRequestID(ctx)
if id == "" {
instance := geminiVeoInstance{Prompt: strings.TrimSpace(input.Prompt)}
if len(input.ReferenceImages) == 1 {
raw, mimeType, err := mediaBytes(input.ReferenceImages[0])
if err != nil {
return nil, fmt.Errorf("读取 Gemini Veo 起始图失败:%w", err)
}
instance.Image = &geminiVeoImage{BytesBase64Encoded: base64.StdEncoding.EncodeToString(raw), MIMEType: mimeType}
}
body := geminiVeoRequest{
Instances: []geminiVeoInstance{instance},
Parameters: geminiVeoParameters{
AspectRatio: normalizeNewAPIChannel2Ratio(input.Config.Size, strings.ToLower(input.Config.Model)),
DurationSeconds: normalizeSeedanceVideosDuration(input.Config.VideoSeconds),
Resolution: normalizeNewAPIChannel2Resolution(input.Config.VQuality, strings.ToLower(input.Config.Model)),
SampleCount: 1,
},
}
var created map[string]interface{}
if err := postGeminiJSON(ctx, input.Config, "/models/"+url.PathEscape(input.Config.Model)+":predictLongRunning", body, &created); err != nil {
return nil, err
}
id = strings.TrimSpace(stringField(created, "name"))
}
if id == "" {
return nil, errors.New("Gemini Veo 没有返回 operation name")
}
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
var operation map[string]interface{}
if err := getGeminiJSON(ctx, input.Config, "/"+strings.TrimLeft(id, "/"), &operation); err != nil {
return nil, err
}
if errorValue, ok := operation["error"].(map[string]interface{}); ok && stringField(errorValue, "message") != "" {
return nil, fmt.Errorf("Gemini Veo 视频生成失败(任务 %s):%s", id, stringField(errorValue, "message"))
}
done, _ := operation["done"].(bool)
if done {
videoURL := findProviderMediaURL(operation["response"])
if videoURL == "" {
return nil, fmt.Errorf("Gemini Veo 任务 %s 已完成但没有返回视频地址", id)
}
data, mimeType, err := getGeminiBinary(withProviderRequestKind(ctx, "download"), input.Config, videoURL)
if err != nil {
return nil, fmt.Errorf("Gemini Veo 视频下载失败(任务 %s):%w", id, err)
}
mimeType = normalizedMediaMimeType(mimeType, data)
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil
}
if err := sleepContext(ctx, 5*time.Second); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("Gemini Veo 视频生成超时(任务 %s)", id)
}
func postGeminiJSON(ctx context.Context, config providerConfig, path string, body interface{}, target interface{}) error {
data, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, geminiVeoURL(config.BaseURL, path), bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("x-goog-api-key", config.APIKey)
req.Header.Set("Content-Type", "application/json")
ApplyOutboundHeaders(req, config.Headers)
return doJSON(req, target)
}
func getGeminiJSON(ctx context.Context, config providerConfig, path string, target interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, geminiVeoURL(config.BaseURL, path), nil)
if err != nil {
return err
}
req.Header.Set("x-goog-api-key", config.APIKey)
ApplyOutboundHeaders(req, config.Headers)
return doJSON(req, target)
}
func getGeminiBinary(ctx context.Context, config providerConfig, rawURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("x-goog-api-key", config.APIKey)
ApplyOutboundHeaders(req, config.Headers)
return doBinary(req)
}
func geminiVeoURL(baseURL string, path string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if !strings.HasSuffix(strings.ToLower(base), "/v1beta") {
base += "/v1beta"
}
return base + "/" + strings.TrimLeft(path, "/")
}
func findProviderMediaURL(value interface{}) string {
switch typed := value.(type) {
case map[string]interface{}:
for _, key := range []string{"uri", "url", "videoUri", "video_url"} {
if candidate := strings.TrimSpace(stringField(typed, key)); isPublicMediaURL(candidate) {
return candidate
}
}
for _, child := range typed {
if candidate := findProviderMediaURL(child); candidate != "" {
return candidate
}
}
case []interface{}:
for _, child := range typed {
if candidate := findProviderMediaURL(child); candidate != "" {
return candidate
}
}
}
return ""
}
func newAPIVideoResultURL(state map[string]interface{}) string {
return nestedNewAPIVideoResultURL(state, false, 0)
}
func nestedNewAPIVideoResultURL(payload map[string]interface{}, allowResultURL bool, depth int) string {
if depth < 2 {
for _, key := range []string{"data", "result", "video"} {
if nested, ok := payload[key].(map[string]interface{}); ok {
if videoURL := nestedNewAPIVideoResultURL(nested, true, depth+1); videoURL != "" {
return videoURL
}
}
}
}
keys := []string{"video_url", "videoUrl", "url"}
if allowResultURL {
keys = append(keys, "result_url", "resultUrl")
}
for _, key := range keys {
if videoURL := strings.TrimSpace(stringField(payload, key)); isPublicMediaURL(videoURL) {
return videoURL
}
}
return ""
}
const newAPIChannel1VideoPollInterval = 20 * time.Second
const (
newAPIChannel2VideoPollInterval = 10 * time.Second
newAPIChannel2VideoRetryInterval = time.Minute
newAPIChannel2VideoMaxQueryRetries = 3
)
type newAPIChannel2ResponseError struct {
Code string
Message string
}
func (e newAPIChannel2ResponseError) Error() string {
return fmt.Sprintf("NewAPI Video Generations 任务查询失败(%s):%s", e.Code, defaultString(e.Message, "上游查询失败"))
}
func runNewAPIChannel2VideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
id := resumedProviderRequestID(ctx)
var created map[string]interface{}
if id == "" {
body, err := newAPIChannel2VideoRequestBody(input)
if err != nil {
return nil, err
}
if err := postJSON(ctx, input.Config, "/video/generations", body, &created); err != nil {
return nil, err
}
id = firstNonEmptyString(stringField(created, "task_id"), stringField(created, "id"))
}
if id == "" {
if data, ok := created["data"].(map[string]interface{}); ok {
id = firstNonEmptyString(stringField(data, "task_id"), stringField(data, "id"))
}
}
if id == "" {
return nil, errors.New("NewAPI Video Generations 没有返回任务 ID")
}
consecutiveQueryFailures := 0
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
result, _, err := queryNewAPIChannel2VideoTask(ctx, input, id)
if err != nil {
if !isTransientNewAPIChannel2QueryError(err) || consecutiveQueryFailures >= newAPIChannel2VideoMaxQueryRetries {
return nil, err
}
consecutiveQueryFailures++
logNewAPIChannel2QueryRetry(ctx, id, consecutiveQueryFailures, err)
if err := sleepContext(ctx, newAPIChannel2VideoRetryInterval); err != nil {
return nil, err
}
continue
}
consecutiveQueryFailures = 0
if result != nil {
return result, nil
}
if err := sleepContext(ctx, newAPIChannel2VideoPollInterval); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("NewAPI Video Generations 视频生成超时(任务 %s)", id)
}
// 单次查询只读取既有上游任务,不创建新任务;自动轮询和人工恢复共用这条安全边界。
func queryNewAPIChannel2VideoTask(ctx context.Context, input canvasGenerationInput, id string) (map[string]interface{}, string, error) {
var payload map[string]interface{}
if err := getJSON(ctx, input.Config, "/video/generations/"+id, &payload); err != nil {
return nil, "", err
}
if err := newAPIChannel2PayloadError(payload); err != nil {
return nil, "", err
}
state := payload
if data, ok := payload["data"].(map[string]interface{}); ok {
state = data
}
status := strings.ToUpper(strings.TrimSpace(stringField(state, "status")))
switch status {
case "SUCCESS":
videoURL := strings.TrimSpace(stringField(state, "result_url"))
if videoURL == "" {
return nil, status, fmt.Errorf("NewAPI Video Generations 任务 %s 已成功但没有返回视频地址", id)
}
data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), input.Config, videoURL)
if err != nil {
return nil, status, fmt.Errorf("NewAPI Video Generations 视频结果下载失败(任务 %s):%w", id, err)
}
mimeType = normalizedMediaMimeType(mimeType, data)
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, status, nil
case "FAILURE":
reason := strings.TrimSpace(stringField(state, "fail_reason"))
return nil, status, fmt.Errorf("NewAPI Video Generations 视频生成失败(任务 %s):%s", id, defaultString(reason, "上游返回失败"))
case "SUBMITTED", "QUEUED", "IN_PROGRESS", "NOT_START", "":
return nil, status, nil
default:
return nil, status, fmt.Errorf("NewAPI Video Generations 任务 %s 返回未知状态:%s", id, status)
}
}
func newAPIChannel2PayloadError(payload map[string]interface{}) error {
code := strings.ToLower(strings.TrimSpace(stringField(payload, "code")))
if code == "" || code == "0" || code == "ok" || code == "success" {
return nil
}
return newAPIChannel2ResponseError{Code: code, Message: firstNonEmptyString(stringField(payload, "message"), stringField(payload, "msg"))}
}
func isTransientNewAPIChannel2QueryError(err error) bool {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
var responseErr newAPIChannel2ResponseError
if errors.As(err, &responseErr) {
return responseErr.Code == "do_request_failed" || strings.Contains(strings.ToLower(responseErr.Message), "do request failed")
}
var httpErr providerHTTPError
if errors.As(err, &httpErr) {
body := strings.ToLower(httpErr.Body)
return httpErr.StatusCode == http.StatusRequestTimeout || httpErr.StatusCode == http.StatusTooManyRequests || httpErr.StatusCode >= http.StatusInternalServerError || strings.Contains(body, "do_request_failed") || strings.Contains(body, "do request failed")
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
var networkErr net.Error
if errors.As(err, &networkErr) {
return true
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "do_request_failed") || strings.Contains(message, "do request failed")
}
func logNewAPIChannel2QueryRetry(ctx context.Context, providerTaskID string, retry int, err error) {
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
if !ok || metadata.Service == nil || metadata.TaskID == "" {
return
}
payload := fmt.Sprintf("供应商任务 %s,第 %d/%d 次重试:%s", providerTaskID, retry, newAPIChannel2VideoMaxQueryRetries, safeProviderLogError(err))
_ = metadata.Service.log(metadata.UserID, metadata.TaskID, "warn", "上游任务查询失败,1 分钟后重试", payload)
}
func newAPIChannel2VideoRequestBody(input canvasGenerationInput) (newAPIVideoRequest, error) {
if len(input.ReferenceImages) > 9 || len(input.ReferenceVideos) > 3 || len(input.ReferenceAudios) > 3 {
return newAPIVideoRequest{}, errors.New("NewAPI Video Generations 最多支持 9 张参考图、3 个参考视频和 3 个参考音频")
}
// NewAPI Video Generations 只接受附着在参考视频上的音频;纯音频请求会被上游拒绝。
if len(input.ReferenceAudios) > 0 && len(input.ReferenceVideos) == 0 {
return newAPIVideoRequest{}, errors.New("NewAPI Video Generations 的参考音频必须同时提供至少 1 个参考视频;纯音频生视频请切换到支持该模式的渠道")
}
modelName := strings.ToLower(strings.TrimSpace(input.Config.Model))
requiresSingleImage := modelName == "grok-video-1.5" || modelName == "grok-video-1.5-1080p"
images := make([]string, 0, len(input.ReferenceImages))
// 单图模型以实际参考图为准,兼容旧画布中未随连接关系更新的 text_to_video 元数据。
if shouldSendNewAPIVideoImages(input) || requiresSingleImage {
for _, image := range input.ReferenceImages {
url, err := videoGenerationsMediaURL(image)
if err != nil {
return newAPIVideoRequest{}, err
}
images = append(images, url)
}
}
if requiresSingleImage {
if len(images) != 1 {
return newAPIVideoRequest{}, fmt.Errorf("NewAPI Video Generations 的 %s 必须且只能提供 1 张参考图(当前 %d 张)", input.Config.Model, len(images))
}
}
frameImages, err := videoFrameImageURLs(input, images)
if err != nil {
return newAPIVideoRequest{}, err
}
if len(frameImages) > 0 {
images = frameImages
}
seconds, secondsErr := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds))
if secondsErr != nil || seconds < 1 {
seconds = 6
}
ratio := normalizeNewAPIChannel2Ratio(input.Config.Size, modelName)
resolution := normalizeNewAPIChannel2Resolution(input.Config.VQuality, modelName)
body := newAPIVideoRequest{
Model: input.Config.Model,
Prompt: strings.TrimSpace(input.Prompt),
Seconds: strconv.Itoa(seconds),
AspectRatio: ratio,
Resolution: resolution,
}
if videoCapabilitySupportsAudio(input) {
value := parseBool(input.Config.VideoGenerateAudio, true)
body.GenerateAudio = &value
}
if len(images) > 0 {
body.ImageURLs = images
}
videoURLs := make([]string, 0, len(input.ReferenceVideos))
for _, video := range input.ReferenceVideos {
url, err := videoGenerationsMediaURL(video)
if err != nil {
return newAPIVideoRequest{}, err
}
videoURLs = append(videoURLs, url)
}
if len(videoURLs) > 0 {
body.VideoURLs = videoURLs
}
audioURLs := make([]string, 0, len(input.ReferenceAudios))
for _, audio := range input.ReferenceAudios {
url, err := videoGenerationsMediaURL(audio)
if err != nil {
return newAPIVideoRequest{}, err
}
audioURLs = append(audioURLs, url)
}
if len(audioURLs) > 0 {
body.AudioURLs = audioURLs
}
return body, nil
}
// 兼容现有单元测试和旧调用方;实际请求路径使用上面的类型化 DTO。
func newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{}, error) {
body, err := newAPIChannel2VideoRequestBody(input)
if err != nil {
return nil, err
}
return requestAsMap(body)
}
func videoGenerationsMediaURL(media providerMedia) (string, error) {
value := strings.TrimSpace(firstNonEmpty(media.URL, media.DataURL))
if isPublicMediaURL(value) || strings.HasPrefix(value, "data:") {
return value, nil
}
return "", errors.New("NewAPI Video Generations 的参考素材需要公网 URL;私有素材请先保存到 OSS")
}
func normalizeNewAPIChannel2Ratio(value string, modelName string) string {
ratio := strings.TrimSpace(value)
if strings.Contains(ratio, "x") {
parts := strings.SplitN(ratio, "x", 2)
width, widthErr := strconv.Atoi(parts[0])
height, heightErr := strconv.Atoi(parts[1])
if widthErr == nil && heightErr == nil && width > 0 && height > 0 {
switch {
case width == height:
ratio = "1:1"
case width > height:
ratio = "16:9"
default:
ratio = "9:16"
}
}
}
if modelName == "grok-video-1.5" || modelName == "grok-video-1.5-1080p" {
if ratio != "9:16" {
return "16:9"
}
return ratio
}
switch ratio {
case "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3":
return ratio
default:
return "16:9"
}
}
func normalizeNewAPIChannel2Resolution(value string, modelName string) string {
if modelName == "grok-video-1.5-1080p" {
return "1080p"
}
switch strings.ToLower(strings.TrimSpace(value)) {
case "480", "480p", "low":
return "480p"
case "1080", "1080p":
return "1080p"
case "2160", "2160p", "4k":
return "2160p"
default:
return "720p"
}
}
func runNewAPIChannel1VideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
id := resumedProviderRequestID(ctx)
var created map[string]interface{}
if id == "" {
body, err := newAPIChannel1VideoBody(input)
if err != nil {
return nil, err
}
if err := postJSON(ctx, input.Config, "/videos", body, &created); err != nil {
return nil, err
}
if data, ok := created["data"].(map[string]interface{}); ok {
created = data
}
id = firstNonEmptyString(stringField(created, "id"), stringField(created, "task_id"))
}
status := strings.ToUpper(strings.TrimSpace(stringField(created, "status")))
if strings.HasPrefix(status, "FAILED") {
return nil, fmt.Errorf("NewAPI 媒体任务视频生成失败(任务 %s):%s", id, strings.TrimSpace(strings.TrimPrefix(status, "FAILED:")))
}
if id == "" {
return nil, errors.New("NewAPI 媒体任务没有返回任务 ID")
}
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
var state map[string]interface{}
if err := getJSON(ctx, input.Config, "/videos/"+id, &state); err != nil {
return nil, err
}
if data, ok := state["data"].(map[string]interface{}); ok {
state = data
}
status := strings.ToUpper(strings.TrimSpace(stringField(state, "status")))
switch {
case status == "SUCCEEDED":
videoURL := stringField(state, "object")
if videoURL == "" {
return nil, fmt.Errorf("NewAPI 媒体任务 %s 已完成但没有返回视频 URL", id)
}
data, mimeType, err := getExternalBinary(withProviderRequestKind(ctx, "download"), videoURL)
if err != nil {
return nil, fmt.Errorf("NewAPI 媒体任务视频结果下载失败(任务 %s):%w", id, err)
}
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil
case strings.HasPrefix(status, "FAILED"):
message := strings.TrimSpace(strings.TrimPrefix(status, "FAILED:"))
return nil, fmt.Errorf("NewAPI 媒体任务视频生成失败(任务 %s):%s", id, defaultString(message, "上游返回失败"))
}
if err := sleepContext(ctx, newAPIChannel1VideoPollInterval); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("NewAPI 媒体任务视频生成超时(任务 %s)", id)
}
func newAPIChannel1VideoBody(input canvasGenerationInput) (map[string]interface{}, error) {
if len(input.ReferenceImages) > 9 || len(input.ReferenceVideos) > 3 || len(input.ReferenceAudios) > 3 {
return nil, errors.New("NewAPI 媒体任务最多支持 9 张参考图、3 个参考视频和 3 个参考音频")
}
media := make([]map[string]string, 0, len(input.ReferenceImages)+len(input.ReferenceVideos)+len(input.ReferenceAudios))
if shouldSendNewAPIVideoImages(input) {
for _, image := range input.ReferenceImages {
url, err := newAPIChannel1MediaURL(image)
if err != nil {
return nil, err
}
media = append(media, map[string]string{"type": seedanceImageRole(input, image), "url": url})
}
}
for _, video := range input.ReferenceVideos {
url, err := newAPIChannel1MediaURL(video)
if err != nil {
return nil, err
}
media = append(media, map[string]string{"type": "reference_video", "url": url})
}
for _, audio := range input.ReferenceAudios {
url, err := newAPIChannel1MediaURL(audio)
if err != nil {
return nil, err
}
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": parameters,
}
if len(media) > 0 {
body["input"].(map[string]interface{})["media"] = media
}
return body, nil
}
func newAPIChannel1MediaURL(media providerMedia) (string, error) {
value := strings.TrimSpace(media.URL)
if !isPublicMediaURL(value) {
return "", errors.New("NewAPI 媒体任务的参考素材必须使用公网 HTTP(S) URL,请启用 OSS 或提供公网素材地址")
}
if _, err := ValidateOutboundURL(value); err != nil {
return "", err
}
return value, nil
}
func normalizeNewAPIChannel1Resolution(value string) string {
resolution := strings.TrimSuffix(strings.TrimSpace(value), "p")
if resolution != "480" && resolution != "720" && resolution != "1080" {
resolution = "720"
}
return resolution + "P"
}
func normalizeNewAPIChannel1Ratio(value string) string {
switch strings.TrimSpace(value) {
case "1:1", "16:9", "9:16", "4:3", "3:4":
return strings.TrimSpace(value)
default:
return "16:9"
}
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func validateGenerationInterface(mode string, interfaceType string) error {
interfaceType = strings.TrimSpace(interfaceType)
if interfaceType == "" {
return nil
}
allowed := map[string]map[string]bool{
"text": {"chat-completion": true, "openai-response": true},
"image": {"openai-image": true, "grok-image": true, "volcengine-ark-image": true, "volcengine-jimeng-image": true},
"video": {"newapi": true, "newapi-channel-1": true, "newapi-channel-2": true, "xai-video": true, "volcengine-ark-video": true, "volcengine-jimeng-video": true, "gemini-veo": true},
"audio": {"openai-audio": true, "async-audio": true},
}
if allowed[mode] != nil && !allowed[mode][interfaceType] {
return fmt.Errorf("接口类型 %s 不支持%s生成", interfaceType, mode)
}
return nil
}
func grokVideoBody(input canvasGenerationInput) (map[string]interface{}, error) {
if input.Config.InterfaceType == "xai-video" {
body, err := xaiVideoRequestBody(input)
if err != nil {
return nil, err
}
return requestAsMap(body)
}
seconds := defaultString(input.Config.VideoSeconds, "6")
duration, err := strconv.Atoi(seconds)
if err != nil || duration <= 0 {
duration = 6
}
body := map[string]interface{}{
"model": input.Config.Model,
"prompt": strings.TrimSpace(input.Prompt),
"duration": duration,
"seconds": strconv.Itoa(duration),
}
if size := normalizeVideoSize(input.Config.Size); size != "" {
body["size"] = size
}
if shouldSendNewAPIVideoImages(input) && len(input.ReferenceImages) > 0 {
images := make([]string, 0, len(input.ReferenceImages))
for _, image := range input.ReferenceImages {
url, err := openAIImageInputURL(image)
if err != nil {
return nil, err
}
images = append(images, url)
}
body["image"] = images[0]
body["images"] = images
}
return body, nil
}
// xAI 生成接口与 legacy /videos 使用不同字段,保持独立可避免兼容字段触发上游 422。
func xaiVideoRequestBody(input canvasGenerationInput) (xaiVideoRequest, error) {
body := xaiVideoRequest{
Model: input.Config.Model,
Prompt: strings.TrimSpace(input.Prompt),
Duration: normalizeXAIVideoDuration(input.Config.VideoSeconds),
AspectRatio: normalizeXAIVideoAspectRatio(input.Config.Size),
Resolution: normalizeXAIVideoResolution(input.Config.VQuality),
}
if !shouldSendNewAPIVideoImages(input) || len(input.ReferenceImages) == 0 {
return body, nil
}
if len(input.ReferenceImages) > 1 {
return xaiVideoRequest{}, fmt.Errorf("xAI 图生视频只支持 1 张起始图,当前连接了 %d 张", len(input.ReferenceImages))
}
imageURL, err := openAIImageInputURL(input.ReferenceImages[0])
if err != nil {
return xaiVideoRequest{}, err
}
body.Image = &xaiVideoImage{URL: imageURL}
return body, nil
}
// 兼容旧的 map 断言调用;xAI 实际请求使用类型化 DTO。
func xaiVideoBody(input canvasGenerationInput) (map[string]interface{}, error) {
body, err := xaiVideoRequestBody(input)
if err != nil {
return nil, err
}
return requestAsMap(body)
}
func runSeedanceVideosTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
id := resumedProviderRequestID(ctx)
var created map[string]interface{}
if id == "" {
body, err := seedanceVideosRequestBody(input)
if err != nil {
return nil, err
}
if err := postJSON(ctx, input.Config, "/videos", body, &created); err != nil {
return nil, err
}
if data, ok := created["data"].(map[string]interface{}); ok {
created = data
}
id = firstNonEmptyString(stringField(created, "id"), stringField(created, "task_id"))
}
if id == "" {
return nil, errors.New("Seedance 接口没有返回任务 ID")
}
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
var state map[string]interface{}
if err := getJSON(ctx, input.Config, "/videos/"+id, &state); err != nil {
return nil, err
}
if data, ok := state["data"].(map[string]interface{}); ok {
state = data
}
status := strings.ToLower(stringField(state, "status"))
if status == "completed" || status == "succeeded" {
videoURL := stringField(state, "video_url")
if videoURL != "" {
data, mimeType, err := getExternalBinary(withProviderRequestKind(ctx, "download"), videoURL)
if err != nil {
return nil, fmt.Errorf("视频结果下载失败:%w", err)
}
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil
}
data, mimeType, err := getBinary(ctx, input.Config, "/videos/"+id+"/content")
if err != nil {
return nil, errors.New("Seedance 任务成功但没有返回视频 URL")
}
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil
}
if status == "failed" || status == "cancelled" || status == "expired" {
return nil, errors.New(defaultString(seedanceErrorMessage(state), "Seedance 视频生成失败"))
}
if err := sleepContext(ctx, 5*time.Second); err != nil {
return nil, err
}
}
return nil, errors.New("Seedance 视频生成超时")
}
func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
providerName := "Seedance"
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) {
providerName = "火山方舟"
}
id := resumedProviderRequestID(ctx)
var created map[string]interface{}
if id == "" {
content, err := seedanceContent(input)
if err != nil {
return nil, err
}
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) {
for _, item := range content {
if item["type"] == "image_url" {
item["role"] = "reference_image"
}
}
}
body := seedanceAgentPlanRequest{
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) {
value := parseBool(input.Config.VideoGenerateAudio, true)
body.GenerateAudio = &value
}
if videoCapabilitySupportsWatermark(input) {
value := parseBool(input.Config.VideoWatermark, false)
body.Watermark = &value
}
if err := postJSON(ctx, input.Config, "/contents/generations/tasks", body, &created); err != nil {
return nil, err
}
if data, ok := created["data"].(map[string]interface{}); ok {
created = data
}
id = stringField(created, "id")
}
if id == "" {
return nil, fmt.Errorf("%s接口没有返回任务 ID", providerName)
}
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
var state map[string]interface{}
if err := getJSON(ctx, input.Config, "/contents/generations/tasks/"+id, &state); err != nil {
return nil, err
}
if data, ok := state["data"].(map[string]interface{}); ok {
state = data
}
status := stringField(state, "status")
if status == "succeeded" {
content, _ := state["content"].(map[string]interface{})
videoURL := stringField(content, "video_url")
if videoURL == "" {
return nil, fmt.Errorf("%s任务成功但没有返回视频 URL", providerName)
}
data, mimeType, err := getExternalBinary(withProviderRequestKind(ctx, "download"), videoURL)
if err != nil {
return nil, fmt.Errorf("视频结果下载失败:%w", err)
}
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil
}
if status == "failed" || status == "cancelled" || status == "expired" {
return nil, fmt.Errorf("%s视频生成失败", providerName)
}
if err := sleepContext(ctx, 5*time.Second); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("%s视频生成超时", providerName)
}
func requestTextProvider(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, stream bool) (string, error) {
if stream {
return postStreamingText(ctx, config, path, body, protocol)
}
var payload map[string]interface{}
if err := postJSON(ctx, config, path, body, &payload); err != nil {
return "", err
}
text := extractTextPayload(payload, protocol)
if text == "" {
return "", errors.New("文本接口没有返回内容")
}
return text, nil
}
func postStreamingText(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string) (string, error) {
// 只把分镜规划/修复切到上游 SSE,完整 JSON 仍在流结束后校验,避免半截结构污染画布。
body["stream"] = true
data, mimeType, err := postStreamingBinary(ctx, config, path, body)
if err != nil {
return "", err
}
if !strings.Contains(strings.ToLower(mimeType), "event-stream") {
var payload map[string]interface{}
if err := json.Unmarshal(data, &payload); err != nil {
return "", fmt.Errorf("流式文本接口返回格式无效:%w", err)
}
if err := validateTextPayload(payload); err != nil {
return "", err
}
text := extractTextPayload(payload, protocol)
if text == "" {
return "", errors.New("文本接口没有返回内容")
}
return text, nil
}
return parseTextEventStream(data, protocol)
}
func extractTextPayload(payload map[string]interface{}, protocol string) string {
if protocol == "responses" {
text := stringField(payload, "output_text")
if text == "" {
text = extractResponseText(payload)
}
return text
}
return extractChatCompletionText(payload)
}
func validateTextPayload(payload map[string]interface{}) error {
if code, ok := payload["code"].(float64); ok && code != 0 {
return errors.New(defaultString(stringField(payload, "msg"), "请求失败"))
}
if errValue, ok := payload["error"].(map[string]interface{}); ok {
if message := stringField(errValue, "message"); message != "" {
return errors.New(message)
}
}
return nil
}
func parseTextEventStream(data []byte, protocol string) (string, error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 64<<10), len(data)+1)
var text strings.Builder
var eventName string
var dataLines []string
flush := func() error {
if len(dataLines) == 0 {
eventName = ""
return nil
}
raw := strings.TrimSpace(strings.Join(dataLines, "\n"))
dataLines = nil
if raw == "" || raw == "[DONE]" {
eventName = ""
return nil
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fmt.Errorf("流式文本事件解析失败:%w", err)
}
if eventName == "error" {
if err := validateTextPayload(payload); err != nil {
return err
}
return errors.New("上游流式文本请求失败")
}
if err := validateTextPayload(payload); err != nil {
return err
}
if protocol == "responses" {
text.WriteString(stringField(payload, "delta"))
} else {
choices, _ := payload["choices"].([]interface{})
for _, choice := range choices {
record, _ := choice.(map[string]interface{})
delta, _ := record["delta"].(map[string]interface{})
text.WriteString(streamContentText(delta["content"]))
}
}
eventName = ""
return nil
}
for scanner.Scan() {
line := strings.TrimSuffix(scanner.Text(), "\r")
if line == "" {
if err := flush(); err != nil {
return "", err
}
continue
}
switch {
case strings.HasPrefix(line, "event:"):
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
value := strings.TrimPrefix(line, "data:")
dataLines = append(dataLines, strings.TrimPrefix(value, " "))
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("读取流式文本响应失败:%w", err)
}
if err := flush(); err != nil {
return "", err
}
if text.Len() == 0 {
return "", errors.New("流式文本接口没有返回内容")
}
return text.String(), nil
}
func streamContentText(value interface{}) string {
if text, ok := value.(string); ok {
return text
}
parts, ok := value.([]interface{})
if !ok {
return ""
}
var result strings.Builder
for _, part := range parts {
record, _ := part.(map[string]interface{})
result.WriteString(stringField(record, "text"))
}
return result.String()
}
func postStreamingBinary(ctx context.Context, config providerConfig, path string, body interface{}) ([]byte, string, error) {
data, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), bytes.NewReader(data))
if err != nil {
return nil, "", err
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
ApplyOutboundHeaders(req, config.Headers)
return doBinary(req)
}
func postJSON(ctx context.Context, config providerConfig, path string, body interface{}, target interface{}) error {
data, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
req.Header.Set("Content-Type", "application/json")
ApplyOutboundHeaders(req, config.Headers)
return doJSON(req, target)
}
func postForm(ctx context.Context, config providerConfig, path string, contentType string, body io.Reader, target interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
req.Header.Set("Content-Type", contentType)
ApplyOutboundHeaders(req, config.Headers)
return doJSON(req, target)
}
func getJSON(ctx context.Context, config providerConfig, path string, target interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL(config.BaseURL, path), nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
ApplyOutboundHeaders(req, config.Headers)
return doJSON(req, target)
}
func postBinary(ctx context.Context, config providerConfig, path string, body interface{}) ([]byte, string, error) {
data, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), bytes.NewReader(data))
if err != nil {
return nil, "", err
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
req.Header.Set("Content-Type", "application/json")
ApplyOutboundHeaders(req, config.Headers)
return doBinary(req)
}
func getBinary(ctx context.Context, config providerConfig, path string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL(config.BaseURL, path), nil)
if err != nil {
return nil, "", err
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
ApplyOutboundHeaders(req, config.Headers)
return doBinary(req)
}
func getExternalBinary(ctx context.Context, rawURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", err
}
return doBinary(req)
}
func getProviderExternalBinary(ctx context.Context, config providerConfig, rawURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", err
}
if sameProviderOrigin(config.BaseURL, rawURL) {
req.Header.Set("Authorization", "Bearer "+config.APIKey)
ApplyOutboundHeaders(req, config.Headers)
}
return doBinary(req)
}
func sameProviderOrigin(baseURL string, rawURL string) bool {
base, baseErr := url.Parse(strings.TrimSpace(baseURL))
target, targetErr := url.Parse(strings.TrimSpace(rawURL))
if baseErr != nil || targetErr != nil || base.Scheme == "" || base.Host == "" || target.Scheme == "" || target.Host == "" {
return false
}
return strings.EqualFold(base.Scheme, target.Scheme) && strings.EqualFold(base.Host, target.Host)
}
func doJSON(req *http.Request, target interface{}) error {
data, mimeType, err := doBinary(req)
if err != nil {
return err
}
if !strings.Contains(mimeType, "json") && !json.Valid(data) {
return fmt.Errorf("接口返回非 JSON 内容:%s", mimeType)
}
if err := json.Unmarshal(data, target); err != nil {
return err
}
if payload, ok := target.(*imageResponse); ok {
if payload.Error != nil && payload.Error.Message != "" {
return errors.New(payload.Error.Message)
}
if payload.Code != nil && *payload.Code != 0 {
return errors.New(defaultString(payload.Msg, "请求失败"))
}
}
if payload, ok := target.(*map[string]interface{}); ok {
if code, ok := (*payload)["code"].(float64); ok && code != 0 {
return errors.New(defaultString(stringField(*payload, "msg"), "请求失败"))
}
if errValue, ok := (*payload)["error"].(map[string]interface{}); ok && stringField(errValue, "message") != "" {
return errors.New(stringField(errValue, "message"))
}
}
return nil
}
func doBinary(req *http.Request) ([]byte, string, error) {
startedAt := time.Now()
requestTimeout := providerHTTPTimeout
if deadline, ok := req.Context().Deadline(); ok {
if remaining := time.Until(deadline); remaining > 0 {
requestTimeout = remaining
}
}
var release func()
var coordinator *runtimeCoordinator
var runtimeService *Service
responseLimit := maxProviderResponseBytes
channelID := ""
if metadata, ok := req.Context().Value(providerAnalyticsKey{}).(providerAnalyticsContext); ok && metadata.Service != nil {
runtimeService = metadata.Service
coordinator = metadata.Service.coordinator
channelID = metadata.ChannelID
policy, err := metadata.Service.RuntimePolicy()
if err != nil {
return nil, "", fmt.Errorf("读取生成资源限制失败:%w", err)
}
responseLimit = megabytes(policy.Resource.GeneratedFileMB)
open, err := coordinator.circuitOpen(req.Context(), channelID)
if err != nil {
return nil, "", fmt.Errorf("读取渠道熔断状态失败:%w", err)
}
if open {
return nil, "", errors.New("当前渠道连续失败,已暂时熔断,请稍后重试")
}
slotID := channelID
if slotID == "" {
slotID = "custom:" + strings.ToLower(req.URL.Host)
}
var concurrencyLimit int
release, concurrencyLimit, err = metadata.Service.AcquireChannelSlot(req.Context(), channelID, slotID, requestTimeout+time.Minute)
metadata.ConcurrencyLimit = concurrencyLimit
req = req.WithContext(context.WithValue(req.Context(), providerAnalyticsKey{}, metadata))
if err != nil {
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
defer release()
}
if _, err := ValidateOutboundURL(req.URL.String()); err != nil {
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
ApplyDefaultOutboundHeaders(req)
client := OutboundHTTPClient(requestTimeout)
resp, err := client.Do(req)
if err != nil {
if runtimeService != nil {
_ = runtimeService.RecordChannelResult(req.Context(), channelID, !errors.Is(err, context.Canceled))
}
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
defer resp.Body.Close()
if resp.ContentLength > responseLimit {
err = fmt.Errorf("上游响应超过 %s 限制", formatStorageLimit(responseLimit))
recordProviderRequest(req, startedAt, resp.StatusCode, nil, err)
return nil, "", err
}
data, err := io.ReadAll(io.LimitReader(resp.Body, responseLimit+1))
if err != nil {
recordProviderRequest(req, startedAt, resp.StatusCode, nil, err)
return nil, "", err
}
if int64(len(data)) > responseLimit {
err = fmt.Errorf("上游响应超过 %s 限制", formatStorageLimit(responseLimit))
recordProviderRequest(req, startedAt, resp.StatusCode, nil, err)
return nil, "", err
}
mimeType := resp.Header.Get("Content-Type")
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if runtimeService != nil {
_ = runtimeService.RecordChannelResult(req.Context(), channelID, resp.StatusCode >= 500)
}
httpErr := providerHTTPError{StatusCode: resp.StatusCode, Status: resp.Status, Body: string(data)}
recordProviderRequest(req, startedAt, resp.StatusCode, data, httpErr)
return nil, "", httpErr
}
recordProviderRequest(req, startedAt, resp.StatusCode, data, nil)
if runtimeService != nil {
_ = runtimeService.RecordChannelResult(req.Context(), channelID, false)
}
return data, mimeType, nil
}
func providerPollingDeadline(ctx context.Context) time.Time {
if deadline, ok := ctx.Deadline(); ok {
return deadline
}
return time.Now().Add(videoPollTimeout)
}
func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode int, responseBody []byte, requestErr error) {
metadata, ok := req.Context().Value(providerAnalyticsKey{}).(providerAnalyticsContext)
if !ok || metadata.Service == nil {
return
}
status := model.ApiCallStatusSucceeded
errorText := ""
if requestErr != nil || statusCode < 200 || statusCode >= 300 {
status = model.ApiCallStatusFailed
if requestErr != nil {
errorText = safeProviderLogError(requestErr)
}
}
requestKind := providerRequestKind(req.Method, req.URL.Path)
if metadata.RequestKind != "" {
requestKind = metadata.RequestKind
}
apiFormat := "openai"
if req.Header.Get("x-goog-api-key") != "" {
apiFormat = "gemini"
}
log := model.ApiCallLog{
UserID: metadata.UserID, ChannelID: metadata.ChannelID, TaskID: metadata.TaskID, BillingOrderID: metadata.BillingOrderID,
Source: "backend-task", Capability: metadata.Capability, Operation: metadata.Operation,
RequestKind: requestKind, Billable: req.Method == http.MethodPost && requestKind != "cancel",
APIFormat: apiFormat, Method: req.Method, Path: req.URL.Path, Model: metadata.Model,
Status: status, StatusCode: statusCode, DurationMs: time.Since(startedAt).Milliseconds(),
Error: errorText, ConcurrencyLimit: metadata.ConcurrencyLimit, UpstreamURL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path,
ProviderRequestID: metadata.ProviderRequestID, RequestContentType: req.Header.Get("Content-Type"), RequestBody: requestPayloadForLog(req), ResponseBody: SanitizeAPICallPayload(responseBody, ""),
}
channelSlotFailure := false
if code, message := ChannelSlotFailureDetails(requestErr); code != "" {
channelSlotFailure = true
log.ErrorCode = code
log.Error = message
}
if requestKind == "create" && metadata.Capability == "video" {
log.VideoSeconds = metadata.VideoSeconds
if log.VideoSeconds <= 0 {
if strings.Contains(strings.ToLower(metadata.Model), "seedance") || strings.Contains(req.URL.Path, "/contents/generations/tasks") {
log.VideoSeconds = 5
} else {
log.VideoSeconds = 6
}
}
}
metadata.Service.EnrichAPICallLog(&log, responseBody)
if err := metadata.Service.LogAPICall(log); err != nil {
if !channelSlotFailure {
_ = metadata.Service.MarkBillingUncertain(metadata.BillingOrderID, "上游调用日志写入失败,费用状态待核对")
}
}
}
func safeProviderLogError(err error) string {
var httpErr providerHTTPError
if errors.As(err, &httpErr) {
return fmt.Sprintf("上游 HTTP %d", httpErr.StatusCode)
}
return truncateRunes(err.Error(), 500)
}
func providerRequestKind(method string, path string) string {
if method == http.MethodGet {
if strings.HasSuffix(strings.TrimRight(path, "/"), "/content") || strings.Contains(path, "/download") {
return "download"
}
return "poll"
}
if strings.Contains(path, "repair") {
return "repair"
}
return "create"
}
func apiURL(baseURL string, path string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if strings.HasSuffix(base, "/v1") || strings.HasSuffix(base, "/v1beta") || strings.HasSuffix(base, "/api/v3") || strings.HasSuffix(base, "/api/plan/v3") {
return base + path
}
return base + "/v1" + path
}
func writeField(writer *multipart.Writer, key string, value string) {
_ = writer.WriteField(key, value)
}
func writeMediaPart(writer *multipart.Writer, field string, media providerMedia) error {
raw, mimeType, err := mediaBytes(media)
if err != nil {
return err
}
filename := providerMediaFilename(media, mimeType)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": field, "filename": filename}))
header.Set("Content-Type", mimeType)
part, err := writer.CreatePart(header)
if err != nil {
return err
}
_, err = part.Write(raw)
return err
}
func providerMediaFilename(media providerMedia, mimeType string) string {
base := strings.TrimSpace(media.ID)
if base == "" {
base = "reference"
}
var builder strings.Builder
for _, char := range base {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' {
builder.WriteRune(char)
if builder.Len() >= 64 {
break
}
}
}
base = builder.String()
if base == "" {
base = "reference"
}
extensions, _ := mime.ExtensionsByType(strings.TrimSpace(strings.Split(mimeType, ";")[0]))
extension := ".bin"
if len(extensions) > 0 {
extension = extensions[0]
}
return "reference-" + base + extension
}
func mediaBytes(media providerMedia) ([]byte, string, error) {
value := media.DataURL
if value == "" {
value = media.URL
}
if !strings.HasPrefix(value, "data:") {
return nil, "", errors.New("后端任务队列需要 data URL 形式的本地参考素材")
}
header, encoded, ok := strings.Cut(value, ",")
if !ok {
return nil, "", errors.New("data URL 格式错误")
}
mimeType := strings.TrimPrefix(strings.Split(strings.TrimPrefix(header, "data:"), ";")[0], " ")
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, "", err
}
return raw, normalizedMediaMimeType(defaultString(mimeType, media.Type), raw), nil
}
func imageDataURLs(payload imageResponse) ([]map[string]string, error) {
if len(payload.Data) == 0 {
return nil, errors.New("接口没有返回图片")
}
images := make([]map[string]string, 0, len(payload.Data))
for _, item := range payload.Data {
if b64, ok := item["b64_json"].(string); ok && b64 != "" {
images = append(images, map[string]string{"dataUrl": "data:image/png;base64," + b64})
continue
}
if url, ok := item["url"].(string); ok && url != "" {
images = append(images, map[string]string{"dataUrl": url})
}
}
if len(images) == 0 {
return nil, errors.New("接口没有返回可用图片")
}
return images, nil
}
func dataURL(mimeType string, data []byte) string {
if mimeType == "" {
mimeType = "application/octet-stream"
}
return "data:" + strings.Split(mimeType, ";")[0] + ";base64," + base64.StdEncoding.EncodeToString(data)
}
func stringField(payload map[string]interface{}, key string) string {
value, _ := payload[key].(string)
return value
}
func extractResponseText(payload map[string]interface{}) string {
output, ok := payload["output"].([]interface{})
if !ok {
return ""
}
var chunks []string
for _, item := range output {
record, ok := item.(map[string]interface{})
if !ok || record["type"] != "message" {
continue
}
content, _ := record["content"].([]interface{})
for _, part := range content {
partRecord, ok := part.(map[string]interface{})
if ok && stringField(partRecord, "text") != "" {
chunks = append(chunks, stringField(partRecord, "text"))
}
}
}
return strings.Join(chunks, "")
}
func extractChatCompletionText(payload map[string]interface{}) string {
if data, ok := payload["data"].(map[string]interface{}); ok {
payload = data
}
choices, ok := payload["choices"].([]interface{})
if !ok {
return ""
}
var chunks []string
for _, choice := range choices {
record, ok := choice.(map[string]interface{})
if !ok {
continue
}
if message, ok := record["message"].(map[string]interface{}); ok {
if text := stringField(message, "content"); text != "" {
chunks = append(chunks, text)
}
}
if text := stringField(record, "text"); text != "" {
chunks = append(chunks, text)
}
}
return strings.Join(chunks, "")
}
func withSystemPrompt(config providerConfig, prompt string) string {
systemPrompt := strings.TrimSpace(config.SystemPrompt)
if systemPrompt == "" {
return prompt
}
return systemPrompt + "\n\n" + prompt
}
func seedanceContent(input canvasGenerationInput) ([]map[string]interface{}, error) {
content := make([]map[string]interface{}, 0, 1+len(input.ReferenceImages)+len(input.ReferenceVideos)+len(input.ReferenceAudios))
text := seedancePromptText(input)
if strings.TrimSpace(text) != "" {
content = append(content, map[string]interface{}{"type": "text", "text": text})
}
for _, image := range input.ReferenceImages {
url, err := mediaReferenceURL(image)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": url}, "role": seedanceImageRole(input, image)})
}
for _, video := range input.ReferenceVideos {
url, err := mediaReferenceURL(video)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "video_url", "video_url": map[string]interface{}{"url": url}, "role": "reference_video"})
}
for _, audio := range input.ReferenceAudios {
url, err := mediaReferenceURL(audio)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "audio_url", "audio_url": map[string]interface{}{"url": url}, "role": "reference_audio"})
}
if len(content) == 0 {
return nil, errors.New("请输入视频提示词或连接参考素材")
}
return content, nil
}
func shouldSendNewAPIVideoImages(input canvasGenerationInput) bool {
if input.Metadata == nil {
return true
}
operation, _ := input.Metadata["videoEditOperation"].(string)
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)
}
func seedanceVideosRequestBody(input canvasGenerationInput) (seedanceVideosRequest, error) {
if (len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0) && len(input.ReferenceImages) == 0 {
return seedanceVideosRequest{}, errors.New("Seedance 参考视频或参考音频需要同时连接至少 1 张主参考图")
}
body := seedanceVideosRequest{
Model: input.Config.Model,
Prompt: seedanceVideosPromptText(input),
AspectRatio: normalizeSeedanceVideosRatio(input.Config.Size),
Duration: normalizeSeedanceVideosDuration(input.Config.VideoSeconds),
}
if videoCapabilitySupportsAudio(input) {
value := parseBool(input.Config.VideoGenerateAudio, true)
body.GenerateAudio = &value
}
imageURLs := make([]string, 0, len(input.ReferenceImages))
for _, image := range input.ReferenceImages {
url, err := openAIImageInputURL(image)
if err != nil {
return seedanceVideosRequest{}, err
}
imageURLs = append(imageURLs, url)
}
frameImageURLs, err := videoFrameImageURLs(input, imageURLs)
if err != nil {
return seedanceVideosRequest{}, err
}
if len(frameImageURLs) > 0 {
body.ImageURLs = frameImageURLs
} else if len(imageURLs) > 0 {
body.ImageURL = imageURLs[0]
if len(imageURLs) > 1 {
body.ReferenceImageURLs = imageURLs[1:]
}
}
videoURLs := make([]string, 0, len(input.ReferenceVideos))
for _, video := range input.ReferenceVideos {
url, err := seedanceVideosMediaURL(video)
if err != nil {
return seedanceVideosRequest{}, err
}
videoURLs = append(videoURLs, url)
}
if len(videoURLs) > 0 {
body.ReferenceVideos = videoURLs
}
audioURLs := make([]string, 0, len(input.ReferenceAudios))
for _, audio := range input.ReferenceAudios {
url, err := seedanceVideosMediaURL(audio)
if err != nil {
return seedanceVideosRequest{}, err
}
audioURLs = append(audioURLs, url)
}
if len(audioURLs) > 0 {
body.ReferenceAudios = audioURLs
}
return body, nil
}
// 兼容旧的 map 断言调用;实际请求路径使用类型化 Seedance DTO。
func seedanceVideosBody(input canvasGenerationInput) (map[string]interface{}, error) {
body, err := seedanceVideosRequestBody(input)
if err != nil {
return nil, err
}
return requestAsMap(body)
}
func seedancePromptText(input canvasGenerationInput) string {
return strings.TrimSpace(input.Prompt)
}
func seedanceVideosPromptText(input canvasGenerationInput) string {
return strings.TrimSpace(input.Prompt)
}
func seedanceImageRole(input canvasGenerationInput, image providerMedia) string {
if id := metadataString(input.Metadata, "videoStartFrameNodeId"); id != "" && image.ID == id {
return "first_frame"
}
if id := metadataString(input.Metadata, "videoEndFrameNodeId"); id != "" && image.ID == id {
return "last_frame"
}
return "reference_image"
}
func videoFrameImageURLs(input canvasGenerationInput, imageURLs []string) ([]string, error) {
startFrameID := metadataString(input.Metadata, "videoStartFrameNodeId")
endFrameID := metadataString(input.Metadata, "videoEndFrameNodeId")
if startFrameID == "" && endFrameID == "" {
return nil, nil
}
// image_urls 按首帧、尾帧、普通参考图排序,保持 JSON 视频协议的结构化帧语义。
ordered := make([]string, 0, len(imageURLs))
used := make([]bool, len(imageURLs))
appendFrame := func(frameID string, label string) error {
if frameID == "" {
return nil
}
for index, image := range input.ReferenceImages {
if index >= len(imageURLs) || image.ID != frameID {
continue
}
ordered = append(ordered, imageURLs[index])
used[index] = true
return nil
}
return fmt.Errorf("已配置的%s参考图未包含在视频请求中", label)
}
if err := appendFrame(startFrameID, "首帧"); err != nil {
return nil, err
}
if err := appendFrame(endFrameID, "尾帧"); err != nil {
return nil, err
}
for index, imageURL := range imageURLs {
if !used[index] {
ordered = append(ordered, imageURL)
}
}
return ordered, nil
}
func metadataString(metadata map[string]interface{}, key string) string {
if metadata == nil {
return ""
}
value, _ := metadata[key].(string)
return strings.TrimSpace(value)
}
func mediaReferenceURL(media providerMedia) (string, error) {
value := strings.TrimSpace(media.URL)
if isPublicMediaURL(value) || strings.HasPrefix(value, "asset://") || strings.HasPrefix(value, "data:") {
return value, nil
}
value = strings.TrimSpace(media.DataURL)
if value != "" {
return value, nil
}
return "", errors.New("参考素材需要公网 URL、asset:// 素材 ID 或 data URL")
}
func seedanceVideosMediaURL(media providerMedia) (string, error) {
value := strings.TrimSpace(media.DataURL)
if strings.HasPrefix(value, "data:") {
return value, nil
}
value = strings.TrimSpace(media.URL)
if strings.HasPrefix(value, "data:") || isPublicMediaURL(value) {
return value, nil
}
return "", errors.New("Seedance /videos 参考素材需要公网 URL 或 data URL")
}
func seedanceErrorMessage(state map[string]interface{}) string {
if errorValue, ok := state["error"].(map[string]interface{}); ok {
message := stringField(errorValue, "message")
code := stringField(errorValue, "code")
if message != "" && code != "" {
return code + ":" + message
}
if message != "" {
return message
}
}
code := stringField(state, "error_code")
if code != "" {
return code
}
return ""
}