feat(leonardo): 接入视频通道并新增 seedance-2.0/minimax-h3 模型

This commit is contained in:
2026-08-08 21:55:33 +08:00
parent 6ba80f5109
commit 6d17caaf80
11 changed files with 1128 additions and 106 deletions
+19
View File
@@ -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{
+242 -12
View File
@@ -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.