package service import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "math" "mime" "mime/multipart" "net" "net/http" "net/textproto" "net/url" "path/filepath" "strconv" "strings" "time" "infinite-canvas/backend/internal/model" ) 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"` } 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"` } 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"` } 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, 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 strings.TrimSpace(input.Prompt) == "" { return nil, errors.New("prompt is required") } if input.Mode == "" && strings.HasPrefix(taskType, "video_") { input.Mode = "video" } config, err := s.resolveProviderConfig(input.Config) if err != nil { return nil, err } input.Config = config 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 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 } } switch input.Mode { case "image": return runImageTask(ctx, input) case "text": return runTextTask(ctx, input) case "video": return runVideoTask(ctx, input) case "audio": return runAudioTask(ctx, input) default: return nil, fmt.Errorf("不支持的生成模式:%s", input.Mode) } } 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("任务参考资源尚未上传完成") } // 本地素材自动迁移到 OSS if resource.Provider == "local" { setting, storageSettingID, useOSS, ossErr := s.activeResourceOSSSetting(userID) if ossErr != nil || !useOSS || setting.Provider != "aliyun" { return errors.New("当前 JSON 视频协议的参考素材需要公网可访问地址,请启用 OSS 后重新上传该素材") } // 读取本地文件并上传到 OSS _, body, readErr := s.OpenResource(userID, resourceID) if readErr != nil { return fmt.Errorf("读取本地素材失败:%w", readErr) } ossKey := ossObjectKey(setting, userID, resource.Kind, filepath.Base(resource.ObjectKey), time.Now()) if _, uploadErr := putOSSObject(setting, ossKey, resource.MimeType, resource.Size, body); uploadErr != nil { body.Close() return fmt.Errorf("素材迁移到 OSS 失败:%w", uploadErr) } body.Close() // 更新资源记录 resource.Provider = "aliyun" resource.ObjectKey = ossKey resource.Endpoint = setting.Endpoint resource.Bucket = setting.Bucket resource.StorageSettingID = storageSettingID if saveErr := s.repo.SaveResource(resource); saveErr != nil { return fmt.Errorf("更新资源记录失败:%w", saveErr) } // 生成签名 URL signedURL, signErr := signedOSSObjectURL(setting, ossKey, time.Now().Add(providerResourceURLTTL)) if signErr != nil { return fmt.Errorf("生成 OSS 签名地址失败:%w", signErr) } media.URL = signedURL media.DataURL = "" media.MimeType = firstNonEmpty(media.MimeType, resource.MimeType) return nil } setting, err := s.ossSettingForResource(userID, resource) if err != nil { return err } if setting.Provider != "aliyun" { return errors.New("当前 JSON 视频协议的私有参考素材暂时只支持阿里云 OSS 签名地址") } signedURL, err := signedOSSObjectURL(setting, resource.ObjectKey, 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) 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 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.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") writeField(writer, "response_format", "b64_json") writeField(writer, "output_format", "png") if input.Config.TransparentBackground == "true" { writeField(writer, "background", "transparent") } if input.Config.Quality != "" { writeField(writer, "quality", normalizeImageQuality(input.Config.Quality)) } if size := normalizePixelSize(input.Config.Size); size != "" { writeField(writer, "size", size) } 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, "response_format": "b64_json", "output_format": "png", } if input.Config.TransparentBackground == "true" { body["background"] = "transparent" } if input.Config.Quality != "" { body["quality"] = normalizeImageQuality(input.Config.Quality) } if size := normalizePixelSize(input.Config.Size); size != "" { body["size"] = size } 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 } 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 size := normalizeVolcengineArkImageSize(input.Config.Size); size != "" { body["size"] = size } 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) { var payload map[string]interface{} responseInput, err := textResponseInput(input) if err != nil { return nil, err } body := map[string]interface{}{"model": input.Config.Model, "input": responseInput} if err := postJSON(ctx, input.Config, "/responses", body, &payload); 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) } text := stringField(payload, "output_text") if text == "" { text = extractResponseText(payload) } if text == "" { return nil, errors.New("文本接口没有返回内容") } return map[string]interface{}{"mode": "text", "text": text}, nil } func runResponsesTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { var payload map[string]interface{} responseInput, err := textResponseInput(input) if err != nil { return nil, err } if err := postJSON(ctx, input.Config, "/responses", map[string]interface{}{"model": input.Config.Model, "input": responseInput}, &payload); err != nil { return nil, err } text := stringField(payload, "output_text") if text == "" { text = extractResponseText(payload) } if text == "" { return nil, errors.New("文本接口没有返回内容") } return map[string]interface{}{"mode": "text", "text": text}, nil } func runChatCompletionsTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { var payload map[string]interface{} 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} if err := postJSON(ctx, input.Config, "/chat/completions", body, &payload); err != nil { return nil, err } text := extractChatCompletionText(payload) if text == "" { return nil, errors.New("文本接口没有返回内容") } 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 } data, mimeType, err := postBinary(ctx, input.Config, "/audio/speech", body) 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 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)) { 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 := getExternalBinary(withProviderRequestKind(ctx, "download"), 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 := map[string]interface{}{"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"] = map[string]interface{}{"bytesBase64Encoded": base64.StdEncoding.EncodeToString(raw), "mimeType": mimeType} } body := map[string]interface{}{ "instances": []interface{}{instance}, "parameters": map[string]interface{}{ "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 := newAPIChannel2VideoBody(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 := getExternalBinary(withProviderRequestKind(ctx, "download"), 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 newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{}, error) { if len(input.ReferenceImages) > 9 || len(input.ReferenceVideos) > 3 || len(input.ReferenceAudios) > 3 { return nil, errors.New("NewAPI Video Generations 最多支持 9 张参考图、3 个参考视频和 3 个参考音频") } 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 nil, err } images = append(images, url) } } if requiresSingleImage { if len(images) != 1 { return nil, fmt.Errorf("NewAPI Video Generations 的 %s 必须且只能提供 1 张参考图(当前 %d 张)", input.Config.Model, len(images)) } } frameImages, err := videoFrameImageURLs(input, images) if err != nil { return nil, 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 := map[string]interface{}{ "model": input.Config.Model, "prompt": strings.TrimSpace(input.Prompt), "seconds": strconv.Itoa(seconds), "aspect_ratio": ratio, "resolution": resolution, "generate_audio": parseBool(input.Config.VideoGenerateAudio, true), } if len(images) > 0 { body["image_urls"] = images } videoURLs := make([]string, 0, len(input.ReferenceVideos)) for _, video := range input.ReferenceVideos { url, err := videoGenerationsMediaURL(video) if err != nil { return nil, err } videoURLs = append(videoURLs, url) } if len(videoURLs) > 0 { body["video_urls"] = videoURLs } audioURLs := make([]string, 0, len(input.ReferenceAudios)) for _, audio := range input.ReferenceAudios { url, err := videoGenerationsMediaURL(audio) if err != nil { return nil, err } audioURLs = append(audioURLs, url) } if len(audioURLs) > 0 { body["audio_urls"] = audioURLs } return body, nil } 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}) } body := map[string]interface{}{ "model": input.Config.Model, "input": map[string]interface{}{"prompt": strings.TrimSpace(input.Prompt)}, "parameters": map[string]interface{}{ "resolution": normalizeNewAPIChannel1Resolution(input.Config.VQuality), "ratio": normalizeNewAPIChannel1Ratio(input.Config.Size), "prompt_extend": false, "watermark": parseBool(input.Config.VideoWatermark, false), "duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds), }, } 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, "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}, } 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" { return xaiVideoBody(input) } 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 xaiVideoBody(input canvasGenerationInput) (map[string]interface{}, error) { body := map[string]interface{}{ "model": input.Config.Model, "prompt": strings.TrimSpace(input.Prompt), "duration": normalizeXAIVideoDuration(input.Config.VideoSeconds), "aspect_ratio": 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 nil, fmt.Errorf("xAI 图生视频只支持 1 张起始图,当前连接了 %d 张", len(input.ReferenceImages)) } imageURL, err := openAIImageInputURL(input.ReferenceImages[0]) if err != nil { return nil, err } body["image"] = map[string]interface{}{"url": imageURL} return body, nil } func runSeedanceVideosTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { id := resumedProviderRequestID(ctx) var created map[string]interface{} if id == "" { body, err := seedanceVideosBody(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 := map[string]interface{}{ "model": input.Config.Model, "content": content, "ratio": normalizeSeedanceRatio(input.Config.Size), "resolution": normalizeSeedanceResolution(input.Config.VQuality, input.Config.Model), "duration": normalizeSeedanceDuration(input.Config.VideoSeconds), "generate_audio": parseBool(input.Config.VideoGenerateAudio, true), "watermark": parseBool(input.Config.VideoWatermark, false), } 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 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 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, 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" } func newAPIVideoPromptText(input canvasGenerationInput) string { return strings.TrimSpace(input.Prompt) } func seedanceVideosBody(input canvasGenerationInput) (map[string]interface{}, error) { if (len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0) && len(input.ReferenceImages) == 0 { return nil, errors.New("Seedance 参考视频或参考音频需要同时连接至少 1 张主参考图") } body := map[string]interface{}{ "model": input.Config.Model, "prompt": seedanceVideosPromptText(input), "aspect_ratio": normalizeSeedanceVideosRatio(input.Config.Size), "duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds), "generate_audio": parseBool(input.Config.VideoGenerateAudio, true), } imageURLs := make([]string, 0, len(input.ReferenceImages)) for _, image := range input.ReferenceImages { url, err := openAIImageInputURL(image) if err != nil { return nil, err } imageURLs = append(imageURLs, url) } frameImageURLs, err := videoFrameImageURLs(input, imageURLs) if err != nil { return nil, err } if len(frameImageURLs) > 0 { body["image_urls"] = frameImageURLs } else if len(imageURLs) > 0 { body["image_url"] = imageURLs[0] if len(imageURLs) > 1 { body["reference_image_urls"] = imageURLs[1:] } } videoURLs := make([]string, 0, len(input.ReferenceVideos)) for _, video := range input.ReferenceVideos { url, err := seedanceVideosMediaURL(video) if err != nil { return nil, err } videoURLs = append(videoURLs, url) } if len(videoURLs) > 0 { body["reference_videos"] = videoURLs } audioURLs := make([]string, 0, len(input.ReferenceAudios)) for _, audio := range input.ReferenceAudios { url, err := seedanceVideosMediaURL(audio) if err != nil { return nil, err } audioURLs = append(audioURLs, url) } if len(audioURLs) > 0 { body["reference_audios"] = audioURLs } return body, nil } 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 "" } func isPublicMediaURL(value string) bool { lower := strings.ToLower(value) return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") } func isSeedanceVideoConfig(config providerConfig) bool { model := strings.ToLower(config.Model) return strings.Contains(model, "seedance") || strings.Contains(model, "doubao-seedance") || isArkPlanVideoConfig(config) } func isGrokVideoConfig(config providerConfig) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(config.Model)), "grok") } func isArkPlanVideoConfig(config providerConfig) bool { return strings.Contains(strings.ToLower(config.BaseURL), "/api/plan/v3") } func normalizeImageQuality(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "1k": return "low" case "2k": return "medium" case "4k": return "high" default: return value } } func normalizePixelSize(value string) string { value = strings.TrimSpace(value) if value == "" || value == "auto" || strings.Contains(value, ":") { return "" } if strings.Contains(value, "x") { return value } return "" } func normalizeVideoSize(value string) string { value = strings.TrimSpace(value) if value == "" || value == "auto" { return "" } if strings.Contains(value, "x") { return value } if value == "9:16" || value == "2:3" || value == "3:4" { return "720x1280" } return "1280x720" } func normalizeVideoResolution(value string) string { value = strings.TrimSpace(value) if value == "" || value == "auto" || value == "medium" || value == "high" { return "720p" } if value == "low" { return "480p" } if strings.EqualFold(value, "4k") { return "2160p" } if strings.HasSuffix(value, "p") { return value } return value + "p" } func normalizeXAIVideoDuration(value string) int { duration, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || duration <= 0 { return 6 } return duration } func normalizeXAIVideoResolution(value string) string { 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 normalizeXAIVideoAspectRatio(value string) string { value = strings.ToLower(strings.TrimSpace(value)) allowed := map[string]bool{ "1:1": true, "16:9": true, "9:16": true, "4:3": true, "3:4": true, "3:2": true, "2:3": true, } if allowed[value] { return value } parts := strings.Split(value, "x") if len(parts) != 2 { return "16:9" } width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { return "16:9" } ratio := float64(width) / float64(height) candidates := []struct { name string ratio float64 }{ {name: "1:1", ratio: 1}, {name: "16:9", ratio: 16.0 / 9}, {name: "9:16", ratio: 9.0 / 16}, {name: "4:3", ratio: 4.0 / 3}, {name: "3:4", ratio: 3.0 / 4}, {name: "3:2", ratio: 3.0 / 2}, {name: "2:3", ratio: 2.0 / 3}, } bestName := "16:9" bestDifference := 2.0 for _, candidate := range candidates { difference := ratio - candidate.ratio if difference < 0 { difference = -difference } if difference < bestDifference { bestName = candidate.name bestDifference = difference } } return bestName } func normalizeSeedanceDuration(value string) int { if strings.TrimSpace(value) == "-1" { return -1 } seconds, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || seconds <= 0 { return 5 } return seconds } func normalizeSeedanceVideosDuration(value string) int { return normalizeSeedanceDuration(value) } func normalizeSeedanceRatio(value string) string { value = strings.TrimSpace(value) if value == "" || value == "auto" || value == "adaptive" { return "adaptive" } switch value { case "16:9", "9:16", "1:1", "4:3", "3:4", "21:9": return value default: return "adaptive" } } func normalizeSeedanceVideosRatio(value string) string { ratio := normalizeSeedanceRatio(value) if ratio == "adaptive" { return "16:9" } return ratio } func normalizeSeedanceResolution(value string, model string) string { resolution := strings.TrimSuffix(strings.TrimSpace(value), "p") if strings.EqualFold(resolution, "4k") { resolution = "2160" } switch resolution { case "480", "720", "1080", "2160": default: if value == "low" { resolution = "480" } else { resolution = "720" } } if strings.Contains(strings.ToLower(model), "fast") && (resolution == "1080" || resolution == "2160") { resolution = "720" } return resolution + "p" } func parseBool(value string, fallback bool) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "true": return true case "false": return false default: return fallback } } func parseFloat(value string, fallback float64) float64 { number, err := strconv.ParseFloat(strings.TrimSpace(value), 64) if err != nil || number == 0 { return fallback } return number } func sleepContext(ctx context.Context, duration time.Duration) error { timer := time.NewTimer(duration) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } }