193 lines
6.0 KiB
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
|
|
}
|