feat: sync all features to image2api

This commit is contained in:
2026-08-15 23:00:30 +08:00
parent 5403acc4c9
commit 4fb7fc9204
19 changed files with 1301 additions and 50 deletions
+117 -3
View File
@@ -15,6 +15,7 @@ import (
"backend/internal/model"
"backend/internal/provider/adobe"
"backend/internal/provider/chatgpt"
"backend/internal/provider/creativefabrica"
"backend/internal/provider/imagine"
"backend/internal/provider/krea"
"backend/internal/provider/leonardo"
@@ -35,6 +36,7 @@ var validTokenPools = map[string]string{
"imagine": "imagine",
"grok": "grok",
"custom": "custom",
"creativefabrica": "creativefabrica",
}
type TokenService struct {
@@ -49,6 +51,7 @@ type TokenService struct {
krea *krea.Client
imagine *imagine.Client
grok *grok.Client
cf *creativefabrica.Client
// sem caps concurrent background pending-probe goroutines (mirrors Python's
// 10-worker _quota_check_pool) so a big paste doesn't fire hundreds of
// simultaneous upstream requests.
@@ -61,7 +64,7 @@ type TokenService struct {
leonardoKeeping atomic.Bool
}
func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client) *TokenService {
func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client, cfClient *creativefabrica.Client) *TokenService {
return &TokenService{
tokens: tokens,
refresh: refresh,
@@ -74,6 +77,7 @@ func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileR
krea: kreaClient,
imagine: imagineClient,
grok: grokClient,
cf: cfClient,
sem: make(chan struct{}, 10),
}
}
@@ -102,6 +106,9 @@ func (s *TokenService) applyProxy(ctx context.Context) {
if s.grok != nil {
s.grok.SetProxy(proxy)
}
if s.cf != nil {
s.cf.SetProxy(proxy)
}
}
// RefreshExpiringTokens proactively renews krea/imagine sessions ~10min before
@@ -1127,6 +1134,85 @@ func (s *TokenService) RefreshGrokLiveness(ctx context.Context) {
}
}
// ImportCreativeFabricaCookie imports a Creative Fabrica session cookie the same
// way Adobe does (paste the whole Cookie header; JSON array/object accepted).
// The cookie IS the credential — a fresh short-lived JWT is minted on demand via
// /query/userAuth, so no RefreshProfile is registered (there is nothing to
// refresh). Accounts are one-shot: their coins buy exactly one generation, so a
// successful render disables the account (see generateCreativeFabricaVideo).
func (s *TokenService) ImportCreativeFabricaCookie(ctx context.Context, cookie, tokenID string) (*model.TokenAccount, error) {
cookie = cleanAdobeCookie(cookie)
if cookie == "" {
return nil, errors.New("cookie required")
}
if tokenID == "" {
tokenID = newTokenID("creativefabrica")
}
meta := datatypes.JSONMap{"pending_check": true}
item, err := s.createToken(ctx, "creativefabrica", tokenID, cookie, "pending", meta)
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
item, err = s.tokens.Update(ctx, "creativefabrica", tokenID, map[string]any{
"status": "pending",
"meta": meta,
})
if err != nil {
return nil, err
}
} else {
return nil, err
}
}
go s.checkPendingCreativeFabrica(tokenID, cookie)
return item, nil
}
// checkPendingCreativeFabrica probes a freshly imported cookie off-thread:
// /query/userAuth must mint a token (else the cookie is dead), then best-effort
// balance hydration. Balance of exactly 0 means the account can't generate →
// dead.
func (s *TokenService) checkPendingCreativeFabrica(tokenID, cookie string) {
defer func() {
if r := recover(); r != nil {
log.Printf("token import: creativefabrica pending check panicked for %s: %v", tokenID, r)
}
}()
s.sem <- struct{}{}
defer func() { <-s.sem }()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
if s.cf == nil {
s.finishPending(ctx, "creativefabrica", tokenID, "disabled", true, nil)
return
}
if _, _, err := s.cf.ExchangeToken(ctx, cookie); err != nil {
log.Printf("token import: creativefabrica %s cookie failed to authenticate, marking dead: %v", tokenID, err)
s.finishPending(ctx, "creativefabrica", tokenID, "disabled", true, nil)
return
}
meta := map[string]any{}
// Best-effort profile hydration: the studio browser hits /query/user on
// every page load, so the email is free to grab here (avoids 邮箱列 showing
// the raw id).
if email, e := s.cf.FetchUser(ctx, cookie); e == nil && strings.TrimSpace(email) != "" {
if _, uerr := s.tokens.Update(ctx, "creativefabrica", tokenID, map[string]any{"account_email": strings.TrimSpace(email)}); uerr != nil {
log.Printf("token import: creativefabrica %s email write failed: %v", tokenID, uerr)
}
}
if bal, e := s.cf.FetchBalance(ctx, cookie); e == nil {
meta["cached_quota_remaining"] = bal
meta["cached_quota_at"] = int(time.Now().Unix())
// One-shot accounts with zero coins left can't generate at all.
if bal <= 0 {
log.Printf("token import: creativefabrica %s has 0 coins, marking dead", tokenID)
s.finishPending(ctx, "creativefabrica", tokenID, "disabled", true, meta)
return
}
}
s.finishPending(ctx, "creativefabrica", tokenID, "active", false, meta)
}
// ImportCustomAccount adds an upstream as a custom account: base_url + key, the
// csv list of model ids it serves (empty = all), plus optional weight and
// per-account concurrency. No probe — the account goes active immediately and is
@@ -1723,9 +1809,34 @@ func (s *TokenService) Email(ctx context.Context, pool, id string) (map[string]a
}
return map[string]any{"email": nil, "cached": false}, nil
}
if poolToType(item.Pool) != "adobe" {
if poolToType(item.Pool) != "adobe" && poolToType(item.Pool) != "creativefabrica" {
return map[string]any{"email": nil}, nil
}
if poolToType(item.Pool) == "creativefabrica" {
email := strings.TrimSpace(item.AccountEmail)
if email != "" {
return map[string]any{"email": email, "cached": true}, nil
}
if s.cf == nil {
return map[string]any{"email": nil, "cached": false}, nil
}
fetched, err := s.cf.FetchUser(ctx, item.Value)
if err != nil {
if errors.Is(err, creativefabrica.ErrAuth) {
_, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{
"status": "disabled",
"dead": true,
"fails": gorm.Expr("fails + 1"),
})
}
return nil, err
}
if fetched = strings.TrimSpace(fetched); fetched != "" {
_, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{"account_email": fetched})
email = fetched
}
return map[string]any{"email": emptyToNil(email), "cached": false}, nil
}
email := strings.TrimSpace(item.AccountEmail)
if email == "" {
if s.adobe == nil {
@@ -1810,7 +1921,7 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
}
grokImages, grokVideos = images, videos
}
hasQuota := typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway" || typeLabel == "leonardo" || typeLabel == "krea" || typeLabel == "imagine" || typeLabel == "grok"
hasQuota := typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway" || typeLabel == "leonardo" || typeLabel == "krea" || typeLabel == "imagine" || typeLabel == "grok" || typeLabel == "creativefabrica"
return map[string]any{
"id": item.ID,
"pool": item.Pool,
@@ -2001,6 +2112,9 @@ func newTokenID(pool string) string {
if pool == "imagine" {
prefix = "IM"
}
if pool == "creativefabrica" {
prefix = "CF"
}
return prefix + randomUpper(10)
}
+121 -27
View File
@@ -23,6 +23,7 @@ import (
"backend/internal/provider/adobe"
"backend/internal/provider/chatgpt"
"backend/internal/provider/custom"
"backend/internal/provider/creativefabrica"
"backend/internal/provider/grok"
"backend/internal/provider/imagine"
"backend/internal/provider/krea"
@@ -80,6 +81,7 @@ type V1Service struct {
imagine *imagine.Client
grok *grok.Client
custom *custom.Client
cf *creativefabrica.Client
store *storage.Client
// refresh re-mints an Adobe access token from its cookie when a request hits a
// 401 mid-flight (set via SetRefresh — wired after construction to avoid an
@@ -227,7 +229,7 @@ type V1VideoRequest struct {
AccountID string
}
func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, cgroups *repo.ConcurrencyGroupRepository, conc *ConcurrencyService, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client, customClient *custom.Client, store *storage.Client) *V1Service {
func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, cgroups *repo.ConcurrencyGroupRepository, conc *ConcurrencyService, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client, customClient *custom.Client, cfClient *creativefabrica.Client, store *storage.Client) *V1Service {
return &V1Service{
cfg: cfg,
models: models,
@@ -245,6 +247,7 @@ func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.
imagine: imagineClient,
grok: grokClient,
custom: customClient,
cf: cfClient,
store: store,
inflight: &InflightRegistry{},
}
@@ -808,6 +811,8 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
videoBytes, videoURL, execErr = s.generateLeonardoVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), !urlOnly)
case "custom":
videoBytes, videoURL, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), !urlOnly)
case "creativefabrica":
videoBytes, videoURL, execErr = s.generateCreativeFabricaVideo(genCtx, eventID, modelItem, in, aspectRatio, !urlOnly)
default:
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
@@ -819,11 +824,11 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
switch {
case errors.Is(execErr, ErrNoProviderAccount):
return nil, ErrNoProviderAccount
case errors.Is(execErr, adobe.ErrAuth), errors.Is(execErr, runway.ErrAuth), errors.Is(execErr, grok.ErrAuth), errors.Is(execErr, leonardo.ErrAuth), errors.Is(execErr, custom.ErrAuth):
case errors.Is(execErr, adobe.ErrAuth), errors.Is(execErr, runway.ErrAuth), errors.Is(execErr, grok.ErrAuth), errors.Is(execErr, leonardo.ErrAuth), errors.Is(execErr, custom.ErrAuth), errors.Is(execErr, creativefabrica.ErrAuth):
return nil, ErrProviderAuth
case errors.Is(execErr, adobe.ErrQuotaExhausted), errors.Is(execErr, runway.ErrQuotaExhausted), errors.Is(execErr, grok.ErrQuotaExhausted), errors.Is(execErr, leonardo.ErrQuotaExhausted), errors.Is(execErr, custom.ErrQuotaExhausted):
case errors.Is(execErr, adobe.ErrQuotaExhausted), errors.Is(execErr, runway.ErrQuotaExhausted), errors.Is(execErr, grok.ErrQuotaExhausted), errors.Is(execErr, leonardo.ErrQuotaExhausted), errors.Is(execErr, custom.ErrQuotaExhausted), errors.Is(execErr, creativefabrica.ErrQuotaExhausted):
return nil, ErrProviderQuota
case errors.Is(execErr, adobe.ErrTemporaryUpstream), errors.Is(execErr, runway.ErrTemporaryUpstream), errors.Is(execErr, grok.ErrTemporaryUpstream), errors.Is(execErr, leonardo.ErrTemporaryUpstream), errors.Is(execErr, custom.ErrTemporaryUpstream):
case errors.Is(execErr, adobe.ErrTemporaryUpstream), errors.Is(execErr, runway.ErrTemporaryUpstream), errors.Is(execErr, grok.ErrTemporaryUpstream), errors.Is(execErr, leonardo.ErrTemporaryUpstream), errors.Is(execErr, custom.ErrTemporaryUpstream), errors.Is(execErr, creativefabrica.ErrTemporaryUpstream):
return nil, ErrProviderTemporary
default:
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr)
@@ -920,29 +925,31 @@ func (s *V1Service) StartVideoJob(ctx context.Context, principal *APIPrincipal,
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", err.Error())
return nil, err
}
// Validate reference_mode against model capabilities and reference count
// BEFORE charging — a bad override must fail fast with no debit, never
// charge-then-reject (which would silently eat the user's credits).
modelItem, err := s.models.Get(ctx, strings.TrimSpace(in.Model))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", ErrUnknownModel.Error())
return nil, ErrUnknownModel
}
return nil, err
}
if rm := strings.TrimSpace(in.ReferenceMode); rm != "" {
if err := validateReferenceMode(rm, modelItem, len(in.ReferenceImages)); err != nil {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", err.Error())
return nil, err
}
if rm == modelItem.ReferenceMode {
in.ReferenceMode = "" // same as default, don't override
}
}
modelItem, resolution, aspectRatio, duration, price, err := s.prepareVideo(ctx, principal, in, true)
if err != nil {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", err.Error())
return nil, err
}
// Validate reference_mode against model capabilities and reference count.
if rm := strings.TrimSpace(in.ReferenceMode); rm != "" {
supported := strings.TrimSpace(modelItem.ReferenceMode)
if supported == "none" || supported == "" {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", "reference_mode not supported for this model")
return nil, errors.New("reference_mode not supported for this model")
}
if rm != "frame" && rm != "asset" {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", "reference_mode must be 'frame' or 'asset'")
return nil, errors.New("reference_mode must be 'frame' or 'asset'")
}
if rm == "frame" && len(in.ReferenceImages) > 2 {
return nil, fmt.Errorf("frame mode supports at most 2 reference images (first+last frame), got %d", len(in.ReferenceImages))
}
if strings.TrimSpace(in.ReferenceMode) == modelItem.ReferenceMode {
in.ReferenceMode = "" // same as default, don't override
}
}
// Source "v1": no output file is allocated — the result is the upstream URL,
// stored on the event when the render completes.
eventID, err := s.logPendingEvent(ctx, "video", modelItem, principal, in.Prompt, aspectRatio, resolution, duration, len(in.ReferenceImages), price, "", "v1", nil, false)
@@ -977,6 +984,8 @@ func (s *V1Service) runVideoJob(ctx context.Context, principal *APIPrincipal, in
_, videoURL, execErr = s.generateLeonardoVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), false)
case "custom":
_, videoURL, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
case "creativefabrica":
_, videoURL, execErr = s.generateCreativeFabricaVideo(genCtx, eventID, modelItem, in, aspectRatio, false)
default:
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
@@ -1379,7 +1388,14 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
}
resolution := strings.TrimSpace(in.Resolution)
if resolution == "" {
resolution = "720p"
// 调用方没指定档位时用模型自己配的第一档,而不是假定 720p ——
// 只卖 1440p 的模型会被 720p 判成"没定价"。
if resList := repo.JSONStrings(modelItem.Resolutions); len(resList) > 0 {
resolution = strings.TrimSpace(resList[0])
}
if resolution == "" {
resolution = "720p"
}
}
price, err := s.chargeForModel(ctx, principal, modelItem, "video", resolution, duration, 0, charge)
if err != nil {
@@ -1704,6 +1720,15 @@ func adobeErrClass(e error) (bool, bool, bool, bool) {
return errors.Is(e, adobe.ErrAuth), errors.Is(e, adobe.ErrQuotaExhausted), errors.Is(e, adobe.ErrTemporaryUpstream) || errors.Is(e, adobe.ErrRateLimited), errors.Is(e, adobe.ErrDeadUpstream)
}
// creativefabricaErrClass maps a creativefabrica upstream error onto the pool's
// (auth, quota, temporary, dead) classification.
func creativefabricaErrClass(e error) (bool, bool, bool, bool) {
return errors.Is(e, creativefabrica.ErrAuth),
errors.Is(e, creativefabrica.ErrQuotaExhausted),
errors.Is(e, creativefabrica.ErrTemporaryUpstream) || errors.Is(e, creativefabrica.ErrRateLimited),
errors.Is(e, creativefabrica.ErrDeadUpstream)
}
// noStore url-only mode: adobe returns a presigned image URL (meta["image_url"]);
// skip the download and return it directly.
func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) {
@@ -1906,6 +1931,75 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
return data, videoURL, err
}
// maxCreativeFabricaRefs caps how many reference images a Creative Fabrica
// generation may carry (the studio UI allows up to 9).
const maxCreativeFabricaRefs = 9
// generateCreativeFabricaVideo renders a video through the Creative Fabrica
// Studio upstream. Accounts are ONE-SHOT: the coins buy exactly one generation,
// so a successful render immediately disables the account. A fresh short-lived
// JWT is minted from the stored cookie for every attempt (there is no
// long-lived token to cache). Only image reference frames are supported.
func (s *V1Service) generateCreativeFabricaVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio string, downloadResult bool) ([]byte, string, error) {
if s.cf == nil {
return nil, "", errors.New("creativefabrica client not configured")
}
if s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
s.cf.SetProxy(proxy)
}
}
items, err := s.tokens.ListByPool(ctx, "creativefabrica")
if err != nil {
return nil, "", err
}
var active []model.TokenAccount
for _, item := range items {
if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
continue
}
active = append(active, item)
}
active = pinTestAccount(items, active, in.AccountID)
if len(active) == 0 {
return nil, "", ErrNoProviderAccount
}
s.rotateRoundRobin("creativefabrica", active)
refLimit := modelItem.MaxReferenceImages
if refLimit <= 0 {
refLimit = maxCreativeFabricaRefs
}
refs, err := decodeReferenceImages(in.ReferenceImages, refLimit)
if err != nil {
return nil, "", err
}
// The studio only accepts image reference frames — reject video/audio refs.
for _, r := range refs {
if detectMediaType(r) != "image" {
return nil, "", errors.New("creativefabrica only supports image reference frames")
}
}
var videoURL string
data, err := s.runPoolWithFailover(ctx, eventID, "creativefabrica", active, "video", func(token model.TokenAccount) ([]byte, error) {
jwt, _, terr := s.cf.ExchangeToken(ctx, token.Value)
if terr != nil {
s.markTokenDead(ctx, "creativefabrica", token, "video")
return nil, terr
}
bytes, url, gerr := s.cf.GenerateVideo(ctx, token.Value, jwt, modelItem.ID, in.Prompt, aspectRatio, refs, downloadResult)
if gerr == nil {
videoURL = url
// One-shot: the account's coins paid for exactly this generation.
s.markTokenDead(ctx, "creativefabrica", token, "video")
}
return bytes, gerr
}, creativefabricaErrClass, nil, true)
return data, videoURL, err
}
// leonardoMinCredits is the per-generation token cost (one Leonardo image = 30
// tokens). An account with fewer is treated as 限额 and skipped — it can't afford
// a generation. Daily renewal (tokenRenewalDate) drives auto-recovery.
@@ -3712,9 +3806,9 @@ func resolveAdobeVideoEngine(modelID string) (string, string) {
return "veo31-fast", ""
case "gemini-veo3.1":
return "veo31-standard", ""
case "seedance-2.0-fast":
case "adobe-seedance-2.0-fast":
return "seedance-2.0-fast", ""
case "seedance-2.0":
case "adobe-seedance-2.0":
return "seedance-2.0", ""
case "firefly-ray":
return "luma", ""
@@ -3779,7 +3873,7 @@ func (s *V1Service) markTokenFailure(ctx context.Context, pool string, token mod
// grok is intentionally excluded: a grok sso can momentarily 401 while
// still valid (upstream blip / proxy / anti-bot), so an auth failure just
// fails over for this request without permanently killing the account.
disable := pool == "chatgpt" || pool == "runway" || pool == "leonardo" || pool == "krea" || pool == "imagine"
disable := pool == "chatgpt" || pool == "runway" || pool == "leonardo" || pool == "krea" || pool == "imagine" || pool == "creativefabrica"
if disable && pool == "leonardo" {
// 两道保险:先重新 get-session 复核(单次失败常是 bearer 轮换竞态),复核
// 也不过就只记一次连续失败,连续到上限才判死。
@@ -3915,7 +4009,7 @@ func (s *V1Service) rotateRoundRobin(pool string, items []model.TokenAccount) {
const freeOnly1KModelID = "nano-banana-2"
func isSeedanceModel(modelID string) bool {
return modelID == "seedance-2.0-fast" || modelID == "seedance-2.0"
return modelID == "adobe-seedance-2.0-fast" || modelID == "adobe-seedance-2.0"
}
// freeAccountsAllowed reports whether 普号(free) may serve this request: the model