Files
2026-10-02 19:56:32 +08:00

1219 lines
41 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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
}