feat(leonardo): 接入视频通道并新增 seedance-2.0/minimax-h3 模型
This commit is contained in:
@@ -460,6 +460,49 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
|||||||
"max_reference_images": 9,
|
"max_reference_images": 9,
|
||||||
"reference_mode": "style",
|
"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",
|
"key": "firefly-video",
|
||||||
"label": "Firefly Video",
|
"label": "Firefly Video",
|
||||||
@@ -670,7 +713,9 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
|||||||
"type": "video",
|
"type": "video",
|
||||||
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
||||||
"resolutions": []string{"720p"},
|
"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,
|
"max_reference_images": 1,
|
||||||
"reference_mode": "frame",
|
"reference_mode": "frame",
|
||||||
"description": "Grok Imagine video (文/图生视频)",
|
"description": "Grok Imagine video (文/图生视频)",
|
||||||
@@ -696,6 +741,41 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
|||||||
"max_reference_images": 6,
|
"max_reference_images": 6,
|
||||||
"description": "Leonardo Seedream 4.5 (生图 / 图生图)",
|
"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",
|
"id": "flux-klein-2",
|
||||||
"provider": "krea",
|
"provider": "krea",
|
||||||
@@ -898,6 +978,33 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
|||||||
"description": "Leonardo Seedream 4.5",
|
"description": "Leonardo Seedream 4.5",
|
||||||
"stub": false,
|
"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",
|
"id": "flux-klein-2",
|
||||||
"provider": "krea",
|
"provider": "krea",
|
||||||
|
|||||||
@@ -52,8 +52,15 @@ var (
|
|||||||
// applies, but callers can single it out — unlike an expired access token this
|
// 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.
|
// cannot be fixed by refreshing from the cookie, so the account is done.
|
||||||
ErrNotEntitled = fmt.Errorf("%w: user not entitled", ErrAuth)
|
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
|
// isContentRejection reports whether an Adobe response (status + body) is a
|
||||||
// content-safety refusal rather than a genuine upstream/account failure. Adobe
|
// content-safety refusal rather than a genuine upstream/account failure. Adobe
|
||||||
// returns HTTP 451 with an "*_unsafe" error_code when moderation blocks the
|
// 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" {
|
if engine == "firefly-video" {
|
||||||
endpoint = fireflyVideoSubmitURL
|
endpoint = fireflyVideoSubmitURL
|
||||||
}
|
}
|
||||||
|
// 连接在拿到响应前就断掉(EOF / reset / 超时)说明上游根本没收到这单,
|
||||||
|
// 换一条新连接原地重试,最多 videoSubmitMaxRetries 次。
|
||||||
respBody, pollURL, err := c.submitVideo(ctx, submitSess, token, endpoint, payload)
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
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)
|
resp, err := sess.client.Do(req)
|
||||||
if err != nil {
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
appBase = "https://app.leonardo.ai"
|
appBase = "https://app.leonardo.ai"
|
||||||
graphqlURL = "https://api.leonardo.ai/v1/graphql"
|
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"
|
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.
|
// (429) hard, so re-using the ~1h JWT is essential.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
sessions map[string]*Session
|
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 {
|
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) {
|
func (c *Client) SetProxy(proxy string) {
|
||||||
@@ -63,6 +68,75 @@ func IsLeonardoCookie(value string) bool {
|
|||||||
strings.Contains(value, "better-auth.session_data")
|
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
|
// 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
|
// 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.
|
// and the CDN image path) and the human-facing account fields.
|
||||||
@@ -73,10 +147,14 @@ type Session struct {
|
|||||||
Email string
|
Email string
|
||||||
Name string
|
Name string
|
||||||
ExpiresAt int64
|
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
|
// GetSession exchanges the cookie for a fresh access token + account ids. Only a
|
||||||
// (or a response with no access token) means the cookie/session is dead → ErrAuth.
|
// 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) {
|
func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error) {
|
||||||
cookie = strings.TrimSpace(cookie)
|
cookie = strings.TrimSpace(cookie)
|
||||||
if cookie == "" {
|
if cookie == "" {
|
||||||
@@ -91,6 +169,15 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
|||||||
}
|
}
|
||||||
c.mu.Unlock()
|
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()
|
client, err := c.newDirectTLSClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -103,7 +190,7 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
|||||||
req.Header = http.Header{
|
req.Header = http.Header{
|
||||||
"accept": {"*/*"},
|
"accept": {"*/*"},
|
||||||
"accept-language": {"en-US,en;q=0.9"},
|
"accept-language": {"en-US,en;q=0.9"},
|
||||||
"cookie": {cookie},
|
"cookie": {send},
|
||||||
"origin": {appBase},
|
"origin": {appBase},
|
||||||
"referer": {appBase + "/"},
|
"referer": {appBase + "/"},
|
||||||
"user-agent": {userAgent},
|
"user-agent": {userAgent},
|
||||||
@@ -121,10 +208,18 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
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
|
return nil, ErrAuth
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 {
|
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))
|
return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, resp.StatusCode, clip(body, 160))
|
||||||
}
|
}
|
||||||
var raw struct {
|
var raw struct {
|
||||||
@@ -162,6 +257,7 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
|||||||
Email: strings.TrimSpace(raw.User.Email),
|
Email: strings.TrimSpace(raw.User.Email),
|
||||||
Name: strings.TrimSpace(raw.User.Name),
|
Name: strings.TrimSpace(raw.User.Name),
|
||||||
ExpiresAt: raw.Session.TokenExpiry,
|
ExpiresAt: raw.Session.TokenExpiry,
|
||||||
|
Cookie: send,
|
||||||
}
|
}
|
||||||
if sess.ExpiresAt > time.Now().Unix() {
|
if sess.ExpiresAt > time.Now().Unix() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -171,6 +267,59 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
|||||||
return sess, nil
|
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) {
|
const qGetTokens = `query GetUserTokensFromSub($sub: String) {
|
||||||
user_details(where: {cognitoId: {_eq: $sub}}) {
|
user_details(where: {cognitoId: {_eq: $sub}}) {
|
||||||
id
|
id
|
||||||
@@ -205,15 +354,12 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[st
|
|||||||
"variables": map[string]any{"sub": sess.CognitoSub},
|
"variables": map[string]any{"sub": sess.CognitoSub},
|
||||||
"query": qGetTokens,
|
"query": qGetTokens,
|
||||||
})
|
})
|
||||||
body, status, err := c.graphqlP(ctx, sess.AccessToken, payload, false)
|
body, err := c.callGraphQL(ctx, cookie, payload, false, "credits")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return unknownBalance("network: " + err.Error()), nil
|
if errors.Is(err, ErrAuth) {
|
||||||
}
|
|
||||||
if status == 401 || status == 403 {
|
|
||||||
return nil, ErrAuth
|
return nil, ErrAuth
|
||||||
}
|
}
|
||||||
if status != 200 {
|
return unknownBalance(err.Error()), nil
|
||||||
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
|
|
||||||
}
|
}
|
||||||
var result struct {
|
var result struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
@@ -248,13 +394,8 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[st
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// graphql runs a GraphQL call through the proxy. graphqlP lets callers pick the
|
// graphqlP runs a GraphQL call; callers pick the egress: only the generate submit
|
||||||
// egress: only the generate submit uses the proxy; reference-image upload and
|
// uses the proxy; reference-image upload and polling run direct (local IP).
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byte, useProxy bool) ([]byte, int, error) {
|
func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byte, useProxy bool) ([]byte, int, error) {
|
||||||
client, err := c.newTLSClientP(useProxy)
|
client, err := c.newTLSClientP(useProxy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -51,24 +52,25 @@ const mUploadImage = `mutation UploadImage($uploadImageInput: UploadImageInput!)
|
|||||||
// uploadInitImage uploads a reference (init) image for image-to-image: it asks
|
// 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
|
// Leonardo for a presigned S3 POST, uploads the bytes, and returns the upload id
|
||||||
// to reference in the Generate request's image_reference guidance.
|
// 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{
|
payload, _ := json.Marshal(map[string]any{
|
||||||
"operationName": "UploadImage",
|
"operationName": "UploadImage",
|
||||||
"query": mUploadImage,
|
"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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("%w: upload-init: %s", ErrTemporaryUpstream, err.Error())
|
return "", err
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
var ur struct {
|
var ur struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
@@ -97,11 +99,11 @@ func (c *Client) uploadInitImage(ctx context.Context, accessToken string, img []
|
|||||||
for k, v := range fields {
|
for k, v := range fields {
|
||||||
_ = w.WriteField(k, v)
|
_ = w.WriteField(k, v)
|
||||||
}
|
}
|
||||||
fw, err := w.CreateFormFile("file", "image.png")
|
fw, err := w.CreateFormFile("file", "asset."+extension)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if _, err := fw.Write(img); err != nil {
|
if _, err := fw.Write(asset); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_ = w.Close()
|
_ = w.Close()
|
||||||
@@ -154,7 +156,7 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string
|
|||||||
if len(img) == 0 {
|
if len(img) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
uploadID, upErr := c.uploadInitImage(ctx, sess.AccessToken, img)
|
uploadID, upErr := c.uploadInitImage(ctx, cookie, img)
|
||||||
if upErr != nil {
|
if upErr != nil {
|
||||||
return nil, nil, upErr
|
return nil, nil, upErr
|
||||||
}
|
}
|
||||||
@@ -192,18 +194,9 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
payload, _ := json.Marshal(genReq)
|
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 {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error())
|
return nil, nil, err
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
var genResp struct {
|
var genResp struct {
|
||||||
Data 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.
|
// 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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
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
|
// pollImage polls one generation until it reports COMPLETE (returning the first
|
||||||
// image url) or FAILED (error). Honors ctx cancellation / deadline.
|
// 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{
|
payload, _ := json.Marshal(map[string]any{
|
||||||
"operationName": "GenerationImages",
|
"operationName": "GenerationImages",
|
||||||
"query": qGenerationImages,
|
"query": qGenerationImages,
|
||||||
@@ -264,14 +257,12 @@ func (c *Client) pollImage(ctx context.Context, accessToken, genID string) (stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
body, status, err := c.graphqlP(ctx, accessToken, payload, false)
|
body, err := c.callGraphQL(ctx, cookie, payload, false, "poll")
|
||||||
if err != nil {
|
if errors.Is(err, ErrAuth) {
|
||||||
return "", fmt.Errorf("%w: poll: %s", ErrTemporaryUpstream, err.Error())
|
return "", err
|
||||||
}
|
}
|
||||||
if status == 401 || status == 403 {
|
// 其它错误(含上游临时抖动)不中断轮询,等 deadline 再判超时。
|
||||||
return "", ErrAuth
|
if err == nil {
|
||||||
}
|
|
||||||
if status == 200 {
|
|
||||||
var pr struct {
|
var pr struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
Generations []struct {
|
Generations []struct {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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:
|
// Grok accounts carry a forced local quota instead of an upstream balance:
|
||||||
// Console 没有额度接口,所以导入时写死 图 5 / 视频 2,用一次扣一次,两个都归零直接判死。
|
// Console 没有额度接口,所以导入时写死 图 5 / 视频 2,下单预扣、失败退回,两个都归零直接判死。
|
||||||
const (
|
const (
|
||||||
GrokImageQuotaKey = "grok_image_remaining"
|
GrokImageQuotaKey = "grok_image_remaining"
|
||||||
GrokVideoQuotaKey = "grok_video_remaining"
|
GrokVideoQuotaKey = "grok_video_remaining"
|
||||||
@@ -133,46 +133,113 @@ const (
|
|||||||
GrokVideoQuota = 2
|
GrokVideoQuota = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConsumeGrokQuota deducts one unit from a grok account's local per-kind quota
|
// grokQuotas reads an account's local per-kind counters, falling back to the
|
||||||
// under a row lock. Zeroed kinds are flagged (image_limited / video_limited) so
|
// forced defaults for accounts imported before the counters existed.
|
||||||
// scheduling skips them; once both are zero the account is dead (no reset time —
|
func grokQuotas(meta datatypes.JSONMap) (images, videos int) {
|
||||||
// 用完就废).
|
images, known := metaInt(meta, GrokImageQuotaKey)
|
||||||
func (r *TokenRepository) ConsumeGrokQuota(ctx context.Context, id, kind string) error {
|
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 {
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var item model.TokenAccount
|
var item model.TokenAccount
|
||||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
|
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
images, known := metaInt(item.Meta, GrokImageQuotaKey)
|
images, videos := grokQuotas(item.Meta)
|
||||||
if !known {
|
|
||||||
images = GrokImageQuota
|
|
||||||
}
|
|
||||||
videos, known := metaInt(item.Meta, GrokVideoQuotaKey)
|
|
||||||
if !known {
|
|
||||||
videos = GrokVideoQuota
|
|
||||||
}
|
|
||||||
if kind == "video" {
|
if kind == "video" {
|
||||||
videos = max(0, videos-1)
|
videos = min(videos+1, GrokVideoQuota)
|
||||||
} else {
|
} else {
|
||||||
images = max(0, images-1)
|
images = min(images+1, GrokImageQuota)
|
||||||
}
|
}
|
||||||
meta := cloneMeta(item.Meta)
|
meta := cloneMeta(item.Meta)
|
||||||
meta[GrokImageQuotaKey] = images
|
meta[GrokImageQuotaKey] = images
|
||||||
meta[GrokVideoQuotaKey] = videos
|
meta[GrokVideoQuotaKey] = videos
|
||||||
patch := map[string]any{
|
return tx.Model(&model.TokenAccount{}).
|
||||||
|
Where("pool = ? AND id = ?", "grok", id).
|
||||||
|
Updates(map[string]any{
|
||||||
"meta": meta,
|
"meta": meta,
|
||||||
"image_limited": images <= 0,
|
"image_limited": images <= 0,
|
||||||
"video_limited": videos <= 0,
|
"video_limited": videos <= 0,
|
||||||
"updated_at": time.Now(),
|
"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 {
|
images, videos := grokQuotas(item.Meta)
|
||||||
patch["status"] = "disabled"
|
if images > 0 || videos > 0 {
|
||||||
patch["dead"] = true
|
return nil
|
||||||
}
|
}
|
||||||
return tx.Model(&model.TokenAccount{}).
|
return tx.Model(&model.TokenAccount{}).
|
||||||
Where("pool = ? AND id = ?", "grok", id).
|
Where("pool = ? AND id = ?", "grok", id).
|
||||||
Updates(patch).Error
|
Updates(map[string]any{
|
||||||
|
"status": "disabled",
|
||||||
|
"dead": true,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -341,6 +341,11 @@ func (s *TokenService) ImportLeonardoCookie(ctx context.Context, cookie, tokenID
|
|||||||
if !leonardo.IsLeonardoCookie(cookie) {
|
if !leonardo.IsLeonardoCookie(cookie) {
|
||||||
return nil, errors.New("not a leonardo 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 == "" {
|
if tokenID == "" {
|
||||||
tokenID = newTokenID("leonardo")
|
tokenID = newTokenID("leonardo")
|
||||||
}
|
}
|
||||||
@@ -371,6 +376,18 @@ func (s *TokenService) ImportLeonardoCookie(ctx context.Context, cookie, tokenID
|
|||||||
return item, nil
|
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:
|
// checkPendingLeonardo validates a freshly imported Leonardo cookie off-thread:
|
||||||
// get-session must succeed (else the cookie is dead → disabled), then it hydrates
|
// 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
|
// 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)
|
s.applyProxy(ctx)
|
||||||
data, err := s.leonardo.FetchCreditsBalance(ctx, cookie)
|
data, err := s.leonardo.FetchCreditsBalance(ctx, cookie)
|
||||||
|
s.persistLeonardoCookie(ctx, tokenID, cookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, leonardo.ErrAuth) {
|
if errors.Is(err, leonardo.ErrAuth) {
|
||||||
s.finishPending(ctx, "leonardo", tokenID, "disabled", true, nil)
|
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 {
|
if poolToType(item.Pool) == "leonardo" && s.leonardo != nil {
|
||||||
s.applyProxy(ctx)
|
s.applyProxy(ctx)
|
||||||
data, err := s.leonardo.FetchCreditsBalance(ctx, item.Value)
|
data, err := s.leonardo.FetchCreditsBalance(ctx, item.Value)
|
||||||
|
s.persistLeonardoCookie(ctx, item.ID, item.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, leonardo.ErrAuth) {
|
if errors.Is(err, leonardo.ErrAuth) {
|
||||||
_, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{
|
_, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{
|
||||||
|
|||||||
+242
-12
@@ -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)
|
videoBytes, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), !urlOnly)
|
||||||
case "grok":
|
case "grok":
|
||||||
videoBytes, videoURL, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), !urlOnly)
|
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":
|
case "custom":
|
||||||
videoBytes, videoURL, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), !urlOnly)
|
videoBytes, videoURL, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), !urlOnly)
|
||||||
default:
|
default:
|
||||||
@@ -801,11 +803,11 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(execErr, ErrNoProviderAccount):
|
case errors.Is(execErr, ErrNoProviderAccount):
|
||||||
return nil, 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
|
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
|
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
|
return nil, ErrProviderTemporary
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr)
|
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)
|
_, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), false)
|
||||||
case "grok":
|
case "grok":
|
||||||
_, videoURL, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
|
_, 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":
|
case "custom":
|
||||||
_, videoURL, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
|
_, videoURL, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
|
||||||
default:
|
default:
|
||||||
@@ -1346,6 +1350,13 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
|
|||||||
return nil, "", "", "", 0, errors.New("runway 图生视频需要且仅需 1 张首帧图")
|
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", ":"))
|
aspectRatio := strings.TrimSpace(strings.ReplaceAll(in.AspectRatio, "x", ":"))
|
||||||
if aspectRatio == "" {
|
if aspectRatio == "" {
|
||||||
aspectRatio = "16:9"
|
aspectRatio = "16:9"
|
||||||
@@ -2285,8 +2296,9 @@ func upstreamQuality(resolution string) string {
|
|||||||
// generateGrokVideo runs grok's imagine video pipeline across the grok pool,
|
// 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
|
// 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.
|
// media API instead of the anti-bot gated grok.com website flow.
|
||||||
// 额度是本地写死的(每号 图 5 / 视频 2):视频计数归零的号不再调度,成功一次扣一个,
|
// 额度是本地写死的(每号 图 5 / 视频 2):视频计数归零的号不再调度,下单先预扣一个、
|
||||||
// 图/视频都归零直接判死;auth / 额度错误同样判死换号(grok sso 不续期,失效就失效)。
|
// 失败退回(并发不超扣),图/视频都归零直接判死;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) {
|
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 {
|
if s.grok == nil {
|
||||||
return nil, "", errors.New("grok client not configured")
|
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)
|
defer s.acctRelease(ctx, token.ID, eventID)
|
||||||
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
|
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
|
||||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
_ = 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)
|
d, meta, genErr := s.grok.GenerateConsoleVideo(ctx, token.Value, in.Prompt, aspectRatio, res, durationSeconds, frames, downloadResult)
|
||||||
if genErr == nil {
|
if genErr == nil {
|
||||||
_, _ = s.tokens.Update(ctx, "grok", token.ID, map[string]any{
|
_, _ = 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"),
|
"success_total": gorm.Expr("success_total + 1"),
|
||||||
"fails": 0,
|
"fails": 0,
|
||||||
})
|
})
|
||||||
// 本地额度各扣各的;图/视频都归零时账号直接判死。
|
// 图/视频都归零时账号直接判死。
|
||||||
_ = s.tokens.ConsumeGrokQuota(ctx, token.ID, "video")
|
_ = s.tokens.FinalizeGrokQuota(ctx, token.ID)
|
||||||
data = d
|
data = d
|
||||||
videoURL = strings.TrimSpace(stringValue(meta["video_url"]))
|
videoURL = strings.TrimSpace(stringValue(meta["video_url"]))
|
||||||
return true, false
|
return true, false
|
||||||
}
|
}
|
||||||
|
_ = s.tokens.RefundGrokQuota(ctx, token.ID, "video")
|
||||||
lastErr = genErr
|
lastErr = genErr
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(genErr, grok.ErrAuth), errors.Is(genErr, grok.ErrQuotaExhausted):
|
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)
|
defer s.acctRelease(ctx, token.ID, eventID)
|
||||||
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
|
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
|
||||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
_ = 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)
|
d, meta, genErr := s.grok.GenerateConsoleImage(ctx, token.Value, in.Prompt, aspectRatio, resolution, refs, urlOnly)
|
||||||
if genErr == nil {
|
if genErr == nil {
|
||||||
_, _ = s.tokens.Update(ctx, "grok", token.ID, map[string]any{
|
_, _ = 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"),
|
"success_total": gorm.Expr("success_total + 1"),
|
||||||
"fails": 0,
|
"fails": 0,
|
||||||
})
|
})
|
||||||
// 本地额度各扣各的;图/视频都归零时账号直接判死。
|
// 图/视频都归零时账号直接判死。
|
||||||
_ = s.tokens.ConsumeGrokQuota(ctx, token.ID, "image")
|
_ = s.tokens.FinalizeGrokQuota(ctx, token.ID)
|
||||||
data = d
|
data = d
|
||||||
artURL = strings.TrimSpace(stringValue(meta["image_url"]))
|
artURL = strings.TrimSpace(stringValue(meta["image_url"]))
|
||||||
return true, false
|
return true, false
|
||||||
}
|
}
|
||||||
|
_ = s.tokens.RefundGrokQuota(ctx, token.ID, "image")
|
||||||
lastErr = genErr
|
lastErr = genErr
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(genErr, grok.ErrAuth), errors.Is(genErr, grok.ErrQuotaExhausted):
|
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
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// token.Value is the cookie; GenerateImage mints a fresh JWT each attempt, so an
|
// token.Value is the cookie; GenerateImage mints a fresh JWT each attempt (and
|
||||||
// auth failure means the cookie itself is dead — no refresher (nil).
|
// 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
|
var imageURL string
|
||||||
data, err := s.runPoolWithFailover(ctx, eventID, "leonardo", active, "image", func(token model.TokenAccount) ([]byte, error) {
|
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
|
// 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
|
return nil, leonardo.ErrQuotaExhausted
|
||||||
}
|
}
|
||||||
data, meta, genErr := s.leonardo.GenerateImage(ctx, token.Value, upstreamModel, in.Prompt, width, height, nil, refs, !urlOnly)
|
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 {
|
if genErr != nil {
|
||||||
// Release the hold so a failed render doesn't burn credits.
|
// Release the hold so a failed render doesn't burn credits.
|
||||||
if deducted {
|
if deducted {
|
||||||
@@ -2811,7 +2837,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m
|
|||||||
imageURL = strings.TrimSpace(stringValue(meta["image_url"]))
|
imageURL = strings.TrimSpace(stringValue(meta["image_url"]))
|
||||||
// Success → overwrite the held value with the REAL upstream balance and
|
// Success → overwrite the held value with the REAL upstream balance and
|
||||||
// sink to 限额 if below the floor (best-effort; never fails a done render).
|
// 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
|
return data, nil
|
||||||
}, func(e error) (bool, bool, bool, bool) {
|
}, func(e error) (bool, bool, bool, bool) {
|
||||||
return errors.Is(e, leonardo.ErrAuth), errors.Is(e, leonardo.ErrQuotaExhausted), errors.Is(e, leonardo.ErrTemporaryUpstream), false
|
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
|
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
|
// 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
|
// 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.
|
// floor. Stores the daily renewal time so RecoverQuota can auto-recover it.
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ async function doSmartImport() {
|
|||||||
<strong class="text-slate-700">Cookie 数组</strong>(多 Adobe 批量)、
|
<strong class="text-slate-700">Cookie 数组</strong>(多 Adobe 批量)、
|
||||||
<strong class="text-slate-700">ChatGPT JWT</strong>(<code class="px-1 bg-slate-100 rounded">eyJhbGciOi...</code>)、
|
<strong class="text-slate-700">ChatGPT JWT</strong>(<code class="px-1 bg-slate-100 rounded">eyJhbGciOi...</code>)、
|
||||||
<strong class="text-slate-700">Runway JWT</strong>(自动与 ChatGPT 区分)、
|
<strong class="text-slate-700">Runway JWT</strong>(自动与 ChatGPT 区分)、
|
||||||
<strong class="text-slate-700">Leonardo Cookie</strong>(含 better-auth)、
|
<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">Krea Cookie</strong>(含 sb-superb-auth)、
|
||||||
<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">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">Grok SSO</strong>(grok.com 的 <code class="px-1 bg-slate-100 rounded">sso</code> 值,仅含 session_id,自动与 ChatGPT/Runway 区分)、
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ const isVideo = computed(() => entry.value?.type === 'video')
|
|||||||
// Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K)
|
// Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K)
|
||||||
// regardless of how the catalog/stored record happens to list them.
|
// regardless of how the catalog/stored record happens to list them.
|
||||||
const resolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
|
const resolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
|
||||||
|
// 按秒计价的视频模型(一个 /s 价,实付 = 分辨率价 + 每秒价 × 秒数)。
|
||||||
const isPerSecond = computed(() => {
|
const isPerSecond = computed(() => {
|
||||||
const id = entry.value?.id || ''
|
const id = entry.value?.id || ''
|
||||||
return id.startsWith('seedance')
|
return id.startsWith('seedance') || id === 'grok-video'
|
||||||
})
|
})
|
||||||
|
|
||||||
const durationTiers = computed(() => {
|
const durationTiers = computed(() => {
|
||||||
|
|||||||
@@ -126,7 +126,19 @@ const supportsBoth = computed(() => { const m = model.value; return m && m.refer
|
|||||||
const refMode = ref(refModeDefault.value)
|
const refMode = ref(refModeDefault.value)
|
||||||
watch(refModeDefault, (v) => { refMode.value = v })
|
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 || ''))
|
const isSeedanceModel = computed(() => /^seedance/.test(model.value?.id || ''))
|
||||||
|
// 支持图片以外的参考资产(视频/音频)的模型:seedance 系 + 预设声明了音视频上限的
|
||||||
|
const supportsMediaRefs = computed(() =>
|
||||||
|
isSeedanceModel.value || maxVideosRaw.value > 0 || maxAudiosRaw.value > 0)
|
||||||
|
|
||||||
const perSecondRate = computed(() => {
|
const perSecondRate = computed(() => {
|
||||||
if (mode.value !== 'video' || !model.value) return null
|
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
|
// here at pick time so an oversized image fails fast with a clear message instead
|
||||||
// of charging + failing upstream after the upload.
|
// of charging + failing upstream after the upload.
|
||||||
const MAX_REF_BYTES = 20 * 1024 * 1024
|
const MAX_REF_BYTES = 20 * 1024 * 1024
|
||||||
// seedance 参考图模式各类上限
|
// seedance 参考图模式各类上限(预设可覆盖:Leonardo 私有 seedance 只吃 1 段音频)
|
||||||
const MAX_VIDEOS = 3
|
const maxImages = computed(() => Number(familyPreset.value?.max_images) || maxRefs.value)
|
||||||
const MAX_AUDIOS = 3
|
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 参考图(非首尾帧)模式下允许图片/视频/音频
|
// seedance 参考图(非首尾帧)模式下允许图片/视频/音频
|
||||||
const fileAccept = computed(() => {
|
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/*,video/*,audio/*'
|
||||||
return 'image/*'
|
return 'image/*'
|
||||||
})
|
})
|
||||||
@@ -246,7 +280,7 @@ function onFiles(ev) {
|
|||||||
// Shared by the file picker AND drag-and-drop. Honors per-model max + per-type
|
// Shared by the file picker AND drag-and-drop. Honors per-model max + per-type
|
||||||
// limits (image/video/audio) + 20MB cap.
|
// limits (image/video/audio) + 20MB cap.
|
||||||
function addFiles(files) {
|
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 = []
|
const tooBig = []
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (!f || !f.type) continue
|
if (!f || !f.type) continue
|
||||||
@@ -257,8 +291,9 @@ function addFiles(files) {
|
|||||||
if (refImages.value.length >= maxRefs.value) break
|
if (refImages.value.length >= maxRefs.value) break
|
||||||
if (f.size > MAX_REF_BYTES) { tooBig.push(f.name); continue }
|
if (f.size > MAX_REF_BYTES) { tooBig.push(f.name); continue }
|
||||||
const { imgs, vids, auds } = refCounts.value
|
const { imgs, vids, auds } = refCounts.value
|
||||||
if (isVideo && vids >= MAX_VIDEOS) continue
|
if (isVideo && vids >= maxVideos.value) continue
|
||||||
if (isAudio && auds >= MAX_AUDIOS) continue
|
if (isAudio && auds >= maxAudios.value) continue
|
||||||
|
if (isImage && allowMedia && imgs >= maxImages.value) continue
|
||||||
const fileType = isVideo ? 'video' : isAudio ? 'audio' : 'image'
|
const fileType = isVideo ? 'video' : isAudio ? 'audio' : 'image'
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
makeThumb(f).then((thumb) => {
|
makeThumb(f).then((thumb) => {
|
||||||
@@ -500,6 +535,8 @@ async function run() {
|
|||||||
error.value = '该视频模型需要至少 1 张参考图 (首帧)'
|
error.value = '该视频模型需要至少 1 张参考图 (首帧)'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const refErr = videoRefDurationError()
|
||||||
|
if (refErr) { error.value = refErr; return }
|
||||||
if (price.value == null) {
|
if (price.value == null) {
|
||||||
error.value = '该参数组合未定价 (留空 = 不支持)'
|
error.value = '该参数组合未定价 (留空 = 不支持)'
|
||||||
return
|
return
|
||||||
@@ -869,7 +906,7 @@ onUnmounted(() => {
|
|||||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">
|
<label class="block text-xs font-medium text-slate-500 mb-1.5">
|
||||||
{{ refMode === 'frame' && mode === 'video' ? '首尾帧' : '参考图' }}
|
{{ refMode === 'frame' && mode === 'video' ? '首尾帧' : '参考图' }}
|
||||||
<span class="text-slate-400 font-normal">
|
<span class="text-slate-400 font-normal">
|
||||||
(最多 {{ maxRefs }} 张{{ isSeedanceModel && refMode !== 'frame' && mode === 'video' ? ` · 图片${refCounts.imgs}/${maxRefs} · 视频${refCounts.vids}/${MAX_VIDEOS} · 音频${refCounts.auds}/${MAX_AUDIOS}` : '' }}{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单文件 ≤20MB)
|
(最多 {{ maxRefs }} 张{{ supportsMediaRefs && refMode !== 'frame' && mode === 'video' ? ` · 图片${refCounts.imgs}/${maxImages} · 视频${refCounts.vids}/${maxVideos} · 音频${refCounts.auds}/${maxAudios}` : '' }}{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单文件 ≤20MB)
|
||||||
</span>
|
</span>
|
||||||
<span v-if="refsRequired" class="text-rose-500">*</span>
|
<span v-if="refsRequired" class="text-rose-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user