386 lines
12 KiB
Go
386 lines
12 KiB
Go
package model
|
||
|
||
import (
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||
)
|
||
|
||
// VideoFlatClipTierRow 单档视频标价(已套用成本折扣+加价折扣的有效展示价,未乘用户分组倍率)。
|
||
type VideoFlatClipTierRow struct {
|
||
UsdAfterChannelDiscount float64 `json:"usd_after_channel_discount"`
|
||
Resolution string `json:"resolution,omitempty"`
|
||
HasAudio *bool `json:"has_audio,omitempty"`
|
||
Lane string `json:"lane,omitempty"`
|
||
}
|
||
|
||
// VideoFlatClipPricingHint 多档视频分辨率价在定价卡片上的摘要(按条或按秒,见 BillingMode):
|
||
// MinUsdAfterChannelDiscount = min(渠道规则价×成本折扣% + 全局规则价×加价折扣%),与实扣公式一致;
|
||
// 前端再乘用户当前分组倍率后走 displayPrice。
|
||
// Tiers 为全部档位(同口径),供「查看更多价格」表格展示。
|
||
type VideoFlatClipPricingHint struct {
|
||
MinUsdAfterChannelDiscount float64 `json:"min_usd_after_channel_discount"`
|
||
Resolution string `json:"resolution,omitempty"`
|
||
HasAudio *bool `json:"has_audio,omitempty"`
|
||
Lane string `json:"lane,omitempty"`
|
||
TierCount int `json:"tier_count"`
|
||
Tiers []VideoFlatClipTierRow `json:"tiers,omitempty"`
|
||
// BillingMode:per_item 为按条成片;per_second 为按秒(如 Seedance2.0 常见 text_to_video_per_second)。
|
||
BillingMode string `json:"billing_mode,omitempty"`
|
||
}
|
||
|
||
type videoFlatTier struct {
|
||
RawUSD float64
|
||
Res string
|
||
HasAudio *bool
|
||
Lane string
|
||
}
|
||
|
||
func laneOrderVideoFlat(l string) int {
|
||
switch l {
|
||
case "text_to_video":
|
||
return 0
|
||
case "image_to_video":
|
||
return 1
|
||
case "video_to_video":
|
||
return 2
|
||
case "text_to_video_legacy":
|
||
return 3
|
||
case "image_to_video_legacy":
|
||
return 4
|
||
case "video_to_video_input_legacy":
|
||
return 5
|
||
case "video_to_video_output_legacy":
|
||
return 6
|
||
case "text_to_video_per_second":
|
||
return 10
|
||
case "image_to_video_per_second":
|
||
return 11
|
||
case "video_to_video_per_second":
|
||
return 12
|
||
default:
|
||
return 99
|
||
}
|
||
}
|
||
|
||
func audioPtrRank(p *bool) int {
|
||
if p == nil {
|
||
return 2
|
||
}
|
||
if !*p {
|
||
return 0
|
||
}
|
||
return 1
|
||
}
|
||
|
||
func tierLessVideoFlat(a, b videoFlatTier) bool {
|
||
ar := strings.TrimSpace(strings.ToLower(a.Res))
|
||
br := strings.TrimSpace(strings.ToLower(b.Res))
|
||
if ar != br {
|
||
return ar < br
|
||
}
|
||
if laneOrderVideoFlat(a.Lane) != laneOrderVideoFlat(b.Lane) {
|
||
return laneOrderVideoFlat(a.Lane) < laneOrderVideoFlat(b.Lane)
|
||
}
|
||
return audioPtrRank(a.HasAudio) < audioPtrRank(b.HasAudio)
|
||
}
|
||
|
||
func tierDedupeKey(ti videoFlatTier) string {
|
||
return ti.Lane + "\x00" + strings.TrimSpace(strings.ToLower(ti.Res)) + "\x00" +
|
||
strconv.FormatFloat(ti.RawUSD, 'f', 8, 64)
|
||
}
|
||
|
||
// collapsePairedUnifiedAudioTiers 同 lane+分辨率+价格 下同时存在 has_audio true/false 时合并为一条(HasAudio=nil),供展示「统一」。
|
||
func collapsePairedUnifiedAudioTiers(tiers []videoFlatTier) []videoFlatTier {
|
||
order := make([]string, 0)
|
||
groups := make(map[string][]videoFlatTier)
|
||
for _, ti := range tiers {
|
||
k := tierDedupeKey(ti)
|
||
if _, ok := groups[k]; !ok {
|
||
order = append(order, k)
|
||
}
|
||
groups[k] = append(groups[k], ti)
|
||
}
|
||
out := make([]videoFlatTier, 0, len(tiers))
|
||
for _, k := range order {
|
||
g := groups[k]
|
||
if len(g) == 2 {
|
||
a, b := g[0], g[1]
|
||
if a.HasAudio != nil && b.HasAudio != nil && *a.HasAudio != *b.HasAudio &&
|
||
math.Abs(a.RawUSD-b.RawUSD) < 1e-9 &&
|
||
strings.EqualFold(strings.TrimSpace(a.Res), strings.TrimSpace(b.Res)) &&
|
||
a.Lane == b.Lane {
|
||
out = append(out, videoFlatTier{
|
||
RawUSD: a.RawUSD,
|
||
Res: a.Res,
|
||
Lane: a.Lane,
|
||
HasAudio: nil,
|
||
})
|
||
continue
|
||
}
|
||
}
|
||
out = append(out, g...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// normalizeLegacyAllFalseToUnifiedHintTiers 旧版前端只写入 has_audio:false 的统一价;当每条档位均为 false(无 nil、无 true)时改为 nil 以便展示「统一」。
|
||
func normalizeLegacyAllFalseToUnifiedHintTiers(tiers []videoFlatTier) []videoFlatTier {
|
||
if len(tiers) == 0 {
|
||
return tiers
|
||
}
|
||
allExplicitFalse := true
|
||
for i := range tiers {
|
||
t := tiers[i]
|
||
if t.HasAudio == nil {
|
||
allExplicitFalse = false
|
||
break
|
||
}
|
||
if *t.HasAudio {
|
||
allExplicitFalse = false
|
||
break
|
||
}
|
||
}
|
||
if !allExplicitFalse {
|
||
return tiers
|
||
}
|
||
out := make([]videoFlatTier, len(tiers))
|
||
for i := range tiers {
|
||
tt := tiers[i]
|
||
tt.HasAudio = nil
|
||
out[i] = tt
|
||
}
|
||
return out
|
||
}
|
||
|
||
func appendPerItemTiers(dst *[]videoFlatTier, rows []ratio_setting.VideoResolutionAudioPriceRule, lane string) {
|
||
for i := range rows {
|
||
r := rows[i]
|
||
if r.Price <= 0 {
|
||
continue
|
||
}
|
||
ha := r.HasAudio
|
||
*dst = append(*dst, videoFlatTier{
|
||
RawUSD: r.Price,
|
||
Res: r.Resolution,
|
||
HasAudio: &ha,
|
||
Lane: lane,
|
||
})
|
||
}
|
||
}
|
||
|
||
func appendLegacyPerVideoTiers(dst *[]videoFlatTier, rows []ratio_setting.VideoResolutionPerVideoRule, lane string) {
|
||
for i := range rows {
|
||
r := rows[i]
|
||
if r.VideoPrice <= 0 {
|
||
continue
|
||
}
|
||
*dst = append(*dst, videoFlatTier{
|
||
RawUSD: r.VideoPrice,
|
||
Res: r.Resolution,
|
||
HasAudio: nil,
|
||
Lane: lane,
|
||
})
|
||
}
|
||
}
|
||
|
||
func collectVideoFlatTiers(rules ratio_setting.VideoPricingRules) []videoFlatTier {
|
||
out := make([]videoFlatTier, 0, 48)
|
||
appendPerItemTiers(&out, rules.TextToVideoPerItem, "text_to_video")
|
||
appendPerItemTiers(&out, rules.ImageToVideoPerItem, "image_to_video")
|
||
appendPerItemTiers(&out, rules.VideoToVideoPerItem, "video_to_video")
|
||
appendLegacyPerVideoTiers(&out, rules.TextToVideoPerVideo, "text_to_video_legacy")
|
||
appendLegacyPerVideoTiers(&out, rules.ImageToVideoPerVideo, "image_to_video_legacy")
|
||
appendLegacyPerVideoTiers(&out, rules.VideoToVideoInputPerVideo, "video_to_video_input_legacy")
|
||
appendLegacyPerVideoTiers(&out, rules.VideoToVideoOutputPerVideo, "video_to_video_output_legacy")
|
||
return out
|
||
}
|
||
|
||
func collectVideoPerSecondTiers(rules ratio_setting.VideoPricingRules) []videoFlatTier {
|
||
out := make([]videoFlatTier, 0, 24)
|
||
appendPerItemTiers(&out, rules.TextToVideoPerSecond, "text_to_video_per_second")
|
||
appendPerItemTiers(&out, rules.ImageToVideoPerSecond, "image_to_video_per_second")
|
||
appendPerItemTiers(&out, rules.VideoToVideoPerSecond, "video_to_video_per_second")
|
||
return out
|
||
}
|
||
|
||
func pickMinVideoFlatTier(tiers []videoFlatTier) (videoFlatTier, bool) {
|
||
if len(tiers) == 0 {
|
||
return videoFlatTier{}, false
|
||
}
|
||
best := 0
|
||
for i := 1; i < len(tiers); i++ {
|
||
a, b := tiers[best], tiers[i]
|
||
if b.RawUSD < a.RawUSD-1e-12 {
|
||
best = i
|
||
continue
|
||
}
|
||
if math.Abs(b.RawUSD-a.RawUSD) < 1e-9 && tierLessVideoFlat(b, a) {
|
||
best = i
|
||
}
|
||
}
|
||
return tiers[best], true
|
||
}
|
||
|
||
func videoRulesUsableForPricingHint(rules ratio_setting.VideoPricingRules) bool {
|
||
return ratio_setting.HasUsableVideoPerVideoRules(rules) ||
|
||
ratio_setting.HasUsableVideoPerSecondRules(rules)
|
||
}
|
||
|
||
func resolveChannelVideoRulesForPricingCardHint(channelID int, modelName string) (ratio_setting.VideoPricingRules, bool) {
|
||
if channelID > 0 {
|
||
if rules, ok := ratio_setting.GetChannelVideoPricingRules(channelID, modelName); ok && videoRulesUsableForPricingHint(rules) {
|
||
return rules, true
|
||
}
|
||
}
|
||
return ratio_setting.VideoPricingRules{}, false
|
||
}
|
||
|
||
func resolveGlobalVideoRulesForPricingCardHint(modelName string) (ratio_setting.VideoPricingRules, bool) {
|
||
if rules, ok := ratio_setting.GetVideoPricingRules(modelName); ok && videoRulesUsableForPricingHint(rules) {
|
||
return rules, true
|
||
}
|
||
return ratio_setting.VideoPricingRules{}, false
|
||
}
|
||
|
||
func lookupVideoTierRawUSD(rules ratio_setting.VideoPricingRules, target videoFlatTier) float64 {
|
||
if !videoRulesUsableForPricingHint(rules) {
|
||
return 0
|
||
}
|
||
tiers := collectVideoFlatTiers(rules)
|
||
if len(tiers) == 0 {
|
||
tiers = collectVideoPerSecondTiers(rules)
|
||
}
|
||
for _, c := range tiers {
|
||
if c.Lane != target.Lane {
|
||
continue
|
||
}
|
||
if !strings.EqualFold(strings.TrimSpace(c.Res), strings.TrimSpace(target.Res)) {
|
||
continue
|
||
}
|
||
if !videoTierAudioMatches(c.HasAudio, target.HasAudio) {
|
||
continue
|
||
}
|
||
return c.RawUSD
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func videoTierAudioMatches(a, b *bool) bool {
|
||
if a == nil || b == nil {
|
||
return true
|
||
}
|
||
return *a == *b
|
||
}
|
||
|
||
func effectiveVideoTierDisplayUSD(channelTier videoFlatTier, globalRules ratio_setting.VideoPricingRules, costDiscPercent, markupDiscPercent float64) float64 {
|
||
globalRaw := lookupVideoTierRawUSD(globalRules, channelTier)
|
||
return EffectiveRuleUnitPrice(channelTier.RawUSD, globalRaw, costDiscPercent, markupDiscPercent)
|
||
}
|
||
|
||
func tierRowLess(a, b VideoFlatClipTierRow) bool {
|
||
ar := strings.TrimSpace(strings.ToLower(a.Resolution))
|
||
br := strings.TrimSpace(strings.ToLower(b.Resolution))
|
||
if ar != br {
|
||
return ar < br
|
||
}
|
||
if laneOrderVideoFlat(a.Lane) != laneOrderVideoFlat(b.Lane) {
|
||
return laneOrderVideoFlat(a.Lane) < laneOrderVideoFlat(b.Lane)
|
||
}
|
||
return audioPtrRank(a.HasAudio) < audioPtrRank(b.HasAudio)
|
||
}
|
||
|
||
func buildSortedTierRows(tiers []videoFlatTier, globalRules ratio_setting.VideoPricingRules, costDiscPercent, markupDiscPercent float64) []VideoFlatClipTierRow {
|
||
rows := make([]VideoFlatClipTierRow, 0, len(tiers))
|
||
for _, ti := range tiers {
|
||
usd := effectiveVideoTierDisplayUSD(ti, globalRules, costDiscPercent, markupDiscPercent)
|
||
if usd <= 0 {
|
||
continue
|
||
}
|
||
var ha *bool
|
||
if ti.HasAudio != nil {
|
||
v := *ti.HasAudio
|
||
ha = &v
|
||
}
|
||
rows = append(rows, VideoFlatClipTierRow{
|
||
UsdAfterChannelDiscount: usd,
|
||
Resolution: strings.TrimSpace(ti.Res),
|
||
HasAudio: ha,
|
||
Lane: ti.Lane,
|
||
})
|
||
}
|
||
sort.Slice(rows, func(i, j int) bool {
|
||
a, b := rows[i], rows[j]
|
||
if math.Abs(a.UsdAfterChannelDiscount-b.UsdAfterChannelDiscount) > 1e-9 {
|
||
return a.UsdAfterChannelDiscount < b.UsdAfterChannelDiscount
|
||
}
|
||
return tierRowLess(a, b)
|
||
})
|
||
return rows
|
||
}
|
||
|
||
func pickMinEffectiveVideoDisplayTier(tiers []videoFlatTier, globalRules ratio_setting.VideoPricingRules, costDiscPercent, markupDiscPercent float64) (videoFlatTier, float64, bool) {
|
||
if len(tiers) == 0 {
|
||
return videoFlatTier{}, 0, false
|
||
}
|
||
bestIdx := 0
|
||
bestUsd := effectiveVideoTierDisplayUSD(tiers[0], globalRules, costDiscPercent, markupDiscPercent)
|
||
for i := 1; i < len(tiers); i++ {
|
||
u := effectiveVideoTierDisplayUSD(tiers[i], globalRules, costDiscPercent, markupDiscPercent)
|
||
if u < bestUsd-1e-12 || (math.Abs(u-bestUsd) < 1e-9 && tierLessVideoFlat(tiers[i], tiers[bestIdx])) {
|
||
bestIdx = i
|
||
bestUsd = u
|
||
}
|
||
}
|
||
if bestUsd <= 0 {
|
||
return videoFlatTier{}, 0, false
|
||
}
|
||
return tiers[bestIdx], bestUsd, true
|
||
}
|
||
|
||
// BuildVideoFlatClipHint 汇总当前模型×渠道下视频分辨率档位(优先按条,否则按秒),返回最低价档(含成本折扣与加价折扣)及总档位数。
|
||
func BuildVideoFlatClipHint(channelID int, modelName string, costDiscPercent, markupDiscPercent float64) *VideoFlatClipPricingHint {
|
||
channelRules, chOK := resolveChannelVideoRulesForPricingCardHint(channelID, modelName)
|
||
globalRules, glOK := resolveGlobalVideoRulesForPricingCardHint(modelName)
|
||
if !chOK && !glOK {
|
||
return nil
|
||
}
|
||
rulesForTiers := channelRules
|
||
if !chOK {
|
||
rulesForTiers = globalRules
|
||
}
|
||
tiers := collectVideoFlatTiers(rulesForTiers)
|
||
billingMode := "per_item"
|
||
if len(tiers) == 0 {
|
||
tiers = collectVideoPerSecondTiers(rulesForTiers)
|
||
billingMode = "per_second"
|
||
}
|
||
if len(tiers) == 0 {
|
||
return nil
|
||
}
|
||
tiers = collapsePairedUnifiedAudioTiers(tiers)
|
||
tiers = normalizeLegacyAllFalseToUnifiedHintTiers(tiers)
|
||
best, minUsd, ok := pickMinEffectiveVideoDisplayTier(tiers, globalRules, costDiscPercent, markupDiscPercent)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
var hasAudioPtr *bool
|
||
if best.HasAudio != nil {
|
||
v := *best.HasAudio
|
||
hasAudioPtr = &v
|
||
}
|
||
rows := buildSortedTierRows(tiers, globalRules, costDiscPercent, markupDiscPercent)
|
||
return &VideoFlatClipPricingHint{
|
||
MinUsdAfterChannelDiscount: minUsd,
|
||
Resolution: strings.TrimSpace(best.Res),
|
||
HasAudio: hasAudioPtr,
|
||
Lane: best.Lane,
|
||
TierCount: len(tiers),
|
||
Tiers: rows,
|
||
BillingMode: billingMode,
|
||
}
|
||
}
|