From 6d17caaf806c8c6b4b476ca1867e1d2480446a31 Mon Sep 17 00:00:00 2001 From: chiyi Date: Sat, 8 Aug 2026 21:55:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(leonardo):=20=E6=8E=A5=E5=85=A5=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E9=80=9A=E9=81=93=E5=B9=B6=E6=96=B0=E5=A2=9E=20seedan?= =?UTF-8?q?ce-2.0/minimax-h3=20=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/http/handler/user_generation.go | 109 ++++- backend/internal/provider/adobe/client.go | 24 +- backend/internal/provider/leonardo/client.go | 183 +++++++- backend/internal/provider/leonardo/image.go | 63 ++- backend/internal/provider/leonardo/video.go | 407 ++++++++++++++++++ backend/internal/repo/token_repo.go | 117 +++-- backend/internal/service/tokens.go | 19 + backend/internal/service/v1.go | 254 ++++++++++- frontend/src/components/ImportModal.vue | 2 +- frontend/src/components/ModelFormModal.vue | 3 +- frontend/src/views/PlaygroundView.vue | 53 ++- 11 files changed, 1128 insertions(+), 106 deletions(-) create mode 100644 backend/internal/provider/leonardo/video.go diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index e6bbb4c..9d85675 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -460,6 +460,49 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) { "max_reference_images": 9, "reference_mode": "style", }, + { + "key": "seedance-2.0-不卡人脸", + "label": "Seedance 2.0 (Leonardo 私有)", + "type": "video", + "provider": "leonardo", + "durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"}, + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p"}, + "max_reference_images": 8, + "max_images": 4, + "max_videos": 3, + "max_audios": 1, + "reference_mode": "style", + }, + { + "key": "seedance-2.0-fast-不卡人脸", + "label": "Seedance 2.0 Fast (Leonardo 私有)", + "type": "video", + "provider": "leonardo", + "durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"}, + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p"}, + "max_reference_images": 8, + "max_images": 4, + "max_videos": 3, + "max_audios": 1, + "reference_mode": "style", + }, + { + "key": "minimax-h3", + "label": "MiniMax H3 (Leonardo 私有)", + "type": "video", + "provider": "leonardo", + "durations": []string{"5s", "10s", "15s"}, + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"1440p"}, + "max_reference_images": 8, + "max_images": 5, + "max_videos": 0, + "max_audios": 3, + "max_audio_seconds": 15, + "reference_mode": "style", + }, { "key": "firefly-video", "label": "Firefly Video", @@ -670,7 +713,9 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) "type": "video", "ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"}, "resolutions": []string{"720p"}, - "durations": []string{"6s", "10s", "15s"}, + // Console 视频吃 1–15 秒的整数时长,所以按秒计价(同 seedance); + // 参考图只有 1 张首帧(上游只有 image 字段,没有尾帧)。 + "durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"}, "max_reference_images": 1, "reference_mode": "frame", "description": "Grok Imagine video (文/图生视频)", @@ -696,6 +741,41 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) "max_reference_images": 6, "description": "Leonardo Seedream 4.5 (生图 / 图生图)", }, + { + "id": "seedance-2.0-不卡人脸", + "provider": "leonardo", + "type": "video", + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p"}, + "durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"}, + // 参考资产总上限 8 = 4 图 + 1 音频 + 3 视频(分类上限在服务端校验)。 + "max_reference_images": 8, + "reference_mode": "style", + "description": "Leonardo Seedance 2.0 (私有生成 / 图音视频参考)", + }, + { + "id": "seedance-2.0-fast-不卡人脸", + "provider": "leonardo", + "type": "video", + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p"}, + "durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"}, + "max_reference_images": 8, + "reference_mode": "style", + "description": "Leonardo Seedance 2.0 Fast (私有生成 / 图音视频参考)", + }, + { + "id": "minimax-h3", + "provider": "leonardo", + "type": "video", + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"1440p"}, + "durations": []string{"5s", "10s", "15s"}, + // 参考资产总上限 8 = 5 图 + 3 音频(分类上限在服务端校验)。 + "max_reference_images": 8, + "reference_mode": "style", + "description": "Leonardo MiniMax H3 / hailuo-03 (1440p / 图音参考)", + }, { "id": "flux-klein-2", "provider": "krea", @@ -898,6 +978,33 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) { "description": "Leonardo Seedream 4.5", "stub": false, }, + { + "id": "seedance-2.0-不卡人脸", + "provider": "leonardo", + "kind": "video", + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p"}, + "description": "Leonardo Seedance 2.0 (私有生成)", + "stub": false, + }, + { + "id": "seedance-2.0-fast-不卡人脸", + "provider": "leonardo", + "kind": "video", + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p"}, + "description": "Leonardo Seedance 2.0 Fast (私有生成)", + "stub": false, + }, + { + "id": "minimax-h3", + "provider": "leonardo", + "kind": "video", + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"1440p"}, + "description": "Leonardo MiniMax H3 (1440p)", + "stub": false, + }, { "id": "flux-klein-2", "provider": "krea", diff --git a/backend/internal/provider/adobe/client.go b/backend/internal/provider/adobe/client.go index 2e9230c..e9f6e75 100644 --- a/backend/internal/provider/adobe/client.go +++ b/backend/internal/provider/adobe/client.go @@ -52,8 +52,15 @@ var ( // applies, but callers can single it out — unlike an expired access token this // cannot be fixed by refreshing from the cookie, so the account is done. ErrNotEntitled = fmt.Errorf("%w: user not entitled", ErrAuth) + // errTransport marks a request that never got a response back (EOF / 连接被切 / + // 超时)。上游没收到就没开始生成,所以原地换条连接重试是安全的。 + errTransport = errors.New("transport failure") ) +// videoSubmitMaxRetries is how many extra in-place attempts a video submit gets +// when the connection dies before any response arrives (EOF/reset/timeout). +const videoSubmitMaxRetries = 3 + // isContentRejection reports whether an Adobe response (status + body) is a // content-safety refusal rather than a genuine upstream/account failure. Adobe // returns HTTP 451 with an "*_unsafe" error_code when moderation blocks the @@ -314,7 +321,22 @@ func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspec if engine == "firefly-video" { endpoint = fireflyVideoSubmitURL } + // 连接在拿到响应前就断掉(EOF / reset / 超时)说明上游根本没收到这单, + // 换一条新连接原地重试,最多 videoSubmitMaxRetries 次。 respBody, pollURL, err := c.submitVideo(ctx, submitSess, token, endpoint, payload) + for attempt := 0; err != nil && errors.Is(err, errTransport) && attempt < videoSubmitMaxRetries && ctx.Err() == nil; attempt++ { + select { + case <-ctx.Done(): + return nil, nil, ctx.Err() + case <-time.After(time.Duration(attempt+1) * 2 * time.Second): + } + retrySess, sessErr := c.newTLSClient() + if sessErr != nil { + break + } + submitSess = retrySess + respBody, pollURL, err = c.submitVideo(ctx, submitSess, token, endpoint, payload) + } if err != nil { return nil, nil, err } @@ -743,7 +765,7 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo resp, err := sess.client.Do(req) if err != nil { - return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + return nil, "", fmt.Errorf("%w: %w: %v", ErrTemporaryUpstream, errTransport, err) } defer resp.Body.Close() diff --git a/backend/internal/provider/leonardo/client.go b/backend/internal/provider/leonardo/client.go index 727297c..ec2a0b4 100644 --- a/backend/internal/provider/leonardo/client.go +++ b/backend/internal/provider/leonardo/client.go @@ -28,7 +28,7 @@ import ( const ( appBase = "https://app.leonardo.ai" graphqlURL = "https://api.leonardo.ai/v1/graphql" - schemaVersion = "1.187.0" + schemaVersion = "1.255.2" userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" ) @@ -45,10 +45,15 @@ type Client struct { // (429) hard, so re-using the ~1h JWT is essential. mu sync.Mutex sessions map[string]*Session + // rotated maps a stored cookie to the freshest value Leonardo handed back via + // Set-Cookie (better-auth rotates its session_data cookie cache). The service + // persists it; keeping it here means an unpersisted rotation still works for + // the rest of the process's life. + rotated map[string]string } func NewClient(proxy string) *Client { - return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}} + return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}, rotated: map[string]string{}} } func (c *Client) SetProxy(proxy string) { @@ -63,6 +68,75 @@ func IsLeonardoCookie(value string) bool { strings.Contains(value, "better-auth.session_data") } +// HasSessionData reports whether the cookie carries better-auth's session_data +// cache. Leonardo authenticates get-session off THAT cookie: session_token alone +// answers 200 null (no bearer), which looks exactly like a dead account — so a +// cookie without it must be rejected at import instead of dying later. +func HasSessionData(value string) bool { + return strings.Contains(value, "better-auth.session_data") +} + +// RotatedCookie returns the freshest value for a stored cookie when Leonardo +// rotated its session_data cache, so the caller can persist it. +func (c *Client) RotatedCookie(cookie string) (string, bool) { + key := strings.TrimSpace(cookie) + c.mu.Lock() + defer c.mu.Unlock() + fresh, ok := c.rotated[key] + return fresh, ok && fresh != key +} + +// mergeCookies applies a response's Set-Cookie pairs onto a request cookie +// string, keeping the original order and appending new names. +func mergeCookies(cookie string, setCookies []string) string { + if len(setCookies) == 0 { + return cookie + } + updates := map[string]string{} + order := []string{} + for _, sc := range setCookies { + pair := strings.TrimSpace(strings.Split(sc, ";")[0]) + name, value, ok := strings.Cut(pair, "=") + name = strings.TrimSpace(name) + if !ok || name == "" { + continue + } + if _, seen := updates[name]; !seen { + order = append(order, name) + } + updates[name] = value + } + if len(updates) == 0 { + return cookie + } + var out []string + used := map[string]bool{} + for _, part := range strings.Split(cookie, ";") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + name, _, _ := strings.Cut(part, "=") + name = strings.TrimSpace(name) + if v, ok := updates[name]; ok { + used[name] = true + if v == "" { // a cleared cookie drops out + continue + } + out = append(out, name+"="+v) + continue + } + out = append(out, part) + } + for _, name := range order { + if used[name] || updates[name] == "" { + continue + } + out = append(out, name+"="+updates[name]) + } + return strings.Join(out, "; ") +} + // Session is the result of /api/auth/get-session: the short-lived bearer plus the // ids the GraphQL API needs (cognitoSub for the quota query, userId for the feed // and the CDN image path) and the human-facing account fields. @@ -73,10 +147,14 @@ type Session struct { Email string Name string ExpiresAt int64 + // Cookie is the cookie that produced this session, with any Set-Cookie + // rotation applied — persist it so the account keeps authenticating. + Cookie string } -// GetSession exchanges the cookie for a fresh access token + account ids. A 401/403 -// (or a response with no access token) means the cookie/session is dead → ErrAuth. +// GetSession exchanges the cookie for a fresh access token + account ids. Only a +// 401 or a 200 carrying no access token means the session is dead → ErrAuth; +// everything else (notably the 403/429 人机校验 page) is a temporary error. func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error) { cookie = strings.TrimSpace(cookie) if cookie == "" { @@ -91,6 +169,15 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error } 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 + c.mu.Lock() + if fresh, ok := c.rotated[cookie]; ok && fresh != "" { + send = fresh + } + c.mu.Unlock() + client, err := c.newDirectTLSClient() if err != nil { return nil, err @@ -103,7 +190,7 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error req.Header = http.Header{ "accept": {"*/*"}, "accept-language": {"en-US,en;q=0.9"}, - "cookie": {cookie}, + "cookie": {send}, "origin": {appBase}, "referer": {appBase + "/"}, "user-agent": {userAgent}, @@ -121,10 +208,18 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - if resp.StatusCode == 401 || resp.StatusCode == 403 { + if merged := mergeCookies(send, resp.Header["Set-Cookie"]); merged != send { + c.mu.Lock() + c.rotated[cookie] = merged + c.mu.Unlock() + send = merged + } + if resp.StatusCode == 401 { return nil, ErrAuth } if resp.StatusCode != 200 { + // 403 / 429 here is the Vercel / Cloudflare 人机校验 页,不是 cookie 失效 — + // 当成临时错误,否则健康的号会被误判死。 return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, resp.StatusCode, clip(body, 160)) } var raw struct { @@ -162,6 +257,7 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error Email: strings.TrimSpace(raw.User.Email), Name: strings.TrimSpace(raw.User.Name), ExpiresAt: raw.Session.TokenExpiry, + Cookie: send, } if sess.ExpiresAt > time.Now().Unix() { c.mu.Lock() @@ -171,6 +267,59 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error return sess, nil } +// session returns the cookie's access token, optionally forcing a fresh mint +// (dropping the cache) — used when the upstream rejected the current bearer. +func (c *Client) session(ctx context.Context, cookie string, force bool) (*Session, error) { + if force { + c.mu.Lock() + delete(c.sessions, strings.TrimSpace(cookie)) + c.mu.Unlock() + } + return c.GetSession(ctx, cookie) +} + +// callGraphQL runs one GraphQL call for an account cookie. The bearer only lives +// ~1h, so a rejected token (401/403 or a JWTExpired GraphQL error) is re-minted +// from the cookie and the call retried once. Only a cookie that itself stops +// authenticating yields ErrAuth — an upstream bearer rejection stays temporary so +// the account is never killed for it. +func (c *Client) callGraphQL(ctx context.Context, cookie string, payload []byte, useProxy bool, label string) ([]byte, error) { + var lastStatus int + var lastBody []byte + for attempt := 0; attempt < 2; attempt++ { + sess, err := c.session(ctx, cookie, attempt > 0) + if err != nil { + return nil, err + } + body, status, err := c.graphqlP(ctx, sess.AccessToken, payload, useProxy) + if err != nil { + return nil, fmt.Errorf("%w: %s: %s", ErrTemporaryUpstream, label, err.Error()) + } + lastStatus, lastBody = status, body + stale := status == 401 || status == 403 + var gqlErr error + if !stale && status == 200 { + gqlErr = graphqlError(body) + stale = errors.Is(gqlErr, ErrAuth) + } + if stale { + if attempt == 0 { + continue + } + break + } + if status != 200 { + return nil, fmt.Errorf("%w: %s http %d: %s", ErrTemporaryUpstream, label, status, clip(body, 160)) + } + if gqlErr != nil { + return nil, gqlErr + } + return body, nil + } + return nil, fmt.Errorf("%w: %s rejected a freshly minted bearer (http %d): %s", + ErrTemporaryUpstream, label, lastStatus, clip(lastBody, 160)) +} + const qGetTokens = `query GetUserTokensFromSub($sub: String) { user_details(where: {cognitoId: {_eq: $sub}}) { id @@ -205,15 +354,12 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[st "variables": map[string]any{"sub": sess.CognitoSub}, "query": qGetTokens, }) - body, status, err := c.graphqlP(ctx, sess.AccessToken, payload, false) + body, err := c.callGraphQL(ctx, cookie, payload, false, "credits") if err != nil { - return unknownBalance("network: " + err.Error()), nil - } - if status == 401 || status == 403 { - return nil, ErrAuth - } - if status != 200 { - return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil + if errors.Is(err, ErrAuth) { + return nil, ErrAuth + } + return unknownBalance(err.Error()), nil } var result struct { Data struct { @@ -248,13 +394,8 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[st }, nil } -// graphql runs a GraphQL call through the proxy. graphqlP lets callers pick the -// egress: only the generate submit uses the proxy; reference-image upload and -// polling run direct (local IP). -func (c *Client) graphql(ctx context.Context, accessToken string, payload []byte) ([]byte, int, error) { - return c.graphqlP(ctx, accessToken, payload, true) -} - +// graphqlP runs a GraphQL call; callers pick the egress: only the generate submit +// uses the proxy; reference-image upload and polling run direct (local IP). func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byte, useProxy bool) ([]byte, int, error) { client, err := c.newTLSClientP(useProxy) if err != nil { diff --git a/backend/internal/provider/leonardo/image.go b/backend/internal/provider/leonardo/image.go index 2aa8287..02dad04 100644 --- a/backend/internal/provider/leonardo/image.go +++ b/backend/internal/provider/leonardo/image.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "mime/multipart" "strings" @@ -51,24 +52,25 @@ 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, accessToken string, img []byte) (string, error) { +func (c *Client) uploadInitImage(ctx context.Context, cookie string, img []byte) (string, error) { + return c.uploadAsset(ctx, cookie, "png", img) +} + +// uploadAsset uploads one reference asset (extension png / mp3 / mp4 …) through +// the same UploadImage presigned-S3 flow images use, and returns its upload id. +func (c *Client) uploadAsset(ctx context.Context, cookie, extension string, asset []byte) (string, error) { + extension = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".") + if extension == "" { + extension = "png" + } payload, _ := json.Marshal(map[string]any{ "operationName": "UploadImage", "query": mUploadImage, - "variables": map[string]any{"uploadImageInput": map[string]any{"uploadType": "INIT", "extension": "png"}}, + "variables": map[string]any{"uploadImageInput": map[string]any{"uploadType": "INIT", "extension": extension}}, }) - body, status, err := c.graphqlP(ctx, accessToken, payload, false) + body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init") if err != nil { - return "", fmt.Errorf("%w: upload-init: %s", ErrTemporaryUpstream, err.Error()) - } - if status == 401 || status == 403 { - return "", ErrAuth - } - if status != 200 { - return "", fmt.Errorf("%w: upload-init http %d: %s", ErrTemporaryUpstream, status, clip(body, 160)) - } - if e := graphqlError(body); e != nil { - return "", e + return "", err } var ur struct { Data struct { @@ -97,11 +99,11 @@ func (c *Client) uploadInitImage(ctx context.Context, accessToken string, img [] for k, v := range fields { _ = w.WriteField(k, v) } - fw, err := w.CreateFormFile("file", "image.png") + fw, err := w.CreateFormFile("file", "asset."+extension) if err != nil { return "", err } - if _, err := fw.Write(img); err != nil { + if _, err := fw.Write(asset); err != nil { return "", err } _ = w.Close() @@ -154,7 +156,7 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string if len(img) == 0 { continue } - uploadID, upErr := c.uploadInitImage(ctx, sess.AccessToken, img) + uploadID, upErr := c.uploadInitImage(ctx, cookie, img) if upErr != nil { return nil, nil, upErr } @@ -192,18 +194,9 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string }, } payload, _ := json.Marshal(genReq) - body, status, err := c.graphql(ctx, sess.AccessToken, payload) + body, err := c.callGraphQL(ctx, cookie, payload, true, "generate") if err != nil { - return nil, nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error()) - } - if status == 401 || status == 403 { - return nil, nil, ErrAuth - } - if status != 200 { - return nil, nil, fmt.Errorf("%w: generate http %d: %s", ErrTemporaryUpstream, status, clip(body, 200)) - } - if e := graphqlError(body); e != nil { - return nil, nil, e + return nil, nil, err } var genResp struct { Data struct { @@ -221,7 +214,7 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string } // 2. poll until COMPLETE, then read the image url. - imageURL, err := c.pollImage(ctx, sess.AccessToken, genID) + imageURL, err := c.pollImage(ctx, cookie, genID) if err != nil { return nil, nil, err } @@ -244,7 +237,7 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string // pollImage polls one generation until it reports COMPLETE (returning the first // image url) or FAILED (error). Honors ctx cancellation / deadline. -func (c *Client) pollImage(ctx context.Context, accessToken, genID string) (string, error) { +func (c *Client) pollImage(ctx context.Context, cookie, genID string) (string, error) { payload, _ := json.Marshal(map[string]any{ "operationName": "GenerationImages", "query": qGenerationImages, @@ -264,14 +257,12 @@ func (c *Client) pollImage(ctx context.Context, accessToken, genID string) (stri } for { - body, status, err := c.graphqlP(ctx, accessToken, payload, false) - if err != nil { - return "", fmt.Errorf("%w: poll: %s", ErrTemporaryUpstream, err.Error()) + body, err := c.callGraphQL(ctx, cookie, payload, false, "poll") + if errors.Is(err, ErrAuth) { + return "", err } - if status == 401 || status == 403 { - return "", ErrAuth - } - if status == 200 { + // 其它错误(含上游临时抖动)不中断轮询,等 deadline 再判超时。 + if err == nil { var pr struct { Data struct { Generations []struct { diff --git a/backend/internal/provider/leonardo/video.go b/backend/internal/provider/leonardo/video.go new file mode 100644 index 0000000..81b098c --- /dev/null +++ b/backend/internal/provider/leonardo/video.go @@ -0,0 +1,407 @@ +package leonardo + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// qGenerationVideos polls one video generation's status AND its produced clip in +// a single round-trip (motionMP4URL carries the mp4). +const qGenerationVideos = `query GenerationVideos($where: generations_bool_exp = {}) { + generations(where: $where) { + id + status + generated_images { + id + motionMP4URL + __typename + } + __typename + } +}` + +// VideoAssets are the decoded reference assets a video request can carry: +// image references (strength MID), one audio track and video references. The +// caller enforces the per-type caps; durations are derived from the bytes here +// because Leonardo requires them for audio/video guidances. +type VideoAssets struct { + Images [][]byte + Audios [][]byte + Videos [][]byte +} + +// GenerateVideo runs the Leonardo video pipeline (seedance-2.0 / -fast, hailuo) against +// one account cookie: upload every reference asset, submit the Generate mutation +// as a PRIVATE generation, poll until COMPLETE, then optionally download the mp4. +func (c *Client) GenerateVideo(ctx context.Context, cookie, model, prompt string, width, height, durationSeconds int, refs VideoAssets, downloadResult bool) ([]byte, map[string]any, error) { + sess, err := c.GetSession(ctx, cookie) + if err != nil { + return nil, nil, err + } + if strings.TrimSpace(model) == "" { + model = "seedance-2.0-fast" + } + + guidances := map[string]any{} + var imageRefs []map[string]any + for _, img := range refs.Images { + if len(img) == 0 { + continue + } + uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(img, "png"), img) + if upErr != nil { + return nil, nil, upErr + } + imageRefs = append(imageRefs, map[string]any{ + "image": map[string]any{"id": uploadID, "type": "UPLOADED"}, + "strength": "MID", + }) + } + 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 { + continue + } + uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(vid, "mp4"), vid) + if upErr != nil { + return nil, nil, upErr + } + video := map[string]any{"id": uploadID, "type": "UPLOADED"} + if secs := MediaDurationSeconds(vid); secs > 0 { + video["duration"] = secs + } + videoRefs = append(videoRefs, map[string]any{"video": video}) + } + if len(videoRefs) > 0 { + guidances["video_reference_base"] = videoRefs + } + + parameters := map[string]any{ + "height": height, + "width": width, + "duration": durationSeconds, + "motion_has_audio": true, + "quantity": 1, + "prompt": prompt, + "guidances": guidances, + } + // seedance 走随机种子;hailuo 的生成页 seedEnabled=false,请求里不带 seed。 + if !strings.HasPrefix(model, "hailuo") { + parameters["seed"] = -1 + } + genReq := map[string]any{ + "operationName": "Generate", + "query": mGenerate, + "variables": map[string]any{ + "request": map[string]any{ + "model": model, + // 私有生成:不进公开 feed。 + "public": false, + "parameters": parameters, + }, + }, + } + payload, _ := json.Marshal(genReq) + body, err := c.callGraphQL(ctx, cookie, payload, true, "generate-video") + if err != nil { + return nil, nil, err + } + var genResp struct { + Data struct { + Generate struct { + GenerationID string `json:"generationId"` + } `json:"generate"` + } `json:"data"` + } + if err := json.Unmarshal(body, &genResp); err != nil { + return nil, nil, fmt.Errorf("%w: generate non-json", ErrTemporaryUpstream) + } + genID := strings.TrimSpace(genResp.Data.Generate.GenerationID) + if genID == "" { + return nil, nil, fmt.Errorf("%w: no generationId: %s", ErrTemporaryUpstream, clip(body, 200)) + } + + videoURL, err := c.pollVideo(ctx, cookie, genID) + if err != nil { + return nil, nil, err + } + info := map[string]any{ + "generation_id": genID, + "video_url": videoURL, + "user_id": sess.UserID, + } + if !downloadResult { + return nil, info, nil + } + data, err := c.downloadImage(ctx, videoURL) + if err != nil { + return nil, nil, err + } + return data, info, nil +} + +// pollVideo polls one generation until COMPLETE (returning the mp4 url) or +// FAILED. Temporary upstream hiccups don't abort the wait — only ctx/deadline do. +func (c *Client) pollVideo(ctx context.Context, cookie, genID string) (string, error) { + payload, _ := json.Marshal(map[string]any{ + "operationName": "GenerationVideos", + "query": qGenerationVideos, + "variables": map[string]any{ + "where": map[string]any{"id": map[string]any{"_in": []string{genID}}}, + }, + }) + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + deadline := time.Now().Add(10 * time.Minute) + if dl, ok := ctx.Deadline(); ok { + deadline = dl.Add(-60 * time.Second) + } + + for { + body, err := c.callGraphQL(ctx, cookie, payload, false, "poll-video") + if errors.Is(err, ErrAuth) { + return "", err + } + if err == nil { + var pr struct { + Data struct { + Generations []struct { + Status string `json:"status"` + GeneratedImages []struct { + MotionMP4URL string `json:"motionMP4URL"` + } `json:"generated_images"` + } `json:"generations"` + } `json:"data"` + } + if err := json.Unmarshal(body, &pr); err == nil && len(pr.Data.Generations) > 0 { + g := pr.Data.Generations[0] + switch strings.ToUpper(g.Status) { + case "COMPLETE": + for _, img := range g.GeneratedImages { + if u := strings.TrimSpace(img.MotionMP4URL); u != "" { + return u, nil + } + } + return "", fmt.Errorf("%w: complete but no video url", ErrTemporaryUpstream) + case "FAILED": + return "", fmt.Errorf("%w: generation failed", ErrTemporaryUpstream) + } + } + } + + if time.Now().After(deadline) { + return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream) + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-ticker.C: + } + } +} + +// assetExtension sniffs the upload extension Leonardo expects for a reference +// asset; fallback is used when the bytes aren't recognized. +func assetExtension(data []byte, fallback string) string { + n := len(data) + switch { + case n >= 8 && string(data[1:4]) == "PNG": + return "png" + case n >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF: + return "jpg" + case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP": + return "webp" + case n >= 12 && string(data[4:8]) == "ftyp": + if n >= 12 && strings.HasPrefix(string(data[8:12]), "qt") { + return "mov" + } + if n >= 12 && strings.HasPrefix(string(data[8:12]), "M4A") { + return "m4a" + } + return "mp4" + case n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3: + return "webm" + case n >= 3 && string(data[0:3]) == "ID3": + return "mp3" + case n >= 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0: + return "mp3" + case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WAVE": + return "wav" + } + return fallback +} + +// MediaDurationSeconds returns a media asset's duration in seconds (0 when it +// can't be determined). Leonardo's audio_reference / video_reference_base +// guidances carry the duration, and the caller validates video lengths with it, +// so it's derived from the uploaded bytes: mp4/mov via the mvhd box, wav via the +// fmt byte rate, mp3 from the first frame's bitrate. +func MediaDurationSeconds(data []byte) float64 { + if secs := mp4Duration(data); secs > 0 { + return secs + } + if secs := wavDuration(data); secs > 0 { + return secs + } + return mp3Duration(data) +} + +// mp4Duration walks the ISOBMFF box tree to moov/mvhd and reads timescale+duration. +func mp4Duration(data []byte) float64 { + if len(data) < 12 || string(data[4:8]) != "ftyp" { + return 0 + } + moov := findBox(data, "moov") + if moov == nil { + return 0 + } + mvhd := findBox(moov, "mvhd") + if len(mvhd) < 20 { + return 0 + } + version := mvhd[0] + if version == 1 { + if len(mvhd) < 32 { + return 0 + } + timescale := binary.BigEndian.Uint32(mvhd[20:24]) + duration := binary.BigEndian.Uint64(mvhd[24:32]) + if timescale == 0 { + return 0 + } + return float64(duration) / float64(timescale) + } + timescale := binary.BigEndian.Uint32(mvhd[12:16]) + duration := binary.BigEndian.Uint32(mvhd[16:20]) + if timescale == 0 { + return 0 + } + return float64(duration) / float64(timescale) +} + +// findBox returns the payload of the first box named typ among data's boxes +// (callers descend one level at a time by passing a parent's payload back in). +func findBox(data []byte, typ string) []byte { + for off := 0; off+8 <= len(data); { + size := int(binary.BigEndian.Uint32(data[off : off+4])) + name := string(data[off+4 : off+8]) + header := 8 + if size == 1 { // 64-bit size + if off+16 > len(data) { + return nil + } + size = int(binary.BigEndian.Uint64(data[off+8 : off+16])) + header = 16 + } else if size == 0 { + size = len(data) - off + } + if size < header || off+size > len(data) { + return nil + } + if name == typ { + return data[off+header : off+size] + } + off += size + } + return nil +} + +// wavDuration reads the RIFF fmt chunk's byte rate and the data chunk size. +func wavDuration(data []byte) float64 { + if len(data) < 44 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" { + return 0 + } + byteRate := 0 + dataSize := 0 + for off := 12; off+8 <= len(data); { + name := string(data[off : off+4]) + size := int(binary.LittleEndian.Uint32(data[off+4 : off+8])) + body := off + 8 + if size < 0 || body > len(data) { + break + } + switch name { + case "fmt ": + if body+16 <= len(data) { + byteRate = int(binary.LittleEndian.Uint32(data[body+8 : body+12])) + } + case "data": + dataSize = size + if body+size > len(data) { + dataSize = len(data) - body + } + } + off = body + size + size%2 + } + if byteRate <= 0 || dataSize <= 0 { + return 0 + } + return float64(dataSize) / float64(byteRate) +} + +// mp3Bitrates are the Layer III bitrate tables (kbps) indexed by the frame +// header's bitrate index: 1 = MPEG1, 2 = MPEG2/2.5. +var mp3Bitrates = map[int][]int{ + 1: {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}, + 2: {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}, +} + +// mp3Duration estimates the duration from the first frame header's bitrate +// (constant-bitrate assumption — good enough for the guidance duration field). +func mp3Duration(data []byte) float64 { + off := 0 + if len(data) >= 10 && string(data[0:3]) == "ID3" { + // syncsafe int: 7 bits per byte + tagSize := int(data[6]&0x7F)<<21 | int(data[7]&0x7F)<<14 | int(data[8]&0x7F)<<7 | int(data[9]&0x7F) + off = 10 + tagSize + } + for ; off+4 <= len(data); off++ { + if data[off] != 0xFF || data[off+1]&0xE0 != 0xE0 { + continue + } + versionBits := (data[off+1] >> 3) & 0x03 + layerBits := (data[off+1] >> 1) & 0x03 + if layerBits != 0x01 { // Layer III only + continue + } + table := 1 + if versionBits != 0x03 { // MPEG2 / 2.5 + table = 2 + } + idx := int((data[off+2] >> 4) & 0x0F) + rates := mp3Bitrates[table] + if idx <= 0 || idx >= len(rates) || rates[idx] == 0 { + continue + } + kbps := rates[idx] + return float64(len(data)-off) * 8 / float64(kbps*1000) + } + return 0 +} diff --git a/backend/internal/repo/token_repo.go b/backend/internal/repo/token_repo.go index 963d5d5..c17bceb 100644 --- a/backend/internal/repo/token_repo.go +++ b/backend/internal/repo/token_repo.go @@ -125,7 +125,7 @@ func (r *TokenRepository) ReserveQuota(ctx context.Context, pool, id string, amo } // Grok accounts carry a forced local quota instead of an upstream balance: -// Console 没有额度接口,所以导入时写死 图 5 / 视频 2,用一次扣一次,两个都归零直接判死。 +// Console 没有额度接口,所以导入时写死 图 5 / 视频 2,下单预扣、失败退回,两个都归零直接判死。 const ( GrokImageQuotaKey = "grok_image_remaining" GrokVideoQuotaKey = "grok_video_remaining" @@ -133,46 +133,113 @@ const ( GrokVideoQuota = 2 ) -// ConsumeGrokQuota deducts one unit from a grok account's local per-kind quota -// under a row lock. Zeroed kinds are flagged (image_limited / video_limited) so -// scheduling skips them; once both are zero the account is dead (no reset time — -// 用完就废). -func (r *TokenRepository) ConsumeGrokQuota(ctx context.Context, id, kind string) error { +// grokQuotas reads an account's local per-kind counters, falling back to the +// forced defaults for accounts imported before the counters existed. +func grokQuotas(meta datatypes.JSONMap) (images, videos int) { + images, known := metaInt(meta, GrokImageQuotaKey) + if !known { + images = GrokImageQuota + } + videos, known = metaInt(meta, GrokVideoQuotaKey) + if !known { + videos = GrokVideoQuota + } + return images, videos +} + +// ReserveGrokQuota pre-deducts one unit of a grok account's local per-kind quota +// under a row lock, so concurrent orders on the same near-empty account can't +// over-commit it(下单即扣,不会超扣)。allowed=false 表示这一份已经用光,调用方换号。 +// 归零的那一份打上 image_limited / video_limited 让调度跳过;判死留给 +// FinalizeGrokQuota(生成真的成功了才锁号),失败时用 RefundGrokQuota 退回。 +func (r *TokenRepository) ReserveGrokQuota(ctx context.Context, id, kind string) (allowed bool, err error) { + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item model.TokenAccount + if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil { + return e + } + images, videos := grokQuotas(item.Meta) + if kind == "video" { + if videos <= 0 { + return nil + } + videos-- + } else { + if images <= 0 { + return nil + } + images-- + } + meta := cloneMeta(item.Meta) + meta[GrokImageQuotaKey] = images + meta[GrokVideoQuotaKey] = videos + if e := tx.Model(&model.TokenAccount{}). + Where("pool = ? AND id = ?", "grok", id). + Updates(map[string]any{ + "meta": meta, + "image_limited": images <= 0, + "video_limited": videos <= 0, + "updated_at": time.Now(), + }).Error; e != nil { + return e + } + allowed = true + return nil + }) + return allowed, err +} + +// RefundGrokQuota gives back a unit reserved by ReserveGrokQuota when the render +// failed, clearing that kind's limited flag again. Capped at the forced default so +// repeated refunds can't inflate the quota. +func (r *TokenRepository) RefundGrokQuota(ctx context.Context, id, kind string) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var item model.TokenAccount if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}). First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil { return e } - images, known := metaInt(item.Meta, GrokImageQuotaKey) - if !known { - images = GrokImageQuota - } - videos, known := metaInt(item.Meta, GrokVideoQuotaKey) - if !known { - videos = GrokVideoQuota - } + images, videos := grokQuotas(item.Meta) if kind == "video" { - videos = max(0, videos-1) + videos = min(videos+1, GrokVideoQuota) } else { - images = max(0, images-1) + images = min(images+1, GrokImageQuota) } meta := cloneMeta(item.Meta) meta[GrokImageQuotaKey] = images meta[GrokVideoQuotaKey] = videos - patch := map[string]any{ - "meta": meta, - "image_limited": images <= 0, - "video_limited": videos <= 0, - "updated_at": time.Now(), + return tx.Model(&model.TokenAccount{}). + Where("pool = ? AND id = ?", "grok", id). + Updates(map[string]any{ + "meta": meta, + "image_limited": images <= 0, + "video_limited": videos <= 0, + "updated_at": time.Now(), + }).Error + }) +} + +// FinalizeGrokQuota locks an account down once a successful generation left both +// local counters at zero (no reset time — 用完就废). +func (r *TokenRepository) FinalizeGrokQuota(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item model.TokenAccount + if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil { + return e } - if images <= 0 && videos <= 0 { - patch["status"] = "disabled" - patch["dead"] = true + images, videos := grokQuotas(item.Meta) + if images > 0 || videos > 0 { + return nil } return tx.Model(&model.TokenAccount{}). Where("pool = ? AND id = ?", "grok", id). - Updates(patch).Error + Updates(map[string]any{ + "status": "disabled", + "dead": true, + "updated_at": time.Now(), + }).Error }) } diff --git a/backend/internal/service/tokens.go b/backend/internal/service/tokens.go index 4b81640..65e4bd8 100644 --- a/backend/internal/service/tokens.go +++ b/backend/internal/service/tokens.go @@ -341,6 +341,11 @@ func (s *TokenService) ImportLeonardoCookie(ctx context.Context, cookie, tokenID if !leonardo.IsLeonardoCookie(cookie) { return nil, errors.New("not a leonardo cookie") } + // get-session 认的是 better-auth 的 session_data 缓存 cookie:只有 session_token + // 时它返回 200 null(换不到 accessToken),和死号一模一样,所以导入就拦掉。 + if !leonardo.HasSessionData(cookie) { + return nil, errors.New("leonardo cookie 缺少 __Secure-better-auth.session_data,请复制完整 cookie") + } if tokenID == "" { tokenID = newTokenID("leonardo") } @@ -371,6 +376,18 @@ func (s *TokenService) ImportLeonardoCookie(ctx context.Context, cookie, tokenID return item, nil } +// persistLeonardoCookie writes back the account cookie when Leonardo rotated its +// better-auth session_data cache — that cache is what authenticates get-session, +// so keeping a stale copy would eventually look like a dead account. +func (s *TokenService) persistLeonardoCookie(ctx context.Context, tokenID, cookie string) { + if s.leonardo == nil { + return + } + if fresh, ok := s.leonardo.RotatedCookie(cookie); ok && strings.TrimSpace(fresh) != "" { + _, _ = s.tokens.Update(ctx, "leonardo", tokenID, map[string]any{"value": fresh}) + } +} + // checkPendingLeonardo validates a freshly imported Leonardo cookie off-thread: // get-session must succeed (else the cookie is dead → disabled), then it hydrates // email/display-name + the token balance and the daily renewal time (so the @@ -392,6 +409,7 @@ func (s *TokenService) checkPendingLeonardo(tokenID, cookie string) { } s.applyProxy(ctx) data, err := s.leonardo.FetchCreditsBalance(ctx, cookie) + s.persistLeonardoCookie(ctx, tokenID, cookie) if err != nil { if errors.Is(err, leonardo.ErrAuth) { s.finishPending(ctx, "leonardo", tokenID, "disabled", true, nil) @@ -1448,6 +1466,7 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a if poolToType(item.Pool) == "leonardo" && s.leonardo != nil { s.applyProxy(ctx) data, err := s.leonardo.FetchCreditsBalance(ctx, item.Value) + s.persistLeonardoCookie(ctx, item.ID, item.Value) if err != nil { if errors.Is(err, leonardo.ErrAuth) { _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{ diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index 67c87cf..767243a 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -788,6 +788,8 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri videoBytes, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), !urlOnly) case "grok": videoBytes, videoURL, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), !urlOnly) + case "leonardo": + 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) default: @@ -801,11 +803,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, 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): return nil, ErrProviderAuth - case errors.Is(execErr, adobe.ErrQuotaExhausted), errors.Is(execErr, runway.ErrQuotaExhausted), errors.Is(execErr, grok.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): return nil, ErrProviderQuota - case errors.Is(execErr, adobe.ErrTemporaryUpstream), errors.Is(execErr, runway.ErrTemporaryUpstream), errors.Is(execErr, grok.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): return nil, ErrProviderTemporary default: return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr) @@ -955,6 +957,8 @@ func (s *V1Service) runVideoJob(ctx context.Context, principal *APIPrincipal, in _, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), false) case "grok": _, videoURL, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false) + case "leonardo": + _, 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) default: @@ -1346,6 +1350,13 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i return nil, "", "", "", 0, errors.New("runway 图生视频需要且仅需 1 张首帧图") } } + // Leonardo seedance 的参考资产分三类且各有上限/时长限制,在扣费前拦掉, + // 不让请求带着非法参考走到上游。 + if modelItem.Provider == "leonardo" { + if _, err := classifyLeonardoVideoRefs(in.ReferenceImages, leonardoVideoSpecOf(modelItem.ID)); err != nil { + return nil, "", "", "", 0, err + } + } aspectRatio := strings.TrimSpace(strings.ReplaceAll(in.AspectRatio, "x", ":")) if aspectRatio == "" { aspectRatio = "16:9" @@ -2285,8 +2296,9 @@ func upstreamQuality(resolution string) string { // generateGrokVideo runs grok's imagine video pipeline across the grok pool, // via Grok Console (console.x.ai) — the same sso account, but the clean JSON // media API instead of the anti-bot gated grok.com website flow. -// 额度是本地写死的(每号 图 5 / 视频 2):视频计数归零的号不再调度,成功一次扣一个, -// 图/视频都归零直接判死;auth / 额度错误同样判死换号(grok sso 不续期,失效就失效)。 +// 额度是本地写死的(每号 图 5 / 视频 2):视频计数归零的号不再调度,下单先预扣一个、 +// 失败退回(并发不超扣),图/视频都归零直接判死;auth / 额度错误同样判死换号 +// (grok sso 不续期,失效就失效)。 func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio, resolution string, durationSeconds int, downloadResult bool) ([]byte, string, error) { if s.grok == nil { return nil, "", errors.New("grok client not configured") @@ -2341,6 +2353,11 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model defer s.acctRelease(ctx, token.ID, eventID) _ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) _ = s.tokens.TouchLastUsed(ctx, token.ID) + // 下单先预扣本地额度(并发时不会超扣),失败再退回。 + allowed, reserveErr := s.tokens.ReserveGrokQuota(ctx, token.ID, "video") + if reserveErr != nil || !allowed { + return false, true + } d, meta, genErr := s.grok.GenerateConsoleVideo(ctx, token.Value, in.Prompt, aspectRatio, res, durationSeconds, frames, downloadResult) if genErr == nil { _, _ = s.tokens.Update(ctx, "grok", token.ID, map[string]any{ @@ -2348,12 +2365,13 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model "success_total": gorm.Expr("success_total + 1"), "fails": 0, }) - // 本地额度各扣各的;图/视频都归零时账号直接判死。 - _ = s.tokens.ConsumeGrokQuota(ctx, token.ID, "video") + // 图/视频都归零时账号直接判死。 + _ = s.tokens.FinalizeGrokQuota(ctx, token.ID) data = d videoURL = strings.TrimSpace(stringValue(meta["video_url"])) return true, false } + _ = s.tokens.RefundGrokQuota(ctx, token.ID, "video") lastErr = genErr switch { case errors.Is(genErr, grok.ErrAuth), errors.Is(genErr, grok.ErrQuotaExhausted): @@ -2437,6 +2455,11 @@ func (s *V1Service) generateGrokImage(ctx context.Context, eventID string, model defer s.acctRelease(ctx, token.ID, eventID) _ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) _ = s.tokens.TouchLastUsed(ctx, token.ID) + // 下单先预扣本地额度(并发时不会超扣),失败再退回。 + allowed, reserveErr := s.tokens.ReserveGrokQuota(ctx, token.ID, "image") + if reserveErr != nil || !allowed { + return false, true + } d, meta, genErr := s.grok.GenerateConsoleImage(ctx, token.Value, in.Prompt, aspectRatio, resolution, refs, urlOnly) if genErr == nil { _, _ = s.tokens.Update(ctx, "grok", token.ID, map[string]any{ @@ -2444,12 +2467,13 @@ func (s *V1Service) generateGrokImage(ctx context.Context, eventID string, model "success_total": gorm.Expr("success_total + 1"), "fails": 0, }) - // 本地额度各扣各的;图/视频都归零时账号直接判死。 - _ = s.tokens.ConsumeGrokQuota(ctx, token.ID, "image") + // 图/视频都归零时账号直接判死。 + _ = s.tokens.FinalizeGrokQuota(ctx, token.ID) data = d artURL = strings.TrimSpace(stringValue(meta["image_url"])) return true, false } + _ = s.tokens.RefundGrokQuota(ctx, token.ID, "image") lastErr = genErr switch { case errors.Is(genErr, grok.ErrAuth), errors.Is(genErr, grok.ErrQuotaExhausted): @@ -2786,8 +2810,9 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m return nil, "", err } - // token.Value is the cookie; GenerateImage mints a fresh JWT each attempt, so an - // auth failure means the cookie itself is dead — no refresher (nil). + // token.Value is the cookie; GenerateImage mints a fresh JWT each attempt (and + // re-mints it internally when the bearer is rejected), so an auth failure means + // the cookie itself no longer authenticates — no refresher (nil). var imageURL string data, err := s.runPoolWithFailover(ctx, eventID, "leonardo", active, "image", func(token model.TokenAccount) ([]byte, error) { // Atomically pre-deduct the per-generation cost so concurrent picks of the @@ -2801,6 +2826,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m return nil, leonardo.ErrQuotaExhausted } data, meta, genErr := s.leonardo.GenerateImage(ctx, token.Value, upstreamModel, in.Prompt, width, height, nil, refs, !urlOnly) + cookie := s.leonardoPersistCookie(ctx, token.ID, token.Value) if genErr != nil { // Release the hold so a failed render doesn't burn credits. if deducted { @@ -2811,7 +2837,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m imageURL = strings.TrimSpace(stringValue(meta["image_url"])) // Success → overwrite the held value with the REAL upstream balance and // sink to 限额 if below the floor (best-effort; never fails a done render). - s.reconcileLeonardoCredits(ctx, token.ID, token.Value) + s.reconcileLeonardoCredits(ctx, token.ID, cookie) return data, nil }, func(e error) (bool, bool, bool, bool) { return errors.Is(e, leonardo.ErrAuth), errors.Is(e, leonardo.ErrQuotaExhausted), errors.Is(e, leonardo.ErrTemporaryUpstream), false @@ -2819,6 +2845,210 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m return data, imageURL, err } +// leonardoPrivateSuffix 标记目录里 Leonardo 私有视频模型(public:false 生成)的 +// 后缀,上游 slug 就是去掉它之后的 id。 +const leonardoPrivateSuffix = "-不卡人脸" + +// leonardoVideoSpec 描述一个 Leonardo 视频模型的上游 slug、输出尺寸和参考资产 +// 限制(各类上限 + 时长约束),校验在扣费前跑,免得非法参考白扣积分。 +type leonardoVideoSpec struct { + upstream string + // long/short 是长边/短边像素,按比例组合成 16:9 或 9:16。 + long int + short int + maxImages int + maxAudios int + maxVideos int + // 视频参考单个时长区间与总时长上限(秒),0 表示不限。 + videoMinSeconds float64 + videoMaxSeconds float64 + videoTotalSeconds float64 + // 音频参考总时长上限(秒),0 表示不限。 + audioTotalSeconds float64 +} + +var leonardoVideoSpecs = map[string]leonardoVideoSpec{ + "seedance-2.0" + leonardoPrivateSuffix: { + upstream: "seedance-2.0", long: 1280, short: 720, + maxImages: 4, maxAudios: 1, maxVideos: 3, + videoMinSeconds: 3, videoMaxSeconds: 10, videoTotalSeconds: 15, + }, + "seedance-2.0-fast" + leonardoPrivateSuffix: { + upstream: "seedance-2.0-fast", long: 1280, short: 720, + maxImages: 4, maxAudios: 1, maxVideos: 3, + videoMinSeconds: 3, videoMaxSeconds: 10, videoTotalSeconds: 15, + }, + "minimax-h3": { + upstream: "hailuo-03", long: 2560, short: 1440, + maxImages: 5, maxAudios: 3, maxVideos: 0, + audioTotalSeconds: 15, + }, +} + +func leonardoVideoSpecOf(modelID string) leonardoVideoSpec { + id := strings.TrimSpace(modelID) + if spec, ok := leonardoVideoSpecs[id]; ok { + return spec + } + // 目录里新增的同族模型退化成 seedance 规格,上游 slug 取去掉私有后缀的 id。 + return leonardoVideoSpec{ + upstream: strings.TrimSuffix(id, leonardoPrivateSuffix), long: 1280, short: 720, + maxImages: 4, maxAudios: 1, maxVideos: 3, + videoMinSeconds: 3, videoMaxSeconds: 10, videoTotalSeconds: 15, + } +} + +// dimensions 按比例返回像素尺寸,只支持 16:9 / 9:16。 +func (spec leonardoVideoSpec) dimensions(aspectRatio string) (int, int) { + if strings.TrimSpace(aspectRatio) == "9:16" { + return spec.short, spec.long + } + return spec.long, spec.short +} + +// classifyLeonardoVideoRefs decodes the mixed reference payload (画图台把图片/ +// 音频/视频一起塞进 reference_images)并按类型分流,同时按模型规格校验各类上限和 +// 时长。 +func classifyLeonardoVideoRefs(inputs []string, spec leonardoVideoSpec) (leonardo.VideoAssets, error) { + var refs leonardo.VideoAssets + decoded, err := decodeReferenceImages(inputs, spec.maxImages+spec.maxAudios+spec.maxVideos) + if err != nil { + return refs, err + } + var videoTotal, audioTotal float64 + for _, ref := range decoded { + switch detectMediaType(ref) { + case "video": + if spec.maxVideos == 0 { + return refs, errors.New("该模型不支持视频参考") + } + secs := leonardo.MediaDurationSeconds(ref) + if secs <= 0 { + return refs, errors.New("无法解析视频参考的时长,请换一个 mp4") + } + if secs < spec.videoMinSeconds || secs > spec.videoMaxSeconds { + return refs, fmt.Errorf("单个视频参考需 %.0f-%.0f 秒,当前 %.1f 秒", + spec.videoMinSeconds, spec.videoMaxSeconds, secs) + } + videoTotal += secs + if spec.videoTotalSeconds > 0 && videoTotal >= spec.videoTotalSeconds { + return refs, fmt.Errorf("视频参考总时长 %.1f 秒,需短于 %.0f 秒", + videoTotal, spec.videoTotalSeconds) + } + refs.Videos = append(refs.Videos, ref) + case "audio": + if spec.maxAudios == 0 { + return refs, errors.New("该模型不支持音频参考") + } + secs := leonardo.MediaDurationSeconds(ref) + if spec.audioTotalSeconds > 0 { + if secs <= 0 { + return refs, errors.New("无法解析音频参考的时长,请换一个 mp3") + } + audioTotal += secs + if audioTotal > spec.audioTotalSeconds { + return refs, fmt.Errorf("音频参考总时长 %.1f 秒,最多 %.0f 秒", + audioTotal, spec.audioTotalSeconds) + } + } + refs.Audios = append(refs.Audios, ref) + default: + refs.Images = append(refs.Images, ref) + } + } + switch { + case len(refs.Images) > spec.maxImages: + return refs, fmt.Errorf("最多 %d 张参考图", spec.maxImages) + case len(refs.Audios) > spec.maxAudios: + return refs, fmt.Errorf("最多 %d 段音频参考", spec.maxAudios) + case len(refs.Videos) > spec.maxVideos: + return refs, fmt.Errorf("最多 %d 段视频参考", spec.maxVideos) + } + return refs, nil +} + +// generateLeonardoVideo renders a Leonardo video across the leonardo pool. Mirrors +// generateLeonardoImage (cookie → JWT, quota reserve, cookie 轮换持久化), only the +// upstream call differs: 私有生成 + 三类参考资产 + motionMP4URL。 +func (s *V1Service) generateLeonardoVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio string, durationSeconds int, downloadResult bool) ([]byte, string, error) { + if s.leonardo == nil { + return nil, "", errors.New("leonardo client not configured") + } + if s.settings != nil { + if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { + s.leonardo.SetProxy(proxy) + } + } + spec := leonardoVideoSpecOf(modelItem.ID) + refs, err := classifyLeonardoVideoRefs(in.ReferenceImages, spec) + if err != nil { + return nil, "", err + } + + items, err := s.tokens.ListByPool(ctx, "leonardo") + 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 + } + if rem, ok := jsonMapInt(item.Meta, "cached_quota_remaining"); ok && rem < leonardoMinCredits { + continue + } + active = append(active, item) + } + active = pinTestAccount(items, active, in.AccountID) + if len(active) == 0 { + return nil, "", ErrNoProviderAccount + } + s.rotateRoundRobin("leonardo", active) + + width, height := spec.dimensions(aspectRatio) + + var videoURL string + data, err := s.runPoolWithFailover(ctx, eventID, "leonardo", active, "video", func(token model.TokenAccount) ([]byte, error) { + allowed, deducted, rerr := s.tokens.ReserveQuota(ctx, "leonardo", token.ID, leonardoMinCredits) + if rerr != nil { + return nil, fmt.Errorf("%w: reserve: %v", leonardo.ErrTemporaryUpstream, rerr) + } + if !allowed { + return nil, leonardo.ErrQuotaExhausted + } + data, meta, genErr := s.leonardo.GenerateVideo(ctx, token.Value, spec.upstream, in.Prompt, width, height, durationSeconds, refs, downloadResult) + cookie := s.leonardoPersistCookie(ctx, token.ID, token.Value) + if genErr != nil { + if deducted { + _ = s.tokens.RefundQuota(ctx, "leonardo", token.ID, leonardoMinCredits) + } + return nil, genErr + } + videoURL = strings.TrimSpace(stringValue(meta["video_url"])) + s.reconcileLeonardoCredits(ctx, token.ID, cookie) + return data, nil + }, func(e error) (bool, bool, bool, bool) { + return errors.Is(e, leonardo.ErrAuth), errors.Is(e, leonardo.ErrQuotaExhausted), errors.Is(e, leonardo.ErrTemporaryUpstream), false + }, nil, true) + return data, videoURL, err +} + +// leonardoPersistCookie writes back the account cookie when Leonardo rotated its +// better-auth session_data cache (that cache is what actually authenticates +// get-session, so a stale copy would eventually look like a dead account). +// Returns the value now stored. +func (s *V1Service) leonardoPersistCookie(ctx context.Context, tokenID, cookie string) string { + if s.leonardo == nil { + return cookie + } + fresh, ok := s.leonardo.RotatedCookie(cookie) + if !ok || strings.TrimSpace(fresh) == "" { + return cookie + } + _, _ = s.tokens.Update(ctx, "leonardo", tokenID, map[string]any{"value": fresh}) + return fresh +} + // reconcileLeonardoCredits re-fetches an account's real token balance after a // render and writes it back, flipping the account to 限额 when below the per-gen // floor. Stores the daily renewal time so RecoverQuota can auto-recover it. diff --git a/frontend/src/components/ImportModal.vue b/frontend/src/components/ImportModal.vue index e1673cd..1a148c5 100644 --- a/frontend/src/components/ImportModal.vue +++ b/frontend/src/components/ImportModal.vue @@ -105,7 +105,7 @@ async function doSmartImport() { Cookie 数组(多 Adobe 批量)、 ChatGPT JWT(eyJhbGciOi...)、 Runway JWT(自动与 ChatGPT 区分)、 - Leonardo Cookie(含 better-auth)、 + Leonardo Cookie(须含 better-auth.session_data)、 Krea Cookie(含 sb-superb-auth)、 Imagine Token({"token","refreshToken","email","parentId"})、 Grok SSO(grok.com 的 sso 值,仅含 session_id,自动与 ChatGPT/Runway 区分)、 diff --git a/frontend/src/components/ModelFormModal.vue b/frontend/src/components/ModelFormModal.vue index 7ce8c21..69d071d 100644 --- a/frontend/src/components/ModelFormModal.vue +++ b/frontend/src/components/ModelFormModal.vue @@ -40,9 +40,10 @@ const isVideo = computed(() => entry.value?.type === 'video') // Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K) // regardless of how the catalog/stored record happens to list them. const resolutions = computed(() => sortResolutions(entry.value?.resolutions || [])) +// 按秒计价的视频模型(一个 /s 价,实付 = 分辨率价 + 每秒价 × 秒数)。 const isPerSecond = computed(() => { const id = entry.value?.id || '' - return id.startsWith('seedance') + return id.startsWith('seedance') || id === 'grok-video' }) const durationTiers = computed(() => { diff --git a/frontend/src/views/PlaygroundView.vue b/frontend/src/views/PlaygroundView.vue index a43067c..d1fd735 100644 --- a/frontend/src/views/PlaygroundView.vue +++ b/frontend/src/views/PlaygroundView.vue @@ -126,7 +126,19 @@ const supportsBoth = computed(() => { const m = model.value; return m && m.refer const refMode = ref(refModeDefault.value) watch(refModeDefault, (v) => { refMode.value = v }) +// 预设里声明的音/视频参考上限(未声明 = null,走 seedance 默认值) +const maxVideosRaw = computed(() => { + const n = familyPreset.value?.max_videos + return n === undefined || n === null ? null : Number(n) +}) +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 || '')) +// 支持图片以外的参考资产(视频/音频)的模型:seedance 系 + 预设声明了音视频上限的 +const supportsMediaRefs = computed(() => + isSeedanceModel.value || maxVideosRaw.value > 0 || maxAudiosRaw.value > 0) const perSecondRate = computed(() => { if (mode.value !== 'video' || !model.value) return null @@ -220,13 +232,35 @@ function openPicker() { fileInput.value && fileInput.value.click() } // here at pick time so an oversized image fails fast with a clear message instead // of charging + failing upstream after the upload. const MAX_REF_BYTES = 20 * 1024 * 1024 -// seedance 参考图模式各类上限 -const MAX_VIDEOS = 3 -const MAX_AUDIOS = 3 +// seedance 参考图模式各类上限(预设可覆盖:Leonardo 私有 seedance 只吃 1 段音频) +const maxImages = computed(() => Number(familyPreset.value?.max_images) || maxRefs.value) +const maxVideos = computed(() => (maxVideosRaw.value === null ? 3 : maxVideosRaw.value)) +const maxAudios = computed(() => (maxAudiosRaw.value === null ? 3 : maxAudiosRaw.value)) +// Leonardo 私有 seedance:单个视频参考 3-10 秒,总时长须短于 15 秒。 +// 服务端同样校验,这里只是提交前先拦一下,避免白跑一次生成。 +function videoRefDurationError() { + if (mode.value !== 'video' || familyPreset.value?.provider !== 'leonardo') return '' + const vids = refImages.value.filter((r) => r.fileType === 'video') + for (const v of vids) { + if (v.duration && (v.duration < 3 || v.duration > 10)) + return `单个视频参考需 3-10 秒:${v.name} 为 ${v.duration.toFixed(1)} 秒` + } + const vTotal = vids.reduce((n, v) => n + (v.duration || 0), 0) + if (vids.length && vTotal >= 15) return `视频参考总时长 ${vTotal.toFixed(1)} 秒,需短于 15 秒` + const audioLimit = Number(familyPreset.value?.max_audio_seconds) || 0 + if (audioLimit > 0) { + const aTotal = refImages.value + .filter((r) => r.fileType === 'audio') + .reduce((n, a) => n + (a.duration || 0), 0) + if (aTotal > audioLimit) + return `音频参考总时长 ${aTotal.toFixed(1)} 秒,最多 ${audioLimit} 秒` + } + return '' +} // seedance 参考图(非首尾帧)模式下允许图片/视频/音频 const fileAccept = computed(() => { - if (isSeedanceModel.value && refMode.value !== 'frame' && mode.value === 'video') + if (supportsMediaRefs.value && refMode.value !== 'frame' && mode.value === 'video') return 'image/*,video/*,audio/*' return 'image/*' }) @@ -246,7 +280,7 @@ function onFiles(ev) { // Shared by the file picker AND drag-and-drop. Honors per-model max + per-type // limits (image/video/audio) + 20MB cap. function addFiles(files) { - const allowMedia = isSeedanceModel.value && refMode.value !== 'frame' && mode.value === 'video' + const allowMedia = supportsMediaRefs.value && refMode.value !== 'frame' && mode.value === 'video' const tooBig = [] for (const f of files) { if (!f || !f.type) continue @@ -257,8 +291,9 @@ function addFiles(files) { if (refImages.value.length >= maxRefs.value) break if (f.size > MAX_REF_BYTES) { tooBig.push(f.name); continue } const { imgs, vids, auds } = refCounts.value - if (isVideo && vids >= MAX_VIDEOS) continue - if (isAudio && auds >= MAX_AUDIOS) continue + if (isVideo && vids >= maxVideos.value) continue + if (isAudio && auds >= maxAudios.value) continue + if (isImage && allowMedia && imgs >= maxImages.value) continue const fileType = isVideo ? 'video' : isAudio ? 'audio' : 'image' if (isImage) { makeThumb(f).then((thumb) => { @@ -500,6 +535,8 @@ async function run() { error.value = '该视频模型需要至少 1 张参考图 (首帧)' return } + const refErr = videoRefDurationError() + if (refErr) { error.value = refErr; return } if (price.value == null) { error.value = '该参数组合未定价 (留空 = 不支持)' return @@ -869,7 +906,7 @@ onUnmounted(() => {