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 }