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 { failed := StatusFailed failure := providerSubmissionFailure(err) if phase == "query" { failure = providerQueryFailure(job.Provider, err) } patch := Patch{Status: &failed, Error: failure} var providerError *providers.ProviderError if errors.As(err, &providerError) { if phase == "submit" && providerError.Status > 0 { failure.Message = providers.FailureMessage(job.Provider, providerError.Status, providerError.Code) } failure.Code = safeFailureToken(providerError.Code, 128) patch.ResponsePayload = providerFailurePayload(job, phase, providerError.Status, providerError.Code, providerError.RequestID, providerError.Detail) patch.SetResponsePayload = true } if p.Store == nil { job.Status, job.Error = failed, failure if patch.SetResponsePayload { job.ResponsePayload = patch.ResponsePayload } return job, nil } return p.Store.UpdateJob(ctx, job.ID, workerPatch(job, patch)) } if result.TaskID == "" { result.TaskID = job.ProviderTaskID } job.ProviderTaskID = result.TaskID terminalFailure := result.Status == providers.StatusFailed || result.Status == providers.StatusCancelled || result.Status == providers.StatusExpired if terminalFailure { detail := result.ErrorDetail if detail == "" { detail = result.ErrorMessage } job.Error = &JobError{Code: safeFailureToken(result.ErrorCode, 128), Message: providers.FailureMessage(job.Provider, 0, result.ErrorCode)} if result.ErrorCode == "" && result.Status == providers.StatusCancelled { job.Error.Message = "服务商已取消本次生成任务,请确认后重新创建任务。" } else if result.ErrorCode == "" && result.Status == providers.StatusExpired { job.Error.Message = "服务商生成任务已过期,请确认任务状态后重新创建。" } // Failed raw responses may echo prompts, signed asset URLs or credentials. // Persist only the user explanation and bounded, redacted diagnostics. job.ResponsePayload = providerFailurePayload(job, phase, 0, result.ErrorCode, result.RequestID, detail) job.Status = Status(result.Status) if p.Store == nil { return job, nil } patch := workerPatch(job, Patch{ Status: &job.Status, ProviderTaskID: &job.ProviderTaskID, Error: job.Error, ResponsePayload: job.ResponsePayload, SetResponsePayload: true, }) patch.ExpectedStatuses = []Status{expectedStatus} return p.Store.UpdateJob(ctx, job.ID, patch) } 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: "服务商返回的生成结果缺少可下载文件,请联系管理员核查。", Retryable: false} encoded = providerFailurePayload(job, phase, 0, "", result.RequestID, "服务商返回成功状态,但没有可下载文件。") 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) job.Error = nil if p.Store != nil { patch := Patch{Status: &job.Status, ProviderTaskID: &job.ProviderTaskID, ResponsePayload: job.ResponsePayload, SetResponsePayload: true, ClearError: true} 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 = "暂时无法确认服务商是否已接收本次请求,为避免重复生成和扣费,平台已停止自动重试。请联系管理员核查任务状态。" ) func providerSubmissionFailure(err error) *JobError { message := unknownProviderSubmissionMessage var providerError *providers.ProviderError if errors.As(err, &providerError) { if providerError.Status > 0 { provider := strings.SplitN(providerError.Operation, " ", 2)[0] if provider == "volcengine" { provider = "volcengine-visual" } message = providers.FailureMessage(provider, providerError.Status, providerError.Code) } } return &JobError{Message: message, Retryable: false} } func providerQueryFailure(provider string, err error) *JobError { failure := &JobError{Message: "暂时无法获取服务商的生成结果,请联系管理员核查任务状态。", Retryable: true} var upstream *providers.ProviderError if errors.As(err, &upstream) && upstream.Status > 0 { failure.Code = safeFailureToken(upstream.Code, 128) failure.Message = providers.FailureMessage(provider, upstream.Status, upstream.Code) failure.Retryable = upstream.Status == http.StatusRequestTimeout || upstream.Status == http.StatusTooManyRequests || upstream.Status >= 500 } return failure } func providerFailurePayload(job Job, phase string, status int, code, requestID, detail string) json.RawMessage { payload, _ := json.Marshal(map[string]any{"providerError": map[string]any{ "phase": phase, "status": status, "code": safeFailureToken(code, 128), "requestId": safeFailureToken(requestID, 128), "detail": providers.SanitizeFailureDetail(detail, job.RequestPayload), }}) return payload } func safeFailureToken(value string, maximum int) string { value = strings.TrimSpace(value) if len(value) > maximum { return "" } for _, char := range value { if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("-_.:", char) { continue } return "" } return value } 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" && engine != "bailian" && engine != "minimax") { return ProviderTarget{}, "", invalidPreparation("unsupported video model") } target, ok := b.VideoModels[model] if !ok || target.Provider != engine || 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" wan30VideoModel = "wan3.0-video" ) 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") } mode := stringValue(input["inputMode"]) if mode != "" && mode != "frames" && mode != "reference" { return nil, invalidPreparation("MiniMax 请选择首帧或多图参考模式") } if mode == "reference" { if len(materials) > 9 { return nil, invalidPreparation("MiniMax 多图参考模式最多支持 9 张图片") } } else if len(materials) > 1 { return nil, invalidPreparation("MiniMax 首帧模式最多只能上传 1 张图片;多张图片请切换到多图参考模式") } for _, material := range materials { if material.Type != "image" { return nil, invalidPreparation("MiniMax 当前仅支持图片素材,请移除视频或音频") } } 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 mode != "reference" && len(materials) == 1 { ratio = "adaptive" } else { if ratio == "" { ratio = "16:9" } if (len(materials) == 0 && ratio == "adaptive") || !oneOf(ratio, "21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "adaptive") { return nil, invalidPreparation("MiniMax H3 text-to-video requires a fixed supported ratio") } } settings := map[string]any{"duration": duration, "resolution": resolution, "ratio": ratio} if mode != "" { settings["inputMode"] = mode } return settings, nil } if engine == "bailian" { wan30 := model == wan30VideoModel mode := stringValue(input["inputMode"]) if mode == "" && wan30 { mode = "reference" } if mode != "" && mode != "frames" && mode != "reference" { return nil, invalidPreparation("百炼请选择首尾帧或多图参考模式") } if !wan30 && mode == "reference" { return nil, invalidPreparation("Wan 2.7 仅支持首尾帧;多图参考请选择 Wan 3.0") } if mode == "reference" && len(materials) > 10 { return nil, invalidPreparation("Wan 3.0 多图参考模式最多支持 10 张图片") } if mode != "reference" && (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("百炼当前视频模式仅支持图片素材,请移除视频或音频") } } duration := float64(10) if input["duration"] != nil { var ok bool duration, ok = finiteNumber(input["duration"]) maximum := float64(15) if wan30 { maximum = 30 } if !ok || math.Trunc(duration) != duration || duration < 2 || duration > maximum { return nil, invalidPreparation(fmt.Sprintf("百炼视频时长需为 2–%.0f 秒整数", maximum)) } } resolution := strings.ToUpper(stringValue(input["resolution"])) if resolution == "" { resolution = "720P" } if resolution != "720P" && resolution != "1080P" && !(wan30 && resolution == "480P") { return nil, invalidPreparation("百炼视频分辨率不支持当前设置") } settings := map[string]any{"duration": duration, "resolution": resolution} if wan30 { ratio := stringValue(input["ratio"]) if ratio != "" { if !oneOf(ratio, "adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16") { return nil, invalidPreparation("Wan 3.0 画面比例支持自适应、21:9、16:9、4:3、1:1、3:4 或 9:16") } settings["ratio"] = ratio } } if mode != "" { settings["inputMode"] = mode } return settings, 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 { counts := map[string]int{"image": 0, "video": 0, "audio": 0} for _, material := range materials { counts[material.Type]++ } if counts["image"] > 9 || counts["video"] > 3 || counts["audio"] > 3 { return nil, invalidPreparation("Seedance 2.0 最多支持 9 张图片、3 个视频、3 个音频") } if counts["audio"] > 0 && counts["image"]+counts["video"] == 0 { return nil, invalidPreparation("Seedance 2.0 的音频不能单独使用,请添加图片或视频素材") } } 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 }