Compare commits
10
Commits
84a2eac64f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fb7fc9204 | ||
|
|
5403acc4c9 | ||
|
|
15602b8033 | ||
|
|
f94a7ace5f | ||
|
|
2c47344ed9 | ||
|
|
b488142495 | ||
|
|
52f9ec967c | ||
|
|
265ff1dd5d | ||
|
|
a9daad2fda | ||
|
|
cfb7bcebc9 |
+1
-1
@@ -90,7 +90,7 @@ It's more than an API proxy: it ships with **credit billing, CDK top-ups, referr
|
||||
- **De-AI fingerprint** (optional): one-click toggle on the playground — generated images get anti-AI-detection post-processing (subtle detail jitter + metadata stripping), charged as a per-tier surcharge (defaults 1K+1 / 2K+2 / 4K+3 credits, admin-configurable, can be disabled globally); processed works carry a "de-AI" badge across the playground, gallery, logs and admin image manager
|
||||
|
||||
#### 🔌 OpenAI Compatible
|
||||
- Text-to-image `/v1/images/generations` · image-to-image `/v1/images/edits` (multipart ref upload) · video `/v1/videos` (Sora-style async: create → poll → `/content`) · `/v1/models`
|
||||
- Text-to-image `/v1/images/generations` · image-to-image `/v1/images/edits` (multipart ref upload) · video `/v1/videos` (Sora-style async: create → poll → `/content`) · `/v1/models` · balance `/v1/user/balance` (remaining / cumulative used)
|
||||
- **Strict OpenAI params**: `size` drives **both aspect ratio + resolution tier** (images by long edge → 1K/2K/4K, videos by short edge → 720p/1080p) — just swap `base_url` + `api_key` into an existing OpenAI SDK
|
||||
- Image results returned **inline as base64** — nothing stored server-side, privacy-friendly; the in-app **/docs** ships a size ↔ tier reference table
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
- **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理(细节微扰 + 去除元数据),按画质档位加收积分(默认 1K+1 / 2K+2 / 4K+3,后台可改价、可整体关闭);带标记的作品在画图台、创作记录、日志与后台图片管理中均有「去AI特征」标识
|
||||
|
||||
#### 🔌 OpenAI 兼容
|
||||
- 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models`
|
||||
- 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models` · 余额 `/v1/user/balance`(剩余/累计已用)
|
||||
- **严格 OpenAI 入参**:`size` **同时决定比例 + 分辨率档**(图像看长边 → 1K/2K/4K,视频看短边 → 720p/1080p),改个 `base_url` + `api_key` 即接现有 OpenAI SDK
|
||||
- 图片结果 **base64 直返**,服务端不留存文件,隐私友好;站内 **/docs** 附「分辨率对照表」直接查 `size` 该传什么
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -41,6 +41,22 @@ func (h *V1Handler) Models(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// UserBalance — GET /v1/user/balance. 返回 API Key 所属用户的账户级余额
|
||||
// (剩余 / 累计已用),与具体令牌无关。
|
||||
func (h *V1Handler) UserBalance(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.v1.UserBalance(c.Request.Context(), principal)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load balance"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ImageGenerations — OpenAI POST /v1/images/generations (text-to-image only).
|
||||
// Accepts exactly OpenAI's fields; size→aspect ratio and quality→resolution tier
|
||||
// are mapped server-side. Returns {created, data:[{b64_json}]}.
|
||||
@@ -276,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
|
||||
|
||||
@@ -49,6 +49,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
engine.GET("/health", handlers.Health.Handle)
|
||||
engine.GET("/images/:user/:name", handlers.Images.Serve)
|
||||
engine.GET("/v1/models", handlers.V1.Models)
|
||||
engine.GET("/v1/user/balance", handlers.V1.UserBalance)
|
||||
engine.POST("/v1/images/generations", handlers.V1.ImageGenerations)
|
||||
engine.POST("/v1/images/edits", handlers.V1.ImageEdits)
|
||||
// OpenAI Sora-style async video: create job → poll → stream content.
|
||||
@@ -128,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)
|
||||
|
||||
@@ -15,6 +15,7 @@ type User struct {
|
||||
Role string `gorm:"size:32;index;not null"`
|
||||
Status string `gorm:"size:32;index;not null"`
|
||||
Credits float64 `gorm:"not null;default:0"`
|
||||
CreditsUsed float64 `gorm:"not null;default:0"` // 累计已消耗额度:扣费时加、退款时减,充值/发放不计入
|
||||
Notes string `gorm:"type:text"`
|
||||
ConcurrencyGroupID string `gorm:"size:32;index"`
|
||||
AnnouncementSeen string `gorm:"size:32"` // version hash of the last announcement this user dismissed
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -40,6 +40,12 @@ const (
|
||||
// attempt is retried, falling back to the proxy for a different exit IP.
|
||||
getSessionAttempts = 10
|
||||
getSessionRetryDelay = 2 * time.Second
|
||||
// A warmed 200 null usually means the session is gone server-side, but the
|
||||
// same answer also comes back when the checkpoint silently serves a session-
|
||||
// less page to an exit IP — so retry it a few times (later attempts through
|
||||
// the proxy) before calling the account dead. Fewer attempts than the 429
|
||||
// budget: each one costs two requests and a truly dead cookie never recovers.
|
||||
getSessionNullAttempts = 3
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -60,10 +66,14 @@ type Client struct {
|
||||
// persists it; keeping it here means an unpersisted rotation still works for
|
||||
// the rest of the process's life.
|
||||
rotated map[string]string
|
||||
// refreshing serialises get-session per cookie. Two concurrent refreshes hand
|
||||
// Cognito the same refresh token twice and its reuse detection revokes the
|
||||
// whole session — the account then answers 401 forever.
|
||||
refreshing map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}, rotated: map[string]string{}}
|
||||
return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}, rotated: map[string]string{}, refreshing: map[string]*sync.Mutex{}}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
@@ -187,6 +197,18 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Only one refresh per cookie at a time; the others wait and then re-use the
|
||||
// token it minted.
|
||||
gate := c.refreshGate(cookie)
|
||||
gate.Lock()
|
||||
defer gate.Unlock()
|
||||
c.mu.Lock()
|
||||
if cs, ok := c.sessions[cookie]; ok && cs.ExpiresAt-60 > time.Now().Unix() {
|
||||
c.mu.Unlock()
|
||||
return cs, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Use the freshest known value (an earlier response may have rotated the
|
||||
// better-auth cookie cache) rather than the possibly stale stored cookie.
|
||||
send := cookie
|
||||
@@ -201,6 +223,9 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
var setCookies []string
|
||||
var err error
|
||||
var warmed bool
|
||||
// 401 from cross-origin-cookie means Leonardo已经作废了这个 session(不是人机校验),
|
||||
// 记下来好把日志写成"会话被吊销"而不是含糊的 get-session null。
|
||||
warmStatus := 0
|
||||
for attempt := 0; attempt < getSessionAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
@@ -221,7 +246,9 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
// better-auth cookie cache (and CF_Access_Token). Without it get-session
|
||||
// answers 200 null even for a perfectly healthy cookie.
|
||||
warmed = false
|
||||
if warmCookies, werr := c.warmSession(ctx, client, send); werr == nil {
|
||||
warmCookies, wstatus, werr := c.warmSession(ctx, client, send)
|
||||
warmStatus = wstatus
|
||||
if werr == nil {
|
||||
warmed = true
|
||||
if merged := mergeCookies(send, warmCookies); merged != send && keepsSession(merged) {
|
||||
send = merged
|
||||
@@ -229,6 +256,9 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
}
|
||||
status, body, setCookies, err = c.fetchSession(ctx, client, send)
|
||||
if err == nil && warmed && status != 429 && status != 403 {
|
||||
if status == 200 && sessionAccessToken(body) == "" && attempt < getSessionNullAttempts-1 {
|
||||
continue // retry a null session on another exit IP before giving up
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -275,6 +305,10 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
return nil, fmt.Errorf("%w: get-session non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
if strings.TrimSpace(raw.Session.AccessToken) == "" {
|
||||
if warmStatus == 401 {
|
||||
// Leonardo 明确拒了这份 session_token:服务端已把会话吊销,重新导入 cookie 才能恢复。
|
||||
return nil, fmt.Errorf("%w: session revoked (cross-origin-cookie 401)", ErrAuth)
|
||||
}
|
||||
if !warmed {
|
||||
// A cold get-session (the cookie cache was never refreshed) answers null
|
||||
// for healthy accounts too — temporary, never a reason to kill the account.
|
||||
@@ -309,26 +343,38 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// refreshGate returns the per-cookie lock that serialises get-session refreshes.
|
||||
func (c *Client) refreshGate(cookie string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
gate, ok := c.refreshing[cookie]
|
||||
if !ok {
|
||||
gate = &sync.Mutex{}
|
||||
c.refreshing[cookie] = gate
|
||||
}
|
||||
return gate
|
||||
}
|
||||
|
||||
// warmSession calls cross-origin-cookie, whose response refreshes better-auth's
|
||||
// cookie cache and CF_Access_Token. It returns the Set-Cookie headers; a
|
||||
// checkpoint answer (403/429) is an error, since get-session would then be cold.
|
||||
func (c *Client) warmSession(ctx context.Context, client tlsclient.HttpClient, cookie string) ([]string, error) {
|
||||
func (c *Client) warmSession(ctx context.Context, client tlsclient.HttpClient, cookie string) ([]string, int, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/cross-origin-cookie", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = sessionHeader(cookie)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("cross-origin-cookie http %d", resp.StatusCode)
|
||||
return nil, resp.StatusCode, fmt.Errorf("cross-origin-cookie http %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Header["Set-Cookie"], nil
|
||||
return resp.Header["Set-Cookie"], resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// fetchSession performs one get-session call and returns its status, body and
|
||||
@@ -349,6 +395,20 @@ func (c *Client) fetchSession(ctx context.Context, client tlsclient.HttpClient,
|
||||
return resp.StatusCode, body, resp.Header["Set-Cookie"], nil
|
||||
}
|
||||
|
||||
// sessionAccessToken pulls the bearer out of a get-session body; "" means the
|
||||
// answer carried none (a null session, or a session without a token).
|
||||
func sessionAccessToken(body []byte) string {
|
||||
var raw struct {
|
||||
Session struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(raw.Session.AccessToken)
|
||||
}
|
||||
|
||||
// sessionHeader is the auth endpoints' request shape, copied from a real
|
||||
// browser's call (HAR): a same-origin GET carries NO origin header and DOES
|
||||
// carry the ua client hints + priority — sending origin while omitting the
|
||||
|
||||
@@ -49,11 +49,112 @@ const mUploadImage = `mutation UploadImage($uploadImageInput: UploadImageInput!)
|
||||
}
|
||||
}`
|
||||
|
||||
// uploadInitImage uploads a reference (init) image for image-to-image: it asks
|
||||
// Leonardo for a presigned S3 POST, uploads the bytes, and returns the upload id
|
||||
// to reference in the Generate request's image_reference guidance.
|
||||
func (c *Client) uploadInitImage(ctx context.Context, cookie string, img []byte) (string, error) {
|
||||
return c.uploadAsset(ctx, cookie, "png", img)
|
||||
const mUploadInitImage = `mutation UploadInitImage($arg1: InitImageUploadInput!) {
|
||||
uploadInitImage(arg1: $arg1) {
|
||||
id
|
||||
url
|
||||
fields
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// initImageExtension narrows a sniffed extension to what uploadInitImage accepts
|
||||
// (png / jpg / jpeg / webp); anything else is sent as png.
|
||||
func initImageExtension(extension string) string {
|
||||
switch strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".") {
|
||||
case "jpg":
|
||||
return "jpg"
|
||||
case "jpeg":
|
||||
return "jpeg"
|
||||
case "webp":
|
||||
return "webp"
|
||||
default:
|
||||
return "png"
|
||||
}
|
||||
}
|
||||
|
||||
// uploadInitImage uploads a reference image and returns the init image id to put
|
||||
// in a Generate request's image_reference guidance. It has to go through
|
||||
// uploadInitImage (permanent init-image bucket): the uploadImage mutation only
|
||||
// hands out temporary-bucket ids, which the generation service can't resolve.
|
||||
func (c *Client) uploadInitImage(ctx context.Context, cookie, extension string, img []byte) (string, error) {
|
||||
extension = initImageExtension(extension)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "UploadInitImage",
|
||||
"query": mUploadInitImage,
|
||||
"variables": map[string]any{"arg1": map[string]any{"extension": extension}},
|
||||
})
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init-image")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var ur struct {
|
||||
Data struct {
|
||||
UploadInitImage struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Fields string `json:"fields"`
|
||||
} `json:"uploadInitImage"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &ur); err != nil {
|
||||
return "", fmt.Errorf("%w: upload-init-image non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
up := ur.Data.UploadInitImage
|
||||
if up.ID == "" || up.URL == "" {
|
||||
return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream)
|
||||
}
|
||||
if err := c.putPresigned(ctx, up.URL, up.Fields, "asset."+extension, img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return up.ID, nil
|
||||
}
|
||||
|
||||
// putPresigned performs the presigned S3 POST: all policy fields first, the file
|
||||
// part LAST.
|
||||
func (c *Client) putPresigned(ctx context.Context, url, fieldsJSON, filename string, asset []byte) error {
|
||||
var fields map[string]string
|
||||
if err := json.Unmarshal([]byte(fieldsJSON), &fields); err != nil {
|
||||
return fmt.Errorf("%w: bad upload fields", ErrTemporaryUpstream)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
_ = w.WriteField(k, v)
|
||||
}
|
||||
fw, err := w.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fw.Write(asset); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"content-type": {w.FormDataContentType()},
|
||||
"user-agent": {userAgent},
|
||||
"origin": {appBase},
|
||||
"referer": {appBase + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: s3 upload: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 204 && resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
return fmt.Errorf("%w: s3 upload http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploadAsset uploads one reference asset (extension png / mp3 / mp4 …) through
|
||||
@@ -66,7 +167,12 @@ func (c *Client) uploadAsset(ctx context.Context, cookie, extension string, asse
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "UploadImage",
|
||||
"query": mUploadImage,
|
||||
"variables": map[string]any{"uploadImageInput": map[string]any{"uploadType": "INIT", "extension": extension}},
|
||||
// originalFilename is mandatory for audio uploads and harmless otherwise.
|
||||
"variables": map[string]any{"uploadImageInput": map[string]any{
|
||||
"uploadType": "INIT",
|
||||
"extension": extension,
|
||||
"originalFilename": "asset." + extension,
|
||||
}},
|
||||
})
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init")
|
||||
if err != nil {
|
||||
@@ -88,49 +194,9 @@ func (c *Client) uploadAsset(ctx context.Context, cookie, extension string, asse
|
||||
if up.UploadID == "" || up.URL == "" {
|
||||
return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream)
|
||||
}
|
||||
var fields map[string]string
|
||||
if err := json.Unmarshal([]byte(up.Fields), &fields); err != nil {
|
||||
return "", fmt.Errorf("%w: bad upload fields", ErrTemporaryUpstream)
|
||||
}
|
||||
|
||||
// Presigned S3 POST: all policy fields first, the file part LAST.
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
_ = w.WriteField(k, v)
|
||||
}
|
||||
fw, err := w.CreateFormFile("file", "asset."+extension)
|
||||
if err != nil {
|
||||
if err := c.putPresigned(ctx, up.URL, up.Fields, "asset."+extension, asset); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fw.Write(asset); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, up.URL, &buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"content-type": {w.FormDataContentType()},
|
||||
"user-agent": {userAgent},
|
||||
"origin": {appBase},
|
||||
"referer": {appBase + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: s3 upload: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 204 && resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
return "", fmt.Errorf("%w: s3 upload http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return up.UploadID, nil
|
||||
}
|
||||
|
||||
@@ -156,7 +222,7 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadInitImage(ctx, cookie, img)
|
||||
uploadID, upErr := c.uploadInitImage(ctx, cookie, assetExtension(img, "png"), img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
@@ -307,12 +373,22 @@ func graphqlError(body []byte) error {
|
||||
var env struct {
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
Extensions struct {
|
||||
Code string `json:"code"`
|
||||
Details struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"details"`
|
||||
} `json:"extensions"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil || len(env.Errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
msg := strings.TrimSpace(env.Errors[0].Message)
|
||||
// The generic "An error occurred." hides the real reason in extensions.
|
||||
if detail := strings.TrimSpace(env.Errors[0].Extensions.Details.Message); detail != "" && detail != msg {
|
||||
msg = msg + " (" + detail + ")"
|
||||
}
|
||||
low := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.Contains(low, "unauthor") || strings.Contains(low, "jwt") || strings.Contains(low, "token is") || strings.Contains(low, "forbidden"):
|
||||
|
||||
@@ -53,7 +53,7 @@ func (c *Client) GenerateVideo(ctx context.Context, cookie, model, prompt string
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(img, "png"), img)
|
||||
uploadID, upErr := c.uploadInitImage(ctx, cookie, assetExtension(img, "png"), img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
@@ -65,24 +65,6 @@ func (c *Client) GenerateVideo(ctx context.Context, cookie, model, prompt string
|
||||
if len(imageRefs) > 0 {
|
||||
guidances["image_reference"] = imageRefs
|
||||
}
|
||||
var audioRefs []map[string]any
|
||||
for _, aud := range refs.Audios {
|
||||
if len(aud) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(aud, "mp3"), aud)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
audio := map[string]any{"id": uploadID, "type": "UPLOADED"}
|
||||
if secs := MediaDurationSeconds(aud); secs > 0 {
|
||||
audio["duration"] = secs
|
||||
}
|
||||
audioRefs = append(audioRefs, map[string]any{"audio": audio})
|
||||
}
|
||||
if len(audioRefs) > 0 {
|
||||
guidances["audio_reference"] = audioRefs
|
||||
}
|
||||
var videoRefs []map[string]any
|
||||
for _, vid := range refs.Videos {
|
||||
if len(vid) == 0 {
|
||||
@@ -101,6 +83,29 @@ func (c *Client) GenerateVideo(ctx context.Context, cookie, model, prompt string
|
||||
if len(videoRefs) > 0 {
|
||||
guidances["video_reference_base"] = videoRefs
|
||||
}
|
||||
// Leonardo rejects an audio reference that isn't paired with an image or
|
||||
// video reference (audio_reference_only_compatible_with_image_or_video_reference).
|
||||
var audioRefs []map[string]any
|
||||
for _, aud := range refs.Audios {
|
||||
if len(aud) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(imageRefs) == 0 && len(videoRefs) == 0 {
|
||||
return nil, nil, errors.New("leonardo: audio reference requires an image or video reference")
|
||||
}
|
||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(aud, "mp3"), aud)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
audio := map[string]any{"id": uploadID, "type": "UPLOADED"}
|
||||
if secs := MediaDurationSeconds(aud); secs > 0 {
|
||||
audio["duration"] = secs
|
||||
}
|
||||
audioRefs = append(audioRefs, map[string]any{"audio": audio})
|
||||
}
|
||||
if len(audioRefs) > 0 {
|
||||
guidances["audio_reference"] = audioRefs
|
||||
}
|
||||
|
||||
parameters := map[string]any{
|
||||
"height": height,
|
||||
|
||||
@@ -87,6 +87,23 @@ func (r *TokenRepository) Update(ctx context.Context, pool, id string, patch map
|
||||
return r.Get(ctx, pool, id)
|
||||
}
|
||||
|
||||
// SwapValue replaces an account's credential only while the stored one is still
|
||||
// the value the caller started from. A rotating cookie is minted from whatever
|
||||
// was in the row, so a goroutine that has been holding an older copy (a long
|
||||
// render, a slow quota probe) must NOT be allowed to write it back over a newer
|
||||
// rotation — that older copy no longer authenticates, and the account then looks
|
||||
// dead. Reports whether the row was updated.
|
||||
func (r *TokenRepository) SwapValue(ctx context.Context, pool, id, from, to string) (bool, error) {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ? AND value = ?", pool, id, from).
|
||||
Updates(map[string]any{"value": to, "updated_at": time.Now()})
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// ReserveQuota atomically pre-deducts `amount` from an account's cached image
|
||||
// token balance under a row lock, so concurrent picks of the same near-empty
|
||||
// account can never over-commit it. Returns:
|
||||
|
||||
@@ -524,6 +524,35 @@ func (r *UserRepository) AdjustCredits(ctx context.Context, userID string, delta
|
||||
return r.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
// RefundCredits 归还一次生成的预扣费:余额加回、累计消耗 credits_used 相应减少
|
||||
// (不低于 0)。发放类加钱(管理员调整 / CDK / 签到)走 AdjustCredits,不动 credits_used。
|
||||
func (r *UserRepository) RefundCredits(ctx context.Context, userID string, amount float64) (*model.User, error) {
|
||||
if amount <= 0 {
|
||||
return r.GetByID(ctx, userID)
|
||||
}
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
nextUsed := user.CreditsUsed - amount
|
||||
if nextUsed < 0 {
|
||||
nextUsed = 0
|
||||
}
|
||||
return tx.Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Updates(map[string]any{
|
||||
"credits": user.Credits + amount,
|
||||
"credits_used": nextUsed,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
// SetCredits sets a user's credit balance to an absolute (non-negative) value.
|
||||
// The row is locked for the duration of the transaction so it stays consistent
|
||||
// with concurrent AdjustCredits/TryDebitCredits operations.
|
||||
@@ -571,11 +600,13 @@ func (r *UserRepository) TryDebitCredits(ctx context.Context, userID string, amo
|
||||
Where("id = ?", userID).
|
||||
Updates(map[string]any{
|
||||
"credits": nextCredits,
|
||||
"credits_used": user.CreditsUsed + amount,
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
user.Credits = nextCredits
|
||||
user.CreditsUsed += amount
|
||||
user.UpdatedAt = time.Now()
|
||||
result = &user
|
||||
debited = true
|
||||
|
||||
@@ -63,6 +63,10 @@ func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService,
|
||||
func (m *MaintenanceService) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(m.interval)
|
||||
defer ticker.Stop()
|
||||
// The leonardo session keep-alive gets its own loop: one tick() can take tens
|
||||
// of minutes (219 adobe cookie profiles alone), which would stretch a 5-minute
|
||||
// keep-alive to the tick's real duration.
|
||||
go m.runLeonardoKeepalive(ctx)
|
||||
m.tick(ctx)
|
||||
for {
|
||||
select {
|
||||
@@ -74,6 +78,25 @@ func (m *MaintenanceService) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// runLeonardoKeepalive re-checks every minute which leonardo accounts are due for
|
||||
// a session renewal (the 5-minute due-ness itself is read per account from the DB,
|
||||
// so a restart can't skip one).
|
||||
func (m *MaintenanceService) runLeonardoKeepalive(ctx context.Context) {
|
||||
if m.tokenSvc == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
m.tokenSvc.RefreshLeonardoSessions(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncRecoveredQuota re-probes each just-recovered account so its displayed
|
||||
// balance reflects the post-reset value (these providers only sync quota when
|
||||
// accessed). krea additionally needs /app (Activate) to actually grant the daily
|
||||
@@ -216,7 +239,7 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
if _, err := m.users.AdjustCredits(ctx, e.UserID, e.Cost); err != nil {
|
||||
if _, err := m.users.RefundCredits(ctx, e.UserID, e.Cost); err != nil {
|
||||
log.Printf("maintenance: refund abandoned event %s (user %s, %.0f): %v", e.ID, e.UserID, e.Cost, err)
|
||||
} else {
|
||||
refunded++
|
||||
|
||||
@@ -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.
|
||||
@@ -56,9 +59,12 @@ type TokenService struct {
|
||||
// kreaActivating guards the once-per-day krea /app activation sweep so the 60s
|
||||
// maintenance tick can't pile up overlapping sweeps.
|
||||
kreaActivating atomic.Bool
|
||||
// leonardoKeeping guards the leonardo session keep-alive sweep so the 60s
|
||||
// maintenance tick can't pile up overlapping sweeps.
|
||||
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,
|
||||
@@ -71,6 +77,7 @@ func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileR
|
||||
krea: kreaClient,
|
||||
imagine: imagineClient,
|
||||
grok: grokClient,
|
||||
cf: cfClient,
|
||||
sem: make(chan struct{}, 10),
|
||||
}
|
||||
}
|
||||
@@ -99,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
|
||||
@@ -137,6 +147,78 @@ func (s *TokenService) RefreshExpiringTokens(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// leonardoKeepaliveEvery is how long a leonardo cookie may go unrenewed. The due
|
||||
// check reads each account's own meta["session_kept_at"], so the cadence survives
|
||||
// a restart (no in-memory timer to lose) — after a restart every account whose
|
||||
// stamp is older than this is simply due again.
|
||||
const leonardoKeepaliveEvery = 5 * time.Minute
|
||||
|
||||
// leonardoKeptAtKey stamps the last successful session keep-alive.
|
||||
const leonardoKeptAtKey = "session_kept_at"
|
||||
|
||||
// RefreshLeonardoSessions re-mints a session for every live leonardo account whose
|
||||
// last keep-alive is older than leonardoKeepaliveEvery, instead of waiting for the
|
||||
// ~1h bearer cache to lapse or for the account to be used. get-session rolls the
|
||||
// better-auth session (expiresAt is pushed out) and may rotate CF_Access_Token /
|
||||
// session_data, so the rotated cookie is written back. It never disables an
|
||||
// account: a dead cookie is left to the quota-refresh strike logic, which
|
||||
// double-checks before killing.
|
||||
func (s *TokenService) RefreshLeonardoSessions(ctx context.Context) {
|
||||
if s.leonardo == nil {
|
||||
return
|
||||
}
|
||||
if !s.leonardoKeeping.CompareAndSwap(false, true) {
|
||||
return // a sweep is already running
|
||||
}
|
||||
bg := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
defer s.leonardoKeeping.Store(false)
|
||||
items, err := s.tokens.ListByPool(bg, "leonardo")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
due := time.Now().Add(-leonardoKeepaliveEvery).Unix()
|
||||
proxied := false
|
||||
renewed, failed := 0, 0
|
||||
for i := range items {
|
||||
a := items[i]
|
||||
if a.Dead || a.Status == "disabled" || strings.TrimSpace(a.Value) == "" {
|
||||
continue
|
||||
}
|
||||
if at, ok := jsonMapInt(a.Meta, leonardoKeptAtKey); ok && int64(at) > due {
|
||||
continue // renewed less than leonardoKeepaliveEvery ago
|
||||
}
|
||||
if !proxied {
|
||||
s.applyProxy(bg)
|
||||
proxied = true
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(bg, 90*time.Second)
|
||||
_, err := s.leonardo.ProbeSession(callCtx, a.Value)
|
||||
if err != nil {
|
||||
cancel()
|
||||
failed++
|
||||
if errors.Is(err, leonardo.ErrAuth) {
|
||||
log.Printf("leonardo %s: session keepalive auth failure (%v)", a.ID, err)
|
||||
}
|
||||
continue // leave the stamp alone so the next tick retries
|
||||
}
|
||||
if fresh, ok := s.leonardo.RotatedCookie(a.Value); ok && strings.TrimSpace(fresh) != "" {
|
||||
_, _ = s.tokens.SwapValue(callCtx, "leonardo", a.ID, a.Value, fresh)
|
||||
}
|
||||
fields := map[string]any{}
|
||||
meta := cloneJSONMap(a.Meta)
|
||||
meta[leonardoKeptAtKey] = int(time.Now().Unix())
|
||||
fields["meta"] = meta
|
||||
_, _ = s.tokens.Update(callCtx, "leonardo", a.ID, fields)
|
||||
cancel()
|
||||
renewed++
|
||||
}
|
||||
if renewed > 0 || failed > 0 {
|
||||
log.Printf("leonardo: session keepalive renewed %d, failed %d", renewed, failed)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ActivateKreaDue loads /app (Activate) for each krea account that hasn't been
|
||||
// synced since the most recent daily reset, then re-syncs its balance. Krea only
|
||||
// grants the daily free balance after the SSR app page loads, so without this an
|
||||
@@ -384,7 +466,7 @@ func (s *TokenService) persistLeonardoCookie(ctx context.Context, tokenID, cooki
|
||||
return
|
||||
}
|
||||
if fresh, ok := s.leonardo.RotatedCookie(cookie); ok && strings.TrimSpace(fresh) != "" {
|
||||
_, _ = s.tokens.Update(ctx, "leonardo", tokenID, map[string]any{"value": fresh})
|
||||
_, _ = s.tokens.SwapValue(ctx, "leonardo", tokenID, cookie, fresh)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,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
|
||||
@@ -1648,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 {
|
||||
@@ -1735,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,
|
||||
@@ -1926,6 +2112,9 @@ func newTokenID(pool string) string {
|
||||
if pool == "imagine" {
|
||||
prefix = "IM"
|
||||
}
|
||||
if pool == "creativefabrica" {
|
||||
prefix = "CF"
|
||||
}
|
||||
return prefix + randomUpper(10)
|
||||
}
|
||||
|
||||
|
||||
+138
-28
@@ -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{},
|
||||
}
|
||||
@@ -391,6 +394,21 @@ func (s *V1Service) ListModels(ctx context.Context) ([]map[string]any, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UserBalance — GET /v1/user/balance 的数据。重新读一次用户行保证实时
|
||||
// (principal 里那份是鉴权时读的,可能已经过期)。
|
||||
func (s *V1Service) UserBalance(ctx context.Context, principal *APIPrincipal) (map[string]any, error) {
|
||||
user := principal.User
|
||||
if fresh, err := s.users.GetByID(ctx, user.ID); err == nil && fresh != nil {
|
||||
user = fresh
|
||||
}
|
||||
return map[string]any{
|
||||
"object": "user.balance",
|
||||
"balance": user.Credits,
|
||||
"used": user.CreditsUsed,
|
||||
"total": user.Credits + user.CreditsUsed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *V1Service) PrepareImageRequest(ctx context.Context, principal *APIPrincipal, in V1ImageRequest) (map[string]any, error) {
|
||||
return s.prepareImageExecution(ctx, principal, in, "v1", true)
|
||||
}
|
||||
@@ -793,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)
|
||||
@@ -804,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)
|
||||
@@ -905,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)
|
||||
@@ -962,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)
|
||||
@@ -1363,9 +1387,16 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
|
||||
aspectRatio = "16:9"
|
||||
}
|
||||
resolution := strings.TrimSpace(in.Resolution)
|
||||
if resolution == "" {
|
||||
// 调用方没指定档位时用模型自己配的第一档,而不是假定 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 {
|
||||
return nil, "", "", "", 0, err
|
||||
@@ -1689,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) {
|
||||
@@ -1891,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.
|
||||
@@ -3046,7 +3155,7 @@ func (s *V1Service) leonardoPersistCookie(ctx context.Context, tokenID, cookie s
|
||||
if !ok || strings.TrimSpace(fresh) == "" {
|
||||
return cookie
|
||||
}
|
||||
_, _ = s.tokens.Update(ctx, "leonardo", tokenID, map[string]any{"value": fresh})
|
||||
_, _ = s.tokens.SwapValue(ctx, "leonardo", tokenID, cookie, fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -3058,6 +3167,7 @@ func (s *V1Service) reconcileLeonardoCredits(ctx context.Context, tokenID, cooki
|
||||
return
|
||||
}
|
||||
data, err := s.leonardo.FetchCreditsBalance(ctx, cookie)
|
||||
s.leonardoPersistCookie(ctx, tokenID, cookie)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -3278,7 +3388,7 @@ func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal,
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
updated, err := s.users.AdjustCredits(ctx, principal.User.ID, price)
|
||||
updated, err := s.users.RefundCredits(ctx, principal.User.ID, price)
|
||||
if err == nil {
|
||||
principal.User = updated
|
||||
}
|
||||
@@ -3696,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", ""
|
||||
@@ -3763,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 轮换竞态),复核
|
||||
// 也不过就只记一次连续失败,连续到上限才判死。
|
||||
@@ -3899,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
|
||||
|
||||
@@ -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://<new-host>: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:
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,6 +59,8 @@ 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 }))
|
||||
: 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++
|
||||
@@ -107,6 +110,7 @@ async function doSmartImport() {
|
||||
<strong class="text-slate-700">Runway JWT</strong>(自动与 ChatGPT 区分)、
|
||||
<strong class="text-slate-700">Leonardo Cookie</strong>(须含 better-auth.session_data)、
|
||||
<strong class="text-slate-700">Krea Cookie</strong>(含 sb-superb-auth)、
|
||||
<strong class="text-slate-700">Creative Fabrica Cookie</strong>(含 cfauth_* 或 wordpress_logged_in_)、
|
||||
<strong class="text-slate-700">Imagine Token</strong>(<code class="px-1 bg-slate-100 rounded">{"token","refreshToken","email","parentId"}</code>)、
|
||||
<strong class="text-slate-700">Grok SSO</strong>(grok.com 的 <code class="px-1 bg-slate-100 rounded">sso</code> 值,仅含 session_id,自动与 ChatGPT/Runway 区分)、
|
||||
<strong class="text-slate-700">多个 JWT</strong>(换行分隔)。
|
||||
@@ -142,6 +146,9 @@ async function doSmartImport() {
|
||||
<span v-if="detected.grok" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-slate-700 bg-slate-100 ring-1 ring-slate-300">
|
||||
Grok · <span class="tabular-nums">{{ detected.grok }}</span>
|
||||
</span>
|
||||
<span v-if="detected.cf" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-fuchsia-700 bg-fuchsia-50 ring-1 ring-fuchsia-200">
|
||||
Creative Fabrica · <span class="tabular-nums">{{ detected.cf }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-rose-600">未识别到任何 Cookie 或 JWT</span>
|
||||
</div>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// 视频模型的参考资产分类上限(图片 / 视频 / 音频)。
|
||||
// 单一真源是后端 /video-presets 的 max_images / max_videos / max_audios(服务端
|
||||
// leonardoVideoSpecs 校验的就是这套数),预设没声明音视频上限的 seedance 系回落
|
||||
// 到 3 / 3,与画图台 (PlaygroundView) 的判定保持一致。
|
||||
const SEEDANCE_FALLBACK = { videos: 3, audios: 3 }
|
||||
|
||||
function declared(v) {
|
||||
return v === undefined || v === null ? null : Number(v)
|
||||
}
|
||||
|
||||
// mediaCaps 返回 { images, videos, audios }(0 = 不支持该类),不支持图片以外的
|
||||
// 参考资产时返回 null。model 兼容 /managed-models(type) 与 /models(kind) 两种字段。
|
||||
export function mediaCaps(model, preset) {
|
||||
if ((model?.type || model?.kind) !== 'video') return null
|
||||
// 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
|
||||
return { images: declared(preset?.max_images) ?? 0, videos, audios }
|
||||
}
|
||||
|
||||
// presetMap 把 /video-presets 的列表转成 key → preset,便于按模型 id 取预设。
|
||||
export function presetMap(list) {
|
||||
const out = {}
|
||||
for (const p of list || []) if (p?.key) out[p.key] = p
|
||||
return out
|
||||
}
|
||||
@@ -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() })
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5">成功/失败/限额</div>
|
||||
</div>
|
||||
<div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-violet-300/80'],['leonardo','Leonardo','text-amber-300/80'],['krea','Krea','text-sky-300/80'],['imagine','Imagine','text-teal-300/80'],['grok','Grok','text-slate-300/80']]"
|
||||
<div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-violet-300/80'],['leonardo','Leonardo','text-amber-300/80'],['krea','Krea','text-sky-300/80'],['imagine','Imagine','text-teal-300/80'],['grok','Grok','text-slate-300/80'],['creativefabrica','Creative Fabrica','text-fuchsia-300/80']]"
|
||||
:key="t[0]" class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider" :class="t[2]">{{ t[1] }}</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">
|
||||
@@ -373,6 +374,9 @@ onMounted(() => { loadAccounts(); loadModelList() })
|
||||
<button @click="setFilter(() => typeFilter = 'grok')" class="fp" :class="typeFilter === 'grok' && 'fp-on'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>Grok
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'creativefabrica')" class="fp" :class="typeFilter === 'creativefabrica' && 'fp-on'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span>Creative Fabrica
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -507,7 +511,7 @@ onMounted(() => { loadAccounts(); loadModelList() })
|
||||
<span class="text-white/20">/</span>
|
||||
<span :class="a.video_remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.video_remaining }}</span>
|
||||
</span>
|
||||
<span v-else-if="(a.type === 'openai' || a.type === 'adobe' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine') && a.remaining != null && a.remaining !== -1"
|
||||
<span v-else-if="(a.type === 'openai' || a.type === 'adobe' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine' || a.type === 'creativefabrica') && a.remaining != null && a.remaining !== -1"
|
||||
class="font-mono font-semibold"
|
||||
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}</span>
|
||||
<span v-else class="text-white/25" :title="a._quotaError || ''">—</span>
|
||||
|
||||
@@ -6,16 +6,26 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import { points } from '../credits'
|
||||
import { mediaCaps, presetMap } from '../videoCaps'
|
||||
|
||||
const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy)
|
||||
const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'YOUR_API_KEY')
|
||||
|
||||
const models = ref([])
|
||||
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
|
||||
onMounted(async () => {
|
||||
const r = await api('/managed-models')
|
||||
const [r, p] = await Promise.all([api('/managed-models'), api('/video-presets')])
|
||||
if (r.ok) models.value = (r.data?.data || []).filter((m) => m.enabled !== false)
|
||||
if (p.ok) presets.value = presetMap(p.data?.data)
|
||||
})
|
||||
|
||||
// 各类参考资产的上限(图片/视频/音频)由后端预设给出,不在这里写死。
|
||||
function caps(m) { return mediaCaps(m, presets.value[m.id]) }
|
||||
// 参考图张数:支持音视频参考的模型,max_reference_images 是各类资产合计,图片自己
|
||||
// 的上限来自预设 max_images。
|
||||
function refImages(m) { return caps(m)?.images || m.max_reference_images }
|
||||
|
||||
const imageModels = computed(() => models.value.filter((m) => m.type === 'image'))
|
||||
const videoModels = computed(() => models.value.filter((m) => m.type === 'video'))
|
||||
function pubName(m) {
|
||||
@@ -27,23 +37,29 @@ const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0]
|
||||
|
||||
function modeEmpty(m) {
|
||||
if (m.type === 'image') return !(m.resolutions || []).length && !m.image_to_image && !(m.ratios || []).length
|
||||
return !(m.resolutions || []).length && !(m.durations || []).length && !m.max_reference_images && !m.id?.startsWith('seedance') && !(m.ratios || []).length
|
||||
return !(m.resolutions || []).length && !(m.durations || []).length && !m.max_reference_images && !caps(m) && !(m.ratios || []).length
|
||||
}
|
||||
|
||||
function priceOf(m) {
|
||||
if (m.type === 'video') {
|
||||
// Video charge = resolution price + duration price; show the combined range.
|
||||
const rv = Object.values(m.prices || {}).filter((v) => v != null).map(Number)
|
||||
const dv = Object.values(m.duration_prices || {}).filter((v) => v != null).map(Number)
|
||||
if (!rv.length || !dv.length) return '—'
|
||||
const lo = Math.min(...rv) + Math.min(...dv)
|
||||
const hi = Math.max(...rv) + Math.max(...dv)
|
||||
return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分`
|
||||
// ---- 定价(同模型管理的分档展示):代理账号看代理价,某档没设代理价时回退普通价;
|
||||
// 普通价为空表示该档不支持,不展示。视频价 = 分辨率价 + 时长价(按秒计价的模型给 /s 价)。
|
||||
const isAgent = computed(() => auth.user?.role === 'agent')
|
||||
function tierPrice(normal, agent, key) {
|
||||
const n = (normal || {})[key]
|
||||
if (n == null) return null
|
||||
if (isAgent.value) {
|
||||
const a = (agent || {})[key]
|
||||
if (a != null) return Number(a)
|
||||
}
|
||||
const vals = Object.values(m.prices || {}).filter((v) => v != null).map(Number)
|
||||
if (!vals.length) return '—'
|
||||
const lo = Math.min(...vals), hi = Math.max(...vals)
|
||||
return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分`
|
||||
return Number(n)
|
||||
}
|
||||
function resPrice(m, r) { return tierPrice(m.prices, m.prices_agent, r) }
|
||||
function durPrice(m, d) { return tierPrice(m.duration_prices, m.duration_prices_agent, d) }
|
||||
function perSecondPrice(m) { return tierPrice(m.duration_prices, m.duration_prices_agent, 'per_second') }
|
||||
function priceEmpty(m) {
|
||||
if ((m.resolutions || []).some((r) => resPrice(m, r) != null)) return false
|
||||
if (m.type !== 'video') return true
|
||||
if (perSecondPrice(m) != null) return false
|
||||
return !(m.durations || []).some((d) => durPrice(m, d) != null)
|
||||
}
|
||||
|
||||
// ---- request parameter tables ----
|
||||
@@ -215,6 +231,14 @@ if s["status"] == "completed":
|
||||
`curl ${base.value}/v1/models \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"`,
|
||||
},
|
||||
{
|
||||
title: '查询余额 · curl',
|
||||
code:
|
||||
`curl ${base.value}/v1/user/balance \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"
|
||||
|
||||
# => {"object":"user.balance","balance":12000,"used":680,"total":12680}`,
|
||||
},
|
||||
])
|
||||
|
||||
// ---- copy + toast ----
|
||||
@@ -256,6 +280,7 @@ async function copy(text) {
|
||||
<h2 class="text-sm font-semibold text-white/80">端点</h2>
|
||||
<ul class="mt-4 space-y-2.5 text-sm font-mono">
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/user/balance</span><span class="text-white/35 font-sans text-xs">查余额</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/generations</span><span class="text-white/35 font-sans text-xs">文生图</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/edits</span><span class="text-white/35 font-sans text-xs">图生图(multipart)</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/videos</span><span class="text-white/35 font-sans text-xs">建视频任务</span></li>
|
||||
@@ -272,15 +297,30 @@ async function copy(text) {
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-3 font-medium">model</th>
|
||||
<th class="px-4 py-3 font-medium">类型</th>
|
||||
<th class="px-4 py-3 font-medium whitespace-nowrap">model</th>
|
||||
<th class="px-4 py-3 font-medium whitespace-nowrap">类型</th>
|
||||
<th class="px-4 py-3 font-medium whitespace-nowrap" title="视频价 = 分辨率价 + 时长价">定价 <span class="normal-case text-white/30">积分</span></th>
|
||||
<th class="px-4 py-3 font-medium">能力</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in models" :key="m.id" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-3 font-mono text-white/90">{{ pubName(m) }}</td>
|
||||
<td class="px-4 py-3 text-white/60">{{ m.type === 'video' ? '视频' : '图像' }}</td>
|
||||
<td class="px-4 py-3 font-mono text-white/90 whitespace-nowrap">{{ pubName(m) }}</td>
|
||||
<td class="px-4 py-3 text-white/60 whitespace-nowrap">{{ m.type === 'video' ? '视频' : '图像' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<template v-for="r in (m.resolutions || [])" :key="'pr'+r">
|
||||
<span v-if="resPrice(m, r) != null" class="cap cap-price">{{ r }}<b class="ml-1 font-semibold tabular-nums">{{ points(resPrice(m, r)) }}</b></span>
|
||||
</template>
|
||||
<template v-if="m.type === 'video'">
|
||||
<span v-if="perSecondPrice(m) != null" class="cap cap-price">/s<b class="ml-1 font-semibold tabular-nums">{{ points(perSecondPrice(m)) }}</b></span>
|
||||
<template v-else v-for="d in (m.durations || [])" :key="'pd'+d">
|
||||
<span v-if="durPrice(m, d) != null" class="cap cap-price">{{ d }}<b class="ml-1 font-semibold tabular-nums">+{{ points(durPrice(m, d)) }}</b></span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-if="priceEmpty(m)" class="text-white/30">—</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<template v-if="m.type === 'image'">
|
||||
@@ -292,17 +332,19 @@ async function copy(text) {
|
||||
<span v-else-if="(m.durations || []).length === 1" class="cap cap-dur">{{ m.durations[0] }}</span>
|
||||
</template>
|
||||
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cap cap-frame">首尾帧 {{ Math.min(m.max_reference_images, 2) }}</span>
|
||||
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap cap-ref">参考图 {{ m.max_reference_images }}</span>
|
||||
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap cap-ref">参考图 {{ m.max_reference_images }}</span>
|
||||
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap cap-ref">参考图 {{ refImages(m) }}</span>
|
||||
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap cap-ref">参考图 {{ refImages(m) }}</span>
|
||||
<span v-if="m.type === 'image' && m.image_to_image && (!m.reference_mode || m.reference_mode === 'none')" class="cap cap-ref">参考图 1</span>
|
||||
<span v-if="m.id?.startsWith('seedance')" class="cap cap-media">视频 3</span>
|
||||
<span v-if="m.id?.startsWith('seedance')" class="cap cap-media">音频 3</span>
|
||||
<template v-if="caps(m)">
|
||||
<span v-if="caps(m).videos" class="cap cap-media">视频 {{ caps(m).videos }}</span>
|
||||
<span v-if="caps(m).audios" class="cap cap-media">音频 {{ caps(m).audios }}</span>
|
||||
</template>
|
||||
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap cap-ratio">{{ r.replace(':', '×') }}</span>
|
||||
<span v-if="modeEmpty(m)" class="text-white/30">—</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!models.length"><td colspan="3" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
|
||||
<tr v-if="!models.length"><td colspan="4" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -490,12 +532,14 @@ html.dark .badge-err { background: rgb(244 63 94 / 0.15); color: rgb(253 164 175
|
||||
.cap-frame { background: rgb(236 72 153 / 0.12); color: rgb(159 18 57); box-shadow: inset 0 0 0 1px rgb(236 72 153 / 0.3); }
|
||||
.cap-ref { background: rgb(16 185 129 / 0.12); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.3); }
|
||||
.cap-media { background: rgb(245 158 11 / 0.12); color: rgb(146 64 14); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.3); }
|
||||
.cap-price { background: rgb(14 165 233 / 0.12); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.3); font-variant-numeric: tabular-nums; }
|
||||
.cap-ratio { background: rgb(100 116 139 / 0.1); color: rgb(51 65 85); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.25); }
|
||||
html.dark .cap-k { background: rgb(16 185 129 / 0.18); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.4); }
|
||||
html.dark .cap-dur { background: rgb(99 102 241 / 0.18); color: rgb(165 180 252); box-shadow: inset 0 0 0 1px rgb(129 140 248 / 0.4); }
|
||||
html.dark .cap-frame { background: rgb(236 72 153 / 0.18); color: rgb(244 114 182); box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.45); }
|
||||
html.dark .cap-ref { background: rgb(16 185 129 / 0.18); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.4); }
|
||||
html.dark .cap-media { background: rgb(245 158 11 / 0.18); color: rgb(252 211 77); box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45); }
|
||||
html.dark .cap-price { background: rgb(14 165 233 / 0.18); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.45); }
|
||||
html.dark .cap-ratio { background: rgb(255 255 255 / 0.06); color: rgb(255 255 255 / 0.65); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12); }
|
||||
|
||||
/* 苹果风代码卡片:自带深色配色,不随页面主题切换,深/浅背景下都可读 */
|
||||
|
||||
@@ -6,6 +6,7 @@ import ModelFormModal from '../components/ModelFormModal.vue'
|
||||
import CustomModelModal from '../components/CustomModelModal.vue'
|
||||
import TestModal from '../components/TestModal.vue'
|
||||
import { points } from '../credits'
|
||||
import { mediaCaps, presetMap } from '../videoCaps'
|
||||
|
||||
const models = ref([])
|
||||
const loading = ref(false)
|
||||
@@ -19,17 +20,24 @@ const statusFilter = ref('') // '' | 'enabled' | 'disabled'
|
||||
const search = ref('')
|
||||
|
||||
const TYPE_LABEL = { image: '生图', video: '生视频' }
|
||||
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
|
||||
|
||||
function caps(m) { return mediaCaps(m, presets.value[m.id]) }
|
||||
// 参考图张数:支持音视频参考的模型,max_reference_images 是各类资产合计,图片自己
|
||||
// 的上限来自预设 max_images。
|
||||
function refImages(m) { return caps(m)?.images || m.max_reference_images }
|
||||
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
|
||||
|
||||
function capEmpty(m) {
|
||||
if (m.type === 'image') return !(m.ratios || []).length && !m.image_to_image && (m.reference_mode === 'none' || !m.reference_mode)
|
||||
return !(m.durations || []).length && !(m.resolutions || []).length && !m.max_reference_images && !m.id?.startsWith('seedance') && !(m.ratios || []).length
|
||||
return !(m.durations || []).length && !(m.resolutions || []).length && !m.max_reference_images && !caps(m) && !(m.ratios || []).length
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
loading.value = true
|
||||
const r = await api('/managed-models')
|
||||
const [r, p] = await Promise.all([api('/managed-models'), api('/video-presets')])
|
||||
models.value = r.data?.data || []
|
||||
presets.value = presetMap(p.data?.data)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -239,12 +247,14 @@ onMounted(loadModels)
|
||||
<!-- Frame mode (首尾帧 2) -->
|
||||
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cap-chip cap-frame">首尾帧 {{ Math.min(m.max_reference_images, 2) }}</span>
|
||||
<!-- Reference image mode (参考图 9) -->
|
||||
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap-chip cap-ref">参考图 {{ m.max_reference_images }}</span>
|
||||
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap-chip cap-ref">参考图 {{ m.max_reference_images }}</span>
|
||||
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap-chip cap-ref">参考图 {{ refImages(m) }}</span>
|
||||
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap-chip cap-ref">参考图 {{ refImages(m) }}</span>
|
||||
<span v-else-if="m.type === 'image' && m.image_to_image" class="cap-chip cap-ref">参考图</span>
|
||||
<!-- Video/Audio for seedance -->
|
||||
<span v-if="m.id?.startsWith('seedance')" class="cap-chip cap-media">视频 3</span>
|
||||
<span v-if="m.id?.startsWith('seedance')" class="cap-chip cap-media">音频 3</span>
|
||||
<!-- 视频/音频参考上限,来自 /video-presets -->
|
||||
<template v-if="caps(m)">
|
||||
<span v-if="caps(m).videos" class="cap-chip cap-media">视频 {{ caps(m).videos }}</span>
|
||||
<span v-if="caps(m).audios" class="cap-chip cap-media">音频 {{ caps(m).audios }}</span>
|
||||
</template>
|
||||
<!-- Ratios -->
|
||||
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap-chip cap-mono">{{ r.replace(':', '×') }}</span>
|
||||
<span v-if="capEmpty(m)" class="text-white/30 text-xs">—</span>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user