From 4fb7fc92040bfd3b951291646cffb00441270981 Mon Sep 17 00:00:00 2001 From: chiyi Date: Sat, 15 Aug 2026 23:00:30 +0800 Subject: [PATCH] feat: sync all features to image2api --- backend/internal/bootstrap/app.go | 6 +- backend/internal/bootstrap/seed.go | 17 + .../internal/http/handler/provider_admin.go | 34 +- .../internal/http/handler/user_generation.go | 86 ++- backend/internal/http/handler/v1.go | 11 +- backend/internal/http/router/router.go | 1 + .../internal/provider/adobe/arp_shape_test.go | 59 ++ .../provider/creativefabrica/client.go | 629 ++++++++++++++++++ .../provider/creativefabrica/gen_test.go | 27 + backend/internal/service/tokens.go | 120 +++- backend/internal/service/v1.go | 148 ++++- docker-compose.prod.yml | 115 ++++ frontend/Dockerfile.prod | 15 + frontend/nginx.prod.conf | 44 ++ frontend/src/components/ImportModal.vue | 13 +- frontend/src/utils/import.js | 8 + frontend/src/videoCaps.js | 4 +- frontend/src/views/AccountsView.vue | 10 +- frontend/src/views/PlaygroundView.vue | 4 +- 19 files changed, 1301 insertions(+), 50 deletions(-) create mode 100644 backend/internal/provider/adobe/arp_shape_test.go create mode 100644 backend/internal/provider/creativefabrica/client.go create mode 100644 backend/internal/provider/creativefabrica/gen_test.go create mode 100644 docker-compose.prod.yml create mode 100644 frontend/Dockerfile.prod create mode 100644 frontend/nginx.prod.conf diff --git a/backend/internal/bootstrap/app.go b/backend/internal/bootstrap/app.go index ad8910f..1603f51 100644 --- a/backend/internal/bootstrap/app.go +++ b/backend/internal/bootstrap/app.go @@ -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. diff --git a/backend/internal/bootstrap/seed.go b/backend/internal/bootstrap/seed.go index 65e313f..594d92b 100644 --- a/backend/internal/bootstrap/seed.go +++ b/backend/internal/bootstrap/seed.go @@ -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 diff --git a/backend/internal/http/handler/provider_admin.go b/backend/internal/http/handler/provider_admin.go index 4768543..1bb8383 100644 --- a/backend/internal/http/handler/provider_admin.go +++ b/backend/internal/http/handler/provider_admin.go @@ -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 }{} diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 7fa1e7b..9e43c20 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -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", diff --git a/backend/internal/http/handler/v1.go b/backend/internal/http/handler/v1.go index 7593aa2..0b46cb5 100644 --- a/backend/internal/http/handler/v1.go +++ b/backend/internal/http/handler/v1.go @@ -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 diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go index dc4c87f..8cbaba9 100644 --- a/backend/internal/http/router/router.go +++ b/backend/internal/http/router/router.go @@ -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) diff --git a/backend/internal/provider/adobe/arp_shape_test.go b/backend/internal/provider/adobe/arp_shape_test.go new file mode 100644 index 0000000..b7a3a8f --- /dev/null +++ b/backend/internal/provider/adobe/arp_shape_test.go @@ -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") + } +} diff --git a/backend/internal/provider/creativefabrica/client.go b/backend/internal/provider/creativefabrica/client.go new file mode 100644 index 0000000..1a1e685 --- /dev/null +++ b/backend/internal/provider/creativefabrica/client.go @@ -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 +// ("__.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 + ")" +} + + diff --git a/backend/internal/provider/creativefabrica/gen_test.go b/backend/internal/provider/creativefabrica/gen_test.go new file mode 100644 index 0000000..558b614 --- /dev/null +++ b/backend/internal/provider/creativefabrica/gen_test.go @@ -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) +} diff --git a/backend/internal/service/tokens.go b/backend/internal/service/tokens.go index eb99657..51ae65c 100644 --- a/backend/internal/service/tokens.go +++ b/backend/internal/service/tokens.go @@ -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) } diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index bb5d29e..44ac13d 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..bb1dac4 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,115 @@ +# image2api — production stack on the new server. +# PostgreSQL + Redis + RustFS (S3) + backend + frontend/nginx (HTTP on port 80). +# TLS is handled externally (CDN/reverse proxy); this stack is plain HTTP. +# +# Usage: +# docker compose -f docker-compose.prod.yml up -d --build +# +# To migrate data in: +# 1. PostgreSQL: pg_restore into the postgres container. +# 2. RustFS: mc mirror from the old bucket to http://:9000/vivid +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: vivid_ai + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vividai_postgres_2026} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d vivid_ai"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + rustfs: + image: rustfs/rustfs:latest + environment: + RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-vividai} + RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-vividai_secret_2026} + RUSTFS_ADDRESS: ":9000" + RUSTFS_VOLUMES: /data + volumes: + - rustfsdata:/data + ports: + # Exposed temporarily for the one-time migration from the old server. + # After migration you may remove this mapping and firewall :9000. + - "9000:9000" + restart: unless-stopped + + createbucket: + image: minio/mc:latest + depends_on: + - rustfs + entrypoint: > + /bin/sh -c " + until mc alias set s3 http://rustfs:9000 ${RUSTFS_ACCESS_KEY:-vividai} ${RUSTFS_SECRET_KEY:-vividai_secret_2026}; do + echo 'waiting for rustfs...'; sleep 2; + done; + mc mb -p s3/vivid || true; + echo 'bucket ready'; + " + restart: "no" + + backend: + build: + context: ./backend + environment: + APP_ENV: production + APP_TITLE: ${APP_TITLE:-Vivid AI} + HTTP_ADDR: 0.0.0.0:6666 + POSTGRES_DSN: host=postgres user=postgres password=${POSTGRES_PASSWORD:-vividai_postgres_2026} dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai + REDIS_ADDR: redis:6379 + REDIS_PASSWORD: "" + REDIS_DB: "0" + RUSTFS_ENDPOINT: http://rustfs:9000 + RUSTFS_BUCKET: vivid + RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-vividai} + RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-vividai_secret_2026} + CORS_ORIGINS: ${CORS_ORIGINS:-http://vividai.run,http://www.vividai.run,http://206.168.190.183} + COOKIE_SECURE: "false" + volumes: + - generated:/app/data/generated + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + createbucket: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:6666/health"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + web: + build: + context: ./frontend + dockerfile: Dockerfile.prod + ports: + - "80:80" + depends_on: + - backend + restart: unless-stopped + +volumes: + pgdata: + redisdata: + rustfsdata: + generated: diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..fbdd8e7 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 +# Production frontend: built from source, served by nginx on HTTP :80. +# ---- build stage ---- +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +# ---- serve stage ---- +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.prod.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/nginx.prod.conf b/frontend/nginx.prod.conf new file mode 100644 index 0000000..5502cae --- /dev/null +++ b/frontend/nginx.prod.conf @@ -0,0 +1,44 @@ +# nginx for the docker production stack — HTTP on :80. +# TLS + domain are handled externally; this just serves the SPA and proxies API. +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 50m; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # video / large-image generation can block minutes — avoid the 60s 504. + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + + # Hashed build assets never change — cache hard. + location /assets/ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # SPA fallback; index.html must never be cached (else stale bundle hash). + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # ---- API / media / health -> backend ---- + location ^~ /admin/api/ { proxy_pass http://backend:6666; add_header Cache-Control "no-store" always; } + location ^~ /images/ { proxy_pass http://backend:6666; } + location = /health { proxy_pass http://backend:6666; } + # /v1 is per-API-key authenticated — never cache. + location ^~ /v1/ { + proxy_pass http://backend:6666; + add_header Cache-Control "no-store" always; + } +} diff --git a/frontend/src/components/ImportModal.vue b/frontend/src/components/ImportModal.vue index 1a148c5..785d6fd 100644 --- a/frontend/src/components/ImportModal.vue +++ b/frontend/src/components/ImportModal.vue @@ -13,7 +13,7 @@ const isError = ref(false) const submitting = ref(false) // type → token pool (for the post-import weight PATCH). -const TYPE_POOL = { openai: 'chatgpt', adobe: 'adobe', runway: 'runway', leonardo: 'leonardo', krea: 'krea', imagine: 'imagine', grok: 'grok' } +const TYPE_POOL = { openai: 'chatgpt', adobe: 'adobe', runway: 'runway', leonardo: 'leonardo', krea: 'krea', imagine: 'imagine', grok: 'grok', creativefabrica: 'creativefabrica' } // Live preview of what the parser would extract — updates as the user types // so they can see whether their paste was understood before clicking import. @@ -26,7 +26,8 @@ const detected = computed(() => { const krea = items.filter((x) => x.type === 'krea').length const imagine = items.filter((x) => x.type === 'imagine').length const grok = items.filter((x) => x.type === 'grok').length - return { total: items.length, openai, adobe, runway, leonardo, krea, imagine, grok } + const cf = items.filter((x) => x.type === 'creativefabrica').length + return { total: items.length, openai, adobe, runway, leonardo, krea, imagine, grok, cf } }) function setStatus(text, err = false) { @@ -58,7 +59,9 @@ async function doSmartImport() { ? await api('/tokens/import-krea-cookie', jsonBody('POST', { cookie: it.value })) : it.type === 'imagine' ? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value })) - : await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value })) + : it.type === 'creativefabrica' + ? await api('/tokens/import-creativefabrica-cookie', jsonBody('POST', { cookie: it.value })) + : await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value })) if (r.ok) { ok++ // Apply the chosen weight to the freshly-imported account (best-effort). @@ -107,6 +110,7 @@ async function doSmartImport() { Runway JWT(自动与 ChatGPT 区分)、 Leonardo Cookie(须含 better-auth.session_data)、 Krea Cookie(含 sb-superb-auth)、 + Creative Fabrica Cookie(含 cfauth_* 或 wordpress_logged_in_)、 Imagine Token({"token","refreshToken","email","parentId"})、 Grok SSO(grok.com 的 sso 值,仅含 session_id,自动与 ChatGPT/Runway 区分)、 多个 JWT(换行分隔)。 @@ -142,6 +146,9 @@ async function doSmartImport() { Grok · {{ detected.grok }} + + Creative Fabrica · {{ detected.cf }} + 未识别到任何 Cookie 或 JWT diff --git a/frontend/src/utils/import.js b/frontend/src/utils/import.js index 1338902..e3802fd 100644 --- a/frontend/src/utils/import.js +++ b/frontend/src/utils/import.js @@ -49,6 +49,13 @@ export function looksLikeKreaCookie(s) { return /sb-superb-auth-token/.test(s || '') } +// Creative Fabrica session cookies carry the cfauth_* family (cfauth_uid / +// cfauth_sig / cfauth_utp) plus the WordPress SSO cookie — those markers tell +// them apart from an Adobe/Krea/Leonardo cookie (all otherwise opaque strings). +export function looksLikeCreativeFabricaCookie(s) { + return /cfauth_/.test(s || '') || /cfAmpAnonymousId/.test(s || '') || /wordpress_logged_in_/.test(s || '') +} + // An Imagine.art credential is a JSON object { token, refreshToken } (both JWTs). function isImagineObj(o) { return !!o && typeof o === 'object' && @@ -67,6 +74,7 @@ function cookieType(v) { if (looksLikeImagineToken(v)) return 'imagine' if (looksLikeKreaCookie(v)) return 'krea' if (looksLikeLeonardoCookie(v)) return 'leonardo' + if (looksLikeCreativeFabricaCookie(v)) return 'creativefabrica' return 'adobe' } diff --git a/frontend/src/videoCaps.js b/frontend/src/videoCaps.js index 3014109..c664f53 100644 --- a/frontend/src/videoCaps.js +++ b/frontend/src/videoCaps.js @@ -12,7 +12,9 @@ function declared(v) { // 参考资产时返回 null。model 兼容 /managed-models(type) 与 /models(kind) 两种字段。 export function mediaCaps(model, preset) { if ((model?.type || model?.kind) !== 'video') return null - const isSeedance = /^seedance/.test(model?.id || '') + // creativefabrica 上游只收图片参考(VIDEO_FRAME_TYPE_REFERENCE),不收视频/音频; + // 它虽然叫 seedance,但要排除,否则会错误地显示「视频 3 音频 3」。 + const isSeedance = /^seedance/.test(model?.id || '') && (model?.provider || '') !== 'creativefabrica' const videos = declared(preset?.max_videos) ?? (isSeedance ? SEEDANCE_FALLBACK.videos : 0) const audios = declared(preset?.max_audios) ?? (isSeedance ? SEEDANCE_FALLBACK.audios : 0) if (!videos && !audios) return null diff --git a/frontend/src/views/AccountsView.vue b/frontend/src/views/AccountsView.vue index d06ccb5..ff0c285 100644 --- a/frontend/src/views/AccountsView.vue +++ b/frontend/src/views/AccountsView.vue @@ -55,7 +55,7 @@ const stats = ref({ total: 0, dead_total: 0, openai: { ...EMPTY_TYPE }, adobe: { ...EMPTY_TYPE }, runway: { ...EMPTY_TYPE }, leonardo: { ...EMPTY_TYPE }, krea: { ...EMPTY_TYPE }, imagine: { ...EMPTY_TYPE }, - grok: { ...EMPTY_TYPE }, + grok: { ...EMPTY_TYPE }, creativefabrica: { ...EMPTY_TYPE }, }) // 异常账号 = 已失效(401)被锁定的号(红色锁定行)。用于「一键删除异常账号」。 @@ -69,6 +69,7 @@ function typePill(t) { leonardo: 'bg-amber-500/10 text-amber-300 ring-amber-400/30', krea: 'bg-sky-500/10 text-sky-300 ring-sky-400/30', imagine: 'bg-teal-500/10 text-teal-300 ring-teal-400/30', + creativefabrica: 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30', }[t] || 'bg-white/[0.06] text-white/70 ring-white/15' } // 与后端 accountConcurrency 保持一致:adobe 普号固定 1 并发,会员号取配置值 @@ -338,7 +339,7 @@ onMounted(() => { loadAccounts(); loadModelList() })
{{ stats.total }}
成功/失败/限额
-
{{ t[1] }}
@@ -373,6 +374,9 @@ onMounted(() => { loadAccounts(); loadModelList() }) +
@@ -507,7 +511,7 @@ onMounted(() => { loadAccounts(); loadModelList() }) / {{ a.video_remaining }} - {{ a.remaining }} diff --git a/frontend/src/views/PlaygroundView.vue b/frontend/src/views/PlaygroundView.vue index ddf3759..d033798 100644 --- a/frontend/src/views/PlaygroundView.vue +++ b/frontend/src/views/PlaygroundView.vue @@ -135,7 +135,9 @@ const maxAudiosRaw = computed(() => { const n = familyPreset.value?.max_audios return n === undefined || n === null ? null : Number(n) }) -const isSeedanceModel = computed(() => /^seedance/.test(model.value?.id || '')) +// creativefabrica 上游只收图片参考,没有首尾帧/视频/音频,虽叫 seedance 也要排除。 +const isSeedanceModel = computed(() => + /^seedance/.test(model.value?.id || '') && (model.value?.provider || '') !== 'creativefabrica') // 支持图片以外的参考资产(视频/音频)的模型:seedance 系 + 预设声明了音视频上限的 const supportsMediaRefs = computed(() => isSeedanceModel.value || maxVideosRaw.value > 0 || maxAudiosRaw.value > 0)