feat: 二次元前端改版 + 后端账号plan探测/调度修复
This commit is contained in:
@@ -342,6 +342,7 @@ func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
|
||||
// 「删除异常账号」to collect every dead id regardless of the current page.
|
||||
typeFilter := strings.TrimSpace(c.Query("type"))
|
||||
statusFilter := strings.TrimSpace(c.Query("status"))
|
||||
planFilter := strings.TrimSpace(c.Query("plan"))
|
||||
deadOnly := c.Query("dead") == "1"
|
||||
q := strings.ToLower(strings.TrimSpace(c.Query("q")))
|
||||
filtered := make([]map[string]any, 0, len(data))
|
||||
@@ -355,6 +356,22 @@ func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
|
||||
if statusFilter != "" && rowStr(row, "status") != statusFilter {
|
||||
continue
|
||||
}
|
||||
// plan filter: vip=非free, free=free, sub=子号, master=vip且非子号
|
||||
if planFilter != "" {
|
||||
plan := strings.ToLower(rowStr(row, "plan"))
|
||||
isSub := rowBool(row, "sub_account")
|
||||
isFree := plan == "" || plan == "free"
|
||||
switch planFilter {
|
||||
case "vip":
|
||||
if isFree { continue }
|
||||
case "free":
|
||||
if !isFree { continue }
|
||||
case "sub":
|
||||
if !isSub { continue }
|
||||
case "master":
|
||||
if isFree || isSub { continue }
|
||||
}
|
||||
}
|
||||
if q != "" {
|
||||
email := strings.ToLower(rowStr(row, "email"))
|
||||
id := strings.ToLower(rowStr(row, "id"))
|
||||
|
||||
@@ -68,6 +68,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) {
|
||||
Resolution string `json:"resolution"`
|
||||
Duration string `json:"duration"`
|
||||
ReferenceImages []string `json:"reference_images"`
|
||||
ReferenceMode string `json:"reference_mode"`
|
||||
DeAI bool `json:"deai"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
@@ -82,6 +83,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) {
|
||||
Resolution: body.Resolution,
|
||||
Duration: body.Duration,
|
||||
ReferenceImages: body.ReferenceImages,
|
||||
ReferenceMode: body.ReferenceMode,
|
||||
DeAI: body.DeAI,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -345,7 +347,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 2,
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
@@ -356,7 +358,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 2,
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
@@ -369,7 +371,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||
"resolutions": []string{"720p"},
|
||||
},
|
||||
{
|
||||
"key": "seedance-fast",
|
||||
"key": "seedance-2.0-fast",
|
||||
"label": "Seedance 2.0 Fast",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
@@ -377,7 +379,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0",
|
||||
@@ -388,7 +390,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "firefly-video",
|
||||
@@ -398,7 +400,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||
"durations": []string{"5s"},
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"540p", "720p", "1080p"},
|
||||
"max_reference_images": 2,
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
@@ -524,7 +526,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"max_reference_images": 2,
|
||||
"max_reference_images": 3,
|
||||
"reference_mode": "frame",
|
||||
"description": "Veo 3.1 Fast",
|
||||
},
|
||||
@@ -533,17 +535,18 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"max_reference_images": 0,
|
||||
"max_reference_images": 3,
|
||||
"reference_mode": "style",
|
||||
"description": "Veo 3.1",
|
||||
},
|
||||
{
|
||||
"id": "firefly-ray",
|
||||
"id": "gemini-veo3.1",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
|
||||
"resolutions": []string{"720p"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"5s", "10s"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
@@ -561,14 +564,14 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
||||
"description": "Adobe Firefly Video",
|
||||
},
|
||||
{
|
||||
"id": "seedance-fast",
|
||||
"id": "seedance-2.0-fast",
|
||||
"provider": "adobe",
|
||||
"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": 9,
|
||||
"reference_mode": "frame",
|
||||
"reference_mode": "style",
|
||||
"description": "Seedance 2.0 Fast",
|
||||
},
|
||||
{
|
||||
@@ -579,7 +582,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
"reference_mode": "style",
|
||||
"description": "Seedance 2.0",
|
||||
},
|
||||
{
|
||||
@@ -763,7 +766,7 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-fast",
|
||||
"id": "seedance-2.0-fast",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
|
||||
@@ -129,10 +129,10 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
var modelID, prompt, seconds, size string
|
||||
var modelID, prompt, seconds, size, referenceMode string
|
||||
var refs []string
|
||||
if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
|
||||
if err := c.Request.ParseMultipartForm(64 << 20); err != nil {
|
||||
if err := c.Request.ParseMultipartForm(256 << 20); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
@@ -140,17 +140,17 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
|
||||
prompt = c.PostForm("prompt")
|
||||
seconds = c.PostForm("seconds")
|
||||
size = c.PostForm("size")
|
||||
referenceMode = c.PostForm("reference_mode")
|
||||
refs = readMultipartImages(c, "input_reference", "input_reference[]")
|
||||
} else {
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Seconds json.RawMessage `json:"seconds"`
|
||||
Size string `json:"size"`
|
||||
// Reference frames (image-to-video / first-last frames) as base64 or
|
||||
// data-URI strings — the JSON equivalent of multipart input_reference.
|
||||
InputReference []string `json:"input_reference"`
|
||||
ReferenceImages []string `json:"reference_images"`
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Seconds json.RawMessage `json:"seconds"`
|
||||
Size string `json:"size"`
|
||||
ReferenceMode string `json:"reference_mode"`
|
||||
InputReference []string `json:"input_reference"`
|
||||
ReferenceImages []string `json:"reference_images"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
@@ -158,6 +158,7 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
|
||||
}
|
||||
modelID, prompt, size = body.Model, body.Prompt, body.Size
|
||||
seconds = rawToString(body.Seconds)
|
||||
referenceMode = strings.TrimSpace(body.ReferenceMode)
|
||||
refs = append(body.InputReference, body.ReferenceImages...)
|
||||
}
|
||||
duration := strings.TrimSpace(seconds)
|
||||
@@ -172,6 +173,7 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
|
||||
AspectRatio: aspect,
|
||||
Resolution: resolution,
|
||||
ReferenceImages: refs,
|
||||
ReferenceMode: referenceMode,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -125,6 +125,7 @@ type ModelConfig struct {
|
||||
Alias string `gorm:"column:alias;size:255;not null;default:''"`
|
||||
Provider string `gorm:"size:100;index;not null"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用
|
||||
Ratios datatypes.JSON `gorm:"type:jsonb"`
|
||||
Prices datatypes.JSONMap `gorm:"type:jsonb"`
|
||||
Resolutions datatypes.JSON `gorm:"type:jsonb"`
|
||||
|
||||
@@ -11,6 +11,11 @@ const (
|
||||
refreshURL = "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"
|
||||
clientID = "projectx_webapp"
|
||||
scopeValue = "AdobeID,firefly_api,openid"
|
||||
// clioClientID / clioScopeValue mint the firefly.adobe.com token that is
|
||||
// entitled to the Firefly-native seedance models (creative_production scope).
|
||||
// Unlike the Express token this exchange requires user_id and no guest_allowed.
|
||||
clioClientID = "clio-playground-web"
|
||||
clioScopeValue = "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext,additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations,additional_info.roles,account_cluster.read,creative_production,tk_platform,tk_platform_sync,profile"
|
||||
)
|
||||
|
||||
var ErrAdobeCookieEmpty = errors.New("cookie is empty")
|
||||
|
||||
@@ -28,8 +28,12 @@ const (
|
||||
fireflyVideoSubmitURL = "https://video-v1.ff.adobe.io/v2/videos/generate"
|
||||
fireflyVideoUploadURL = "https://video-v1.ff.adobe.io/v2/storage/image"
|
||||
uploadURL = "https://firefly-3p.ff.adobe.io/v2/storage/image"
|
||||
creditsURL = "https://firefly.adobe.io/v1/credits/balance"
|
||||
creditsAPIKey = "SunbreakWebUI1"
|
||||
// Seedance reference video/audio go to their own storage endpoints; posting
|
||||
// them to /v2/storage/image is rejected.
|
||||
uploadVideoURL = "https://firefly-3p.ff.adobe.io/v2/storage/video"
|
||||
uploadAudioURL = "https://firefly-3p.ff.adobe.io/v2/storage/audio"
|
||||
creditsURL = "https://firefly.adobe.io/v1/credits/balance"
|
||||
creditsAPIKey = "SunbreakWebUI1"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -43,14 +47,24 @@ var (
|
||||
// the account's — every account rejects the same content — so the caller must
|
||||
// surface it as-is without penalizing/killing the account or failing over.
|
||||
ErrContentRejected = errors.New("adobe content rejected")
|
||||
// ErrNotEntitled is a 403 user_not_entitled on submit: the account has no
|
||||
// Firefly entitlement at all. It wraps ErrAuth so existing auth handling still
|
||||
// applies, but callers can single it out — unlike an expired access token this
|
||||
// cannot be fixed by refreshing from the cookie, so the account is done.
|
||||
ErrNotEntitled = fmt.Errorf("%w: user not entitled", ErrAuth)
|
||||
)
|
||||
|
||||
// isContentRejection reports whether an Adobe response (status + body) is a
|
||||
// content-safety refusal rather than a genuine upstream/account failure. Adobe
|
||||
// returns HTTP 451 with an "*_unsafe" error_code when moderation blocks the
|
||||
// prompt or the produced image.
|
||||
// prompt or the produced image, and "reference_image_privacy_error" when a
|
||||
// reference image contains a real person's face. Both are the request's fault —
|
||||
// every account rejects them identically — so neither may penalize the account.
|
||||
func isContentRejection(status int, body string) bool {
|
||||
return status == 451 && strings.Contains(body, "unsafe")
|
||||
if status != 451 {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(body, "unsafe") || strings.Contains(body, "privacy_error")
|
||||
}
|
||||
|
||||
var profileURLs = []string{
|
||||
@@ -59,9 +73,8 @@ var profileURLs = []string{
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
apiKey string
|
||||
proxy string
|
||||
arpSessionID string // cached per-client, reused across requests (matches adobe2api)
|
||||
apiKey string
|
||||
proxy string
|
||||
}
|
||||
|
||||
func NewClient(apiKey, proxy string) *Client {
|
||||
@@ -71,16 +84,11 @@ func NewClient(apiKey, proxy string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// getARPSessionID returns a cached ARP session id matching adobe2api's format:
|
||||
// base64({"sid":"<uuid>","ftr":"<hex16>_<ts_ms>_<pid>_dUAL43-mnts-ants-d4_31ck__tt"})
|
||||
// Generated once per client and reused — adobe2api reuses the same session id per
|
||||
// token/profile instead of rotating every request.
|
||||
func (c *Client) getARPSessionID() string {
|
||||
if c.arpSessionID != "" {
|
||||
return c.arpSessionID
|
||||
}
|
||||
c.arpSessionID = buildARPSessionID()
|
||||
return c.arpSessionID
|
||||
// getARPSessionID returns a fresh ARP session id for this token. The value is
|
||||
// regenerated per call, but the embedded pid stays stable per token; see
|
||||
// buildARPSessionID for the wire format.
|
||||
func (c *Client) getARPSessionID(token string) string {
|
||||
return buildARPSessionID(token)
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
@@ -116,19 +124,36 @@ func (c *Client) UploadImage(ctx context.Context, token string, content []byte,
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("adobe upload bad response: %w (%s)", err, clip(body, 300))
|
||||
}
|
||||
if images, ok := payload["images"].([]any); ok && len(images) > 0 {
|
||||
if first, ok := images[0].(map[string]any); ok {
|
||||
if id := strings.TrimSpace(stringValue(first["id"])); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if id := strings.TrimSpace(stringValue(payload["id"])); id != "" {
|
||||
if id := blobIDFromUploadPayload(payload); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
return "", errors.New("adobe upload missing blob id")
|
||||
return "", fmt.Errorf("adobe upload missing blob id: %s", clip(body, 300))
|
||||
}
|
||||
|
||||
// blobIDFromUploadPayload digs the blob id out of a storage response. The image
|
||||
// endpoint answers {"images":[{"id":...}]}, video/audio use their own key
|
||||
// ("videos"/"audios"/"assets"/"files"), and some responses put the id at the top
|
||||
// level — accept any of them instead of only "images".
|
||||
func blobIDFromUploadPayload(payload map[string]any) string {
|
||||
if id := strings.TrimSpace(stringValue(payload["id"])); id != "" {
|
||||
return id
|
||||
}
|
||||
for _, key := range []string{"images", "videos", "audios", "audio", "assets", "files"} {
|
||||
items, ok := payload[key].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
first, ok := items[0].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if id := strings.TrimSpace(stringValue(first["id"])); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// uploadImageOnce performs a single upload attempt and returns the raw response
|
||||
@@ -141,8 +166,19 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by
|
||||
}
|
||||
|
||||
endpoint := uploadURL
|
||||
if engine == "firefly-video" {
|
||||
switch {
|
||||
case engine == "firefly-video":
|
||||
endpoint = fireflyVideoUploadURL
|
||||
case strings.HasPrefix(contentType, "video/"):
|
||||
endpoint = uploadVideoURL
|
||||
case strings.HasPrefix(contentType, "audio/"):
|
||||
endpoint = uploadAudioURL
|
||||
}
|
||||
// Seedance is a Firefly-native model: its uploads need the clio-playground-web
|
||||
// key, the express key is rejected with 403 access_error.
|
||||
apiKey := c.apiKey
|
||||
if strings.HasPrefix(engine, "seedance-") {
|
||||
apiKey = clioClientID
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
@@ -151,7 +187,7 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"authorization": {"Bearer " + strings.TrimSpace(token)},
|
||||
"x-api-key": {c.apiKey},
|
||||
"x-api-key": {apiKey},
|
||||
"content-type": {defaultString(contentType, "image/png")},
|
||||
"accept": {"*/*"},
|
||||
"user-agent": {sess.fp.userAgent},
|
||||
@@ -233,24 +269,36 @@ func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspe
|
||||
// Content-safety refusal: the prompt/image is blocked, retrying other payloads
|
||||
// or accounts is pointless — surface it as-is so the pool doesn't fail over.
|
||||
if errors.Is(lastErr, ErrContentRejected) {
|
||||
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrContentRejected, clip(lastBody, 300))
|
||||
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrContentRejected, submitDetail(lastErr, lastBody))
|
||||
}
|
||||
// Preserve the temporary classification so the pool retries (overload / 5xx /
|
||||
// rate-limit) instead of failing the request outright.
|
||||
if errors.Is(lastErr, ErrDeadUpstream) {
|
||||
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrDeadUpstream, clip(lastBody, 300))
|
||||
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrDeadUpstream, submitDetail(lastErr, lastBody))
|
||||
}
|
||||
if errors.Is(lastErr, ErrTemporaryUpstream) {
|
||||
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrTemporaryUpstream, clip(lastBody, 300))
|
||||
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrTemporaryUpstream, submitDetail(lastErr, lastBody))
|
||||
}
|
||||
return nil, nil, fmt.Errorf("adobe submit failed: %s", clip(lastBody, 300))
|
||||
return nil, nil, fmt.Errorf("adobe submit failed: %s", submitDetail(lastErr, lastBody))
|
||||
}
|
||||
|
||||
// submitDetail 拼接上游细节:优先用响应体,响应体为空时(传输层报错,例如超时、
|
||||
// 连接被切)退回内层错误文本,避免错误只剩一句 "adobe submit:" 无从排查。
|
||||
func submitDetail(err error, body []byte) string {
|
||||
if s := clip(body, 300); strings.TrimSpace(s) != "" {
|
||||
return s
|
||||
}
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return "(empty response)"
|
||||
}
|
||||
|
||||
// GenerateVideo renders the clip and (when downloadResult) downloads the MP4.
|
||||
// With downloadResult=false it returns nil bytes and the upstream presigned URL
|
||||
// in meta["video_url"] — used by the async /v1/videos job, which proxies that URL
|
||||
// on /content instead of persisting the file.
|
||||
func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs, videoBlobIDs, audioBlobIDs []string, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
// Only the submit goes through the proxy; polling + download run on the local IP.
|
||||
submitSess, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
@@ -261,7 +309,7 @@ func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspec
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
payload := BuildVideoPayload(engine, prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs)
|
||||
payload := BuildVideoPayload(engine, prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs, videoBlobIDs, audioBlobIDs)
|
||||
endpoint := videoSubmitURL
|
||||
if engine == "firefly-video" {
|
||||
endpoint = fireflyVideoSubmitURL
|
||||
@@ -368,7 +416,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
|
||||
}, nil
|
||||
}
|
||||
|
||||
sess, err := c.newDirectTLSClient()
|
||||
sess, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -444,6 +492,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
|
||||
"used": intOrNil(quota["used"]),
|
||||
"total": intOrNil(quota["total"]),
|
||||
"available_until": emptyStringNil(strings.TrimSpace(stringValue(totalInfo["availableUntil"]))),
|
||||
"plan": strings.TrimSpace(stringValue(totalInfo["planCap"])),
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
}, nil
|
||||
@@ -462,6 +511,7 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
|
||||
req.Header = http.Header{
|
||||
"authorization": {"Bearer " + strings.TrimSpace(token)},
|
||||
"x-api-key": {c.apiKey},
|
||||
"x-arp-session-id": {c.getARPSessionID(token)},
|
||||
"content-type": {"application/json"},
|
||||
"accept": {"*/*"},
|
||||
"origin": {"https://new.express.adobe.com"},
|
||||
@@ -477,6 +527,7 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
|
||||
http.HeaderOrderKey: {
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-arp-session-id",
|
||||
"content-type",
|
||||
"accept",
|
||||
"origin",
|
||||
@@ -506,10 +557,14 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") {
|
||||
accessErr := resp.Header.Get("x-access-error")
|
||||
if strings.EqualFold(accessErr, "taste_exhausted") {
|
||||
return respBody, "", ErrQuotaExhausted
|
||||
}
|
||||
return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300))
|
||||
if strings.EqualFold(accessErr, "user_not_entitled") {
|
||||
return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrNotEntitled, resp.StatusCode, accessErr, clip(respBody, 300))
|
||||
}
|
||||
return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrAuth, resp.StatusCode, accessErr, clip(respBody, 300))
|
||||
}
|
||||
// "system under load" / timeout_error = adobe rate-limit/overload (can come on a
|
||||
// non-5xx) — treat as temporary so the pool retries instead of failing.
|
||||
@@ -520,10 +575,10 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
|
||||
return respBody, "", ErrContentRejected
|
||||
}
|
||||
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
||||
return respBody, "", ErrDeadUpstream
|
||||
return respBody, "", fmt.Errorf("%w (submit %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(respBody, 300))
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return respBody, "", errors.New("submit rejected")
|
||||
return respBody, "", fmt.Errorf("submit rejected: %d %s", resp.StatusCode, clip(respBody, 300))
|
||||
}
|
||||
|
||||
var payloadResp map[string]any
|
||||
@@ -588,7 +643,7 @@ func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL
|
||||
return nil, nil, fmt.Errorf("%w: %s", ErrContentRejected, clip(body, 300))
|
||||
}
|
||||
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
||||
return nil, nil, ErrDeadUpstream
|
||||
return nil, nil, fmt.Errorf("%w (poll %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(body, 300))
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("adobe poll failed: %d %s", resp.StatusCode, clip(body, 300))
|
||||
@@ -635,13 +690,23 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo
|
||||
return nil, "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
// Seedance is a Firefly-native model and needs the clio-playground-web key;
|
||||
// the express key is rejected with 403 access_error. The origin stays on the
|
||||
// Express surface for every engine — the upstream only gates on the key and
|
||||
// x-arp-session-id, not on origin.
|
||||
apiKey := c.apiKey
|
||||
origin := "https://new.express.adobe.com"
|
||||
if strings.EqualFold(stringValue(payload["modelId"]), "seedance") {
|
||||
apiKey = clioClientID
|
||||
}
|
||||
req.Header = http.Header{
|
||||
"authorization": {"Bearer " + strings.TrimSpace(token)},
|
||||
"x-api-key": {c.apiKey},
|
||||
"x-api-key": {apiKey},
|
||||
"x-arp-session-id": {c.getARPSessionID(token)},
|
||||
"content-type": {"application/json"},
|
||||
"accept": {"*/*"},
|
||||
"origin": {"https://new.express.adobe.com"},
|
||||
"referer": {"https://new.express.adobe.com/"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"sec-ch-ua": {sess.fp.secCHUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
@@ -653,6 +718,7 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo
|
||||
http.HeaderOrderKey: {
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-arp-session-id",
|
||||
"content-type",
|
||||
"accept",
|
||||
"origin",
|
||||
@@ -686,18 +752,22 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") {
|
||||
accessErr := resp.Header.Get("x-access-error")
|
||||
if strings.EqualFold(accessErr, "taste_exhausted") {
|
||||
return respBody, "", ErrQuotaExhausted
|
||||
}
|
||||
if strings.EqualFold(accessErr, "user_not_entitled") {
|
||||
return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrNotEntitled, resp.StatusCode, accessErr, clip(respBody, 300))
|
||||
}
|
||||
// Surface Adobe's response body — "adobe auth failed" alone hides whether
|
||||
// it's a bad token, a missing scope, or a WAF/fingerprint block.
|
||||
return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300))
|
||||
return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, accessErr, clip(respBody, 300))
|
||||
}
|
||||
if isContentRejection(resp.StatusCode, string(respBody)) {
|
||||
return respBody, "", ErrContentRejected
|
||||
}
|
||||
if resp.StatusCode == 408 || resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
||||
return respBody, "", ErrDeadUpstream
|
||||
return respBody, "", fmt.Errorf("%w (video submit %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(respBody, 300))
|
||||
}
|
||||
// "system under load" / timeout_error = adobe overload — treat as a temporary
|
||||
// error so the tempFailover policy moves to the next account (same as the image path).
|
||||
@@ -773,7 +843,7 @@ func (c *Client) pollVideo(ctx context.Context, sess *tlsSession, token, pollURL
|
||||
return nil, nil, fmt.Errorf("%w: %s", ErrContentRejected, clip(body, 300))
|
||||
}
|
||||
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
||||
return nil, nil, ErrDeadUpstream
|
||||
return nil, nil, fmt.Errorf("%w (video poll %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(body, 300))
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("adobe video poll failed: %d %s", resp.StatusCode, clip(body, 300))
|
||||
@@ -889,7 +959,12 @@ const (
|
||||
osMac
|
||||
)
|
||||
|
||||
func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int) fingerprint {
|
||||
// newChromeFingerprint pairs a TLS ClientProfile with headers for the same
|
||||
// Chrome major. secCHUA is passed in verbatim rather than templated: the GREASE
|
||||
// brand entry ("Not(A:Brand", "Not_A Brand", …), its version, and its position
|
||||
// in the list all rotate per Chrome release, so there is no formula to derive
|
||||
// it from the major.
|
||||
func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int, secCHUA string) fingerprint {
|
||||
ua := fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", major)
|
||||
platform := `"Windows"`
|
||||
if osKind == osMac {
|
||||
@@ -899,7 +974,7 @@ func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int) fin
|
||||
return fingerprint{
|
||||
profile: profile,
|
||||
userAgent: ua,
|
||||
secCHUA: fmt.Sprintf(`"Not?A_Brand";v="99", "Google Chrome";v="%d", "Chromium";v="%d"`, major, major),
|
||||
secCHUA: secCHUA,
|
||||
platform: platform,
|
||||
}
|
||||
}
|
||||
@@ -908,12 +983,21 @@ func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int) fin
|
||||
// Windows/macOS. Each request draws one at random so the outbound TLS
|
||||
// fingerprint and headers vary from call to call instead of presenting a single
|
||||
// static signature to Adobe's anti-bot layer.
|
||||
//
|
||||
// The majors here are capped by what tls-client ships a TLS profile for
|
||||
// (Chrome_146 is the newest in v1.15.1). Live Adobe traffic is on Chromium
|
||||
// 150+, so the UA is deliberately NOT set higher than the pool: advertising a
|
||||
// newer UA than the JA3/JA4 handshake backs up is a sharper signal than being a
|
||||
// few releases behind. tls-client has no Edge profile at any version, so the
|
||||
// pool stays pure Chrome rather than pairing an Edge UA with a Chrome
|
||||
// handshake.
|
||||
var adobeFingerprints = []fingerprint{
|
||||
newChromeFingerprint(profiles.Chrome_133, 133, osWindows),
|
||||
newChromeFingerprint(profiles.Chrome_131, 131, osWindows),
|
||||
newChromeFingerprint(profiles.Chrome_124, 124, osWindows),
|
||||
newChromeFingerprint(profiles.Chrome_133, 133, osMac),
|
||||
newChromeFingerprint(profiles.Chrome_131, 131, osMac),
|
||||
newChromeFingerprint(profiles.Chrome_146, 146, osWindows, `"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"`),
|
||||
newChromeFingerprint(profiles.Chrome_146, 146, osMac, `"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"`),
|
||||
newChromeFingerprint(profiles.Chrome_144, 144, osWindows, `"Chromium";v="144", "Google Chrome";v="144", "Not?A_Brand";v="24"`),
|
||||
newChromeFingerprint(profiles.Chrome_144, 144, osMac, `"Chromium";v="144", "Google Chrome";v="144", "Not?A_Brand";v="24"`),
|
||||
newChromeFingerprint(profiles.Chrome_133, 133, osWindows, `"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"`),
|
||||
newChromeFingerprint(profiles.Chrome_131, 131, osMac, `"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"`),
|
||||
}
|
||||
|
||||
func randomFingerprint() fingerprint {
|
||||
@@ -962,14 +1046,41 @@ func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie s
|
||||
return nil, ErrAdobeCookieEmpty
|
||||
}
|
||||
|
||||
// Prefer user_id (HAR behavior); fall back to guest_allowed for first-time login.
|
||||
userID := extractUserIDFromCookie(cookie)
|
||||
var body string
|
||||
if userID != "" {
|
||||
body = "client_id=" + clientID + "&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C") + "&user_id=" + url.QueryEscape(userID)
|
||||
} else {
|
||||
body = "client_id=" + clientID + "&guest_allowed=true&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C")
|
||||
body := "client_id=" + clientID + "&guest_allowed=true&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C")
|
||||
payload, err := doCookieExchange(ctx, sess, cookie, body, "https://new.express.adobe.com")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := strings.TrimSpace(stringValue(payload["access_token"]))
|
||||
if token == "" {
|
||||
return nil, errors.New("adobe cookie exchange missing access_token")
|
||||
}
|
||||
// The Express token (projectx_webapp) covers image/veo/luma/sora but is not
|
||||
// entitled to the Firefly-native seedance models — those return 403
|
||||
// model_not_authorized. firefly.adobe.com mints a second token for
|
||||
// client_id=clio-playground-web with the creative_production scope, keyed by
|
||||
// user_id, which IS entitled. Mint that here too (best-effort: if it fails we
|
||||
// keep the Express token so the other models still work).
|
||||
if uid := ExtractAccountID(token); uid != "" {
|
||||
clioBody := "client_id=" + clioClientID + "&scope=" + strings.ReplaceAll(clioScopeValue, ",", "%2C") + "&user_id=" + url.QueryEscape(uid)
|
||||
if clioPayload, err := doCookieExchange(ctx, sess, cookie, clioBody, "https://firefly.adobe.com"); err == nil {
|
||||
if clioToken := strings.TrimSpace(stringValue(clioPayload["access_token"])); clioToken != "" {
|
||||
return &CookieExchangeResult{
|
||||
AccessToken: clioToken,
|
||||
ExpiresIn: intValue(clioPayload["expires_in"]),
|
||||
Raw: clioPayload,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return &CookieExchangeResult{
|
||||
AccessToken: token,
|
||||
ExpiresIn: intValue(payload["expires_in"]),
|
||||
Raw: payload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func doCookieExchange(ctx context.Context, sess *tlsSession, cookie, body, origin string) (map[string]any, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, refreshURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -977,20 +1088,15 @@ func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie s
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"zh-CN,zh;q=0.9"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"content-type": {"application/x-www-form-urlencoded;charset=UTF-8"},
|
||||
"cookie": {cookie},
|
||||
"origin": {"https://new.express.adobe.com"},
|
||||
"referer": {"https://new.express.adobe.com/"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"user-agent": {sess.fp.userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept",
|
||||
"accept-language",
|
||||
"content-type",
|
||||
"cookie",
|
||||
"origin",
|
||||
"referer",
|
||||
"user-agent",
|
||||
"accept", "accept-language", "content-type", "cookie",
|
||||
"origin", "referer", "user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := sess.client.Do(req)
|
||||
@@ -1010,15 +1116,7 @@ func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie s
|
||||
if err := json.Unmarshal(respBody, &payload); err != nil {
|
||||
return nil, fmt.Errorf("adobe cookie exchange invalid json: %w", err)
|
||||
}
|
||||
token := strings.TrimSpace(stringValue(payload["access_token"]))
|
||||
if token == "" {
|
||||
return nil, errors.New("adobe cookie exchange missing access_token")
|
||||
}
|
||||
return &CookieExchangeResult{
|
||||
AccessToken: token,
|
||||
ExpiresIn: intValue(payload["expires_in"]),
|
||||
Raw: payload,
|
||||
}, nil
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func buildSubmitNonce(token, prompt string) string {
|
||||
|
||||
@@ -251,7 +251,7 @@ func cloneMap(in map[string]any) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string) map[string]any {
|
||||
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs, videoBlobIDs, audioBlobIDs []string) map[string]any {
|
||||
seedVal := rand.Intn(999999)
|
||||
engine = defaultString(engine, "sora2")
|
||||
resolution = defaultString(resolution, "720p")
|
||||
@@ -274,10 +274,10 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
|
||||
"prompt": prompt,
|
||||
"seeds": []int{seedVal},
|
||||
"sizes": []any{map[string]any{"width": w, "height": h, "numFrames": frames}},
|
||||
"videoSettings": map[string]any{},
|
||||
"locale": "en-US",
|
||||
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"videoSettings": map[string]any{},
|
||||
"locale": "en-US",
|
||||
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
conds := make([]any, 0, 2)
|
||||
@@ -307,7 +307,7 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
|
||||
"prompt": prompt,
|
||||
"negativePrompt": "",
|
||||
"duration": durationSeconds,
|
||||
"generateAudio": true,
|
||||
"generateAudio": false,
|
||||
"generationMetadata": map[string]any{
|
||||
"module": "text2video",
|
||||
"submodule": "ff-video-generate",
|
||||
@@ -330,38 +330,48 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
|
||||
payload["referenceBlobs"] = refs
|
||||
}
|
||||
return payload
|
||||
case "seedance-fast", "seedance-2.0":
|
||||
case "seedance-2.0-fast", "seedance-2.0":
|
||||
modelVersion := "seedance_2.0"
|
||||
if engine == "seedance-fast" {
|
||||
if engine == "seedance-2.0-fast" {
|
||||
modelVersion = "seedance_2.0_fast"
|
||||
}
|
||||
payload := map[string]any{
|
||||
"modelId": "seedance",
|
||||
"modelVersion": modelVersion,
|
||||
"size": videoSize(aspectRatio, resolution),
|
||||
"seeds": []int{seedVal},
|
||||
"prompt": prompt,
|
||||
"negativePrompt": "",
|
||||
"duration": durationSeconds,
|
||||
"generateAudio": true,
|
||||
"generationSettings": map[string]any{"aspectRatio": aspectRatio},
|
||||
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
"modelId": "seedance",
|
||||
"modelVersion": modelVersion,
|
||||
"size": videoSize(aspectRatio, resolution),
|
||||
"seeds": []int{seedVal},
|
||||
"prompt": prompt,
|
||||
"negativePrompt": "",
|
||||
"duration": durationSeconds,
|
||||
"generateAudio": true,
|
||||
"generationSettings": map[string]any{"aspectRatio": aspectRatio},
|
||||
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
|
||||
refs := make([]any, 0, min(len(blobIDs), 9))
|
||||
// Seedance keeps module "text2video" even with reference blobs
|
||||
// (HAR-confirmed) — unlike veo/luma it does not switch to image2video.
|
||||
if referenceMode == "frame" {
|
||||
for idx, id := range blobIDs[:min(len(blobIDs), 9)] {
|
||||
refs := make([]any, 0, min(len(blobIDs), 2))
|
||||
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
} else {
|
||||
refs := make([]any, 0, min(len(blobIDs), 9))
|
||||
for _, id := range blobIDs[:min(len(blobIDs), 9)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "asset"})
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "style"})
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
}
|
||||
// Video/audio source refs (seedance)
|
||||
for _, id := range videoBlobIDs[:min(len(videoBlobIDs), 3)] {
|
||||
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
|
||||
}
|
||||
for _, id := range audioBlobIDs[:min(len(audioBlobIDs), 3)] {
|
||||
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
|
||||
}
|
||||
return payload
|
||||
case "luma":
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -19,6 +20,13 @@ import (
|
||||
// "4BDA81F069FC6DA40A495FAB@AdobeID").
|
||||
var adobeUserIDPat = regexp.MustCompile(`[A-Fa-f0-9]{20,}@AdobeID`)
|
||||
|
||||
// PID pool: one PID per token, reused across requests for the same account.
|
||||
var (
|
||||
arpPIDMu sync.Mutex
|
||||
arpTokenPID = map[string]int{} // token → pid
|
||||
arpPIDToken = map[int]string{} // pid → token
|
||||
)
|
||||
|
||||
func extractUserIDFromCookie(cookie string) string {
|
||||
decoded, err := url.QueryUnescape(cookie)
|
||||
if err != nil {
|
||||
@@ -95,19 +103,102 @@ func decodeJWTPayload(token string) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
func buildARPSessionID() string {
|
||||
// Matches adobe2api's format exactly:
|
||||
// base64({"sid":"<uuid>","ftr":"<hex16>_<ts_ms>_<pid>_dUAL43-mnts-ants-d4_31ck__tt"})
|
||||
// Two fields only (no "ark") — mirrors what a real browser session sends.
|
||||
ftr := randomHex(16) + "_" + strconv.FormatInt(time.Now().UnixMilli(), 10) + "_" + strconv.Itoa(randomInt(1000, 99999)) + "_dUAL43-mnts-ants-d4_31ck__tt"
|
||||
raw := map[string]any{
|
||||
// arkHead and arkTail are the site-constant halves of the ark blob, as captured
|
||||
// from firefly.adobe.com. The leading "<17hex>.<10digits>" pair and the rid
|
||||
// field sit between them and vary per session, so buildARKBlob randomizes those
|
||||
// instead of shipping one frozen blob for every account.
|
||||
const (
|
||||
arkHead = "|r=ap-southeast-1|meta=3|metabgclr=transparent|metaiconclr=%23757575|guitextcolor=%23000000|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40|sup=1"
|
||||
arkTail = "|ag=101|cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|surl=https%3A%2F%2Farks-client.adobe.com|smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager"
|
||||
)
|
||||
|
||||
func buildARKBlob() string {
|
||||
return randomHex(9)[:17] + "." + randomDigits(10) +
|
||||
arkHead + "|rid=" + strconv.Itoa(randomInt(1, 99)) + arkTail
|
||||
}
|
||||
|
||||
// buildARPSessionID returns the x-arp-session-id value: base64 of
|
||||
// {"sid","ark","ftr"}, shaped to match live firefly.adobe.com traffic:
|
||||
//
|
||||
// ark: <17hex>.<10digits>|…|rid=<n>|…
|
||||
// ftr: <32hex>_<unix_ms>_<pid>_UDF43-m4_31ck__tt
|
||||
//
|
||||
// The pid is allocated per token (allocPID) so it stays stable across an
|
||||
// account's requests, the way a real browser session would.
|
||||
//
|
||||
// The Arkose segment is left empty on purpose. In a real browser that slot
|
||||
// carries a "<b64>=-<4digits>-v2" payload emitted by Adobe's Arkose bot-manager
|
||||
// script; a synthesized one has the right shape but fails Adobe's server-side
|
||||
// validation, and 3p-images/generate-async rejects it with a misleading
|
||||
// {"error_code":"timeout_error","message":"system under load"}. Sending the
|
||||
// slot empty reads as "no Arkose payload yet" and is accepted.
|
||||
func buildARPSessionID(token string) string {
|
||||
ftr := strings.Join([]string{
|
||||
randomHex(16), // 32 hex chars, matching the capture
|
||||
strconv.FormatInt(time.Now().UnixMilli(), 10),
|
||||
strconv.Itoa(allocPID(token)),
|
||||
"UDF43-m4",
|
||||
"31ck",
|
||||
"",
|
||||
"tt",
|
||||
}, "_")
|
||||
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"sid": uuid.NewString(),
|
||||
"ark": buildARKBlob(),
|
||||
"ftr": ftr,
|
||||
}
|
||||
b, _ := json.Marshal(raw)
|
||||
})
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// randomDigits returns n decimal digits with a non-zero leading digit, so the
|
||||
// rendered value keeps a constant width.
|
||||
func randomDigits(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(n)
|
||||
b.WriteByte(byte('1' + randomInt(0, 8)))
|
||||
for i := 1; i < n; i++ {
|
||||
b.WriteByte(byte('0' + randomInt(0, 9)))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func randomBase64(n int) string {
|
||||
buf := make([]byte, n)
|
||||
rand.Read(buf)
|
||||
return base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func allocPID(token string) int {
|
||||
arpPIDMu.Lock()
|
||||
defer arpPIDMu.Unlock()
|
||||
if pid, ok := arpTokenPID[token]; ok {
|
||||
return pid
|
||||
}
|
||||
for {
|
||||
pid := randomInt(1000, 99999)
|
||||
if _, used := arpPIDToken[pid]; !used {
|
||||
arpPIDToken[pid] = token
|
||||
arpTokenPID[token] = pid
|
||||
return pid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReleasePID releases the PID bound to token so it can be reused by another
|
||||
// account.
|
||||
func ReleasePID(token string) {
|
||||
arpPIDMu.Lock()
|
||||
defer arpPIDMu.Unlock()
|
||||
if pid, ok := arpTokenPID[token]; ok {
|
||||
delete(arpPIDToken, pid)
|
||||
delete(arpTokenPID, token)
|
||||
}
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user