Files
NianAIGC/backend/internal/jobs/provider.go
T
2026-09-23 15:10:13 +08:00

1118 lines
37 KiB
Go

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
}