feat: veo3.1 拆分 fast/standard, seedance双模型, per_second定价, 子号过滤, 随机种子, UI优化
This commit is contained in:
@@ -339,26 +339,57 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
|||||||
"data": []gin.H{
|
"data": []gin.H{
|
||||||
{
|
{
|
||||||
"key": "gemini-veo31",
|
"key": "gemini-veo31",
|
||||||
"label": "Veo31",
|
"label": "Veo31 Fast",
|
||||||
"type": "video",
|
"type": "video",
|
||||||
"provider": "adobe",
|
"provider": "adobe",
|
||||||
"durations": []string{"4s", "6s", "8s"},
|
"durations": []string{"4s", "6s", "8s"},
|
||||||
"ratios": []string{"16x9", "9x16"},
|
"ratios": []string{"16:9", "9:16"},
|
||||||
"resolutions": []string{"720p", "1080p"},
|
"resolutions": []string{"720p", "1080p"},
|
||||||
"max_reference_images": 2,
|
"max_reference_images": 2,
|
||||||
"reference_mode": "frame",
|
"reference_mode": "frame",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "firefly-ray",
|
"key": "gemini-veo3.1-fast",
|
||||||
"label": "Luma Ray",
|
"label": "Veo 3.1 Fast",
|
||||||
"type": "video",
|
"type": "video",
|
||||||
"provider": "adobe",
|
"provider": "adobe",
|
||||||
"durations": []string{"5s", "10s"},
|
"durations": []string{"4s", "6s", "8s"},
|
||||||
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
|
"ratios": []string{"16:9", "9:16"},
|
||||||
"resolutions": []string{"720p"},
|
"resolutions": []string{"720p", "1080p"},
|
||||||
"max_reference_images": 2,
|
"max_reference_images": 2,
|
||||||
"reference_mode": "frame",
|
"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",
|
"key": "firefly-video",
|
||||||
"label": "Firefly Video",
|
"label": "Firefly Video",
|
||||||
@@ -442,16 +473,6 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
|||||||
"image_to_image": true,
|
"image_to_image": true,
|
||||||
"description": "Adobe Firefly Image 5",
|
"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",
|
"id": "runway-nano-banana-2",
|
||||||
"provider": "runway",
|
"provider": "runway",
|
||||||
@@ -497,15 +518,25 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
|||||||
"description": "Nano Banana Pro (图/参考图)",
|
"description": "Nano Banana Pro (图/参考图)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "gemini-veo31",
|
"id": "gemini-veo3.1-fast",
|
||||||
"provider": "adobe",
|
"provider": "adobe",
|
||||||
"type": "video",
|
"type": "video",
|
||||||
"ratios": []string{"16x9", "9x16"},
|
"ratios": []string{"16:9", "9:16"},
|
||||||
"resolutions": []string{"720p", "1080p"},
|
"resolutions": []string{"720p", "1080p"},
|
||||||
"durations": []string{"4s", "6s", "8s"},
|
"durations": []string{"4s", "6s", "8s"},
|
||||||
"max_reference_images": 2,
|
"max_reference_images": 2,
|
||||||
"reference_mode": "frame",
|
"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",
|
"id": "firefly-ray",
|
||||||
@@ -529,6 +560,28 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
|||||||
"reference_mode": "frame",
|
"reference_mode": "frame",
|
||||||
"description": "Adobe Firefly Video",
|
"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",
|
"id": "runway-gen4-turbo",
|
||||||
"provider": "runway",
|
"provider": "runway",
|
||||||
@@ -637,15 +690,6 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
|||||||
"description": "Adobe Firefly Image 5",
|
"description": "Adobe Firefly Image 5",
|
||||||
"stub": false,
|
"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",
|
"id": "runway-nano-banana-2",
|
||||||
"provider": "runway",
|
"provider": "runway",
|
||||||
@@ -683,12 +727,21 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
|||||||
"stub": false,
|
"stub": false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "gemini-veo31",
|
"id": "gemini-veo3.1-fast",
|
||||||
"provider": "adobe",
|
"provider": "adobe",
|
||||||
"kind": "video",
|
"kind": "video",
|
||||||
"ratios": []string{"16x9", "9x16"},
|
"ratios": []string{"16:9", "9:16"},
|
||||||
"resolutions": []string{"720p", "1080p"},
|
"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,
|
"stub": false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -709,6 +762,24 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
|||||||
"description": "Adobe Firefly Video",
|
"description": "Adobe Firefly Video",
|
||||||
"stub": false,
|
"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",
|
"id": "runway-gen4-turbo",
|
||||||
"provider": "runway",
|
"provider": "runway",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ var (
|
|||||||
ErrQuotaExhausted = errors.New("adobe quota exhausted")
|
ErrQuotaExhausted = errors.New("adobe quota exhausted")
|
||||||
ErrTemporaryUpstream = errors.New("adobe upstream temporary error")
|
ErrTemporaryUpstream = errors.New("adobe upstream temporary error")
|
||||||
ErrDeadUpstream = errors.New("adobe upstream fatal 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
|
// 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 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
|
// 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 {
|
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
|
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
|
return nil, fmt.Errorf("adobe upload failed: %d %s", resp.StatusCode, clip(body, 300)), true
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package adobe
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type modelSpec struct {
|
type modelSpec struct {
|
||||||
@@ -68,7 +68,7 @@ func ResolveModelSpec(modelID string) modelSpec {
|
|||||||
func buildImage5Payload(prompt, aspectRatio, resolution string, blobIDs []string) map[string]any {
|
func buildImage5Payload(prompt, aspectRatio, resolution string, blobIDs []string) map[string]any {
|
||||||
p := map[string]any{
|
p := map[string]any{
|
||||||
"n": 1,
|
"n": 1,
|
||||||
"seeds": []int{int(time.Now().Unix()) % 999999},
|
"seeds": []int{rand.Intn(999999)},
|
||||||
"output": map[string]any{"storeInputs": true},
|
"output": map[string]any{"storeInputs": true},
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"referenceBlobs": []any{},
|
"referenceBlobs": []any{},
|
||||||
@@ -125,7 +125,7 @@ func buildGPTImagePayloads(spec modelSpec, prompt, ratio, resolution string, blo
|
|||||||
"modelVersion": spec.UpstreamModelVersion,
|
"modelVersion": spec.UpstreamModelVersion,
|
||||||
"n": 1,
|
"n": 1,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"seeds": []int{int(time.Now().Unix()) % 999999},
|
"seeds": []int{rand.Intn(999999)},
|
||||||
"output": map[string]any{"storeInputs": true},
|
"output": map[string]any{"storeInputs": true},
|
||||||
"referenceBlobs": []any{},
|
"referenceBlobs": []any{},
|
||||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
"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,
|
"n": 1,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"size": map[string]any{"width": size[0], "height": size[1]},
|
"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},
|
"output": map[string]any{"storeInputs": true},
|
||||||
"referenceBlobs": []any{},
|
"referenceBlobs": []any{},
|
||||||
"modelSpecificPayload": map[string]any{
|
"modelSpecificPayload": map[string]any{
|
||||||
@@ -182,7 +182,7 @@ func buildDefaultPayloads(spec modelSpec, prompt, ratio, resolution string, blob
|
|||||||
"n": 1,
|
"n": 1,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"size": map[string]any{"width": size[0], "height": size[1]},
|
"size": map[string]any{"width": size[0], "height": size[1]},
|
||||||
"seeds": []int{int(time.Now().Unix()) % 999999},
|
"seeds": []int{rand.Intn(999999)},
|
||||||
"groundSearch": false,
|
"groundSearch": false,
|
||||||
"output": map[string]any{"storeInputs": true},
|
"output": map[string]any{"storeInputs": true},
|
||||||
"generationMetadata": map[string]any{
|
"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 {
|
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")
|
engine = defaultString(engine, "sora2")
|
||||||
resolution = defaultString(resolution, "720p")
|
resolution = defaultString(resolution, "720p")
|
||||||
aspectRatio = defaultString(aspectRatio, "16:9")
|
aspectRatio = defaultString(aspectRatio, "16:9")
|
||||||
@@ -299,9 +299,6 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
|
|||||||
if engine == "veo31-standard" {
|
if engine == "veo31-standard" {
|
||||||
modelVersion = "3.1-generate"
|
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{
|
payload := map[string]any{
|
||||||
"modelId": "veo",
|
"modelId": "veo",
|
||||||
"modelVersion": modelVersion,
|
"modelVersion": modelVersion,
|
||||||
@@ -310,7 +307,7 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
|
|||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"negativePrompt": "",
|
"negativePrompt": "",
|
||||||
"duration": durationSeconds,
|
"duration": durationSeconds,
|
||||||
"generateAudio": false,
|
"generateAudio": true,
|
||||||
"generationMetadata": map[string]any{
|
"generationMetadata": map[string]any{
|
||||||
"module": "text2video",
|
"module": "text2video",
|
||||||
"submodule": "ff-video-generate",
|
"submodule": "ff-video-generate",
|
||||||
@@ -320,9 +317,49 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
|
|||||||
}
|
}
|
||||||
if len(blobIDs) > 0 {
|
if len(blobIDs) > 0 {
|
||||||
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
|
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
|
||||||
refs := make([]any, 0, min(len(blobIDs), 2))
|
refs := make([]any, 0, min(len(blobIDs), 3))
|
||||||
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
|
if referenceMode == "frame" {
|
||||||
refs = append(refs, map[string]any{"id": id, "usage": "general", "promptReference": idx + 1})
|
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
|
payload["referenceBlobs"] = refs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -716,6 +716,9 @@ func (s *TokenService) checkPendingAdobe(tokenID, cookie string) {
|
|||||||
quotaMeta["cached_quota_at"] = int(time.Now().Unix())
|
quotaMeta["cached_quota_at"] = int(time.Now().Unix())
|
||||||
if rem, ok := cb["remaining"].(int); ok {
|
if rem, ok := cb["remaining"].(int); ok {
|
||||||
quotaMeta["cached_quota_remaining"] = rem
|
quotaMeta["cached_quota_remaining"] = rem
|
||||||
|
if rem <= 4000 {
|
||||||
|
quotaMeta["is_sub_account"] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if prof, e := s.adobe.FetchAccountProfile(ctx, result.AccessToken); e == nil {
|
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())
|
meta["cached_quota_at"] = int(time.Now().Unix())
|
||||||
if remaining, ok := data["remaining"].(int); ok {
|
if remaining, ok := data["remaining"].(int); ok {
|
||||||
meta["cached_quota_remaining"] = remaining
|
meta["cached_quota_remaining"] = remaining
|
||||||
|
meta["is_sub_account"] = remaining <= 4000
|
||||||
}
|
}
|
||||||
if used, ok := data["used"].(int); ok {
|
if used, ok := data["used"].(int); ok {
|
||||||
meta["cached_quota_used"] = used
|
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")
|
remaining, hasRemaining := jsonMapInt(item.Meta, "cached_quota_remaining")
|
||||||
quotaAt, _ := jsonMapInt(item.Meta, "cached_quota_at")
|
quotaAt, _ := jsonMapInt(item.Meta, "cached_quota_at")
|
||||||
pending, _ := jsonMapBool(item.Meta, "pending_check")
|
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
|
// 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
|
// 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
|
// 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,
|
"image_limited": item.ImageLimited,
|
||||||
"video_limited": item.VideoLimited,
|
"video_limited": item.VideoLimited,
|
||||||
"pending": pending,
|
"pending": pending,
|
||||||
|
"sub_account": subAccount,
|
||||||
"quota_supported": hasQuota,
|
"quota_supported": hasQuota,
|
||||||
"needs_reset_fetch": typeLabel == "adobe" && item.Status == "active" && strings.TrimSpace(item.CachedQuotaResetAfter) == "",
|
"needs_reset_fetch": typeLabel == "adobe" && item.Status == "active" && strings.TrimSpace(item.CachedQuotaResetAfter) == "",
|
||||||
"weight": item.Weight,
|
"weight": item.Weight,
|
||||||
|
|||||||
@@ -1588,7 +1588,7 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token
|
|||||||
}
|
}
|
||||||
|
|
||||||
func adobeErrClass(e error) (bool, bool, bool, bool) {
|
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"]);
|
// 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
|
var active []model.TokenAccount
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
// Adobe accounts are credit-based (积分号) — no per-kind quota locks.
|
if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
|
||||||
// Only skip accounts that are dead or disabled.
|
continue
|
||||||
if item.Status == "active" && !item.Dead && strings.TrimSpace(item.Value) != "" {
|
|
||||||
active = append(active, item)
|
|
||||||
}
|
}
|
||||||
|
if isSeedanceModel(modelItem.ID) && isSubAccount(item.Meta) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
active = append(active, item)
|
||||||
}
|
}
|
||||||
active = pinTestAccount(items, active, in.AccountID)
|
active = pinTestAccount(items, active, in.AccountID)
|
||||||
if len(active) == 0 {
|
if len(active) == 0 {
|
||||||
@@ -3070,7 +3072,20 @@ func modelPrice(item *model.ModelConfig, kind, resolution, duration string, agen
|
|||||||
}
|
}
|
||||||
if kind == "video" {
|
if kind == "video" {
|
||||||
rv, rok := tierPrice(item.Prices, item.PricesAgent, resolution)
|
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 {
|
if !rok || !dok {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
@@ -3143,12 +3158,14 @@ func parseDurationSeconds(raw string) int {
|
|||||||
|
|
||||||
func resolveAdobeVideoEngine(modelID string) (string, string) {
|
func resolveAdobeVideoEngine(modelID string) (string, string) {
|
||||||
switch strings.ToLower(strings.TrimSpace(modelID)) {
|
switch strings.ToLower(strings.TrimSpace(modelID)) {
|
||||||
case "gemini-veo31", "firefly-veo31":
|
case "gemini-veo3.1-fast", "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.
|
|
||||||
return "veo31-fast", ""
|
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":
|
case "firefly-ray":
|
||||||
return "luma", ""
|
return "luma", ""
|
||||||
case "firefly-video":
|
case "firefly-video":
|
||||||
@@ -3311,3 +3328,19 @@ func (s *V1Service) rotateRoundRobin(pool string, items []model.TokenAccount) {
|
|||||||
i = j
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,12 +40,15 @@ const isVideo = computed(() => entry.value?.type === 'video')
|
|||||||
// Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K)
|
// Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K)
|
||||||
// regardless of how the catalog/stored record happens to list them.
|
// regardless of how the catalog/stored record happens to list them.
|
||||||
const resolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
|
const resolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
|
||||||
// Duration tiers for the price inputs. Prefer the declared `durations`; fall
|
const isPerSecond = computed(() => {
|
||||||
// back to the keys of any stored duration_prices so a model saved without a
|
const id = entry.value?.id || ''
|
||||||
// `durations` array (the legacy sync bug) is still viewable/editable.
|
return id.startsWith('seedance')
|
||||||
|
})
|
||||||
|
|
||||||
const durationTiers = computed(() => {
|
const durationTiers = computed(() => {
|
||||||
const e = entry.value
|
const e = entry.value
|
||||||
if (!e) return []
|
if (!e) return []
|
||||||
|
if (isPerSecond.value) return ['per_second']
|
||||||
const ds = e.durations || []
|
const ds = e.durations || []
|
||||||
return ds.length ? ds : Object.keys(e.duration_prices || {})
|
return ds.length ? ds : Object.keys(e.duration_prices || {})
|
||||||
})
|
})
|
||||||
@@ -67,9 +70,14 @@ function resetPrices(e) {
|
|||||||
// (real video price = resolution price + duration price).
|
// (real video price = resolution price + duration price).
|
||||||
for (const r of (e.resolutions || [])) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' }
|
for (const r of (e.resolutions || [])) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' }
|
||||||
if (e.type === 'video') {
|
if (e.type === 'video') {
|
||||||
|
if (isPerSecond.value) {
|
||||||
|
videoPrices.value['per_second'] = ''
|
||||||
|
videoPricesAgent.value['per_second'] = ''
|
||||||
|
} else {
|
||||||
for (const d of (e.durations || [])) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
|
for (const d of (e.durations || [])) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// In add mode, switching the selected model rebuilds the price inputs to match
|
// In add mode, switching the selected model rebuilds the price inputs to match
|
||||||
// that model's resolution / duration tiers.
|
// that model's resolution / duration tiers.
|
||||||
@@ -88,6 +96,10 @@ onMounted(async () => {
|
|||||||
imagePricesAgent.value[r] = m.prices_agent?.[r] ?? ''
|
imagePricesAgent.value[r] = m.prices_agent?.[r] ?? ''
|
||||||
}
|
}
|
||||||
if (m.type === 'video') {
|
if (m.type === 'video') {
|
||||||
|
if (m.duration_prices?.per_second != null) {
|
||||||
|
videoPrices.value['per_second'] = m.duration_prices.per_second
|
||||||
|
videoPricesAgent.value['per_second'] = m.duration_prices_agent?.per_second ?? ''
|
||||||
|
} else {
|
||||||
const durs = (m.durations && m.durations.length) ? m.durations : Object.keys(m.duration_prices || {})
|
const durs = (m.durations && m.durations.length) ? m.durations : Object.keys(m.duration_prices || {})
|
||||||
for (const d of durs) {
|
for (const d of durs) {
|
||||||
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
|
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
|
||||||
@@ -95,6 +107,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -116,12 +129,8 @@ async function save() {
|
|||||||
|
|
||||||
let payload
|
let payload
|
||||||
if (e.type === 'video') {
|
if (e.type === 'video') {
|
||||||
// Real video price = resolution price + duration price. Both tiers are
|
|
||||||
// priced independently; a blank tier on either axis = unsupported. Charge
|
|
||||||
// happens server-side as prices[res] + duration_prices[dur].
|
|
||||||
const prices = collect(imagePrices.value, e.resolutions || [])
|
const prices = collect(imagePrices.value, e.resolutions || [])
|
||||||
const duration_prices = collect(videoPrices.value, durationTiers.value)
|
const duration_prices = collect(videoPrices.value, durationTiers.value)
|
||||||
// 代理价:可选覆盖,留空的档跟随普通价。
|
|
||||||
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
|
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
|
||||||
const duration_prices_agent = collect(videoPricesAgent.value, durationTiers.value)
|
const duration_prices_agent = collect(videoPricesAgent.value, durationTiers.value)
|
||||||
if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return }
|
if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return }
|
||||||
@@ -132,10 +141,7 @@ async function save() {
|
|||||||
ratios: e.ratios || [],
|
ratios: e.ratios || [],
|
||||||
resolutions: e.resolutions || [],
|
resolutions: e.resolutions || [],
|
||||||
prices,
|
prices,
|
||||||
// durations MUST track duration_prices — the model list / docs iterate
|
durations: isPerSecond.value ? (e.durations || []) : Object.keys(duration_prices),
|
||||||
// `durations` to render the per-second price chips. Persisting prices
|
|
||||||
// without the matching durations array hides them. Keep them in sync.
|
|
||||||
durations: Object.keys(duration_prices),
|
|
||||||
duration_prices,
|
duration_prices,
|
||||||
prices_agent,
|
prices_agent,
|
||||||
duration_prices_agent,
|
duration_prices_agent,
|
||||||
@@ -282,7 +288,7 @@ async function save() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- duration prices (video only): 普通价 + 代理价 -->
|
<!-- duration prices (video only): per-tier or per-second -->
|
||||||
<div v-if="isVideo">
|
<div v-if="isVideo">
|
||||||
<label class="lbl">时长价格 <span class="text-white/35">(代理价留空 = 跟随普通价)</span></label>
|
<label class="lbl">时长价格 <span class="text-white/35">(代理价留空 = 跟随普通价)</span></label>
|
||||||
<div v-if="durationTiers.length" class="space-y-2">
|
<div v-if="durationTiers.length" class="space-y-2">
|
||||||
@@ -291,7 +297,7 @@ async function save() {
|
|||||||
<span class="flex-1">代理价</span>
|
<span class="flex-1">代理价</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="d in durationTiers" :key="d" class="flex items-center gap-2">
|
<div v-for="d in durationTiers" :key="d" class="flex items-center gap-2">
|
||||||
<div class="w-12 shrink-0 text-sm text-white/85 font-mono">{{ d }}</div>
|
<div class="w-12 shrink-0 text-sm text-white/85 font-mono">{{ d === 'per_second' ? '/s' : d }}</div>
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<input v-model="videoPrices[d]" type="number" min="0" step="1" class="field !pr-10" placeholder="普通价" />
|
<input v-model="videoPrices[d]" type="number" min="0" step="1" class="field !pr-10" placeholder="普通价" />
|
||||||
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/30 text-[10px]">积分</span>
|
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/30 text-[10px]">积分</span>
|
||||||
@@ -305,7 +311,8 @@ async function save() {
|
|||||||
<p v-else class="text-xs text-white/35">该模型未声明时长档位</p>
|
<p v-else class="text-xs text-white/35">该模型未声明时长档位</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="isVideo" class="text-[11px] text-white/40">实付 = 分辨率价 + 时长价(例:720p 50 + 5s 30 = 80 积分)</p>
|
<p v-if="isVideo && !isPerSecond" class="text-[11px] text-white/40">实付 = 分辨率价 + 时长价(例:720p 50 + 5s 30 = 80 积分)</p>
|
||||||
|
<p v-if="isPerSecond" class="text-[11px] text-white/40">实付 = 分辨率价 + 每秒价 × 秒数</p>
|
||||||
|
|
||||||
<!-- display weight: admin-set ordering (not a catalog param) -->
|
<!-- display weight: admin-set ordering (not a catalog param) -->
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -453,6 +453,9 @@ onMounted(() => { loadAccounts(); loadModelList() })
|
|||||||
case. -->
|
case. -->
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span class="text-sm text-white/90 truncate" :title="a.email || '-'">{{ a.email || '-' }}</span>
|
<span class="text-sm text-white/90 truncate" :title="a.email || '-'">{{ a.email || '-' }}</span>
|
||||||
|
<span v-if="a.sub_account"
|
||||||
|
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-sky-500/15 text-sky-300 ring-1 ring-sky-400/20"
|
||||||
|
:title="'子号 (剩余 ' + (a.remaining ?? '—') + ' 积分)'">子号</span>
|
||||||
<span v-if="a.image_limited && a.status !== 'quota'"
|
<span v-if="a.image_limited && a.status !== 'quota'"
|
||||||
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20"
|
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20"
|
||||||
title="图片额度耗尽,仅视频可用">图片限额</span>
|
title="图片额度耗尽,仅视频可用">图片限额</span>
|
||||||
|
|||||||
@@ -725,7 +725,7 @@ onUnmounted(() => {
|
|||||||
<!-- reference images -->
|
<!-- reference images -->
|
||||||
<div v-if="maxRefs > 0">
|
<div v-if="maxRefs > 0">
|
||||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">
|
<label class="block text-xs font-medium text-slate-500 mb-1.5">
|
||||||
参考图
|
{{ refMode === 'frame' && mode === 'video' ? '首尾帧' : '参考图' }}
|
||||||
<span class="text-slate-400 font-normal">
|
<span class="text-slate-400 font-normal">
|
||||||
(最多 {{ maxRefs }} 张{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单张 ≤20MB)
|
(最多 {{ maxRefs }} 张{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单张 ≤20MB)
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user