Files
NianAIGC/backend/internal/billing/settlement.go

193 lines
6.0 KiB
Go

package billing
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
)
const seedanceTokenScale = 1_000_000
const (
seedanceFPS = 24
seedanceMaximumInputDuration = 15
)
type SeedanceEstimateInput struct {
Resolution, AspectRatio string
OutputDurationSeconds float64
InputVideoDurationSeconds float64
InputVideo bool
MarkupMultiplier float64
}
type SeedanceActualAmountInput struct {
Resolution string
InputVideo bool
CompletionTokens int64
MarkupMultiplier float64
}
func SeedanceTokenPriceFenPerMillion(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 inputVideo {
return withVideo
}
return withoutVideo
}
func CalculateSeedanceActualAmountFen(input SeedanceActualAmountInput) (int64, error) {
if input.CompletionTokens <= 0 {
return 0, errors.New("seedance completion tokens must be positive")
}
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)
if amount < 1 {
amount = 1
}
return int64(amount), nil
}
func EstimateSeedanceAmountFen(input SeedanceEstimateInput) (int64, error) {
if math.IsNaN(input.OutputDurationSeconds) || math.IsInf(input.OutputDurationSeconds, 0) || input.OutputDurationSeconds <= 0 {
return 0, errors.New("seedance output duration must be positive")
}
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")
}
inputDuration := 0.0
if input.InputVideo {
inputDuration = input.InputVideoDurationSeconds
if math.IsNaN(inputDuration) || math.IsInf(inputDuration, 0) || inputDuration <= 0 {
inputDuration = seedanceMaximumInputDuration
}
inputDuration = math.Min(seedanceMaximumInputDuration, 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)
return int64(math.Max(1, amount)), nil
}
func seedanceOutputDimensions(resolution, aspectRatio string) (int, int) {
baseWidth, baseHeight := 1280, 720
switch strings.ToLower(strings.TrimSpace(resolution)) {
case "480p":
baseWidth, baseHeight = 854, 480
case "1080p":
baseWidth, baseHeight = 1920, 1080
case "4k":
baseWidth, baseHeight = 3840, 2160
}
ratio := 16.0 / 9.0
parts := strings.FieldsFunc(strings.TrimSpace(aspectRatio), func(r rune) bool { return r == ':' || r == '/' })
if len(parts) == 2 {
if width, widthErr := strconv.ParseFloat(parts[0], 64); widthErr == nil && width > 0 {
if height, heightErr := strconv.ParseFloat(parts[1], 64); heightErr == nil && height > 0 {
ratio = width / height
}
}
}
area := float64(baseWidth * baseHeight)
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) {
materials := billingMaterials(payload)
total, known := 0.0, false
for _, material := range materials {
if !strings.EqualFold(strings.TrimSpace(fmt.Sprint(material["type"])), "video") {
continue
}
for _, value := range []any{material["duration"], material["durationSeconds"], record(material["metadata"])["duration"], record(material["metadata"])["durationSeconds"]} {
if duration, ok := number(value); ok && duration > 0 {
total += duration
known = true
break
}
}
}
if len(materials) == 0 {
return false, 0
}
inputVideo := false
for _, material := range materials {
if strings.EqualFold(strings.TrimSpace(fmt.Sprint(material["type"])), "video") {
inputVideo = true
break
}
}
if !known {
return inputVideo, 0
}
return inputVideo, math.Min(seedanceMaximumInputDuration, total)
}
func billingMaterials(payload map[string]any) []map[string]any {
for _, candidate := range []any{record(payload["assembled"])["materials"], record(payload["promptAssembly"])["materials"], record(payload["input"])["materials"], payload["materials"]} {
values, ok := candidate.([]any)
if !ok || len(values) == 0 {
continue
}
out := make([]map[string]any, 0, len(values))
for _, value := range values {
if material := record(value); len(material) > 0 {
out = append(out, material)
}
}
if len(out) > 0 {
return out
}
}
return nil
}
// SettlementRequest expresses actual usage minus the amount already charged.
// A positive DeltaFen is an additional charge; a negative value is a refund.
type SettlementRequest struct {
OrganizationID, AccountID, JobID, Description string
DeltaFen int64
Metadata map[string]any
}
func (l Ledger) Settle(ctx context.Context, input SettlementRequest) (WalletPosting, error) {
if input.DeltaFen == 0 {
return WalletPosting{}, errors.New("billing settlement delta must be non-zero")
}
kind, walletDelta := "charge", -input.DeltaFen
if input.DeltaFen < 0 {
kind = "refund"
}
return l.post(ctx, ChargeRequest{
OrganizationID: input.OrganizationID,
AccountID: input.AccountID,
JobID: input.JobID,
AmountFen: absInt64(input.DeltaFen),
Description: input.Description,
Metadata: input.Metadata,
}, kind, walletDelta, "job-settlement:"+input.JobID)
}
func absInt64(value int64) int64 {
if value < 0 {
return -value
}
return value
}