1219 lines
41 KiB
Go
1219 lines
41 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 {
|
||
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
|
||
}
|