feat: veo3.1 拆分 fast/standard, seedance双模型, per_second定价, 子号过滤, 随机种子, UI优化

This commit is contained in:
2026-08-03 20:27:20 +08:00
parent e90aeecea3
commit 7f4701d362
8 changed files with 238 additions and 77 deletions
+103 -32
View File
@@ -339,26 +339,57 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"data": []gin.H{
{
"key": "gemini-veo31",
"label": "Veo31",
"label": "Veo31 Fast",
"type": "video",
"provider": "adobe",
"durations": []string{"4s", "6s", "8s"},
"ratios": []string{"16x9", "9x16"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"max_reference_images": 2,
"reference_mode": "frame",
},
{
"key": "firefly-ray",
"label": "Luma Ray",
"key": "gemini-veo3.1-fast",
"label": "Veo 3.1 Fast",
"type": "video",
"provider": "adobe",
"durations": []string{"5s", "10s"},
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
"resolutions": []string{"720p"},
"durations": []string{"4s", "6s", "8s"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"max_reference_images": 2,
"reference_mode": "frame",
},
{
"key": "gemini-veo3.1",
"label": "Veo 3.1",
"type": "video",
"provider": "adobe",
"durations": []string{"4s", "6s", "8s"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
},
{
"key": "seedance-fast",
"label": "Seedance 2.0 Fast",
"type": "video",
"provider": "adobe",
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"max_reference_images": 9,
"reference_mode": "frame",
},
{
"key": "seedance-2.0",
"label": "Seedance 2.0",
"type": "video",
"provider": "adobe",
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"max_reference_images": 9,
"reference_mode": "frame",
},
{
"key": "firefly-video",
"label": "Firefly Video",
@@ -442,16 +473,6 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"image_to_image": true,
"description": "Adobe Firefly Image 5",
},
{
"id": "flux-kontext-max",
"provider": "adobe",
"type": "image",
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
"resolutions": []string{"1K"},
"image_to_image": true,
"max_reference_images": 4,
"description": "Adobe Flux Kontext Max",
},
{
"id": "runway-nano-banana-2",
"provider": "runway",
@@ -497,15 +518,25 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"description": "Nano Banana Pro (图/参考图)",
},
{
"id": "gemini-veo31",
"id": "gemini-veo3.1-fast",
"provider": "adobe",
"type": "video",
"ratios": []string{"16x9", "9x16"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"4s", "6s", "8s"},
"max_reference_images": 2,
"reference_mode": "frame",
"description": "Veo31 video",
"description": "Veo 3.1 Fast",
},
{
"id": "gemini-veo3.1",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"durations": []string{"4s", "6s", "8s"},
"max_reference_images": 0,
"description": "Veo 3.1",
},
{
"id": "firefly-ray",
@@ -529,6 +560,28 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"reference_mode": "frame",
"description": "Adobe Firefly Video",
},
{
"id": "seedance-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",
"description": "Seedance 2.0 Fast",
},
{
"id": "seedance-2.0",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"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",
"description": "Seedance 2.0",
},
{
"id": "runway-gen4-turbo",
"provider": "runway",
@@ -637,15 +690,6 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"description": "Adobe Firefly Image 5",
"stub": false,
},
{
"id": "flux-kontext-max",
"provider": "adobe",
"kind": "image",
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
"resolutions": []string{"1K"},
"description": "Adobe Flux Kontext Max",
"stub": false,
},
{
"id": "runway-nano-banana-2",
"provider": "runway",
@@ -683,12 +727,21 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"stub": false,
},
{
"id": "gemini-veo31",
"id": "gemini-veo3.1-fast",
"provider": "adobe",
"kind": "video",
"ratios": []string{"16x9", "9x16"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"description": "Veo31 video",
"description": "Veo 3.1 Fast",
"stub": false,
},
{
"id": "gemini-veo3.1",
"provider": "adobe",
"kind": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"description": "Veo 3.1",
"stub": false,
},
{
@@ -709,6 +762,24 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"description": "Adobe Firefly Video",
"stub": false,
},
{
"id": "seedance-fast",
"provider": "adobe",
"kind": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"description": "Seedance 2.0 Fast",
"stub": false,
},
{
"id": "seedance-2.0",
"provider": "adobe",
"kind": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"description": "Seedance 2.0",
"stub": false,
},
{
"id": "runway-gen4-turbo",
"provider": "runway",
+5 -1
View File
@@ -37,6 +37,7 @@ var (
ErrQuotaExhausted = errors.New("adobe quota exhausted")
ErrTemporaryUpstream = errors.New("adobe upstream temporary error")
ErrDeadUpstream = errors.New("adobe upstream fatal error")
ErrRateLimited = errors.New("adobe rate limited")
// ErrContentRejected is Adobe's content-safety filter refusing the prompt or
// the generated image (HTTP 451 image_unsafe). It is the prompt's fault, not
// the account's — every account rejects the same content — so the caller must
@@ -178,7 +179,10 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, fmt.Errorf("%w (upload %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300)), false
}
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
if resp.StatusCode == 429 {
return nil, fmt.Errorf("%w (upload %d): %s", ErrRateLimited, resp.StatusCode, clip(body, 300)), false
}
if resp.StatusCode == 451 || resp.StatusCode >= 500 {
return nil, fmt.Errorf("adobe upload failed: %d %s", resp.StatusCode, clip(body, 300)), true
}
if resp.StatusCode != 200 {
+50 -13
View File
@@ -2,8 +2,8 @@ package adobe
import (
"encoding/json"
"math/rand"
"strings"
"time"
)
type modelSpec struct {
@@ -68,7 +68,7 @@ func ResolveModelSpec(modelID string) modelSpec {
func buildImage5Payload(prompt, aspectRatio, resolution string, blobIDs []string) map[string]any {
p := map[string]any{
"n": 1,
"seeds": []int{int(time.Now().Unix()) % 999999},
"seeds": []int{rand.Intn(999999)},
"output": map[string]any{"storeInputs": true},
"prompt": prompt,
"referenceBlobs": []any{},
@@ -125,7 +125,7 @@ func buildGPTImagePayloads(spec modelSpec, prompt, ratio, resolution string, blo
"modelVersion": spec.UpstreamModelVersion,
"n": 1,
"prompt": prompt,
"seeds": []int{int(time.Now().Unix()) % 999999},
"seeds": []int{rand.Intn(999999)},
"output": map[string]any{"storeInputs": true},
"referenceBlobs": []any{},
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
@@ -151,7 +151,7 @@ func buildFluxPayloads(spec modelSpec, prompt, ratio string, blobIDs []string) [
"n": 1,
"prompt": prompt,
"size": map[string]any{"width": size[0], "height": size[1]},
"seeds": []int{int(time.Now().Unix()) % 999999},
"seeds": []int{rand.Intn(999999)},
"output": map[string]any{"storeInputs": true},
"referenceBlobs": []any{},
"modelSpecificPayload": map[string]any{
@@ -182,7 +182,7 @@ func buildDefaultPayloads(spec modelSpec, prompt, ratio, resolution string, blob
"n": 1,
"prompt": prompt,
"size": map[string]any{"width": size[0], "height": size[1]},
"seeds": []int{int(time.Now().Unix()) % 999999},
"seeds": []int{rand.Intn(999999)},
"groundSearch": false,
"output": map[string]any{"storeInputs": true},
"generationMetadata": map[string]any{
@@ -252,7 +252,7 @@ func cloneMap(in map[string]any) map[string]any {
}
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string) map[string]any {
seedVal := int(time.Now().Unix()) % 999999
seedVal := rand.Intn(999999)
engine = defaultString(engine, "sora2")
resolution = defaultString(resolution, "720p")
aspectRatio = defaultString(aspectRatio, "16:9")
@@ -299,9 +299,6 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
if engine == "veo31-standard" {
modelVersion = "3.1-generate"
}
// Shape mirrors a captured working firefly.adobe.com video request: flat
// top-level duration / negativePrompt / generateAudio, submodule set, and
// NO `n` / NO modelSpecificPayload (sending those got 403).
payload := map[string]any{
"modelId": "veo",
"modelVersion": modelVersion,
@@ -310,7 +307,7 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
"prompt": prompt,
"negativePrompt": "",
"duration": durationSeconds,
"generateAudio": false,
"generateAudio": true,
"generationMetadata": map[string]any{
"module": "text2video",
"submodule": "ff-video-generate",
@@ -320,9 +317,49 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
}
if len(blobIDs) > 0 {
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
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": "general", "promptReference": idx + 1})
refs := make([]any, 0, min(len(blobIDs), 3))
if referenceMode == "frame" {
for idx, id := range blobIDs[:min(len(blobIDs), 3)] {
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
}
} else {
for _, id := range blobIDs[:min(len(blobIDs), 3)] {
refs = append(refs, map[string]any{"id": id, "usage": "asset"})
}
}
payload["referenceBlobs"] = refs
}
return payload
case "seedance-fast", "seedance-2.0":
modelVersion := "seedance_2.0"
if engine == "seedance-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{},
}
if len(blobIDs) > 0 {
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
refs := make([]any, 0, min(len(blobIDs), 9))
if referenceMode == "frame" {
for idx, id := range blobIDs[:min(len(blobIDs), 9)] {
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
}
} else {
for _, id := range blobIDs[:min(len(blobIDs), 9)] {
refs = append(refs, map[string]any{"id": id, "usage": "asset"})
}
}
payload["referenceBlobs"] = refs
}
+6
View File
@@ -716,6 +716,9 @@ func (s *TokenService) checkPendingAdobe(tokenID, cookie string) {
quotaMeta["cached_quota_at"] = int(time.Now().Unix())
if rem, ok := cb["remaining"].(int); ok {
quotaMeta["cached_quota_remaining"] = rem
if rem <= 4000 {
quotaMeta["is_sub_account"] = true
}
}
}
if prof, e := s.adobe.FetchAccountProfile(ctx, result.AccessToken); e == nil {
@@ -1273,6 +1276,7 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
meta["cached_quota_at"] = int(time.Now().Unix())
if remaining, ok := data["remaining"].(int); ok {
meta["cached_quota_remaining"] = remaining
meta["is_sub_account"] = remaining <= 4000
}
if used, ok := data["used"].(int); ok {
meta["cached_quota_used"] = used
@@ -1643,6 +1647,7 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
remaining, hasRemaining := jsonMapInt(item.Meta, "cached_quota_remaining")
quotaAt, _ := jsonMapInt(item.Meta, "cached_quota_at")
pending, _ := jsonMapBool(item.Meta, "pending_check")
subAccount, _ := jsonMapBool(item.Meta, "is_sub_account")
// 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
@@ -1680,6 +1685,7 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
"image_limited": item.ImageLimited,
"video_limited": item.VideoLimited,
"pending": pending,
"sub_account": subAccount,
"quota_supported": hasQuota,
"needs_reset_fetch": typeLabel == "adobe" && item.Status == "active" && strings.TrimSpace(item.CachedQuotaResetAfter) == "",
"weight": item.Weight,
+44 -11
View File
@@ -1588,7 +1588,7 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token
}
func adobeErrClass(e error) (bool, bool, bool, bool) {
return errors.Is(e, adobe.ErrAuth), errors.Is(e, adobe.ErrQuotaExhausted), errors.Is(e, adobe.ErrTemporaryUpstream), errors.Is(e, adobe.ErrDeadUpstream)
return errors.Is(e, adobe.ErrAuth), errors.Is(e, adobe.ErrQuotaExhausted), errors.Is(e, adobe.ErrTemporaryUpstream) || errors.Is(e, adobe.ErrRateLimited), errors.Is(e, adobe.ErrDeadUpstream)
}
// noStore url-only mode: adobe returns a presigned image URL (meta["image_url"]);
@@ -1668,11 +1668,13 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
}
var active []model.TokenAccount
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 || strings.TrimSpace(item.Value) == "" {
continue
}
if isSeedanceModel(modelItem.ID) && isSubAccount(item.Meta) {
continue
}
active = append(active, item)
}
active = pinTestAccount(items, active, in.AccountID)
if len(active) == 0 {
@@ -3070,7 +3072,20 @@ func modelPrice(item *model.ModelConfig, kind, resolution, duration string, agen
}
if kind == "video" {
rv, rok := tierPrice(item.Prices, item.PricesAgent, resolution)
dv, dok := tierPrice(item.DurationPrices, item.DurationPricesAgent, duration)
var dv float64
var dok bool
if pps, hasPerSec := jsonMapFloat(item.DurationPrices, "per_second"); hasPerSec {
secs := parseDurationSeconds(duration)
dv = pps * float64(secs)
dok = true
if agent {
if av, aok := jsonMapFloat(item.DurationPricesAgent, "per_second"); aok {
dv = av * float64(secs)
}
}
} else {
dv, dok = tierPrice(item.DurationPrices, item.DurationPricesAgent, duration)
}
if !rok || !dok {
return 0, false
}
@@ -3143,12 +3158,14 @@ func parseDurationSeconds(raw string) int {
func resolveAdobeVideoEngine(modelID string) (string, string) {
switch strings.ToLower(strings.TrimSpace(modelID)) {
case "gemini-veo31", "firefly-veo31":
// Use the fast tier — it's the only Veo 3.1 version this account is
// entitled to (standard "3.1-generate" returns 403 user_not_entitled).
// "firefly-veo31" is the legacy id, kept for back-compat with historical
// rows/logs; the model is branded "gemini-veo31" now.
case "gemini-veo3.1-fast", "gemini-veo31", "firefly-veo31":
return "veo31-fast", ""
case "gemini-veo3.1":
return "veo31-standard", ""
case "seedance-fast":
return "seedance-fast", ""
case "seedance-2.0":
return "seedance-2.0", ""
case "firefly-ray":
return "luma", ""
case "firefly-video":
@@ -3311,3 +3328,19 @@ func (s *V1Service) rotateRoundRobin(pool string, items []model.TokenAccount) {
i = j
}
}
func isSeedanceModel(modelID string) bool {
return modelID == "seedance-fast" || modelID == "seedance-2.0"
}
func isSubAccount(meta map[string]interface{}) bool {
if meta == nil {
return false
}
v, ok := meta["is_sub_account"]
if !ok {
return false
}
b, _ := v.(bool)
return b
}