增加即梦图片5.0和视频2.5的能力

This commit is contained in:
andy committed 2026-08-21 13:44:56 +08:00
1 parent 542246e4ce
commit 88a4268d40
55 files changed
+1412 -200

No files matched your search

+33 -6
View File
@@ -9,7 +9,7 @@ import (
const (
DefaultBillingMarkupMultiplier = 1.2
BillingCatalogObservedAt = "2026-08-11"
BillingCatalogObservedAt = "2026-08-21"
)
// PriceRuleSeeder is an optional store capability. Existing Store
@@ -24,12 +24,16 @@ func DefaultBillingPriceRules() []PriceRule {
{ID: "base-volcengine-jimeng-seedream46", Provider: "volcengine-visual", Capability: "image.generate", ReqKey: "jimeng_seedream46_cvtob", Unit: UnitImage, StandardUnitPriceFen: 20, Note: "即梦4.6公开资源包基准。", Source: catalogSource("https://www.volcengine.com/activity/jimeng")},
{ID: "base-evolink-gpt-image-2", Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2", Unit: UnitImage, StandardUnitPriceFen: 34, Note: "medium / 1K / 1:1 / 无参考图基准。", Source: catalogSource("https://evolink.ai/zh/gpt-image-2"), Dimensions: evolinkDimensions()},
{ID: "base-bailian-wan27-image-pro", Provider: "bailian", Capability: "image.generate", ReqKey: "wan2.7-image-pro", Unit: UnitImage, StandardUnitPriceFen: 50, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-image-pro")},
{ID: "base-seedream-5-0-pro", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 30, Note: "基础生图:1K/1.5K ¥0.30,2K ¥0.60;首张输入图免费,额外输入图 ¥0.02/张。", Source: catalogSource("https://docs.volcengine.com/docs/82379/1544106?lang=zh"), Dimensions: seedreamDimensions()},
{ID: "base-bailian-wan27-i2v-720p", Provider: "bailian", Capability: "video.generate", ReqKey: "wan2.7-i2v-2026-04-25", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 60, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-i2v")},
{ID: "base-bailian-wan27-i2v-1080p", Provider: "bailian", Capability: "video.generate", ReqKey: "wan2.7-i2v-2026-04-25", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 100, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-i2v")},
{ID: "base-seedance-2-0-480p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=480p", Unit: UnitVideoSecond, StandardUnitPriceFen: 46, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")},
{ID: "base-seedance-2-0-720p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 99, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")},
{ID: "base-seedance-2-0-1080p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 248, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")},
{ID: "base-seedance-2-0-4k", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=4k", Unit: UnitVideoSecond, StandardUnitPriceFen: 505, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")},
{ID: "base-seedance-2-5-480p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-5-260628", VariantKey: "resolution=480p", Unit: UnitVideoSecond, StandardUnitPriceFen: 67, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh#sd25_price")},
{ID: "base-seedance-2-5-720p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-5-260628", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 151, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh#sd25_price")},
{ID: "base-seedance-2-5-1080p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-5-260628", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 374, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh#sd25_price")},
}
for i := range rules {
rules[i].Enabled = true
@@ -38,6 +42,17 @@ func DefaultBillingPriceRules() []PriceRule {
return rules
}
func seedreamDimensions() []ParameterDimension {
return []ParameterDimension{{
Key: "size", Label: "分辨率", BaselineValue: "1.5K", DefaultValue: "1.5K",
Tiers: []ParameterTier{
{Value: "1K", Label: "1K", StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
{Value: "1.5K", Label: "1.5K", StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
{Value: "2K", Label: "2K", StandardFactor: 2, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
},
}}
}
func catalogSource(url string) map[string]any {
return map[string]any{"url": url, "observedAt": BillingCatalogObservedAt}
}
@@ -81,12 +96,14 @@ func NormalizeBillingParameters(payload map[string]any, fallback Parameters) Par
setText(out, "resolution", first(settings["resolution"], providerParameters["resolution"], providerPayload["resolution"], inputSettings["resolution"], input["resolution"]))
setText(out, "quality", first(input["quality"], settings["quality"], providerPayload["quality"], providerParameters["quality"]))
setNumber(out, "duration", first(settings["duration"], providerParameters["duration"], providerPayload["duration"], inputSettings["duration"], input["duration"]), false)
setNumber(out, "imageCount", first(providerPayload["n"], providerParameters["n"], input["n"], input["imageCount"]), true)
setNumber(out, "imageCount", first(settings["imageCount"], providerPayload["n"], providerParameters["n"], input["n"], input["imageCount"]), true)
setNumber(out, "scale", first(input["scale"], settings["scale"]), false)
setText(out, "outputFormat", first(settings["outputFormat"], providerPayload["output_format"], inputSettings["outputFormat"], input["outputFormat"]))
setText(out, "optimizeMode", first(settings["optimizeMode"], record(providerPayload["optimize_prompt_options"])["mode"], inputSettings["optimizeMode"], input["optimizeMode"]))
width, widthOK := finiteNumber(first(providerPayload["width"], input["width"]))
height, heightOK := finiteNumber(first(providerPayload["height"], input["height"]))
if size := normalizedText(first(providerPayload["size"], providerParameters["size"], inputSettings["size"], input["size"])); size != "" {
if size := normalizedText(first(settings["size"], providerPayload["size"], providerParameters["size"], inputSettings["size"], input["size"])); size != "" {
out["size"] = strings.ReplaceAll(size, "×", "*")
} else if widthOK && heightOK {
out["size"] = fmt.Sprintf("%g*%g", width, height)
@@ -164,13 +181,23 @@ func aspectRatio(width, height float64) string {
return fmt.Sprintf("%g:%g", width, height)
}
func referenceCount(payload, input, providerPayload map[string]any) int {
count := 0
for _, value := range []any{payload["imageUrls"], payload["inputUrls"], input["imageUrls"], providerPayload["image_urls"], providerPayload["imageUrls"]} {
switch items := value.(type) {
case []any:
count += len(items)
if len(items) > 0 {
return len(items)
}
case []string:
count += len(items)
if len(items) > 0 {
return len(items)
}
}
}
count := 0
for _, material := range billingMaterials(payload) {
materialType := strings.ToLower(strings.TrimSpace(fmt.Sprint(material["type"])))
if materialType == "" || materialType == "image" {
count++
}
}
return count
@@ -8,12 +8,16 @@ func TestDefaultBillingPriceRulesFreezeOfficialCatalogMatchKeys(t *testing.T) {
"base-volcengine-jimeng-seedream46": false,
"base-evolink-gpt-image-2": false,
"base-bailian-wan27-image-pro": false,
"base-seedream-5-0-pro": false,
"base-bailian-wan27-i2v-720p": false,
"base-bailian-wan27-i2v-1080p": false,
"base-seedance-2-0-480p": false,
"base-seedance-2-0-720p": false,
"base-seedance-2-0-1080p": false,
"base-seedance-2-0-4k": false,
"base-seedance-2-5-480p": false,
"base-seedance-2-5-720p": false,
"base-seedance-2-5-1080p": false,
}
for _, rule := range rules {
if _, ok := want[rule.ID]; !ok {
+30
View File
@@ -0,0 +1,30 @@
package billing
import (
"errors"
"math"
"strings"
)
const Seedream50ProModel = "doubao-seedream-5-0-pro-260628"
// CalculateSeedreamAmountFen applies Seedream's additive input-image price
// before the platform markup: the first reference image is free and every
// additional reference image costs 2 fen.
func CalculateSeedreamAmountFen(outputPriceFen int64, referenceImageCount int, markupMultiplier float64) (int64, error) {
if outputPriceFen <= 0 {
return 0, errors.New("seedream output price must be positive")
}
if referenceImageCount < 0 || referenceImageCount > 10 {
return 0, errors.New("seedream reference image count must be between 0 and 10")
}
if math.IsNaN(markupMultiplier) || math.IsInf(markupMultiplier, 0) || markupMultiplier < 1 {
return 0, errors.New("seedream markup multiplier must be at least 1")
}
extraInputPriceFen := int64(max(0, referenceImageCount-1) * 2)
return int64(math.Ceil(float64(outputPriceFen+extraInputPriceFen) * markupMultiplier)), nil
}
func IsSeedreamBillingModel(model string) bool {
return strings.TrimSpace(model) == Seedream50ProModel
}
+24 -2
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"math"
"strings"
"time"
)
@@ -77,11 +78,32 @@ func (s *Service) Quote(ctx context.Context, command QuoteCommand) (*Quote, erro
}
parameters := NormalizeBillingParameters(command.Payload, command.Parameters)
quote, err := (Catalog{Rules: rules}).Quote(QuoteInput{Provider: command.Provider, Capability: command.Capability, ReqKey: command.ReqKey, Source: "platform", Role: command.Role, Parameters: parameters})
if err != nil || quote == nil || command.Provider != "seedance" || command.ReqKey != "doubao-seedance-2-0-260128" {
if err != nil || quote == nil {
return quote, err
}
inputVideo, inputDuration := seedanceInputVideo(command.Payload)
if command.Provider == "seedream" && IsSeedreamBillingModel(command.ReqKey) {
referenceImageCount := 0
if count, ok := number(parameters["referenceImageCount"]); ok {
referenceImageCount = int(math.Ceil(count))
}
amount, calculateErr := CalculateSeedreamAmountFen(quote.StandardUnitPriceFen, referenceImageCount, quote.MarkupMultiplier)
if calculateErr != nil {
return nil, calculateErr
}
quote.AmountFen = amount
if quote.Parameters == nil {
quote.Parameters = Parameters{}
}
quote.Parameters["referenceImageCount"] = float64(referenceImageCount)
quote.Parameters["inputReferencePriceFen"] = float64(max(0, referenceImageCount-1) * 2)
return quote, nil
}
if command.Provider != "seedance" || !IsSeedanceBillingModel(command.ReqKey) {
return quote, nil
}
inputVideo, inputDuration := seedanceInputVideo(command.Payload, command.ReqKey)
estimated, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{
Model: command.ReqKey,
Resolution: fmt.Sprint(parameters["resolution"]), AspectRatio: fmt.Sprint(parameters["aspectRatio"]),
OutputDurationSeconds: quote.Quantity, InputVideo: inputVideo, InputVideoDurationSeconds: inputDuration,
MarkupMultiplier: quote.MarkupMultiplier,
+72
View File
@@ -78,20 +78,92 @@ func TestServiceQuoteFreezesConservativeSeedanceReserve(t *testing.T) {
}
}
func TestServiceQuoteUsesSeedance25CatalogAndTokenEstimate(t *testing.T) {
store := &quoteStoreStub{rules: []PriceRule{{
ID: "seedance-25-720", Provider: "seedance", Capability: "video.generate",
ReqKey: Seedance25Model, VariantKey: "resolution=720p", Unit: UnitVideoSecond,
StandardUnitPriceFen: 151, MarkupMultiplier: 1.2, Enabled: true,
}}}
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
Provider: "seedance", Capability: "video.generate", ReqKey: Seedance25Model,
Payload: map[string]any{"settings": map[string]any{"duration": 5.0, "resolution": "720p", "ratio": "16:9"}},
})
if err != nil {
t.Fatal(err)
}
if quote.AmountFen != 908 || quote.ReservedAmountFen != 908 || quote.MarkupMultiplier != 1.2 {
t.Fatalf("quote = %#v", quote)
}
}
func TestServiceQuoteAppliesSeedreamSizeAndReferenceInputPricing(t *testing.T) {
store := &quoteStoreStub{rules: []PriceRule{{
ID: "seedream-5", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel,
Unit: UnitImage, StandardUnitPriceFen: 30, MarkupMultiplier: 1.2, Enabled: true, Dimensions: seedreamDimensions(),
}}}
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel,
Payload: map[string]any{
"settings": map[string]any{"size": "2K", "imageCount": 1.0},
"materials": []any{
map[string]any{"type": "image", "url": "https://in.test/1.png"},
map[string]any{"type": "image", "url": "https://in.test/2.png"},
map[string]any{"type": "image", "url": "https://in.test/3.png"},
},
},
})
if err != nil {
t.Fatal(err)
}
// 2K output = 60 fen, two billable references = 4 fen, then 1.2x.
if quote.StandardUnitPriceFen != 60 || quote.AmountFen != 77 || quote.Parameters["referenceImageCount"] != float64(3) || quote.Parameters["inputReferencePriceFen"] != float64(4) {
t.Fatalf("quote=%#v", quote)
}
}
func TestCalculateSeedreamAmountFenOfficialVectors(t *testing.T) {
tests := []struct {
output, references int
want int64
}{{30, 0, 36}, {30, 1, 36}, {30, 3, 41}, {60, 3, 77}, {60, 10, 94}}
for _, test := range tests {
got, err := CalculateSeedreamAmountFen(int64(test.output), test.references, 1.2)
if err != nil || got != test.want {
t.Fatalf("output=%d refs=%d got=%d err=%v want=%d", test.output, test.references, got, err, test.want)
}
}
}
func TestSeedanceEstimateMatchesExecutableTypeScriptVectors(t *testing.T) {
withoutVideo, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{
Model: Seedance20Model,
Resolution: "720p", AspectRatio: "16:9", OutputDurationSeconds: 5, MarkupMultiplier: 1.2,
})
if err != nil || withoutVideo != 597 {
t.Fatalf("without video = %d, %v", withoutVideo, err)
}
withUnknownVideo, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{
Model: Seedance20Model,
Resolution: "720p", AspectRatio: "9:16", OutputDurationSeconds: 5,
InputVideo: true, MarkupMultiplier: 1.2,
})
if err != nil || withUnknownVideo != 1452 {
t.Fatalf("with video = %d, %v", withUnknownVideo, err)
}
seedance25, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{
Model: Seedance25Model, Resolution: "720p", AspectRatio: "16:9", OutputDurationSeconds: 5,
MarkupMultiplier: 1.2,
})
if err != nil || seedance25 != 908 {
t.Fatalf("seedance 2.5 without video = %d, %v", seedance25, err)
}
seedance25WithShortVideo, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{
Model: Seedance25Model, Resolution: "720p", AspectRatio: "16:9", OutputDurationSeconds: 5,
InputVideo: true, InputVideoDurationSeconds: 2, MarkupMultiplier: 1.2,
})
if err != nil || seedance25WithShortVideo != 980 {
t.Fatalf("seedance 2.5 with short video = %d, %v", seedance25WithShortVideo, err)
}
}
type quoteStoreStub struct {
+49 -18
View File
@@ -12,11 +12,13 @@ import (
const seedanceTokenScale = 1_000_000
const (
seedanceFPS = 24
seedanceMaximumInputDuration = 15
Seedance20Model = "doubao-seedance-2-0-260128"
Seedance25Model = "doubao-seedance-2-5-260628"
seedanceFPS = 24
)
type SeedanceEstimateInput struct {
Model string
Resolution, AspectRatio string
OutputDurationSeconds float64
InputVideoDurationSeconds float64
@@ -25,24 +27,34 @@ type SeedanceEstimateInput struct {
}
type SeedanceActualAmountInput struct {
Model string
Resolution string
InputVideo bool
CompletionTokens int64
MarkupMultiplier float64
}
func SeedanceTokenPriceFenPerMillion(resolution string, inputVideo bool) int64 {
func SeedanceTokenPriceFenPerMillion(model, resolution string, inputVideo bool) int64 {
normalized := strings.ToLower(strings.TrimSpace(resolution))
var withoutVideo, withVideo int64
switch normalized {
case "480p", "720p":
withoutVideo, withVideo = 4600, 2800
case "1080p":
withoutVideo, withVideo = 5100, 3100
case "4k":
withoutVideo, withVideo = 2600, 1600
default:
withoutVideo, withVideo = 4600, 2800
if strings.TrimSpace(model) == Seedance25Model {
switch normalized {
case "1080p":
withoutVideo, withVideo = 7700, 4600
default:
withoutVideo, withVideo = 7000, 4200
}
} else {
switch normalized {
case "480p", "720p":
withoutVideo, withVideo = 4600, 2800
case "1080p":
withoutVideo, withVideo = 5100, 3100
case "4k":
withoutVideo, withVideo = 2600, 1600
default:
withoutVideo, withVideo = 4600, 2800
}
}
if inputVideo {
return withVideo
@@ -57,7 +69,7 @@ func CalculateSeedanceActualAmountFen(input SeedanceActualAmountInput) (int64, e
if math.IsNaN(input.MarkupMultiplier) || math.IsInf(input.MarkupMultiplier, 0) || input.MarkupMultiplier < 1 {
return 0, errors.New("seedance markup multiplier must be at least 1")
}
amount := math.Ceil(float64(input.CompletionTokens) * float64(SeedanceTokenPriceFenPerMillion(input.Resolution, input.InputVideo)) * input.MarkupMultiplier / seedanceTokenScale)
amount := math.Ceil(float64(input.CompletionTokens) * float64(SeedanceTokenPriceFenPerMillion(input.Model, input.Resolution, input.InputVideo)) * input.MarkupMultiplier / seedanceTokenScale)
if amount < 1 {
amount = 1
}
@@ -75,13 +87,13 @@ func EstimateSeedanceAmountFen(input SeedanceEstimateInput) (int64, error) {
if input.InputVideo {
inputDuration = input.InputVideoDurationSeconds
if math.IsNaN(inputDuration) || math.IsInf(inputDuration, 0) || inputDuration <= 0 {
inputDuration = seedanceMaximumInputDuration
inputDuration = seedanceMaximumInputDuration(input.Model)
}
inputDuration = math.Min(seedanceMaximumInputDuration, inputDuration)
inputDuration = normalizeSeedanceInputDuration(input.Model, inputDuration)
}
width, height := seedanceOutputDimensions(input.Resolution, input.AspectRatio)
tokens := math.Ceil((inputDuration + input.OutputDurationSeconds) * float64(width*height*seedanceFPS) / 1024)
amount := math.Ceil(tokens * float64(SeedanceTokenPriceFenPerMillion(input.Resolution, input.InputVideo)) * input.MarkupMultiplier / seedanceTokenScale)
amount := math.Ceil(tokens * float64(SeedanceTokenPriceFenPerMillion(input.Model, input.Resolution, input.InputVideo)) * input.MarkupMultiplier / seedanceTokenScale)
return int64(math.Max(1, amount)), nil
}
@@ -108,7 +120,7 @@ func seedanceOutputDimensions(resolution, aspectRatio string) (int, int) {
return max(1, int(math.Round(math.Sqrt(area*ratio)))), max(1, int(math.Round(math.Sqrt(area/ratio))))
}
func seedanceInputVideo(payload map[string]any) (bool, float64) {
func seedanceInputVideo(payload map[string]any, model string) (bool, float64) {
materials := billingMaterials(payload)
total, known := 0.0, false
for _, material := range materials {
@@ -136,7 +148,26 @@ func seedanceInputVideo(payload map[string]any) (bool, float64) {
if !known {
return inputVideo, 0
}
return inputVideo, math.Min(seedanceMaximumInputDuration, total)
return inputVideo, normalizeSeedanceInputDuration(model, total)
}
func IsSeedanceBillingModel(model string) bool {
model = strings.TrimSpace(model)
return model == Seedance20Model || model == Seedance25Model
}
func seedanceMaximumInputDuration(model string) float64 {
if strings.TrimSpace(model) == Seedance25Model {
return 30
}
return 15
}
func normalizeSeedanceInputDuration(model string, duration float64) float64 {
if strings.TrimSpace(model) == Seedance25Model && duration < 4 {
duration = 4
}
return math.Min(seedanceMaximumInputDuration(model), duration)
}
func billingMaterials(payload map[string]any) []map[string]any {
+8 -5
View File
@@ -8,20 +8,23 @@ import (
func TestSeedanceActualAmountUsesFrozenTokenPricingAndRoundsUp(t *testing.T) {
tests := []struct {
model string
resolution string
inputVideo bool
wantPrice int64
wantAmount int64
}{
{resolution: "720P", wantPrice: 4600, wantAmount: 6},
{resolution: "1080p", inputVideo: true, wantPrice: 3100, wantAmount: 4},
{resolution: "4K", wantPrice: 2600, wantAmount: 4},
{model: Seedance20Model, resolution: "720P", wantPrice: 4600, wantAmount: 6},
{model: Seedance20Model, resolution: "1080p", inputVideo: true, wantPrice: 3100, wantAmount: 4},
{model: Seedance20Model, resolution: "4K", wantPrice: 2600, wantAmount: 4},
{model: Seedance25Model, resolution: "720P", wantPrice: 7000, wantAmount: 9},
{model: Seedance25Model, resolution: "1080p", inputVideo: true, wantPrice: 4600, wantAmount: 6},
}
for _, test := range tests {
if got := SeedanceTokenPriceFenPerMillion(test.resolution, test.inputVideo); got != test.wantPrice {
if got := SeedanceTokenPriceFenPerMillion(test.model, test.resolution, test.inputVideo); got != test.wantPrice {
t.Fatalf("price(%q, %v) = %d, want %d", test.resolution, test.inputVideo, got, test.wantPrice)
}
got, err := CalculateSeedanceActualAmountFen(SeedanceActualAmountInput{Resolution: test.resolution, InputVideo: test.inputVideo, CompletionTokens: 1001, MarkupMultiplier: 1.2})
got, err := CalculateSeedanceActualAmountFen(SeedanceActualAmountInput{Model: test.model, Resolution: test.resolution, InputVideo: test.inputVideo, CompletionTokens: 1001, MarkupMultiplier: 1.2})
if err != nil || got != test.wantAmount {
t.Fatalf("amount(%q, %v) = %d, %v; want %d", test.resolution, test.inputVideo, got, err, test.wantAmount)
}