feat: 二次元前端改版 + 后端账号plan探测/调度修复

This commit is contained in:
2026-08-06 10:11:05 +08:00
parent 7f4701d362
commit f76ecd5769
37 changed files with 2484 additions and 813 deletions
+5
View File
@@ -165,6 +165,11 @@ func (m *MaintenanceService) tick(ctx context.Context) {
// 2. Auto-renew Adobe cookies whose refresh interval has elapsed.
if m.refresh != nil {
if m.settings != nil {
if proxy, err := m.settings.GetValue(ctx, "proxy.url"); err == nil && proxy != "" {
m.refresh.SetProxy(proxy)
}
}
if n, err := m.refresh.RefreshDue(ctx); err != nil {
log.Printf("maintenance: refresh_due: %v", err)
} else if n > 0 {
+42 -11
View File
@@ -26,6 +26,12 @@ func NewRefreshProfileService(profiles *repo.RefreshProfileRepository, tokens *r
}
}
func (s *RefreshProfileService) SetProxy(proxy string) {
if s.adobe != nil {
s.adobe.SetProxy(proxy)
}
}
func (s *RefreshProfileService) List(ctx context.Context) ([]model.RefreshProfile, error) {
return s.profiles.List(ctx)
}
@@ -64,12 +70,11 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
"consecutive_failures": failures,
"next_retry_at": now.Add(time.Duration(secs) * time.Second),
})
// After repeated failures the cookie can no longer mint a token — it's
// genuinely dead (expired/revoked). Lock the pool token (disabled+dead)
// so the UI flags it red. A single failure may be a transient blip, so
// only escalate after a few in a row (mirrors Python RefreshManager).
if failures >= 3 {
_, _ = s.tokens.Update(ctx, profile.Pool, id, map[string]any{
// Mark the token dead when the cookie exchange keeps failing:
// - 5 consecutive failures → cookie is likely expired/revoked
// - ride_AdobeID_acct_actreq (Adobe requires account action) → permanently broken
if failures >= 5 || strings.Contains(msg, "ride_AdobeID_acct_actreq") {
_, _ = s.tokens.Update(ctx, "adobe", id, map[string]any{
"status": "disabled",
"dead": true,
})
@@ -84,12 +89,14 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
"fails": 0,
"updated_at": now,
}
email, exp := parseJWTEmailExpiry(result.AccessToken)
email, _ := parseJWTEmailExpiry(result.AccessToken)
if email != "" {
tokenPatch["account_email"] = email
}
if exp != nil {
tokenPatch["cached_quota_reset_after"] = exp.Format(time.RFC3339)
if profile.IntervalSeconds > 0 {
tokenPatch["cached_quota_reset_after"] = now.Add(time.Duration(profile.IntervalSeconds) * time.Second).Format(time.RFC3339)
} else {
tokenPatch["cached_quota_reset_after"] = now.Add(54000 * time.Second).Format(time.RFC3339)
}
if profileData, profileErr := s.adobe.FetchAccountProfile(ctx, result.AccessToken); profileErr == nil {
if email := strings.TrimSpace(stringValue(profileData["email"])); email != "" {
@@ -99,6 +106,7 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
tokenPatch["account_display_name"] = displayName
}
}
planKnown := false
if quotaData, quotaErr := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); quotaErr == nil {
meta := datatypes.JSONMap{
"cached_quota_at": int(time.Now().Unix()),
@@ -113,10 +121,33 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
meta["cached_quota_total"] = total
}
tokenPatch["meta"] = meta
if resetAfter := strings.TrimSpace(stringValue(quotaData["available_until"])); resetAfter != "" {
tokenPatch["cached_quota_reset_after"] = resetAfter
planCap := strings.ToLower(strings.TrimSpace(stringValue(quotaData["plan"])))
meta["plan"] = planCap
isVIP := planCap != "" && !strings.EqualFold(planCap, "free")
planKnown = planCap != ""
// VIP: use Adobe's reset time; set concurrency 5.
// 母号/子号 身份固定,不随积分动态变化——只有降级普号才刷新身份。
if isVIP {
if resetAfter := strings.TrimSpace(stringValue(quotaData["available_until"])); resetAfter != "" {
tokenPatch["cached_quota_reset_after"] = resetAfter
}
tokenPatch["concurrency"] = 5
} else {
// Free account: concurrency 1, limit image+video
tokenPatch["concurrency"] = 1
tokenPatch["image_limited"] = true
tokenPatch["video_limited"] = true
meta["is_sub_account"] = false
}
}
// 探测不到会员身份(credits 接口失败或没返回 plan)的号既不算普号也不算会员号,
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号。
if !planKnown {
tokenPatch["status"] = "disabled"
tokenPatch["dead"] = true
}
if _, err := s.tokens.Update(ctx, "adobe", id, tokenPatch); err != nil {
return err
}
+63 -5
View File
@@ -709,17 +709,44 @@ func (s *TokenService) checkPendingAdobe(tokenID, cookie string) {
_, _ = s.tokens.Update(ctx, "adobe", tokenID, seed)
quotaMeta := map[string]any{}
planKnown := false
if cb, e := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); e == nil {
if ra := strings.TrimSpace(stringValue(cb["available_until"])); ra != "" {
_, _ = s.tokens.Update(ctx, "adobe", tokenID, map[string]any{"cached_quota_reset_after": ra})
}
quotaMeta["cached_quota_at"] = int(time.Now().Unix())
planCap := strings.ToLower(strings.TrimSpace(stringValue(cb["plan"])))
quotaMeta["plan"] = planCap
isVIP := planCap != "" && planCap != "free"
planKnown = planCap != ""
if rem, ok := cb["remaining"].(int); ok {
quotaMeta["cached_quota_remaining"] = rem
if rem <= 4000 {
if isVIP && rem > 0 && rem <= 4000 {
quotaMeta["is_sub_account"] = true
}
}
// Set concurrency + limits based on plan
concurrency := 1
imageLimit := true
videoLimit := true
if isVIP {
concurrency = 5
imageLimit = false
videoLimit = false
if ra := strings.TrimSpace(stringValue(cb["available_until"])); ra != "" {
_, _ = s.tokens.Update(ctx, "adobe", tokenID, map[string]any{"cached_quota_reset_after": ra})
}
}
patch := map[string]any{
"concurrency": concurrency,
"image_limited": imageLimit,
"video_limited": videoLimit,
"meta": quotaMeta,
}
if planKnown {
patch["status"] = "active"
patch["dead"] = false
}
_, _ = s.tokens.Update(ctx, "adobe", tokenID, patch)
}
if prof, e := s.adobe.FetchAccountProfile(ctx, result.AccessToken); e == nil {
p := map[string]any{}
@@ -734,6 +761,13 @@ func (s *TokenService) checkPendingAdobe(tokenID, cookie string) {
}
}
// 探测不到会员身份(credits 接口失败或没返回 plan)的号既不算普号也不算会员号,
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号,等重新探测到 plan 再启用。
if !planKnown {
log.Printf("token import: adobe %s plan unknown, marking dead", tokenID)
s.finishPending(ctx, "adobe", tokenID, "disabled", true, quotaMeta)
return
}
s.finishPending(ctx, "adobe", tokenID, "active", false, quotaMeta)
_, _ = s.refresh.Update(ctx, tokenID, map[string]any{
"last_attempt_at": time.Now(),
@@ -1274,9 +1308,21 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
patch := map[string]any{}
meta := cloneJSONMap(item.Meta)
meta["cached_quota_at"] = int(time.Now().Unix())
plan := strings.ToLower(strings.TrimSpace(stringValue(data["plan"])))
if plan != "" {
meta["plan"] = plan
}
if remaining, ok := data["remaining"].(int); ok {
meta["cached_quota_remaining"] = remaining
meta["is_sub_account"] = remaining <= 4000
// is_sub_account 仅在导入时写入,刷新配额时不覆盖
if _, hasFlag := meta["is_sub_account"]; !hasFlag && plan != "free" {
meta["is_sub_account"] = remaining > 0 && remaining <= 4000
}
}
// 子号必然是会员号:plan 查回 free 说明这个号没有会员,不能再挂子号标记
// (否则同一行会同时显示 子号 和 普号)。
if plan == "free" {
meta["is_sub_account"] = false
}
if used, ok := data["used"].(int); ok {
meta["cached_quota_used"] = used
@@ -1285,6 +1331,12 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
meta["cached_quota_total"] = total
}
patch["meta"] = meta
// 探测不到会员身份(新旧 plan 都为空)的号既不算普号也不算会员号,
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号。
if strings.TrimSpace(stringValue(meta["plan"])) == "" {
patch["status"] = "disabled"
patch["dead"] = true
}
if resetAfter := strings.TrimSpace(stringValue(data["available_until"])); resetAfter != "" {
patch["cached_quota_reset_after"] = resetAfter
item.CachedQuotaResetAfter = resetAfter
@@ -1294,6 +1346,7 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
item = updated
}
}
subAccount, _ := jsonMapBool(meta, "is_sub_account")
return map[string]any{
"supported": true,
"remaining": data["remaining"],
@@ -1304,6 +1357,9 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
"unchanged": false,
"unknown": boolValueWithDefault(data["unknown"], false),
"error": data["error"],
// 会员身份跟着额度一起返回,前端查一次额度就能刷新 子号/母号/普号 徽章。
"plan": emptyToNil(strings.ToLower(strings.TrimSpace(stringValue(meta["plan"])))),
"sub_account": subAccount,
}, nil
}
if poolToType(item.Pool) == "krea" && s.krea != nil {
@@ -1648,6 +1704,7 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
quotaAt, _ := jsonMapInt(item.Meta, "cached_quota_at")
pending, _ := jsonMapBool(item.Meta, "pending_check")
subAccount, _ := jsonMapBool(item.Meta, "is_sub_account")
plan, _ := item.Meta["plan"]
// OpenAI email lives in the token's JWT (nested profile claim). Decode it at
// render time like the Python reference (_account_row) so accounts imported
// before the email was persisted still show a name; fall back to the cached
@@ -1686,6 +1743,7 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
"video_limited": item.VideoLimited,
"pending": pending,
"sub_account": subAccount,
"plan": emptyToNil(strings.ToLower(strings.TrimSpace(stringValue(plan)))),
"quota_supported": hasQuota,
"needs_reset_fetch": typeLabel == "adobe" && item.Status == "active" && strings.TrimSpace(item.CachedQuotaResetAfter) == "",
"weight": item.Weight,
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"backend/internal/model"
@@ -33,6 +34,9 @@ type UserGenerateRequest struct {
Resolution string
Duration string
ReferenceImages []string
// ReferenceMode overrides the model's default ("frame" or "asset") —
// the 画图台 首尾帧/参考图 toggle for models that support both.
ReferenceMode string
// DeAI applies 去AI特征 post-processing to the generated image (image only)
// and charges the per-tier surcharge on top of the model price.
DeAI bool
@@ -59,6 +63,9 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
switch modelItem.Type {
case "video":
if err := validateReferenceMode(in.ReferenceMode, modelItem, len(in.ReferenceImages)); err != nil {
return nil, err
}
resp, err := s.v1.prepareSessionVideo(ctx, principal, V1VideoRequest{
Model: in.Model,
Prompt: in.Prompt,
@@ -66,6 +73,7 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
AspectRatio: in.Ratio,
Resolution: in.Resolution,
ReferenceImages: in.ReferenceImages,
ReferenceMode: strings.TrimSpace(in.ReferenceMode),
})
if err != nil {
return nil, err
@@ -87,6 +95,27 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
}
}
// validateReferenceMode mirrors the /v1 checks: the override must be "frame"
// or "asset", the model must support references at all, and frame mode carries
// at most 2 images (first+last frame).
func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int) error {
rm = strings.TrimSpace(rm)
if rm == "" {
return nil
}
supported := strings.TrimSpace(modelItem.ReferenceMode)
if supported == "" || supported == "none" {
return fmt.Errorf("%w: reference_mode not supported for this model", ErrUnsupportedParams)
}
if rm != "frame" && rm != "asset" {
return fmt.Errorf("%w: reference_mode must be 'frame' or 'asset'", ErrUnsupportedParams)
}
if rm == "frame" && refCount > 2 {
return fmt.Errorf("%w: frame mode supports at most 2 reference images (first+last frame), got %d", ErrUnsupportedParams, refCount)
}
return nil
}
func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) {
if user == nil || strings.TrimSpace(user.ID) == "" {
return nil, errors.New("未登录或会话已过期")
+295 -33
View File
@@ -219,6 +219,7 @@ type V1VideoRequest struct {
AspectRatio string
Resolution string
ReferenceImages []string
ReferenceMode string // "frame" or "asset", overrides model default
// BaseURL — see V1ImageRequest.BaseURL.
BaseURL string
// AccountID — see V1ImageRequest.AccountID.
@@ -325,6 +326,11 @@ func (s *V1Service) refreshAdobeToken(ctx context.Context, tokenID string) (mode
if s.refresh == nil {
return model.TokenAccount{}, false
}
if s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil && proxy != "" {
s.refresh.SetProxy(proxy)
}
}
if err := s.refresh.RefreshNow(ctx, tokenID); err != nil {
return model.TokenAccount{}, false
}
@@ -883,6 +889,24 @@ func (s *V1Service) StartVideoJob(ctx context.Context, principal *APIPrincipal,
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", err.Error())
return nil, err
}
// Validate reference_mode against model capabilities and reference count.
if rm := strings.TrimSpace(in.ReferenceMode); rm != "" {
supported := strings.TrimSpace(modelItem.ReferenceMode)
if supported == "none" || supported == "" {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", "reference_mode not supported for this model")
return nil, errors.New("reference_mode not supported for this model")
}
if rm != "frame" && rm != "asset" {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", "reference_mode must be 'frame' or 'asset'")
return nil, errors.New("reference_mode must be 'frame' or 'asset'")
}
if rm == "frame" && len(in.ReferenceImages) > 2 {
return nil, fmt.Errorf("frame mode supports at most 2 reference images (first+last frame), got %d", len(in.ReferenceImages))
}
if strings.TrimSpace(in.ReferenceMode) == modelItem.ReferenceMode {
in.ReferenceMode = "" // same as default, don't override
}
}
// Source "v1": no output file is allocated — the result is the upstream URL,
// stored on the event when the render completes.
eventID, err := s.logPendingEvent(ctx, "video", modelItem, principal, in.Prompt, aspectRatio, resolution, duration, len(in.ReferenceImages), price, "", "v1", nil, false)
@@ -1251,6 +1275,26 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
if !modelItem.Enabled || modelItem.Type != "video" {
return nil, "", "", "", 0, ErrUnknownModel
}
// Validate duration against model's supported range (from Durations JSON array).
if secs := parseDurationSeconds(duration); secs > 0 {
if durList := repo.JSONStrings(modelItem.Durations); len(durList) > 0 {
minSecs, maxSecs := 9999, 0
for _, d := range durList {
n := parseDurationSeconds(d)
if n > 0 {
if n < minSecs {
minSecs = n
}
if n > maxSecs {
maxSecs = n
}
}
}
if secs < minSecs || secs > maxSecs {
return nil, "", "", "", 0, fmt.Errorf("duration %ds out of range [%d-%d] for model %s", secs, minSecs, maxSecs, modelItem.EffectiveName())
}
}
}
// Fail fast before charging — effective provider (custom upstream by id, else native).
if eff := s.effectiveProvider(ctx, modelItem); eff == "custom" {
// custom serves this id (effectiveProvider guaranteed it) — precheck ok
@@ -1296,6 +1340,11 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
if err != nil {
return nil, "", "", "", 0, err
}
// 规范化 duration 字段:前端 per_second 计费模式可能发来 "per_second" 字符串,
// 统一转为 "Xs" 格式(如 "4s")存库,避免日志显示原始键名。
if n := parseDurationSeconds(duration); n > 0 {
duration = fmt.Sprintf("%ds", n)
}
return modelItem, resolution, aspectRatio, duration, price, nil
}
@@ -1438,7 +1487,6 @@ func (s *V1Service) finishUnimplementedEvent(ctx context.Context, eventID string
return s.events.UpdateStatus(ctx, eventID, "failed", "generation executor not implemented yet", 0)
}
// grokConcurrencyPerAccount is how many simultaneous generations one grok account
// may run (grok tolerates 10, unlike the 1-per-account default elsewhere).
const grokConcurrencyPerAccount = 10
@@ -1447,7 +1495,7 @@ const grokConcurrencyPerAccount = 10
// policy may burn per request before giving up, so an upstream-wide blip
// ("system under load") can't fan a single request out across the whole pool.
// After this many accounts fail this way, the request fails.
const maxTempDeadAccounts = 3
const maxTempDeadAccounts = 10
// runPoolWithFailover drives a generation across a round-robin-ordered account
// list with per-error-class behavior, so a bad request never burns the whole
@@ -1479,8 +1527,8 @@ func (s *V1Service) runPoolWithFailover(ctx context.Context, eventID, pool strin
busy := 0
tempDeadCount := 0
for _, token := range active {
// 1 concurrent job per account: skip any account already generating.
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
// Per-account concurrency gate (defaults to 1 for built-in pools).
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
busy++
continue
}
@@ -1533,6 +1581,15 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
_ = s.tokens.TouchLastUsed(ctx, token.ID)
authRefreshed := false
if strings.TrimSpace(token.Value) == "" && refreshOnAuth != nil {
if refreshed, ok := refreshOnAuth(token.ID); ok {
token = refreshed
authRefreshed = true
} else {
s.markTokenDead(ctx, pool, token, kind)
return nil, ErrProviderExecution, true, true
}
}
for {
data, err := attempt(token)
if err == nil {
@@ -1549,6 +1606,13 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token
return nil, err, true, false
}
if isAuth {
// A 403 user_not_entitled means the account has no Firefly entitlement
// — refreshing the access token can't grant one, so kill it now instead
// of leaving it in rotation to burn every future request.
if errors.Is(err, adobe.ErrNotEntitled) {
s.markTokenDead(ctx, pool, token, kind)
return nil, err, true, true
}
// Refresh from cookie and retry ONCE; otherwise the credential is dead.
if refreshOnAuth != nil && !authRefreshed {
if refreshed, ok := refreshOnAuth(token.ID); ok {
@@ -1612,15 +1676,27 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
for _, item := range items {
// Adobe accounts are credit-based (积分号) — no per-kind quota locks.
// Only skip accounts that are dead or disabled.
if item.Status == "active" && !item.Dead && strings.TrimSpace(item.Value) != "" {
active = append(active, item)
if item.Status != "active" || item.Dead {
continue
}
// plan 未探测到的号既不算普号也不算会员号,置死号、不参与调度
if planUnknown(item.Meta) {
s.markPlanUnknownDead(ctx, "adobe", item.ID)
continue
}
// 普号(free)只能调度 free_allowed 的模型(香蕉2 仅 1K
if !freeAccountsAllowed(modelItem, resolution) && isFreeAccount(item.Meta) {
continue
}
active = append(active, item)
}
active = pinTestAccount(items, active, in.AccountID)
if len(active) == 0 {
return nil, "", ErrNoProviderAccount
}
s.rotateRoundRobin("adobe", active)
// 非 seedance 图片生成:普号 → 子号 → 母号
active = prioritizeSubAccounts(active)
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
if err != nil {
@@ -1637,6 +1713,13 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
for _, ref := range refs {
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", "")
if upErr != nil {
if errors.Is(upErr, adobe.ErrRateLimited) {
recoverAt := time.Now().Add(4 * time.Hour)
s.tokens.Update(ctx, "adobe", token.ID, map[string]any{
"status": "quota",
"quota_recover_at": &recoverAt,
})
}
return nil, upErr
}
blobIDs = append(blobIDs, id)
@@ -1668,10 +1751,21 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
}
var active []model.TokenAccount
for _, item := range items {
if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
if item.Status != "active" || item.Dead {
continue
}
if isSeedanceModel(modelItem.ID) && isSubAccount(item.Meta) {
// plan 未探测到的号既不算普号也不算会员号,置死号、不参与调度
if planUnknown(item.Meta) {
s.markPlanUnknownDead(ctx, "adobe", item.ID)
continue
}
// Seedance 模型只允许 VIP 母号:必须正向识别(plan 非 free、非子号、
// 积分 >4000),plan/额度未探测的账号一律不参与调度
if isSeedanceModel(modelItem.ID) && !isVipMotherAccount(item.Meta) {
continue
}
// 普号(free)只能调度 free_allowed 的模型
if !freeAccountsAllowed(modelItem, resolution) && isFreeAccount(item.Meta) {
continue
}
active = append(active, item)
@@ -1681,6 +1775,11 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
return nil, "", ErrNoProviderAccount
}
s.rotateRoundRobin("adobe", active)
// 非 seedance 视频生成:普号 → 子号 → 母号
// (seedance 已在上面过滤掉子号,此处无需额外处理)
if !isSeedanceModel(modelItem.ID) {
active = prioritizeSubAccounts(active)
}
refLimit := modelItem.MaxReferenceImages
if refLimit <= 0 {
@@ -1690,9 +1789,24 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
if err != nil {
return nil, "", err
}
// Classify refs for seedance: images (usage:style), videos, audio (usage:source).
var imgRefs, vidRefs, audRefs [][]byte
for _, r := range refs {
switch detectMediaType(r) {
case "video":
vidRefs = append(vidRefs, r)
case "audio":
audRefs = append(audRefs, r)
default:
imgRefs = append(imgRefs, r)
}
}
engine, upstreamModel := resolveAdobeVideoEngine(modelItem.ID)
referenceMode := defaultString(strings.TrimSpace(modelItem.ReferenceMode), "frame")
if rm := strings.TrimSpace(in.ReferenceMode); rm != "" {
referenceMode = rm
}
// Round-robin order; fail over to the next account on auth/quota; temporary
// upstream errors fail over too without penalizing the account (tempFailover,
@@ -1701,14 +1815,37 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
var videoURL string
data, err := s.runPoolWithFailover(ctx, eventID, "adobe", active, "video", func(token model.TokenAccount) ([]byte, error) {
var blobIDs []string
for _, ref := range refs {
for _, ref := range imgRefs {
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", engine)
if upErr != nil {
if errors.Is(upErr, adobe.ErrRateLimited) {
recoverAt := time.Now().Add(4 * time.Hour)
s.tokens.Update(ctx, "adobe", token.ID, map[string]any{
"status": "quota",
"quota_recover_at": &recoverAt,
})
}
return nil, upErr
}
blobIDs = append(blobIDs, id)
}
bytes, meta, genErr := s.adobe.GenerateVideo(ctx, token.Value, engine, in.Prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs, downloadResult)
var videoBlobIDs []string
for _, ref := range vidRefs {
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "video/mp4", engine)
if upErr != nil {
return nil, upErr
}
videoBlobIDs = append(videoBlobIDs, id)
}
var audioBlobIDs []string
for _, ref := range audRefs {
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "audio/mp3", engine)
if upErr != nil {
return nil, upErr
}
audioBlobIDs = append(audioBlobIDs, id)
}
bytes, meta, genErr := s.adobe.GenerateVideo(ctx, token.Value, engine, in.Prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs, videoBlobIDs, audioBlobIDs, downloadResult)
if genErr == nil {
videoURL = strings.TrimSpace(stringValue(meta["video_url"]))
}
@@ -1771,8 +1908,8 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
var videoURL string
busy := 0
for _, token := range active {
// 1 concurrent job per account: skip any account already generating.
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
// Per-account concurrency gate (defaults to 1 for built-in pools).
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
busy++
continue
}
@@ -1869,9 +2006,21 @@ func (s *V1Service) customActive(ctx context.Context, modelID string) ([]model.T
// accountConcurrency is the per-account simultaneous-job cap. Custom accounts use
// their configured Concurrency (default 1); built-in pools use the system value.
func accountConcurrency(item model.TokenAccount) int {
if item.Pool == "adobe" {
if isFreeAccount(item.Meta) {
return 1 // FREE 普号 / 降级号限制为 1 并发
}
if item.Concurrency > 0 {
return item.Concurrency
}
return 5 // VIP 会员号默认 5 并发
}
if item.Concurrency > 0 {
return item.Concurrency
}
if item.Pool == "grok" {
return grokConcurrencyPerAccount // 10
}
return 1
}
@@ -2159,9 +2308,8 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
var videoURL string
busy := 0
for _, token := range active {
// grok allows 10 concurrent jobs per account (unlike the 1-per-account
// default of the other pools).
if !s.acctAcquire(ctx, token.ID, eventID, grokConcurrencyPerAccount) {
// Per-account concurrency gate (defaults to 1 for built-in pools).
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
busy++
continue
}
@@ -2267,8 +2415,8 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
var lastErr error
busy := 0
for _, token := range active {
// 1 concurrent job per account: skip any account already generating.
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
// Per-account concurrency gate (defaults to 1 for built-in pools).
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
busy++
continue
}
@@ -2860,6 +3008,40 @@ func decodeReferenceImages(inputs []string, limit int) ([][]byte, error) {
return out, nil
}
// detectMediaType inspects the first bytes of a decoded reference to classify it
// as "video", "audio", or "image". Used to route refs to the correct upload MIME
// and the correct referenceBlobs usage for seedance.
func detectMediaType(data []byte) string {
n := len(data)
if n < 8 {
return "image"
}
// MP4 / ISOBMFF
if n >= 12 && string(data[4:8]) == "ftyp" {
return "video"
}
// WebM
if n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3 {
return "video"
}
// MP3: ID3 header or sync word 0xFFFx
if n >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
return "audio"
}
if n >= 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0 {
return "audio"
}
// WAV
if n >= 4 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 {
return "audio"
}
// OGG
if n >= 4 && data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53 {
return "audio"
}
return "image"
}
func parseImageSize(size, aspectRatio, resolution string) (string, string) {
ar := strings.TrimSpace(strings.ReplaceAll(aspectRatio, "x", ":"))
rs := strings.TrimSpace(resolution)
@@ -3162,8 +3344,8 @@ func resolveAdobeVideoEngine(modelID string) (string, string) {
return "veo31-fast", ""
case "gemini-veo3.1":
return "veo31-standard", ""
case "seedance-fast":
return "seedance-fast", ""
case "seedance-2.0-fast":
return "seedance-2.0-fast", ""
case "seedance-2.0":
return "seedance-2.0", ""
case "firefly-ray":
@@ -3216,16 +3398,8 @@ func (s *V1Service) markTokenFailure(ctx context.Context, pool string, token mod
}
switch {
case isQuota:
// Adobe accounts are credit-based (积分号) — quota exhaustion is
// non-locking: track the failure for rotation but don't limit or
// sink the account. Other pools go straight to "quota" as before.
if pool == "adobe" {
// No-op: just track fails (already patched above), leave
// image_limited/video_limited/status untouched.
} else {
patch["status"] = "quota"
}
if pool != "adobe" && strings.TrimSpace(token.CachedQuotaResetAfter) == "" {
patch["status"] = "quota"
if strings.TrimSpace(token.CachedQuotaResetAfter) == "" {
recoverAt := time.Unix((time.Now().Unix()/86400+1)*86400, 0).UTC()
patch["quota_recover_at"] = &recoverAt
}
@@ -3329,18 +3503,106 @@ func (s *V1Service) rotateRoundRobin(pool string, items []model.TokenAccount) {
}
}
// freeOnly1KModelID is the one free-allowed model 普号 may only serve at 1K
// (香蕉2 的 2K/4K 需要会员号) — see freeAccountsAllowed.
const freeOnly1KModelID = "nano-banana-2"
func isSeedanceModel(modelID string) bool {
return modelID == "seedance-fast" || modelID == "seedance-2.0"
return modelID == "seedance-2.0-fast" || modelID == "seedance-2.0"
}
func isSubAccount(meta map[string]interface{}) bool {
// freeAccountsAllowed reports whether 普号(free) may serve this request: the model
// must be marked free_allowed. 香蕉2 另外只允许 1K 档,它的 2K/4K 只走会员号。
func freeAccountsAllowed(modelItem *model.ModelConfig, resolution string) bool {
if modelItem == nil || !modelItem.FreeAllowed {
return false
}
if modelItem.ID == freeOnly1KModelID {
return strings.EqualFold(strings.TrimSpace(resolution), "1K")
}
return true
}
func isFreeAccount(meta map[string]interface{}) bool {
if meta == nil {
return false
}
plan := strings.ToLower(strings.TrimSpace(stringValue(meta["plan"])))
return plan == "free"
}
// planUnknown 报告账号的会员身份还没探测出来(meta.plan 缺失或为空)。这类号
// 既不能当普号也不能当会员号用:当会员号派出去会在需要会员的模型上撞 403
// user_not_entitled。
func planUnknown(meta map[string]interface{}) bool {
if meta == nil {
return true
}
return strings.TrimSpace(stringValue(meta["plan"])) == ""
}
// markPlanUnknownDead 把选号时遇到的 plan 未探测账号置为死号,等重新探测到
// plan 后再由额度刷新恢复。
func (s *V1Service) markPlanUnknownDead(ctx context.Context, pool, id string) {
s.tokens.Update(ctx, pool, id, map[string]any{"status": "disabled", "dead": true})
}
// prioritizeSubAccounts 对非 Seedance 模型按 普号 → 子号 → 母号 的顺序排序:
// 先消耗普号,普号不可用再用低积分子号,最后才动 vip 母号。
func prioritizeSubAccounts(active []model.TokenAccount) []model.TokenAccount {
var frees, subs, mothers []model.TokenAccount
for _, a := range active {
switch {
case isFreeAccount(a.Meta):
frees = append(frees, a)
case isLowCredits(a.Meta):
subs = append(subs, a)
default:
mothers = append(mothers, a)
}
}
return append(append(frees, subs...), mothers...)
}
// isVipMotherAccount 正向识别 VIP 母号:plan 已知且非 free,且 is_sub_account
// 显式为 false。只看身份不看积分余额(低积分母号也可用);plan 未探测或
// is_sub_account 缺失的账号返回 false,等刷新补齐后才可被 Seedance 调度。
func isVipMotherAccount(meta map[string]interface{}) bool {
if meta == nil {
return false
}
plan := strings.ToLower(strings.TrimSpace(stringValue(meta["plan"])))
if plan == "" || plan == "free" {
return false
}
v, ok := meta["is_sub_account"]
if !ok {
return false
}
b, _ := v.(bool)
return b
switch val := v.(type) {
case bool:
return !val
case float64:
return val == 0
}
return false
}
func isLowCredits(meta map[string]interface{}) bool {
if meta == nil {
return false
}
if v, ok := meta["is_sub_account"]; ok {
switch val := v.(type) {
case bool:
return val
case float64:
return val != 0
}
}
// 兼容存量账号:is_sub_account 字段不存在时,用积分余额判断(>0 且 ≤4000 视为子号)
if rem, ok := jsonMapInt(meta, "cached_quota_remaining"); ok {
return rem > 0 && rem <= 4000
}
return false
}