package jobs import ( "context" "encoding/json" "errors" "fmt" "log" "math" "net/http" "net/url" "strings" "time" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/prompt" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/providers" ) type ProviderRegistry map[string]providers.Adapter type ProviderResolver interface { Resolve(context.Context, string) (providers.Adapter, error) } type ProviderAssetURLResolver interface { ResolveProviderAssetURL(context.Context, string, string, time.Duration) (sourceURL string, accessURL string, err error) } func (registry ProviderRegistry) Resolve(_ context.Context, name string) (providers.Adapter, error) { return registry[name], nil } type ProviderProcessor struct { Providers ProviderResolver Store Store AssetURLs ProviderAssetURLResolver AssetURLTTL time.Duration } func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) { // A persisted terminal provider result is the recovery checkpoint. Replaying // it must run downstream idempotent finalizers, never submit/query again. if job.Status.Terminal() { return job, nil } if p.Providers == nil { return Job{}, fmt.Errorf("generation provider %q is unavailable", job.Provider) } adapter, err := p.Providers.Resolve(ctx, job.Provider) if err != nil { return Job{}, fmt.Errorf("resolve generation provider %q: %w", job.Provider, err) } if adapter == nil { return Job{}, fmt.Errorf("generation provider %q is unavailable", job.Provider) } var request providers.Request if err := json.Unmarshal(job.RequestPayload, &request); err != nil { return Job{}, errors.New("invalid provider request payload") } if job.ProviderTaskID == "" && job.ProviderDispatchStartedAt == nil { request, err = p.refreshAssetURLs(ctx, job, request) if err != nil { return Job{}, fmt.Errorf("prepare provider asset URLs: %w", err) } } var result providers.Result phase := "submit" expectedStatus := job.Status if job.ProviderTaskID == "" { if job.ProviderDispatchStartedAt != nil { logProviderFailureDiagnostic(providerFailureDiagnostic{ JobID: safeProviderLogToken(job.ID), Provider: safeProviderLogToken(job.Provider), Phase: "submit", ErrorClass: "unknown_outcome", }) failed := StatusFailed failure := &JobError{Message: unknownProviderSubmissionMessage, Retryable: false} if p.Store == nil { job.Status, job.Error = failed, failure return job, nil } return p.Store.UpdateJob(ctx, job.ID, workerPatch(job, Patch{Status: &failed, Error: failure})) } if p.Store != nil { started := time.Now().UTC() prepared, persistErr := p.Store.UpdateJob(ctx, job.ID, workerPatch(job, Patch{ProviderDispatchStartedAt: &started})) if persistErr != nil { return Job{}, persistErr } job = prepared } result, err = adapter.Submit(ctx, request) } else { phase = "query" if modeled, ok := adapter.(providers.ModelQueryAdapter); ok { result, err = modeled.QueryModel(ctx, job.ProviderTaskID, job.ReqKey) } else { result, err = adapter.Query(ctx, job.ProviderTaskID) } } if err != nil { logProviderFailure(job, phase, err) } if err != nil && job.ProviderTaskID == "" && p.Store != nil { failed := StatusFailed failure := providerSubmissionFailure(err) patch := Patch{Status: &failed, Error: failure} var providerError *providers.ProviderError if job.Provider == "minimax" && errors.As(err, &providerError) && providerError.Status > 0 { // Keep only the bounded, redacted diagnostic, never the raw response. patch.ResponsePayload, _ = json.Marshal(map[string]any{"providerError": map[string]any{ "status": providerError.Status, "code": providerError.Code, "requestId": providerError.RequestID, "detail": providerError.Detail, }}) patch.SetResponsePayload = true failure.Code = providerError.Code } return p.Store.UpdateJob(ctx, job.ID, workerPatch(job, patch)) } if err != nil { return Job{}, err } job.ProviderTaskID = result.TaskID encoded, err := providers.EncodeResult(result) if err != nil { return Job{}, errors.New("encode provider result") } if result.Status == providers.StatusSucceeded && len(result.OutputURLs) == 0 { failed := StatusFailed failure := &JobError{Message: "provider reported success without an output URL", Retryable: false} if p.Store == nil { job.Status, job.Error, job.ResponsePayload = failed, failure, encoded return job, nil } patch := workerPatch(job, Patch{ Status: &failed, Error: failure, ResponsePayload: encoded, SetResponsePayload: true, }) if result.TaskID != "" { patch.ProviderTaskID = &result.TaskID } return p.Store.UpdateJob(ctx, job.ID, patch) } job.ResponsePayload = encoded job.Status = Status(result.Status) if result.ErrorMessage != "" { job.Error = &JobError{Message: result.ErrorMessage} } if p.Store != nil { patch := Patch{Status: &job.Status, ProviderTaskID: &job.ProviderTaskID, ResponsePayload: job.ResponsePayload, SetResponsePayload: true, ClearError: result.ErrorMessage == ""} patch.ExpectedStatuses = []Status{expectedStatus} if job.LockedBy != "" { worker := job.LockedBy patch.ExpectedLockedBy = &worker } if job.Error != nil { patch.Error = job.Error } return p.Store.UpdateJob(ctx, job.ID, patch) } return job, nil } const ( unknownProviderSubmissionMessage = "provider submission outcome is unknown; refusing duplicate submission" outputImageSafetyMessage = "生成结果触发内容安全审核,请更换素材或调整内容后重试。" ) func providerSubmissionFailure(err error) *JobError { message := unknownProviderSubmissionMessage var providerError *providers.ProviderError if errors.As(err, &providerError) { if providerError.Code == "OutputImageSensitiveContentDetected" || strings.HasPrefix(providerError.Code, "OutputImageSensitiveContentDetected.") { message = outputImageSafetyMessage } else if strings.HasPrefix(providerError.Operation, "minimax ") { message = minimaxSubmissionFailureMessage(providerError) } } return &JobError{Message: message, Retryable: false} } func minimaxSubmissionFailureMessage(providerError *providers.ProviderError) string { switch providerError.Status { case http.StatusBadRequest: return "MiniMax 请求参数不符合要求,请检查提示词、素材和生成参数。" case http.StatusUnauthorized: return "MiniMax 鉴权失败,请检查 API Key 是否正确且已开通 H3。" case http.StatusForbidden: return "MiniMax API Key 无 H3 调用权限,请确认已购买并开通该模型。" case http.StatusPaymentRequired: return "MiniMax API 账户余额不足,请充值后重试。" case http.StatusNotFound: return "MiniMax H3 接口或模型不可用,请检查 Base URL 和账号模型权限。" case http.StatusUnprocessableEntity: return "视频描述或输入素材触发内容安全审核,请调整后重试。" case http.StatusTooManyRequests: return "MiniMax 请求频率过高,请稍后重试。" case http.StatusInternalServerError: return "MiniMax 服务暂时不可用,请稍后重试。" default: return unknownProviderSubmissionMessage } } type providerFailureDiagnostic struct { JobID string Provider string Phase string Status int ErrorClass string } func logProviderFailure(job Job, phase string, err error) { diagnostic := providerFailureDiagnostic{ JobID: safeProviderLogToken(job.ID), Provider: safeProviderLogToken(job.Provider), Phase: safeProviderPhase(phase), ErrorClass: "provider", } if errors.Is(err, context.Canceled) { diagnostic.ErrorClass = "canceled" } else if errors.Is(err, context.DeadlineExceeded) { diagnostic.ErrorClass = "timeout" } else { var providerError *providers.ProviderError if errors.As(err, &providerError) { diagnostic.Status = providerError.Status if providerError.Status > 0 { diagnostic.ErrorClass = "service" } } } logProviderFailureDiagnostic(diagnostic) } func logProviderFailureDiagnostic(diagnostic providerFailureDiagnostic) { log.Printf( "zhinian-api generation provider failed jobId=%s provider=%s phase=%s status=%d errorClass=%s", diagnostic.JobID, diagnostic.Provider, diagnostic.Phase, diagnostic.Status, diagnostic.ErrorClass, ) } func safeProviderPhase(phase string) string { if phase == "query" { return "query" } return "submit" } func safeProviderLogToken(value string) string { value = strings.TrimSpace(value) if len(value) == 0 || len(value) > 96 { return "invalid" } for _, character := range value { if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) { continue } return "invalid" } return value } func (p ProviderProcessor) refreshAssetURLs(ctx context.Context, job Job, request providers.Request) (providers.Request, error) { if p.AssetURLs == nil || len(job.InputAssetIDs) == 0 { return request, nil } ttl := p.AssetURLTTL if ttl <= 0 { ttl = time.Hour } replacements := make(map[string]string, len(job.InputAssetIDs)) seen := make(map[string]struct{}, len(job.InputAssetIDs)) for _, assetID := range job.InputAssetIDs { assetID = strings.TrimSpace(assetID) if assetID == "" { continue } if _, ok := seen[assetID]; ok { continue } seen[assetID] = struct{}{} sourceURL, accessURL, err := p.AssetURLs.ResolveProviderAssetURL(ctx, job.OwnerID, assetID, ttl) if err != nil { return providers.Request{}, err } if strings.TrimSpace(sourceURL) == "" || strings.TrimSpace(accessURL) == "" { return providers.Request{}, errors.New("provider asset URL is unavailable") } replacements[sourceURL] = accessURL } for index, inputURL := range request.InputURLs { if accessURL, ok := replacements[inputURL]; ok { request.InputURLs[index] = accessURL } } for index := range request.Materials { if accessURL, ok := replacements[request.Materials[index].URL]; ok { request.Materials[index].URL = accessURL } } return request, nil } type ProviderJobBuilder struct { ImageProvider, VideoProvider string ImageModel, VideoModel string ImageEngine, VideoEngine string ImageEngines map[string]ProviderTarget ImageModels map[string]ProviderTarget VideoEngines map[string]ProviderTarget VideoModels map[string]ProviderTarget UnavailableProviders map[string]string NewID func() string } // ProviderTarget is server-owned routing configuration for one UI engine. // The request may select a known engine and an allow-listed model, but it // cannot supply an arbitrary provider or model identifier. type ProviderTarget struct { Provider string Model string Settings map[string]any } func (b ProviderJobBuilder) Build(_ context.Context, owner, client, capability, idempotency string, body map[string]any) (CreateCommand, error) { if capability != "image.generate" && capability != "video.generate" { return CreateCommand{}, &Error{Kind: ErrorInvalid, Status: 400, Message: "Unsupported capability: " + capability} } target, engine, err := b.target(capability, body["engine"], body["model"]) if err != nil { return CreateCommand{}, err } if message := strings.TrimSpace(b.UnavailableProviders[target.Provider]); message != "" { return CreateCommand{}, &Error{Kind: ErrorUnavailable, Status: 503, Message: message} } if target.Provider == "" || target.Model == "" || b.NewID == nil { return CreateCommand{}, errors.New("provider job builder is not configured") } prepared, err := prepareProviderRequest(capability, engine, target.Model, target.Settings, body) if err != nil { return CreateCommand{}, err } prepared.request.Model = target.Model raw, err := json.Marshal(prepared.request) if err != nil { return CreateCommand{}, errors.New("encode provider request") } priority := NormalizePriority(intFromAny(body["priority"])) webhook, _ := body["webhookUrl"].(string) webhook = strings.TrimSpace(webhook) if client != "" && webhook != "" && !validWebhookURL(webhook) { return CreateCommand{}, invalidPreparation("webhookUrl must be an HTTP or HTTPS URL") } return CreateCommand{Job: Job{ID: b.NewID(), OwnerID: owner, ExternalClientID: client, Capability: capability, Provider: target.Provider, ReqKey: target.Model, Status: StatusQueued, Prompt: prepared.request.Prompt, InputURLs: prepared.request.InputURLs, InputAssetIDs: prepared.assetIDs, OutputAssetIDs: []string{}, RequestPayload: raw, IdempotencyKey: idempotency, Priority: priority, WebhookURL: webhook}, IdempotencyBody: body}, nil } func validWebhookURL(value string) bool { parsed, err := url.Parse(value) return err == nil && parsed.IsAbs() && parsed.Host != "" && parsed.User == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") } func (b ProviderJobBuilder) target(capability string, rawEngine, rawModel any) (ProviderTarget, string, error) { engine := engineName(rawEngine) if capability == "image.generate" { if rawEngine != nil && engine == "" { return ProviderTarget{}, "", invalidPreparation("unsupported image engine") } configured := firstConfiguredEngine(b.ImageEngine, b.ImageProvider, "image") if rawModel != nil { if engine == "" { engine = configured } model := stringValue(rawModel) if model == "" || engine != "evolink" { return ProviderTarget{}, "", invalidPreparation("unsupported image model") } target, ok := b.ImageModels[model] if !ok || target.Provider != "evolink" || target.Model == "" { return ProviderTarget{}, "", invalidPreparation("unsupported image model") } return target, engine, nil } if engine != "" { if engine != "jimeng" && engine != "evolink" && engine != "bailian" && engine != "seedream" { return ProviderTarget{}, "", invalidPreparation("unsupported image engine") } target, ok := b.ImageEngines[engine] if !ok || target.Provider == "" || target.Model == "" { return ProviderTarget{}, "", errors.New("provider job builder is not configured") } return target, engine, nil } if target, ok := b.ImageEngines[configured]; ok && target.Provider == b.ImageProvider && target.Model == b.ImageModel { return target, configured, nil } return ProviderTarget{Provider: b.ImageProvider, Model: b.ImageModel}, configured, nil } if rawEngine != nil && engine == "" { return ProviderTarget{}, "", invalidPreparation("unsupported video engine") } configured := firstConfiguredEngine(b.VideoEngine, b.VideoProvider, "video") if engine == "" { engine = configured } if rawModel != nil { model := stringValue(rawModel) if model == "" || engine != "seedance" { return ProviderTarget{}, "", invalidPreparation("unsupported video model") } target, ok := b.VideoModels[model] if !ok || target.Provider != "seedance" || target.Model == "" { return ProviderTarget{}, "", invalidPreparation("unsupported video model") } return target, engine, nil } if engine != "" { if engine != "seedance" && engine != "bailian" && engine != "minimax" { return ProviderTarget{}, "", invalidPreparation("unsupported video engine") } target, ok := b.VideoEngines[engine] if !ok || target.Provider == "" || target.Model == "" { return ProviderTarget{}, "", errors.New("provider job builder is not configured") } return target, engine, nil } if target, ok := b.VideoEngines[configured]; ok && target.Provider == b.VideoProvider && target.Model == b.VideoModel { return target, configured, nil } return ProviderTarget{Provider: b.VideoProvider, Model: b.VideoModel}, configured, nil } func firstConfiguredEngine(configured, provider, capabilityKind string) string { if engine := engineName(configured); engine != "" { return engine } if capabilityKind == "image" { switch provider { case "volcengine-visual": return "jimeng" case "evolink", "bailian", "seedream": return provider } } else if provider == "seedance" || provider == "bailian" || provider == "minimax" { return provider } return "" } type preparedProviderRequest struct { request providers.Request assetIDs []string } func prepareProviderRequest(capability, engine, model string, defaults map[string]any, body map[string]any) (preparedProviderRequest, error) { materials, assembly, err := preparationMaterials(body, capability) if err != nil { return preparedProviderRequest{}, err } text := stringValue(body["prompt"]) if text == "" && assembly != nil { text = prompt.Assemble(*assembly).Prompt } text = strings.TrimSpace(text) if text == "" && !seedreamLayerDecompositionRequested(capability, engine, body) { return preparedProviderRequest{}, invalidPreparation("prompt is required") } assetIDs := stringsFromAny(body["inputAssetIds"]) if len(assetIDs) == 0 { for _, material := range materials { if material.ID != "" { assetIDs = append(assetIDs, material.ID) } } } var urls []string var settings map[string]any if capability == "image.generate" { urls = stringsFromAny(firstNonNil(body["imageUrls"], body["inputUrls"])) if len(urls) == 0 { for _, material := range materials { if material.Type == "image" { urls = append(urls, material.URL) } } } if err := validateImageCoverage(text, materials, len(urls)); err != nil { return preparedProviderRequest{}, err } settings, err = imageSettings(body) if err == nil { applyImageSettingDefaults(settings, defaults) } if err == nil && engine == "evolink" && isEvoLinkImage25(model) { err = validateEvoLinkImageQuality(body, settings) } if err == nil && engine == "evolink" && isEvoLinkImage25(model) { err = validateEvoLinkImage25(body, urls, settings) } if err == nil && engine == "bailian" { err = validateBailianImage(urls, settings) } if err == nil && engine == "seedream" { err = validateSeedreamImage(urls, materials, settings) } } else { if err := validateMaterialCoverage(text, materials); err != nil { return preparedProviderRequest{}, err } for _, material := range materials { urls = append(urls, material.URL) } settings, err = videoSettings(engine, model, defaults, body["settings"], materials) } if err != nil { return preparedProviderRequest{}, err } providerMaterials := make([]providers.Material, 0, len(materials)) for _, material := range materials { materialType := providers.MaterialImage switch material.Type { case "video": materialType = providers.MaterialVideo case "audio": materialType = providers.MaterialAudio } providerMaterials = append(providerMaterials, providers.Material{ URL: material.URL, Type: materialType, Role: material.Role, Label: material.Label, }) } return preparedProviderRequest{request: providers.Request{Capability: capability, Prompt: text, InputURLs: urls, Materials: providerMaterials, Settings: settings}, assetIDs: assetIDs}, nil } func validateEvoLinkImageQuality(body, settings map[string]any) error { quality := body["quality"] if quality == nil { if nested, ok := body["settings"].(map[string]any); ok { quality = nested["quality"] } } if quality != nil { value, ok := quality.(string) if !ok || !validImageQuality(strings.ToLower(strings.TrimSpace(value))) { return invalidPreparation("invalid image parameter: quality") } } if settings["quality"] == nil { settings["quality"] = "medium" } value, ok := settings["quality"].(string) if !ok || !validImageQuality(value) { return invalidPreparation("invalid image parameter: quality") } return nil } func validImageQuality(value string) bool { return value == "low" || value == "medium" || value == "high" } func isEvoLinkImage25(model string) bool { return model == "gpt-image-2.5-flare" || model == "gpt-image-2.5-sunburst" } func validateEvoLinkImage25(body map[string]any, urls []string, settings map[string]any) error { if len(urls) > 16 { return invalidPreparation("EvoLink Image 2.5 supports at most 16 reference images") } resolutions := []any{body["resolution"]} counts := []any{body["n"]} if nested, ok := body["settings"].(map[string]any); ok { resolutions = append(resolutions, nested["resolution"]) counts = append(counts, nested["n"]) } for _, resolution := range resolutions { if resolution != nil && !strings.EqualFold(strings.TrimSpace(stringValue(resolution)), "1K") { return invalidPreparation("EvoLink Image 2.5 resolution must be 1K") } } for _, count := range counts { if count != nil { value, ok := finiteNumber(count) if !ok || value != 1 { return invalidPreparation("EvoLink Image 2.5 n must be 1") } } } if raw := settings["imageCount"]; raw != nil { count, ok := finiteNumber(raw) if !ok || count != 1 { return invalidPreparation("EvoLink Image 2.5 imageCount must be 1") } } if settings["force_single"] == false { return invalidPreparation("EvoLink Image 2.5 requires force_single") } if raw := settings["size"]; raw != nil { size, _ := raw.(string) if !evoLink1KSize(size) { return invalidPreparation("EvoLink Image 2.5 size must be a supported 1K aspect ratio or A4 size") } } widthRaw, heightRaw := settings["width"], settings["height"] if (widthRaw == nil) != (heightRaw == nil) { return invalidPreparation("EvoLink Image 2.5 requires both width and height") } if widthRaw != nil { width, widthOK := finiteNumber(widthRaw) height, heightOK := finiteNumber(heightRaw) if !widthOK || !heightOK || width < 1 || height < 1 || width > 8192 || height > 8192 || width != math.Trunc(width) || height != math.Trunc(height) { return invalidPreparation("invalid EvoLink Image 2.5 dimensions") } if !evoLink1KDimensions(int(width), int(height)) { return invalidPreparation("EvoLink Image 2.5 custom dimensions exceed the supported 1K size") } } else if settings["size"] == nil { // EvoLink's auto size may choose a larger output even with resolution=1K. settings["size"] = "1:1" } settings["imageCount"] = float64(1) return nil } func evoLink1KSize(size string) bool { switch strings.TrimSpace(size) { case "1:1", "1:2", "2:1", "1:3", "3:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "9:21", "21:9", "848x1200", "1200x848": return true default: return false } } func evoLink1KDimensions(width, height int) bool { if (width == 848 && height == 1200) || (width == 1200 && height == 848) || (width == 1680 && height == 2376) || (width == 2376 && height == 1680) { return true } divisor := gcdImageDimensions(width, height) if evoLink1KSize(fmt.Sprintf("%d:%d", width/divisor, height/divisor)) { return true } shorter, longer := min(width, height), max(width, height) pixels := width * height return width%16 == 0 && height%16 == 0 && pixels >= 655360 && pixels <= 1024*1024 && longer <= 3*shorter } func gcdImageDimensions(left, right int) int { for right != 0 { left, right = right, left%right } return left } func applyImageSettingDefaults(settings, defaults map[string]any) { for key, value := range defaults { if _, configured := settings[key]; !configured { settings[key] = value } } } func validateImageCoverage(text string, materials []prompt.Material, imageURLCount int) error { required := prompt.ExtractRequirements(text) if required.Video > 0 || required.Audio > 0 { return validateMaterialCoverage(text, materials) } if required.Image > imageURLCount { return invalidPreparation(fmt.Sprintf("prompt requires @图片%d but only %d image materials were supplied", required.Image, imageURLCount)) } return nil } func preparationMaterials(body map[string]any, capability string) ([]prompt.Material, *prompt.Input, error) { assemblyRecord, hasAssembly := body["promptAssembly"].(map[string]any) var assembly *prompt.Input if hasAssembly { var decoded prompt.Input if err := decodeViaJSON(assemblyRecord, &decoded); err != nil { return nil, nil, invalidPreparation("invalid promptAssembly") } if capability == "image.generate" { decoded.Mode = "image" } else { decoded.Mode = "video" } assembly = &decoded } var materials []prompt.Material if raw, ok := body["materials"]; ok { if err := decodeViaJSON(raw, &materials); err != nil { return nil, nil, invalidPreparation("invalid materials") } } else if assembly != nil { materials = assembly.Materials } materials = prompt.NormalizeMaterials(materials) if assembly != nil { assembly.Materials = materials } return materials, assembly, nil } func validateMaterialCoverage(text string, materials []prompt.Material) error { required := prompt.ExtractRequirements(text) available := prompt.Requirements{} for _, material := range materials { switch material.Type { case "video": available.Video++ case "audio": available.Audio++ default: available.Image++ } } if required.Image > available.Image { return invalidPreparation(fmt.Sprintf("prompt requires @图片%d but only %d image materials were supplied", required.Image, available.Image)) } if required.Video > available.Video { return invalidPreparation(fmt.Sprintf("prompt requires @视频%d but only %d video materials were supplied", required.Video, available.Video)) } if required.Audio > available.Audio { return invalidPreparation(fmt.Sprintf("prompt requires @音频%d but only %d audio materials were supplied", required.Audio, available.Audio)) } return nil } func imageSettings(body map[string]any) (map[string]any, error) { out := map[string]any{} if nested, ok := body["settings"].(map[string]any); ok { for _, key := range []string{"scale", "width", "height", "min_ratio", "max_ratio", "imageCount", "force_single", "quality", "size", "outputFormat", "optimizeMode", "creationMode", "layerDecomposition"} { if nested[key] != nil { out[key] = nested[key] } } } for _, key := range []string{"scale", "width", "height", "min_ratio", "max_ratio", "imageCount"} { raw := body[key] if raw == nil { raw = out[key] } if value, ok := finiteNumber(raw); ok { out[key] = value } else if raw != nil && raw != "" { return nil, invalidPreparation("invalid image parameter: " + key) } else { delete(out, key) } } forceSingle := body["force_single"] if forceSingle == nil { forceSingle = out["force_single"] } if value, ok := forceSingle.(bool); ok { out["force_single"] = value } else if forceSingle != nil { return nil, invalidPreparation("invalid image parameter: force_single") } else { delete(out, "force_single") } qualityRaw := body["quality"] if qualityRaw == nil { qualityRaw = out["quality"] } delete(out, "quality") if raw, ok := qualityRaw.(string); ok { quality := strings.ToLower(strings.TrimSpace(raw)) if quality == "low" || quality == "medium" || quality == "high" { out["quality"] = quality } } for _, key := range []string{"size", "outputFormat", "optimizeMode"} { raw := body[key] if raw == nil { raw = out[key] } delete(out, key) if raw == nil || raw == "" { continue } value, ok := raw.(string) if !ok || strings.TrimSpace(value) == "" { return nil, invalidPreparation("invalid image parameter: " + key) } out[key] = strings.TrimSpace(value) } creationModeRaw := firstNonNil(body["creationMode"], out["creationMode"]) delete(out, "creationMode") if creationModeRaw != nil && creationModeRaw != "" { value, ok := creationModeRaw.(string) if !ok || strings.TrimSpace(value) == "" { return nil, invalidPreparation("invalid image parameter: creationMode") } out["creationMode"] = strings.ToLower(strings.TrimSpace(value)) } layerRaw := firstNonNil(body["layerDecomposition"], out["layerDecomposition"]) delete(out, "layerDecomposition") if layerRaw != nil { value, ok := layerRaw.(bool) if !ok { return nil, invalidPreparation("invalid image parameter: layerDecomposition") } out["layerDecomposition"] = value } if count, ok := out["imageCount"].(float64); ok && (count <= 0 || count > 9 || math.Trunc(count) != count) { return nil, invalidPreparation("imageCount must be an integer between 1 and 9") } return out, nil } func validateSeedreamImage(urls []string, materials []prompt.Material, settings map[string]any) error { creationMode := strings.ToLower(strings.TrimSpace(stringValue(settings["creationMode"]))) layerDecomposition, _ := settings["layerDecomposition"].(bool) if layerDecomposition { creationMode = "layers" } if creationMode == "" { creationMode = "basic" } if creationMode != "basic" && creationMode != "interactive" && creationMode != "layers" { return invalidPreparation("unsupported seedream creation mode") } if creationMode == "basic" && len(urls) > 10 { return invalidPreparation("seedream supports at most 10 reference images") } if creationMode == "interactive" && (len(urls) < 1 || len(urls) > 10) { return invalidPreparation("seedream interactive editing requires between 1 and 10 input images") } if creationMode == "layers" && len(urls) != 1 { return invalidPreparation("seedream layer decomposition requires exactly one input image") } for _, material := range materials { if material.Type != "image" { return invalidPreparation("seedream supports image materials only") } } if settings["width"] != nil || settings["height"] != nil { return invalidPreparation("seedream requires a size preset instead of width and height") } size := strings.ToUpper(strings.TrimSpace(stringValue(settings["size"]))) if creationMode == "layers" && size == "AUTO" { size = "auto" } else if size != "1K" && size != "1.5K" && size != "2K" { if creationMode == "layers" { return invalidPreparation("seedream layer size must be auto, 1K, 1.5K, or 2K") } return invalidPreparation("seedream size must be 1K, 1.5K, or 2K") } format := strings.ToLower(strings.TrimSpace(stringValue(settings["outputFormat"]))) if format != "png" && format != "jpeg" { return invalidPreparation("seedream output format must be png or jpeg") } mode := strings.ToLower(strings.TrimSpace(stringValue(settings["optimizeMode"]))) if mode != "standard" && mode != "fast" { return invalidPreparation("seedream prompt optimization mode must be standard or fast") } if count, ok := finiteNumber(settings["imageCount"]); ok && count != 1 { return invalidPreparation("seedream basic generation supports one output image") } settings["size"] = size settings["outputFormat"] = format settings["optimizeMode"] = mode settings["imageCount"] = float64(1) settings["creationMode"] = creationMode if creationMode == "layers" { settings["layerDecomposition"] = true } else { delete(settings, "layerDecomposition") } return nil } func seedreamLayerDecompositionRequested(capability, engine string, body map[string]any) bool { if capability != "image.generate" || engine != "seedream" { return false } settings, _ := body["settings"].(map[string]any) if value, ok := firstNonNil(body["layerDecomposition"], settings["layerDecomposition"]).(bool); ok && value { return true } return strings.EqualFold(strings.TrimSpace(stringValue(firstNonNil(body["creationMode"], settings["creationMode"]))), "layers") } func validateBailianImage(urls []string, settings map[string]any) error { if len(urls) > 9 { return invalidPreparation("bailian supports at most 9 reference images") } width, hasWidth := settings["width"].(float64) height, hasHeight := settings["height"].(float64) if !hasWidth && !hasHeight { return nil } if !hasWidth || !hasHeight || math.Trunc(width) != width || math.Trunc(height) != height || width <= 0 || height <= 0 { return invalidPreparation("bailian image dimensions must be positive integers") } pixels := width * height maximum := float64(4096 * 4096) if len(urls) > 0 { maximum = float64(2048 * 2048) } if pixels < float64(768*768) || pixels > maximum || width/height < 1.0/8.0 || width/height > 8 { return invalidPreparation("bailian image dimensions are outside supported size constraints") } return nil } const ( seedance20Model = "doubao-seedance-2-0-260128" seedance25Model = "doubao-seedance-2-5-260628" ) func videoSettings(engine, model string, defaults map[string]any, raw any, materials []prompt.Material) (map[string]any, error) { input, _ := raw.(map[string]any) merged := make(map[string]any, len(defaults)+len(input)) for key, value := range defaults { merged[key] = value } for key, value := range input { merged[key] = value } input = merged settings := map[string]any{} if engine == "minimax" { if model != providers.MinimaxH3Model { return nil, invalidPreparation("unsupported MiniMax video model") } if len(materials) > 1 { return nil, invalidPreparation("MiniMax H3 first release supports at most one first-frame image") } if len(materials) == 1 && materials[0].Type != "image" { return nil, invalidPreparation("MiniMax H3 first release supports image material only") } duration := float64(5) if input["duration"] != nil { var ok bool duration, ok = finiteNumber(input["duration"]) if !ok || math.Trunc(duration) != duration || duration < 4 || duration > 15 { return nil, invalidPreparation("MiniMax H3 video duration must be an integer between 4 and 15 seconds") } } resolution := strings.ToUpper(stringValue(input["resolution"])) if resolution == "" { resolution = "768P" } if resolution != "768P" && resolution != "2K" { return nil, invalidPreparation("MiniMax H3 video resolution must be 768P or 2K") } ratio := stringValue(input["ratio"]) if len(materials) == 1 { ratio = "adaptive" } else { if ratio == "" { ratio = "16:9" } if ratio == "adaptive" || !oneOf(ratio, "21:9", "16:9", "4:3", "1:1", "3:4", "9:16") { return nil, invalidPreparation("MiniMax H3 text-to-video requires a fixed supported ratio") } } return map[string]any{"duration": duration, "resolution": resolution, "ratio": ratio}, nil } if engine == "bailian" { if len(materials) < 1 || len(materials) > 2 { return nil, invalidPreparation("bailian video requires 1 or 2 image materials") } for _, material := range materials { if material.Type != "image" { return nil, invalidPreparation("bailian video requires 1 or 2 image materials") } } duration := float64(10) if input["duration"] != nil { var ok bool duration, ok = finiteNumber(input["duration"]) if !ok || math.Trunc(duration) != duration || duration < 2 || duration > 15 { return nil, invalidPreparation("bailian video duration must be an integer between 2 and 15 seconds") } } resolution := strings.ToUpper(stringValue(input["resolution"])) if resolution == "" { resolution = "720P" } if resolution != "720P" && resolution != "1080P" { return nil, invalidPreparation("bailian video resolution must be 720P or 1080P") } return map[string]any{"duration": duration, "resolution": resolution}, nil } if engine != "" && engine != "seedance" { return nil, invalidPreparation("unsupported video engine") } if model == seedance25Model { counts := map[string]int{"image": 0, "video": 0, "audio": 0} for _, material := range materials { counts[material.Type]++ } if len(materials) > 50 || counts["image"] > 30 || counts["video"] > 10 || counts["audio"] > 10 { return nil, invalidPreparation("seedance 2.5 supports at most 30 image, 10 video, and 10 audio materials") } } else if len(materials) > 4 { return nil, invalidPreparation("seedance 2.0 supports at most 4 materials") } ratio := stringValue(input["ratio"]) if ratio != "" && !oneOf(ratio, "9:16", "16:9", "1:1", "4:3", "3:4", "21:9", "adaptive") { return nil, invalidPreparation("unsupported video ratio") } if ratio != "" { settings["ratio"] = ratio } if input["duration"] != nil { duration, ok := finiteNumber(input["duration"]) maximum := float64(15) if model == seedance25Model { maximum = 30 } if !ok || math.Trunc(duration) != duration || duration < 4 || duration > maximum { return nil, invalidPreparation(fmt.Sprintf("video duration must be an integer between 4 and %.0f seconds", maximum)) } settings["duration"] = duration } resolution := stringValue(input["resolution"]) if resolution != "" && !oneOf(resolution, "480p", "720p", "1080p") { return nil, invalidPreparation("unsupported video resolution") } if resolution != "" { settings["resolution"] = resolution } return settings, nil } func invalidPreparation(message string) error { return &Error{Kind: ErrorInvalid, Status: 400, Message: message} } func decodeViaJSON(input, output any) error { raw, err := json.Marshal(input) if err != nil { return err } return json.Unmarshal(raw, output) } func finiteNumber(value any) (float64, bool) { var number float64 switch typed := value.(type) { case float64: number = typed case float32: number = float64(typed) case int: number = float64(typed) case int64: number = float64(typed) default: return 0, false } return number, !math.IsNaN(number) && !math.IsInf(number, 0) } func stringValue(value any) string { text, _ := value.(string) return strings.TrimSpace(text) } func engineName(value any) string { return strings.ToLower(stringValue(value)) } func oneOf(value string, options ...string) bool { for _, option := range options { if value == option { return true } } return false } func stringsFromAny(v any) []string { if values, ok := v.([]string); ok { out := make([]string, 0, len(values)) for _, value := range values { if value = strings.TrimSpace(value); value != "" { out = append(out, value) } } return out } values, _ := v.([]any) out := []string{} for _, x := range values { if s, ok := x.(string); ok && strings.TrimSpace(s) != "" { out = append(out, strings.TrimSpace(s)) } } return out } func intFromAny(v any) int { switch x := v.(type) { case float64: return int(x) case int: return x } return 0 } func firstNonNil(v ...any) any { for _, x := range v { if x != nil { return x } } return nil }