Files
ttstd-infinite-canvas/backend/internal/app/video_token_billing.go

242 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"math"
"strconv"
"strings"
)
// VideoTokenEstimate separates the provider's pixel-frame estimate from our
// reservation margin. The formula snapshot can settle videos without provider usage.
type VideoTokenEstimate struct {
FormulaTokens int64 `json:"formulaTokens"`
ReservedTokens int64 `json:"reservedTokens"`
OutputWidth int64 `json:"outputWidth"`
OutputHeight int64 `json:"outputHeight"`
FramesPerSecond int64 `json:"framesPerSecond"`
OutputSeconds int64 `json:"outputSeconds"`
ReferenceSeconds float64 `json:"referenceSeconds"`
ReferenceDurationEstimated bool `json:"referenceDurationEstimated"`
DimensionsEstimated bool `json:"dimensionsEstimated"`
ReservationMarginPercent int64 `json:"reservationMarginPercent"`
}
type videoDimensions struct{ width, height int64 }
// Official Seedance dimensions, not resolution-label multiplication. 4K's
// 4:3 and 21:9 dimensions in particular differ from twice the 1080p dimensions.
var arkVideoDimensions = map[string][]videoDimensions{
"480p": {{864, 496}, {752, 560}, {640, 640}, {560, 752}, {496, 864}, {992, 432}},
"720p": {{1280, 720}, {1112, 834}, {960, 960}, {834, 1112}, {720, 1280}, {1470, 630}},
"1080p": {{1920, 1080}, {1664, 1248}, {1440, 1440}, {1248, 1664}, {1080, 1920}, {2206, 946}},
"2160p": {{3840, 2160}, {3326, 2494}, {2880, 2880}, {2494, 3326}, {2160, 3840}, {4398, 1886}},
}
var arkVideo10Dimensions = map[string][]videoDimensions{
"480p": {{864, 480}, {736, 544}, {640, 640}, {544, 736}, {480, 864}, {960, 416}},
"720p": {{1248, 704}, {1120, 832}, {960, 960}, {832, 1120}, {704, 1248}, {1504, 640}},
"1080p": {{1920, 1088}, {1664, 1248}, {1440, 1440}, {1248, 1664}, {1088, 1920}, {2176, 928}},
}
func estimateArkVideoTokens(input map[string]any) tokenBillingEstimate {
detail, err := estimateArkVideoTokenUsage(input)
if err != nil {
return tokenBillingEstimate{Err: err}
}
return tokenBillingEstimate{OutputTokens: detail.ReservedTokens, Video: detail}
}
func estimateArkVideoTokenUsage(input map[string]any) (*VideoTokenEstimate, error) {
config, _ := input["config"].(map[string]any)
seconds, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(config["videoSeconds"])), 10, 64)
if err != nil || seconds <= 0 || seconds > (math.MaxInt64-15_000)/1000 {
return nil, BadAuthRequest("视频 Token 预估需要有效的正整数生成时长")
}
modelName := firstNonEmpty(stringValue(config["providerModelKey"]), stringValue(config["model"]))
dimensions, dimensionsEstimated, err := arkVideoBillingDimensions(stringValue(config["vquality"]), stringValue(config["size"]), modelName)
if err != nil {
return nil, err
}
referenceMillis, durationEstimated, err := videoReferenceMillis(input["referenceVideos"])
if err != nil {
return nil, err
}
const fps, margin = int64(24), int64(10)
if dimensions.width > math.MaxInt64/dimensions.height/fps {
return nil, BadAuthRequest("视频尺寸超出 Token 计算范围")
}
pixels := dimensions.width * dimensions.height
if seconds > (math.MaxInt64-referenceMillis)/1000 {
return nil, BadAuthRequest("视频时长超出 Token 计算范围")
}
millis := seconds*1000 + referenceMillis
if millis > (math.MaxInt64-1_023_999)/(pixels*fps) {
return nil, BadAuthRequest("视频 Token 预估用量超出支持范围")
}
// Official formula: (input seconds + output seconds) * W * H * FPS / 1024.
// Provider usage takes precedence; otherwise the formula snapshot is billable.
formulaTokens := (millis*pixels*fps + 1_023_999) / 1_024_000
if formulaTokens > (math.MaxInt64-99)/(100+margin) {
return nil, BadAuthRequest("视频 Token 预授权用量超出支持范围")
}
return &VideoTokenEstimate{
FormulaTokens: formulaTokens, ReservedTokens: (formulaTokens*(100+margin) + 99) / 100,
OutputWidth: dimensions.width, OutputHeight: dimensions.height, FramesPerSecond: fps,
OutputSeconds: seconds, ReferenceSeconds: float64(referenceMillis) / 1000,
ReferenceDurationEstimated: durationEstimated, DimensionsEstimated: dimensionsEstimated,
ReservationMarginPercent: margin,
}, nil
}
func videoReferenceMillis(raw any) (int64, bool, error) {
if raw == nil {
return 0, false, nil
}
references, ok := raw.([]any)
if !ok || len(references) > 128 {
return 0, false, BadAuthRequest("视频 Token 预估的参考视频参数无效")
}
var total int64
unknown := false
for _, rawReference := range references {
media, ok := rawReference.(map[string]any)
if rawReference != nil && !ok {
return 0, false, BadAuthRequest("视频 Token 预估的参考视频参数无效")
}
value := media["durationMs"]
if value == nil {
unknown = true
continue
}
duration, err := strconv.ParseInt(fmt.Sprint(value), 10, 64)
if err != nil || duration < 0 || duration > math.MaxInt64-total {
return 0, false, BadAuthRequest("参考视频时长参数无效,durationMs 必须为非负整数且不能溢出")
}
if duration == 0 {
unknown = true
}
total += duration
}
if unknown {
// The catalog quote has only reference counts; reserve the documented
// maximum and disclose it instead of pretending the references cost zero.
return max(total, 15_000), true, nil
}
return total, false, nil
}
func arkVideoBillingDimensions(resolution, ratio, modelName string) (videoDimensions, bool, error) {
resolution = strings.ToLower(strings.TrimSpace(resolution))
ratio = strings.ToLower(strings.TrimSpace(ratio))
// Some video protocols expose exact pixels in size instead of a resolution tier.
if dimensions, ok := videoPixelDimensions(ratio); ok {
return dimensions, false, nil
}
defaulted := resolution == "" || resolution == "auto" || resolution == "high" || resolution == "medium"
if defaulted {
resolution = "720p"
}
if resolution == "low" {
resolution = "480p"
}
if ratio == "" {
ratio = "16:9"
defaulted = true
}
switch resolution {
case "480", "720", "1080", "2160":
resolution += "p"
case "4k":
resolution = "2160p"
case "2k":
resolution = "1440p"
}
base, exists := arkVideoDimensions[resolution]
if !exists {
return videoScaledDimensions(resolution, ratio)
}
ratios := []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9"}
ratio = strings.ToLower(strings.TrimSpace(ratio))
adaptive := ratio == "adaptive" || ratio == "auto"
ratioIndex := -1
for index, value := range ratios {
if value == ratio {
ratioIndex = index
}
}
if ratioIndex < 0 && !adaptive {
return videoScaledDimensions(resolution, ratio)
}
name := strings.ReplaceAll(strings.ToLower(modelName), ".", "-")
is10 := strings.Contains(name, "seedance-1-0")
is25 := strings.Contains(name, "seedance-2-5")
known := is10 || is25 || strings.Contains(name, "seedance-1-5") || strings.Contains(name, "seedance-2-0")
values := append([]videoDimensions(nil), base...)
if is10 {
if resolution == "2160p" {
defaulted = true
} else {
values = arkVideo10Dimensions[resolution]
}
} else if is25 && resolution == "480p" {
values[0], values[4] = videoDimensions{854, 480}, videoDimensions{480, 854}
} else if !known {
// Endpoint IDs don't identify a model generation. Use the largest
// documented dimensions per ratio, with an explicit estimate flag.
for index, value := range arkVideo10Dimensions[resolution] {
if value.width*value.height > values[index].width*values[index].height {
values[index] = value
}
}
}
if !adaptive {
return values[ratioIndex], !known || defaulted, nil
}
largest := values[0]
for _, value := range values[1:] {
if value.width*value.height > largest.width*largest.height {
largest = value
}
}
return largest, true, nil
}
func videoPixelDimensions(size string) (videoDimensions, bool) {
parts := strings.Split(strings.ReplaceAll(size, "×", "x"), "x")
if len(parts) != 2 {
return videoDimensions{}, false
}
w, ew := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
h, eh := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
return videoDimensions{w, h}, ew == nil && eh == nil && w > 0 && h > 0
}
// Generic video tiers use the short edge and requested aspect ratio. This is a
// platform pricing convention for non-Seedance sizes, not a provider pixel claim.
func videoScaledDimensions(resolution, ratio string) (videoDimensions, bool, error) {
raw := strings.TrimSuffix(strings.TrimSuffix(resolution, "横"), "竖")
edge, err := strconv.ParseInt(strings.TrimSuffix(raw, "p"), 10, 64)
if err != nil || edge <= 0 {
return videoDimensions{}, false, BadAuthRequest("视频 Token 计费需要有效分辨率或明确的宽×高尺寸")
}
if ratio == "auto" || ratio == "adaptive" {
ratio = "16:9"
}
parts := strings.Split(ratio, ":")
if len(parts) != 2 {
return videoDimensions{}, false, BadAuthRequest("视频 Token 计费需要有效画幅比例")
}
w, ew := strconv.ParseInt(parts[0], 10, 64)
h, eh := strconv.ParseInt(parts[1], 10, 64)
if ew != nil || eh != nil || w <= 0 || h <= 0 || edge > (math.MaxInt64-min(w, h))/max(w, h) {
return videoDimensions{}, false, BadAuthRequest("视频画幅超出 Token 计算范围")
}
longEdge := (edge*max(w, h) + min(w, h) - 1) / min(w, h)
longEdge = (longEdge + 1) / 2 * 2
if w >= h {
return videoDimensions{longEdge, edge}, true, nil
}
return videoDimensions{edge, longEdge}, true, nil
}