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
+4 -2
View File
@@ -15,6 +15,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"
@@ -129,14 +130,15 @@ func NewApp(ctx context.Context) (*App, error) {
// (a reship made the recipe stale). No polling.
startGrokStatsigRefresh(siteRepo)
customClient := custom.NewClient()
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, rustfsClient)
cfClient := creativefabrica.NewClient("")
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, cfClient, rustfsClient)
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
showcaseSvc := service.NewShowcaseService(showcaseRepo)
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient, showcaseRepo)
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo, orderRepo)
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo, orderRepo)
apiKeySvc := service.NewAPIKeyService(apiKeyRepo)
tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient)
tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, cfClient)
refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient)
// Enable refresh-then-retry on a mid-request Adobe 401 (re-mint access token
// from the cookie). Wired post-construction to avoid a ctor init cycle.
+17
View File
@@ -55,6 +55,23 @@ func seedDefaults(ctx context.Context, db *gorm.DB) error {
return err
}
}
// Adobe 的 Seedance 目录 ID 带 adobe- 前缀(与 Leonardo 私有款区分);把旧 ID
// 的存量行改名,保留计价配置、次数和历史日志归属。
for _, r := range [][2]string{
{"seedance-2.0", "adobe-seedance-2.0"},
{"seedance-2.0-fast", "adobe-seedance-2.0-fast"},
} {
if err := db.WithContext(ctx).Exec(
`UPDATE model_configs SET id = ? WHERE id = ?
AND NOT EXISTS (SELECT 1 FROM model_configs WHERE id = ?)`,
r[1], r[0], r[1]).Error; err != nil {
return err
}
if err := db.WithContext(ctx).Exec(
`UPDATE event_logs SET model = ? WHERE model = ?`, r[1], r[0]).Error; err != nil {
return err
}
}
// One-time backfill of the persistent per-model generation counter from
// historical success logs, so the admin "次数" keeps its running total when we
// switch it off the (retention-pruned) event_log. Only touches models still at
@@ -279,6 +279,38 @@ func (h *ProviderAdminHandler) ImportAdobeCookie(c *gin.Context) {
})
}
func (h *ProviderAdminHandler) ImportCreativeFabricaCookie(c *gin.Context) {
var body struct {
Cookie string `json:"cookie"`
Value string `json:"value"`
Name string `json:"name"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
cookie := body.Cookie
if cookie == "" {
cookie = body.Value
}
name := body.Name
if name == "" {
name = body.ID
}
item, err := h.tokens.ImportCreativeFabricaCookie(c.Request.Context(), cookie, name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"id": item.ID,
"status": item.Status,
"pending": item.Status == "pending",
})
}
func (h *ProviderAdminHandler) TokenUpdate(c *gin.Context) {
var body map[string]any
if err := c.ShouldBindJSON(&body); err != nil {
@@ -392,7 +424,7 @@ func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
// accountsStats reproduces the 账号 KPI strip: per-type 正常/失效/限额 counts plus a
// grand total and total dead count (drives 「删除异常账号 (N)」).
func accountsStats(rows []map[string]any) gin.H {
types := []string{"openai", "adobe", "runway", "leonardo", "krea", "imagine", "grok"}
types := []string{"openai", "adobe", "runway", "leonardo", "krea", "imagine", "grok", "creativefabrica"}
by := map[string]*struct{ N, Ok, Dead, Quota int }{}
for _, t := range types {
by[t] = &struct{ N, Ok, Dead, Quota int }{}
@@ -439,7 +439,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"resolutions": []string{"720p", "1080p"},
},
{
"key": "seedance-2.0-fast",
"key": "adobe-seedance-2.0-fast",
"label": "Seedance 2.0 Fast",
"type": "video",
"provider": "adobe",
@@ -450,7 +450,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"reference_mode": "style",
},
{
"key": "seedance-2.0",
"key": "adobe-seedance-2.0",
"label": "Seedance 2.0",
"type": "video",
"provider": "adobe",
@@ -460,6 +460,36 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"max_reference_images": 9,
"reference_mode": "style",
},
{
"key": "seedance-2.0-fast",
"label": "Seedance 2.0 Fast (Creative Fabrica)",
"type": "video",
"provider": "creativefabrica",
"durations": []string{"14s"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
// Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE),
// 没有首尾帧,也不收视频/音频参考。
"max_reference_images": 9,
"reference_mode": "asset",
"max_videos": 0,
"max_audios": 0,
},
{
"key": "seedance-2.0",
"label": "Seedance 2.0 (Creative Fabrica)",
"type": "video",
"provider": "creativefabrica",
"durations": []string{"10s"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
// Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE),
// 没有首尾帧,也不收视频/音频参考。
"max_reference_images": 9,
"reference_mode": "asset",
"max_videos": 0,
"max_audios": 0,
},
{
"key": "seedance-2.0-不卡人脸",
"label": "Seedance 2.0 (Leonardo 私有)",
@@ -675,7 +705,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"description": "Adobe Firefly Video",
},
{
"id": "seedance-2.0-fast",
"id": "adobe-seedance-2.0-fast",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
@@ -687,7 +717,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"description": "Seedance 2.0 Fast",
},
{
"id": "seedance-2.0",
"id": "adobe-seedance-2.0",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
@@ -698,6 +728,32 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"reference_mode": "style",
"description": "Seedance 2.0",
},
{
"id": "seedance-2.0-fast",
"provider": "creativefabrica",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
// 一次性账号:积分刚好够一次生成,时长固定 14 秒。
"durations": []string{"14s"},
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
"max_reference_images": 9,
"reference_mode": "asset",
"description": "Seedance 2.0 Fast (Creative Fabrica)",
},
{
"id": "seedance-2.0",
"provider": "creativefabrica",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
// 一次性账号:积分刚好够一次生成,时长固定 10 秒。
"durations": []string{"10s"},
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
"max_reference_images": 9,
"reference_mode": "asset",
"description": "Seedance 2.0 (Creative Fabrica)",
},
{
"id": "runway-gen4-turbo",
"provider": "runway",
@@ -932,7 +988,7 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"stub": false,
},
{
"id": "seedance-2.0-fast",
"id": "adobe-seedance-2.0-fast",
"provider": "adobe",
"kind": "video",
"ratios": []string{"16:9", "9:16"},
@@ -941,7 +997,7 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"stub": false,
},
{
"id": "seedance-2.0",
"id": "adobe-seedance-2.0",
"provider": "adobe",
"kind": "video",
"ratios": []string{"16:9", "9:16"},
@@ -949,6 +1005,24 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"description": "Seedance 2.0",
"stub": false,
},
{
"id": "seedance-2.0-fast",
"provider": "creativefabrica",
"kind": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"description": "Seedance 2.0 Fast (Creative Fabrica)",
"stub": false,
},
{
"id": "seedance-2.0",
"provider": "creativefabrica",
"kind": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"description": "Seedance 2.0 (Creative Fabrica)",
"stub": false,
},
{
"id": "runway-gen4-turbo",
"provider": "runway",
+8 -3
View File
@@ -292,19 +292,24 @@ func rawToString(raw json.RawMessage) string {
}
// videoSizeToInternal maps OpenAI's "WxH" size to our aspect ratio + resolution
// tier (height ≥1080 → 1080p, else 720p).
// tier. An absent/unparsable size leaves the resolution empty so the caller can
// fall back to whatever tier the model actually prices — hardcoding 720p here
// rejects models that only offer 1440p.
func videoSizeToInternal(size string) (ratio, resolution string) {
var w, h int
if s := strings.TrimSpace(strings.ToLower(size)); s != "" {
_, _ = fmt.Sscanf(s, "%dx%d", &w, &h)
}
if w == 0 || h == 0 {
return "16:9", "720p"
return "16:9", ""
}
// The "p" resolution is the SHORT edge (720p = 1280×720, 1080p = 1920×1080),
// so a standard 1280×720 must read as 720p — not 1080p off the long edge.
resolution = "720p"
if min(w, h) >= 1080 {
switch {
case min(w, h) >= 1440:
resolution = "1440p"
case min(w, h) >= 1080:
resolution = "1080p"
}
return guessRatioWH(w, h), resolution
+1
View File
@@ -129,6 +129,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
authed.POST("/tokens", handlers.ProviderAdmin.TokensCreate)
authed.POST("/tokens/import-chatgpt-token", handlers.ProviderAdmin.ImportChatGPTToken)
authed.POST("/tokens/import-adobe-cookie", handlers.ProviderAdmin.ImportAdobeCookie)
authed.POST("/tokens/import-creativefabrica-cookie", handlers.ProviderAdmin.ImportCreativeFabricaCookie)
authed.POST("/tokens/import-runway-token", handlers.ProviderAdmin.ImportRunwayToken)
authed.POST("/tokens/import-leonardo-cookie", handlers.ProviderAdmin.ImportLeonardoCookie)
authed.POST("/tokens/import-krea-cookie", handlers.ProviderAdmin.ImportKreaCookie)
@@ -0,0 +1,59 @@
package adobe
import (
"encoding/base64"
"encoding/json"
"regexp"
"testing"
)
// captured from live firefly.adobe.com traffic:
//
// ark: 91818c89a54748463.1048135404|r=ap-southeast-1|…|rid=84|ag=101|…
// ftr: dbd9d77a491b4437bc5c4d649a04a794_1785846934401_6890_UDF43-m4_31ck_YRQXWT0P1AE=-7389-v2_tt
//
// The Arkose slot is deliberately emitted empty rather than synthesized — see
// buildARPSessionID — so the expected ftr ends in "_31ck__tt".
var (
arkPat = regexp.MustCompile(`^[0-9a-f]{17}\.[1-9][0-9]{9}\|r=ap-southeast-1\|.*\|rid=[0-9]{1,2}\|ag=101\|`)
ftrPat = regexp.MustCompile(`^[0-9a-f]{32}_[0-9]{13}_[0-9]{4,5}_UDF43-m4_31ck__tt$`)
)
func TestARPSessionIDShape(t *testing.T) {
raw, err := base64.StdEncoding.DecodeString(buildARPSessionID("tok-a"))
if err != nil {
t.Fatalf("not base64: %v", err)
}
var got struct{ Sid, Ark, Ftr string }
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("not json: %v", err)
}
if !arkPat.MatchString(got.Ark) {
t.Errorf("ark shape mismatch:\n%s", got.Ark)
}
if !ftrPat.MatchString(got.Ftr) {
t.Errorf("ftr shape mismatch:\n%s", got.Ftr)
}
if len(got.Sid) != 36 {
t.Errorf("sid not a uuid: %q", got.Sid)
}
}
// ark must differ per call — a frozen blob is a cross-account correlation key.
func TestARKVariesAcrossCalls(t *testing.T) {
if buildARKBlob() == buildARKBlob() {
t.Error("ark is constant across calls")
}
}
// pid is stable per token but distinct across tokens.
func TestPIDStablePerToken(t *testing.T) {
defer ReleasePID("tok-x")
defer ReleasePID("tok-y")
if allocPID("tok-x") != allocPID("tok-x") {
t.Error("pid changed for the same token")
}
if allocPID("tok-x") == allocPID("tok-y") {
t.Error("two tokens share a pid")
}
}
@@ -0,0 +1,629 @@
// Package creativefabrica implements the Creative Fabrica Studio
// (studio.creativefabrica.com) video-generation upstream.
//
// One-shot accounts: every account's coins are just enough for exactly one
// generation, so a successful render kills the account. The credential is a
// .creativefabrica.com session cookie; a short-lived JWT is minted from it on
// demand via GraphQL /query/userAuth, and every model request authenticates
// with that JWT (the cookie is sent along as a fallback).
package creativefabrica
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"time"
fhttp "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
)
var (
ErrAuth = errors.New("creativefabrica auth failed")
ErrQuotaExhausted = errors.New("creativefabrica quota exhausted")
ErrTemporaryUpstream = errors.New("creativefabrica upstream temporary error")
ErrDeadUpstream = errors.New("creativefabrica upstream fatal error")
ErrRateLimited = errors.New("creativefabrica rate limited")
// ErrPaymentRequired marks an account whose payment intent is in a failed
// state — it can never generate (the studio answers 400 failed_precondition
// "payment required ... COIN_PAYMENT_INTENT_STATUS_FAILED"). It wraps ErrAuth
// so the pool kills the account and fails over instead of burning retries.
ErrPaymentRequired = fmt.Errorf("%w: payment required", ErrAuth)
)
// isPaymentRequired reports whether a non-200 body is the account-level
// "payment required" rejection rather than a request-level parameter error.
//
// Connect unary errors don't always carry the marker in plaintext: the studio
// answers failed_precondition with the real detail base64-protobuf-encoded in
// details[].value ("payment required. payment status: COIN_PAYMENT_INTENT_...").
// Decode those values and scan the decoded bytes, so a failed coin intent still
// kills the account instead of being misread as a request-level 400.
func isPaymentRequired(status int, body string) bool {
if status != 400 {
return false
}
b := strings.ToLower(body)
if strings.Contains(b, "payment required") || strings.Contains(b, "coin_payment_intent") {
return true
}
var env struct {
Details []struct {
Value string `json:"value"`
} `json:"details"`
}
if err := json.Unmarshal([]byte(body), &env); err != nil {
return false
}
for _, d := range env.Details {
raw, err := base64.StdEncoding.DecodeString(d.Value)
if err != nil {
continue
}
low := strings.ToLower(string(raw))
if strings.Contains(low, "payment required") ||
strings.Contains(low, "coin_payment_intent") ||
strings.Contains(low, "coin_error_code_payment_required") {
return true
}
}
return false
}
const (
graphQLHost = "https://graphql-gw.creativefabrica.com"
mediaMatrixHost = "https://studio-media-matrix.creativefabrica.com"
userAuthPath = "/query/userAuth"
userBalancePath = "/query/userBalance"
userPath = "/query/user"
initiatePath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/InitiateSession"
listSessionsPath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/ListSessions"
origin = "https://studio.creativefabrica.com"
pollInterval = 5 * time.Second
pollTimeout = 16 * time.Minute
downloadTimeout = 3 * time.Minute
videoServiceType = "SERVICE_TYPE_VIDEO_GENERATOR"
videoFrameRef = "VIDEO_FRAME_TYPE_REFERENCE"
visibilityPrivate = "SESSION_VISIBILITY_PRIVATE"
)
// Model is one Creative Fabrica video model: the local catalog id, the upstream
// enum, the fixed duration in seconds, and the upstream resolution label.
type Model struct {
ID string // local model_configs id, e.g. "seedance-2.0"
Enum string // upstream enum, e.g. VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2
Duration int // fixed seconds (account plan is fixed-length)
Resolution string // upstream resolution label, e.g. 720p
}
// Models returns the two Creative Fabrica seedance models. The upstream enum
// differs between the two (SEEDANCE_2_FAST vs SEEDDREAM_2), matching the
// studio frontend's InitiateSession payloads.
func Models() map[string]Model {
return map[string]Model{
"seedance-2.0-fast": {
ID: "seedance-2.0-fast",
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDANCE_2_FAST",
Duration: 14,
Resolution: "720p",
},
"seedance-2.0": {
ID: "seedance-2.0",
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2",
Duration: 10,
Resolution: "720p",
},
}
}
// LookupModel resolves a local model id to its upstream config. Returns ok=false
// when the id isn't a Creative Fabrica model.
func LookupModel(modelID string) (Model, bool) {
m, ok := Models()[modelID]
return m, ok
}
// Client talks to the Creative Fabrica Studio API through a Chrome-fingerprinted
// TLS client so the Cloudflare-protected Connect endpoints don't reject us.
type Client struct {
proxy string
}
func NewClient(proxy string) *Client {
return &Client{proxy: strings.TrimSpace(proxy)}
}
func (c *Client) SetProxy(proxy string) {
c.proxy = strings.TrimSpace(proxy)
}
// ExchangeToken mints the short-lived JWT from the account cookie via GraphQL
// /query/userAuth. Returns the token and the user id. A null / missing me means
// the cookie no longer authenticates → ErrAuth.
func (c *Client) ExchangeToken(ctx context.Context, cookie string) (token, userID string, err error) {
query := `{"query":"\n query userAuth {\n me {\n token\n user {\n id\n isTemporary\n }\n }\n}\n "}`
var payload struct {
Data struct {
Me *struct {
Token string `json:"token"`
User struct {
ID string `json:"id"`
} `json:"user"`
} `json:"me"`
} `json:"data"`
}
body, err := c.postGraphQL(ctx, cookie, "", userAuthPath, query)
if err != nil {
return "", "", err
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", "", fmt.Errorf("%w: bad token response: %s", ErrAuth, clip(body, 300))
}
if payload.Data.Me == nil || strings.TrimSpace(payload.Data.Me.Token) == "" {
return "", "", fmt.Errorf("%w: cookie did not authenticate", ErrAuth)
}
return payload.Data.Me.Token, payload.Data.Me.User.ID, nil
}
// FetchBalance reads the coin balance via GraphQL /query/userBalance (the
// request authenticates with the cookie alone). Negative value on error.
func (c *Client) FetchBalance(ctx context.Context, cookie string) (int64, error) {
query := `{"query":"\nquery userBalance {\n userBalance {\n balance\n }\n}\n\n"}`
var payload struct {
Data struct {
UserBalance *struct {
Balance json.Number `json:"balance"`
} `json:"userBalance"`
} `json:"data"`
}
body, err := c.postGraphQL(ctx, cookie, "", userBalancePath, query)
if err != nil {
return -1, err
}
if err := json.Unmarshal(body, &payload); err != nil {
return -1, fmt.Errorf("bad balance response: %s", clip(body, 300))
}
if payload.Data.UserBalance == nil {
return -1, ErrAuth
}
b, _ := payload.Data.UserBalance.Balance.Int64()
return b, nil
}
// FetchUser reads the profile (email, name) via GraphQL /query/user, which the
// studio browser hits on every page load. The request authenticates with the
// cookie alone. Returns the email (empty on error); a null me means the cookie
// no longer authenticates → ErrAuth.
func (c *Client) FetchUser(ctx context.Context, cookie string) (string, error) {
query := `{"query":"\n query user {\n me {\n token\n user {\n id\n email\n }\n }\n}\n "}`
var payload struct {
Data struct {
Me *struct {
Token string `json:"token"`
User struct {
ID string `json:"id"`
Email string `json:"email"`
} `json:"user"`
} `json:"me"`
} `json:"data"`
}
body, err := c.postGraphQL(ctx, cookie, "", userPath, query)
if err != nil {
return "", err
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", fmt.Errorf("%w: bad user response: %s", ErrAuth, clip(body, 300))
}
if payload.Data.Me == nil || payload.Data.Me.User.ID == "" {
return "", ErrAuth
}
return strings.TrimSpace(payload.Data.Me.User.Email), nil
}
// GenerateVideo runs the full generation: InitiateSession → PUT reference
// images to the presigned S3 URLs → poll ListSessions until COMPLETED → (when
// downloadResult) fetch the MP4. Returns the bytes (nil when url-only) and the
// previewMediaUrl. durationSeconds is ignored: the plan fixes the length per
// model (LookupModel.Duration).
func (c *Client) GenerateVideo(ctx context.Context, cookie, token, modelID, prompt, aspectRatio string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
m, ok := LookupModel(modelID)
if !ok {
return nil, "", fmt.Errorf("creativefabrica: unknown model %q", modelID)
}
sessionID, uploads, err := c.initiateSession(ctx, cookie, token, m, prompt, aspectRatio, refs)
if err != nil {
return nil, "", err
}
// Each ref uploads to the presigned URL the session returned for it.
for i, u := range uploads {
if err := c.putS3(ctx, u, refs[i]); err != nil {
return nil, "", err
}
}
videoURL, err := c.pollSession(ctx, cookie, token, sessionID)
if err != nil {
return nil, "", err
}
if !downloadResult {
return nil, videoURL, nil
}
data, err := c.download(ctx, videoURL)
if err != nil {
return nil, "", err
}
return data, videoURL, nil
}
// initiateSession submits the generation and returns the session id plus the
// presigned upload URLs (one per reference image).
func (c *Client) initiateSession(ctx context.Context, cookie, token string, m Model, prompt, aspectRatio string, refs [][]byte) (string, []string, error) {
frames := make([]any, 0, len(refs))
refPrompt := strings.TrimSpace(prompt)
for i := range refs {
ref := fmt.Sprintf("img%d", i+1)
frames = append(frames, map[string]any{
"type": videoFrameRef,
"fileSize": fmt.Sprintf("%d", len(refs[i])),
"fileName": randomFileName(i + 1),
"ref": ref,
})
if !strings.Contains(refPrompt, "["+ref+"]") {
refPrompt += " Use [" + ref + "]"
}
}
reqBody := map[string]any{
"visibility": visibilityPrivate,
"sessionRequestPromptToVideoGeneratorContent": map[string]any{
"serviceType": videoServiceType,
"promptContent": map[string]any{"prompt": refPrompt},
"resolution": resolutionEnum(m.Resolution),
"model": m.Enum,
"frames": frames,
"aspectRatio": aspectRatioEnum(aspectRatio),
"directorConfig": map[string]any{
"filmStock": map[string]any{"color": "DIRECTOR_FILM_STOCK_COLOR_FULL_COLOR"},
},
"videoDuration": map[string]any{"inSeconds": m.Duration},
},
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", nil, err
}
resp, err := c.postConnect(ctx, cookie, token, initiatePath, body, true)
if err != nil {
return "", nil, err
}
var payload struct {
Session struct {
ID string `json:"id"`
Status string `json:"status"`
// The presigned upload URLs are nested under promptToVideoGeneratorContent.frames
// (each frame echoes the request frame + the S3 presigned `url`), not under a
// top-level session.frames. Reading the wrong level yields 0 uploads.
PromptToVideoGeneratorContent struct {
Frames []struct {
URL string `json:"url"`
} `json:"frames"`
} `json:"promptToVideoGeneratorContent"`
} `json:"session"`
}
if err := json.Unmarshal(resp, &payload); err != nil {
return "", nil, fmt.Errorf("creativefabrica bad initiate response: %s", clip(resp, 300))
}
if strings.TrimSpace(payload.Session.ID) == "" {
return "", nil, fmt.Errorf("creativefabrica initiate missing session: %s", clip(resp, 300))
}
respFrames := payload.Session.PromptToVideoGeneratorContent.Frames
uploads := make([]string, 0, len(respFrames))
for _, f := range respFrames {
if u := strings.TrimSpace(f.URL); u != "" {
uploads = append(uploads, u)
}
}
if len(uploads) != len(refs) {
return "", nil, fmt.Errorf("creativefabrica initiate returned %d upload urls for %d refs", len(uploads), len(refs))
}
return payload.Session.ID, uploads, nil
}
// pollSession polls ListSessions until the session is COMPLETED / FAILED and
// returns previewMediaUrl on success.
func (c *Client) pollSession(ctx context.Context, cookie, token, sessionID string) (string, error) {
reqBody, _ := json.Marshal(map[string]any{
"serviceType": videoServiceType,
"pagination": map[string]any{"take": 100},
"surface": "SURFACE_STUDIO",
})
deadline := time.Now().Add(pollTimeout)
for {
if err := ctx.Err(); err != nil {
return "", err
}
if time.Now().After(deadline) {
return "", fmt.Errorf("creativefabrica generation timed out after %v", pollTimeout)
}
resp, err := c.postConnect(ctx, cookie, token, listSessionsPath, reqBody, false)
if err != nil {
return "", err
}
var payload struct {
Sessions []map[string]any `json:"sessions"`
}
if err := json.Unmarshal(resp, &payload); err != nil {
return "", fmt.Errorf("creativefabrica bad list response: %s", clip(resp, 300))
}
for _, s := range payload.Sessions {
id, _ := s["id"].(string)
if id != sessionID {
continue
}
status, _ := s["status"].(string)
switch status {
case "SESSION_STATUS_COMPLETED":
if u := strings.TrimSpace(stringValue(s["previewMediaUrl"])); u != "" {
return u, nil
}
return "", fmt.Errorf("creativefabrica session completed without preview url")
case "SESSION_STATUS_FAILED", "SESSION_STATUS_CANCELLED", "SESSION_STATUS_ERROR":
detail := ""
for _, k := range []string{"errorMessage", "failureReason", "error", "message"} {
if v, ok := s[k]; ok {
if d := strings.TrimSpace(stringValue(v)); d != "" {
detail = d
break
}
}
}
return "", fmt.Errorf("creativefabrica session %s%s", status, withDetail(detail))
}
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(pollInterval):
}
}
}
// putS3 uploads a reference image to the presigned URL. S3 doesn't care about
// TLS fingerprinting, so a plain client is fine here.
func (c *Client) putS3(ctx context.Context, presigned string, data []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, presigned, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "image/png")
req.Header.Set("Origin", origin)
req.Header.Set("Referer", origin+"/")
req.Header.Set("User-Agent", defaultUA())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("creativefabrica s3 upload: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("creativefabrica s3 upload failed: %d %s", resp.StatusCode, clip(body, 200))
}
return nil
}
// download fetches the finished MP4 from the public video-v2 URL.
func (c *Client) download(parent context.Context, videoURL string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), downloadTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, videoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", defaultUA())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: download: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("creativefabrica download failed: %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrTemporaryUpstream, err)
}
return data, nil
}
// postGraphQL calls a graphql-gw endpoint with the raw JSON body. Authenticated
// by cookie (balance) and optionally the JWT too. Direct connection (no proxy).
func (c *Client) postGraphQL(ctx context.Context, cookie, token, path, body string) ([]byte, error) {
return c.postJSON(ctx, graphQLHost+path, token, cookie, []byte(body), false, true, false)
}
// postConnect calls a Connect unary endpoint on the media-matrix service. The
// Connect-Protocol-Version header marks the POST as a Connect RPC (distinct from
// a plain JSON REST POST) — the studio browser always sends it. Only the
// InitiateSession call (下单) egresses through the proxy; polling runs direct.
func (c *Client) postConnect(ctx context.Context, cookie, token, path string, body []byte, useProxy bool) ([]byte, error) {
return c.postJSON(ctx, mediaMatrixHost+path, token, cookie, body, true, false, useProxy)
}
func (c *Client) postJSON(ctx context.Context, url, token, cookie string, body []byte, connect, graphql, useProxy bool) ([]byte, error) {
sess, err := c.newTLSClient(useProxy)
if err != nil {
return nil, err
}
req, err := fhttp.NewRequest(fhttp.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = fhttp.Header{
"content-type": {"application/json"},
"accept": {"*/*"},
"origin": {origin},
"referer": {origin + "/"},
"user-agent": {defaultUA()},
}
if graphql {
req.Header.Set("accept", "application/json, multipart/mixed")
}
if connect {
req.Header.Set("connect-protocol-version", "1")
}
if cookie != "" {
req.Header.Set("cookie", cookie)
}
if token != "" {
req.Header.Set("authorization", "Bearer "+token)
}
resp, err := sess.client.Do(req)
if err != nil {
return nil, fmt.Errorf("creativefabrica request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
switch {
case resp.StatusCode == 401 || resp.StatusCode == 403:
return nil, fmt.Errorf("%w (%d: %s)", ErrAuth, resp.StatusCode, clip(data, 300))
case isPaymentRequired(resp.StatusCode, string(data)):
return nil, fmt.Errorf("%w (%d: %s)", ErrPaymentRequired, resp.StatusCode, clip(data, 300))
case resp.StatusCode == 429:
return nil, fmt.Errorf("%w (429)", ErrRateLimited)
case resp.StatusCode >= 500:
return nil, fmt.Errorf("%w (%d: %s)", ErrDeadUpstream, resp.StatusCode, clip(data, 300))
case resp.StatusCode != 200:
return nil, fmt.Errorf("creativefabrica %d: %s", resp.StatusCode, clip(data, 300))
}
return data, nil
}
func resolutionEnum(res string) string {
switch strings.ToLower(strings.TrimSpace(res)) {
case "720p":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
case "1080p":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_1080P"
default:
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
}
}
func aspectRatioEnum(ratio string) string {
switch strings.ReplaceAll(strings.TrimSpace(ratio), " ", "") {
case "16:9":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
case "9:16":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_9_16"
case "1:1":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_1_1"
case "4:3":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_4_3"
case "3:4":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_3_4"
default:
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
}
}
// randomFileName mimics the studio's client-generated reference name
// ("_<base36-ish id>_<n>.png"). The value only needs to be unique per upload.
func randomFileName(n int) string {
return "_" + randomID() + "_" + fmt.Sprintf("%d", n) + ".png"
}
func randomID() string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 26)
for i := range b {
v, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
b[i] = alphabet[v.Int64()]
}
return string(b)
}
// TLS client plumbing: a Chrome-fingerprinted client so the Cloudflare front of
// the Connect endpoints sees a plausible browser handshake.
var fingerprints = []profiles.ClientProfile{
profiles.Chrome_146,
profiles.Chrome_144,
profiles.Chrome_133,
profiles.Chrome_131,
}
type tlsSession struct {
client tlsclient.HttpClient
}
func (c *Client) newTLSClient(useProxy bool) (*tlsSession, error) {
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(fingerprints))))
options := []tlsclient.HttpClientOption{
tlsclient.WithTimeoutSeconds(60),
tlsclient.WithClientProfile(fingerprints[idx.Int64()]),
tlsclient.WithNotFollowRedirects(),
tlsclient.WithRandomTLSExtensionOrder(),
}
if useProxy && c.proxy != "" {
options = append(options, tlsclient.WithProxyUrl(c.proxy))
}
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
if err != nil {
return nil, err
}
return &tlsSession{client: client}, nil
}
func defaultUA() string {
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
}
func clip(v []byte, n int) string {
s := strings.TrimSpace(string(v))
if len(s) <= n {
return s
}
return s[:n]
}
// stringValue coerces a decoded JSON value to its string form.
func stringValue(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return fmt.Sprintf("%.0f", x)
case json.Number:
return x.String()
case bool:
if x {
return "true"
}
return "false"
case nil:
return ""
default:
return fmt.Sprintf("%v", x)
}
}
// withDetail appends a failure detail to an error message when present.
func withDetail(detail string) string {
if detail == "" {
return ""
}
return " (" + detail + ")"
}
@@ -0,0 +1,27 @@
package creativefabrica
import (
"context"
"os"
"testing"
"time"
)
func TestBalance(t *testing.T) {
cookie := os.Getenv("CF_COOKIE")
if cookie == "" {
t.Skip("CF_COOKIE not set")
}
c := NewClient("")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
tok, uid, err := c.ExchangeToken(ctx, cookie)
if err != nil {
t.Fatalf("exchange: %v", err)
}
bal, err := c.FetchBalance(ctx, cookie)
if err != nil {
t.Fatalf("balance: %v", err)
}
t.Logf("user=%s token=%d bal=%d", uid, len(tok), bal)
}
+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