diff --git a/crates/api_clients/seedance2pro_client/src/cost/kinovi_fractional_generation_cost.rs b/crates/api_clients/seedance2pro_client/src/cost/kinovi_fractional_generation_cost.rs new file mode 100644 index 0000000000..964b44ebcf --- /dev/null +++ b/crates/api_clients/seedance2pro_client/src/cost/kinovi_fractional_generation_cost.rs @@ -0,0 +1,85 @@ +use crate::cost::constants::CREDITS_PER_DOLLAR; + +/// The cost of a Kinovi generation that bills FRACTIONAL credits, in both +/// native credits and USD cents. +/// +/// This is the fractional-credit counterpart of +/// [`crate::cost::kinovi_generation_cost::KinoviGenerationCost`] (which uses +/// `u64`). Some Kinovi models bill non-integer credits — e.g. Seedance 2.0 +/// Mini at 480p bills 7.5 credits/sec, landing on half-credits like 37.5 at +/// odd durations. Any model with a fractional credit rate uses this type; +/// whole-credit models use the cheaper `u64` `KinoviGenerationCost`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KinoviFractionalGenerationCost { + /// The cost of the generation in Kinovi credits. May be fractional (e.g. + /// 37.5 for Seedance 2.0 Mini 480p at 5 seconds). + pub kinovi_credits: f64, + + /// Estimated cost in cents, rounded up to the nearest whole cent. + /// This does not account for discounts, prorations, etc. + pub usd_cents_rounded_up: u64, + + /// Estimated cost in cents, rounded down to the nearest whole cent. + pub usd_cents_rounded_down: u64, + + /// Estimated cost in cents without rounding (the exact fractional value). + pub usd_cents_fractional: f64, +} + +impl KinoviFractionalGenerationCost { + /// Build a cost from a (possibly fractional) Kinovi credit amount, deriving + /// the USD cents via the credit-package rate. + /// + /// The conversion is done on integer hundredths-of-a-credit, so the integer + /// cent results are exact (no floating-point drift). Credit amounts are + /// rounded to the nearest hundredth of a credit, which covers every Kinovi + /// rate seen so far (e.g. Mini's half-credits). + pub fn from_kinovi_credits(kinovi_credits: f64) -> Self { + // usd_cents == credits × 100 / CREDITS_PER_DOLLAR == hundredths / CREDITS_PER_DOLLAR. + let hundredths_of_credit = (kinovi_credits * 100.0).round() as u64; + Self { + kinovi_credits, + usd_cents_rounded_up: hundredths_of_credit.div_ceil(CREDITS_PER_DOLLAR), + usd_cents_rounded_down: hundredths_of_credit / CREDITS_PER_DOLLAR, + usd_cents_fractional: hundredths_of_credit as f64 / CREDITS_PER_DOLLAR as f64, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FLOAT_TOLERANCE: f64 = 1e-9; + + #[test] + fn fractional_credits_convert_to_usd() { + // 37.5 credits; 3750/243 = 15.4320 cents. + let cost = KinoviFractionalGenerationCost::from_kinovi_credits(37.5); + assert_eq!(cost.kinovi_credits, 37.5); + assert_eq!(cost.usd_cents_rounded_up, 16); + assert_eq!(cost.usd_cents_rounded_down, 15); + assert!((cost.usd_cents_fractional - (3750.0 / 243.0)).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn whole_credits_convert_to_usd() { + // 100 credits; 10000/243 = 41.1522 cents. + let cost = KinoviFractionalGenerationCost::from_kinovi_credits(100.0); + assert_eq!(cost.kinovi_credits, 100.0); + assert_eq!(cost.usd_cents_rounded_up, 42); + assert_eq!(cost.usd_cents_rounded_down, 41); + assert!((cost.usd_cents_fractional - (10000.0 / 243.0)).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn rounded_bounds_bracket_the_fractional_value() { + for half_credits in [0u64, 1, 15, 75, 95, 200, 225, 600, 720] { + let credits = half_credits as f64 / 2.0; + let cost = KinoviFractionalGenerationCost::from_kinovi_credits(credits); + assert!(cost.usd_cents_rounded_down as f64 <= cost.usd_cents_fractional); + assert!(cost.usd_cents_fractional <= cost.usd_cents_rounded_up as f64); + assert!(cost.usd_cents_rounded_up - cost.usd_cents_rounded_down <= 1); + } + } +} diff --git a/crates/api_clients/seedance2pro_client/src/cost/kinovi_seedance_generation_cost.rs b/crates/api_clients/seedance2pro_client/src/cost/kinovi_seedance_generation_cost.rs index 353c6aa2c5..d6cd31a080 100644 --- a/crates/api_clients/seedance2pro_client/src/cost/kinovi_seedance_generation_cost.rs +++ b/crates/api_clients/seedance2pro_client/src/cost/kinovi_seedance_generation_cost.rs @@ -3,6 +3,11 @@ use crate::cost::kinovi_generation_cost::KinoviGenerationCost; /// The cost of a Kinovi Seedance generation, with the video-reference /// surcharge broken out from the base price. /// +/// Used by the WHOLE-credit Seedance video models (regular + Fast). Seedance +/// 2.0 Mini bills fractional credits and has its own `f64` twin, +/// [`crate::cost::kinovi_seedance_mini_generation_cost::KinoviSeedanceMiniGenerationCost`] +/// — see that file for why they aren't merged. +/// /// `total_cost` covers base + surcharge. NB: the total's USD conversions are /// computed from the SUMMED credits (rounded once), so they may differ by a /// cent from adding the parts' rounded USD values together. diff --git a/crates/api_clients/seedance2pro_client/src/cost/kinovi_seedance_mini_generation_cost.rs b/crates/api_clients/seedance2pro_client/src/cost/kinovi_seedance_mini_generation_cost.rs index a5e4a09afc..268467b75d 100644 --- a/crates/api_clients/seedance2pro_client/src/cost/kinovi_seedance_mini_generation_cost.rs +++ b/crates/api_clients/seedance2pro_client/src/cost/kinovi_seedance_mini_generation_cost.rs @@ -1,12 +1,31 @@ -use crate::cost::constants::CREDITS_PER_DOLLAR; +use crate::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost; + +// ───────────────────────────────────────────────────────────────────────────── +// WHY THIS TYPE EXISTS (don't merge it into KinoviSeedanceGenerationCost): +// +// This is the Mini twin of `KinoviSeedanceGenerationCost`. It is structurally +// identical (base + optional video-reference surcharge = total) and prices the +// SAME way conceptually — but its credits are fractional +// (`KinoviFractionalGenerationCost`, `f64`) rather than whole +// (`KinoviGenerationCost`, `u64`). +// +// Seedance 2.0 Mini at 480p bills 7.5 credits/sec, so odd durations land on +// FRACTIONAL credits (5s = 37.5, 15s = 112.5). Every OTHER whole-credit +// Seedance video model uses `KinoviSeedanceGenerationCost` (u64 credits). +// +// Making the shared `u64` credit type fractional would ripple `f64` through all +// of those whole-credit models AND `artcraft_router` (which stores credits as +// `u64`), forcing hundreds of integer-literal edits for one model's benefit. +// Keeping Mini's fractional credits in their own small type is the cheaper, +// better-isolated choice. The inner `KinoviFractionalGenerationCost` is generic +// (not Mini-specific) — other fractional-credit models can reuse it. +// ───────────────────────────────────────────────────────────────────────────── /// The cost of a Kinovi Seedance 2.0 Mini generation, with the video-reference /// surcharge broken out from the base price. /// -/// Unlike the other Seedance models, Mini credits can be FRACTIONAL (480p is -/// 7.5 credits/sec, so odd durations land on half-credits like 37.5). Credits -/// are therefore tracked as `f64`. USD conversions are derived from the exact -/// fractional credit amount. +/// Mirrors [`crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost`], +/// but with FRACTIONAL credits (see the module note above). /// /// `total_cost` covers base + surcharge. NB: the total's USD conversions are /// computed from the SUMMED credits (rounded once), so they may differ by a @@ -14,15 +33,15 @@ use crate::cost::constants::CREDITS_PER_DOLLAR; #[derive(Clone, Copy, Debug, PartialEq)] pub struct KinoviSeedanceMiniGenerationCost { /// The full cost (base + any surcharge), in credits and USD cents. - pub total_cost: KinoviMiniGenerationCost, + pub total_cost: KinoviFractionalGenerationCost, /// The base generation cost (resolution rate × output duration × batch /// count), excluding any surcharges. - pub base_cost: KinoviMiniGenerationCost, + pub base_cost: KinoviFractionalGenerationCost, /// The video-reference surcharge, when one or more reference videos are /// attached. `None` when no reference videos are attached. - pub video_reference_surcharge_cost: Option, + pub video_reference_surcharge_cost: Option, } impl KinoviSeedanceMiniGenerationCost { @@ -35,51 +54,10 @@ impl KinoviSeedanceMiniGenerationCost { ) -> Self { let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0.0); Self { - total_cost: KinoviMiniGenerationCost::from_kinovi_credits(total_credits), - base_cost: KinoviMiniGenerationCost::from_kinovi_credits(base_credits), + total_cost: KinoviFractionalGenerationCost::from_kinovi_credits(total_credits), + base_cost: KinoviFractionalGenerationCost::from_kinovi_credits(base_credits), video_reference_surcharge_cost: maybe_video_reference_surcharge_credits - .map(KinoviMiniGenerationCost::from_kinovi_credits), - } - } -} - -/// The cost of a Kinovi Seedance 2.0 Mini generation, in both native credits -/// (fractional) and USD cents. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct KinoviMiniGenerationCost { - /// The cost of the generation in Kinovi credits. May be fractional (e.g. - /// 37.5 for 480p at 5 seconds). - pub kinovi_credits: f64, - - /// Estimated cost in cents, rounded up to the nearest whole cent. - /// This does not account for discounts, prorations, etc. - pub usd_cents_rounded_up: u64, - - /// Estimated cost in cents, rounded down to the nearest whole cent. - pub usd_cents_rounded_down: u64, - - /// Estimated cost in cents without rounding (the exact fractional value). - pub usd_cents_fractional: f64, -} - -impl KinoviMiniGenerationCost { - /// Build a cost from a (possibly fractional) Kinovi credit amount, deriving - /// the USD cents via the credit-package rate. - /// - /// Mini credits are always multiples of 0.5, so the USD conversion is done - /// on integer half-credits to keep it exact (no floating-point rounding in - /// the integer cent results). - pub fn from_kinovi_credits(kinovi_credits: f64) -> Self { - // half_credits = credits × 2 (exact, since credits is a multiple of 0.5). - // credits × 100 == half_credits × 50, so this is the same numerator the - // integer KinoviGenerationCost uses, computed without fractional drift. - let half_credits = (kinovi_credits * 2.0).round() as u64; - let total_hundredths = half_credits * 50; - Self { - kinovi_credits, - usd_cents_rounded_up: total_hundredths.div_ceil(CREDITS_PER_DOLLAR), - usd_cents_rounded_down: total_hundredths / CREDITS_PER_DOLLAR, - usd_cents_fractional: total_hundredths as f64 / CREDITS_PER_DOLLAR as f64, + .map(KinoviFractionalGenerationCost::from_kinovi_credits), } } } @@ -88,67 +66,26 @@ impl KinoviMiniGenerationCost { mod tests { use super::*; - const FLOAT_TOLERANCE: f64 = 1e-9; - - mod generation_cost { - use super::*; - - #[test] - fn fractional_credits_convert_to_usd() { - // 37.5 credits; 3750/243 = 15.4320 cents. - let cost = KinoviMiniGenerationCost::from_kinovi_credits(37.5); - assert_eq!(cost.kinovi_credits, 37.5); - assert_eq!(cost.usd_cents_rounded_up, 16); - assert_eq!(cost.usd_cents_rounded_down, 15); - assert!((cost.usd_cents_fractional - (3750.0 / 243.0)).abs() < FLOAT_TOLERANCE); - } - - #[test] - fn whole_credits_convert_to_usd() { - // 100 credits; 10000/243 = 41.1522 cents. - let cost = KinoviMiniGenerationCost::from_kinovi_credits(100.0); - assert_eq!(cost.kinovi_credits, 100.0); - assert_eq!(cost.usd_cents_rounded_up, 42); - assert_eq!(cost.usd_cents_rounded_down, 41); - assert!((cost.usd_cents_fractional - (10000.0 / 243.0)).abs() < FLOAT_TOLERANCE); - } - - #[test] - fn rounded_bounds_bracket_the_fractional_value() { - for half_credits in [0u64, 1, 15, 75, 95, 200, 225, 600, 720] { - let credits = half_credits as f64 / 2.0; - let cost = KinoviMiniGenerationCost::from_kinovi_credits(credits); - assert!(cost.usd_cents_rounded_down as f64 <= cost.usd_cents_fractional); - assert!(cost.usd_cents_fractional <= cost.usd_cents_rounded_up as f64); - assert!(cost.usd_cents_rounded_up - cost.usd_cents_rounded_down <= 1); - } - } + #[test] + fn without_surcharge_total_equals_base() { + let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, None); + assert!(cost.video_reference_surcharge_cost.is_none()); + assert_eq!(cost.base_cost.kinovi_credits, 37.5); + assert_eq!(cost.total_cost, cost.base_cost); } - mod seedance_mini_cost { - use super::*; - - #[test] - fn without_surcharge_total_equals_base() { - let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, None); - assert!(cost.video_reference_surcharge_cost.is_none()); - assert_eq!(cost.base_cost.kinovi_credits, 37.5); - assert_eq!(cost.total_cost, cost.base_cost); - } - - #[test] - fn with_surcharge_sums_into_total() { - // 480p 5s with video ref: 37.5 base + 10 surcharge = 47.5 total. - let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, Some(10.0)); - assert_eq!(cost.base_cost.kinovi_credits, 37.5); - assert_eq!( - cost.video_reference_surcharge_cost.map(|c| c.kinovi_credits), - Some(10.0), - ); - assert_eq!(cost.total_cost.kinovi_credits, 47.5); - // 47.5 credits; 4750/243 = 19.5473 → 20¢. - assert_eq!(cost.total_cost.usd_cents_rounded_up, 20); - assert_eq!(cost.total_cost.usd_cents_rounded_down, 19); - } + #[test] + fn with_surcharge_sums_into_total() { + // 480p 5s with video ref: 37.5 base + 10 surcharge = 47.5 total. + let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, Some(10.0)); + assert_eq!(cost.base_cost.kinovi_credits, 37.5); + assert_eq!( + cost.video_reference_surcharge_cost.map(|c| c.kinovi_credits), + Some(10.0), + ); + assert_eq!(cost.total_cost.kinovi_credits, 47.5); + // 47.5 credits; 4750/243 = 19.5473 → 20¢. + assert_eq!(cost.total_cost.usd_cents_rounded_up, 20); + assert_eq!(cost.total_cost.usd_cents_rounded_down, 19); } } diff --git a/crates/api_clients/seedance2pro_client/src/cost/mod.rs b/crates/api_clients/seedance2pro_client/src/cost/mod.rs index ac84623beb..541ebcd176 100644 --- a/crates/api_clients/seedance2pro_client/src/cost/mod.rs +++ b/crates/api_clients/seedance2pro_client/src/cost/mod.rs @@ -1,4 +1,5 @@ pub mod constants; +pub mod kinovi_fractional_generation_cost; pub mod kinovi_generation_cost; pub mod kinovi_seedance_generation_cost; pub mod kinovi_seedance_mini_generation_cost;