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 eb0ed619b6..37eb2dc9d7 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,34 +3,37 @@ 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. /// -/// `total_cost` covers `base_credits_cost + video_reference_surcharge_cost`. +/// `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. #[derive(Clone, Copy, Debug, PartialEq)] pub struct KinoviSeedanceGenerationCost { /// The full cost (base + any surcharge), in credits and USD cents. pub total_cost: KinoviGenerationCost, - /// The base generation cost in Kinovi credits (resolution rate × output - /// duration × batch count), excluding any surcharges. - pub base_credits_cost: u64, + /// The base generation cost (resolution rate × output duration × batch + /// count), excluding any surcharges. + pub base_cost: KinoviGenerationCost, - /// The video-reference surcharge in Kinovi credits, when one or more - /// reference videos are attached. `None` when no reference videos are - /// attached. - pub video_reference_surcharge_cost: Option, + /// 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, } impl KinoviSeedanceGenerationCost { /// Build a cost from base credits plus an optional video-reference - /// surcharge. The total (and its USD conversion) is derived. + /// surcharge (both in Kinovi credits). The per-part and total USD + /// conversions are derived. pub fn from_base_and_surcharge( - base_credits_cost: u64, - video_reference_surcharge_cost: Option, + base_credits: u64, + maybe_video_reference_surcharge_credits: Option, ) -> Self { - let total_credits = base_credits_cost + video_reference_surcharge_cost.unwrap_or(0); + let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0); Self { total_cost: KinoviGenerationCost::from_kinovi_credits(total_credits), - base_credits_cost, - video_reference_surcharge_cost, + base_cost: KinoviGenerationCost::from_kinovi_credits(base_credits), + video_reference_surcharge_cost: maybe_video_reference_surcharge_credits + .map(KinoviGenerationCost::from_kinovi_credits), } } } @@ -44,32 +47,64 @@ mod tests { #[test] fn without_surcharge() { let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, None); - assert_eq!(cost.base_credits_cost, 200); - assert_eq!(cost.video_reference_surcharge_cost, None); - assert_eq!(cost.total_cost.kinovi_credits, 200); - // 20000/193 = 103.63. - assert_eq!(cost.total_cost.usd_cents_rounded_up, 104); - assert_eq!(cost.total_cost.usd_cents_rounded_down, 103); - assert!((cost.total_cost.usd_cents_fractional - (20000.0 / 193.0)).abs() < FLOAT_TOLERANCE); + + assert!(cost.video_reference_surcharge_cost.is_none()); + + // Base: 200 credits; 20000/193 = 103.63. + assert_eq!(cost.base_cost.kinovi_credits, 200); + assert_eq!(cost.base_cost.usd_cents_rounded_up, 104); + assert_eq!(cost.base_cost.usd_cents_rounded_down, 103); + assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 193.0)).abs() < FLOAT_TOLERANCE); + + // Total equals base when there's no surcharge. + assert_eq!(cost.total_cost, cost.base_cost); } #[test] fn with_surcharge() { let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, Some(40)); - assert_eq!(cost.base_credits_cost, 200); - assert_eq!(cost.video_reference_surcharge_cost, Some(40)); + + // Base: 200 credits; 20000/193 = 103.63. + assert_eq!(cost.base_cost.kinovi_credits, 200); + assert_eq!(cost.base_cost.usd_cents_rounded_up, 104); + assert_eq!(cost.base_cost.usd_cents_rounded_down, 103); + assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 193.0)).abs() < FLOAT_TOLERANCE); + + // Surcharge: 40 credits; 4000/193 = 20.73. + let surcharge = cost.video_reference_surcharge_cost.expect("should have surcharge"); + assert_eq!(surcharge.kinovi_credits, 40); + assert_eq!(surcharge.usd_cents_rounded_up, 21); + assert_eq!(surcharge.usd_cents_rounded_down, 20); + assert!((surcharge.usd_cents_fractional - (4000.0 / 193.0)).abs() < FLOAT_TOLERANCE); + + // Total: 240 credits; 24000/193 = 124.35. assert_eq!(cost.total_cost.kinovi_credits, 240); - // 24000/193 = 124.35. assert_eq!(cost.total_cost.usd_cents_rounded_up, 125); assert_eq!(cost.total_cost.usd_cents_rounded_down, 124); assert!((cost.total_cost.usd_cents_fractional - (24000.0 / 193.0)).abs() < FLOAT_TOLERANCE); } #[test] - fn total_is_base_plus_surcharge() { + fn total_credits_are_base_plus_surcharge() { let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(450, Some(90)); assert_eq!( cost.total_cost.kinovi_credits, - cost.base_credits_cost + cost.video_reference_surcharge_cost.unwrap()); + cost.base_cost.kinovi_credits + cost.video_reference_surcharge_cost.unwrap().kinovi_credits); + } + + /// The total's USD is rounded ONCE on the summed credits, so it can be a + /// cent under the sum of the parts' rounded-up values. + #[test] + fn total_usd_is_rounded_once_not_per_part() { + let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, Some(40)); + let surcharge = cost.video_reference_surcharge_cost.unwrap(); + + // Parts round up to 104 + 21 = 125 — which happens to match here… + assert_eq!(cost.base_cost.usd_cents_rounded_up + surcharge.usd_cents_rounded_up, 125); + assert_eq!(cost.total_cost.usd_cents_rounded_up, 125); + + // …but the fractional parts always sum exactly to the fractional total. + let parts = cost.base_cost.usd_cents_fractional + surcharge.usd_cents_fractional; + assert!((parts - cost.total_cost.usd_cents_fractional).abs() < FLOAT_TOLERANCE); } } diff --git a/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs b/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs index fa37926bec..6a33f98a72 100644 --- a/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs +++ b/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs @@ -115,16 +115,16 @@ impl GenerateSeedance2p0Request { }; let duration = u64::from(self.duration_seconds); - let base_credits_cost = duration * credits_per_second * batch_multiplier; - let video_reference_surcharge_cost = if self.has_video_reference() { + let base_credits = duration * credits_per_second * batch_multiplier; + let maybe_video_reference_surcharge_credits = if self.has_video_reference() { Some(duration * video_reference_surcharge_per_second * batch_multiplier) } else { None }; KinoviSeedanceGenerationCost::from_base_and_surcharge( - base_credits_cost, - video_reference_surcharge_cost, + base_credits, + maybe_video_reference_surcharge_credits, ) } @@ -433,8 +433,8 @@ mod tests { assert_eq!(costs.total_cost.usd_cents_rounded_up, 39); assert_eq!(costs.total_cost.usd_cents_rounded_down, 38); assert!((costs.total_cost.usd_cents_fractional - (7500.0 / 193.0)).abs() < 1e-9); - assert_eq!(costs.base_credits_cost, 75); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert_eq!(costs.base_cost.kinovi_credits, 75); + assert!(costs.video_reference_surcharge_cost.is_none()); } #[test] @@ -445,8 +445,8 @@ mod tests { assert_eq!(costs.total_cost.usd_cents_rounded_up, 104); assert_eq!(costs.total_cost.usd_cents_rounded_down, 103); assert!((costs.total_cost.usd_cents_fractional - (20000.0 / 193.0)).abs() < 1e-9); - assert_eq!(costs.base_credits_cost, 200); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert_eq!(costs.base_cost.kinovi_credits, 200); + assert!(costs.video_reference_surcharge_cost.is_none()); } #[test] @@ -457,8 +457,8 @@ mod tests { assert_eq!(costs.total_cost.usd_cents_rounded_up, 234); assert_eq!(costs.total_cost.usd_cents_rounded_down, 233); assert!((costs.total_cost.usd_cents_fractional - (45000.0 / 193.0)).abs() < 1e-9); - assert_eq!(costs.base_credits_cost, 450); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert_eq!(costs.base_cost.kinovi_credits, 450); + assert!(costs.video_reference_surcharge_cost.is_none()); } #[test] @@ -469,8 +469,8 @@ mod tests { assert_eq!(costs.total_cost.usd_cents_rounded_up, 311); assert_eq!(costs.total_cost.usd_cents_rounded_down, 310); assert!((costs.total_cost.usd_cents_fractional - (60000.0 / 193.0)).abs() < 1e-9); - assert_eq!(costs.base_credits_cost, 600); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert_eq!(costs.base_cost.kinovi_credits, 600); + assert!(costs.video_reference_surcharge_cost.is_none()); } #[test] @@ -481,8 +481,8 @@ mod tests { assert_eq!(costs.total_cost.usd_cents_rounded_up, 700); assert_eq!(costs.total_cost.usd_cents_rounded_down, 699); assert!((costs.total_cost.usd_cents_fractional - (135000.0 / 193.0)).abs() < 1e-9); - assert_eq!(costs.base_credits_cost, 1350); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert_eq!(costs.base_cost.kinovi_credits, 1350); + assert!(costs.video_reference_surcharge_cost.is_none()); } #[test] @@ -493,8 +493,8 @@ mod tests { assert_eq!(costs.total_cost.usd_cents_rounded_up, 415); assert_eq!(costs.total_cost.usd_cents_rounded_down, 414); assert!((costs.total_cost.usd_cents_fractional - (80000.0 / 193.0)).abs() < 1e-9); - assert_eq!(costs.base_credits_cost, 800); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert_eq!(costs.base_cost.kinovi_credits, 800); + assert!(costs.video_reference_surcharge_cost.is_none()); } /// The deprecated shims return the corresponding struct fields. @@ -547,8 +547,8 @@ mod tests { for (make, duration, base, surcharge) in cases { let costs = with_video_ref(make(*duration)).calculate_costs(); - assert_eq!(costs.base_credits_cost, *base, "base for {duration}s"); - assert_eq!(costs.video_reference_surcharge_cost, Some(*surcharge), "surcharge for {duration}s"); + assert_eq!(costs.base_cost.kinovi_credits, *base, "base for {duration}s"); + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(*surcharge), "surcharge for {duration}s"); assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total for {duration}s"); } } @@ -557,21 +557,39 @@ mod tests { fn surcharge_includes_usd_cents() { // 720p 5s + video ref = 200 + 40 = 240 credits; 24000/193 = 124.35 → 125¢ let costs = with_video_ref(r720(5)).calculate_costs(); - assert_eq!(costs.base_credits_cost, 200); - assert_eq!(costs.video_reference_surcharge_cost, Some(40)); + assert_eq!(costs.base_cost.kinovi_credits, 200); + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(40)); assert_eq!(costs.total_cost.kinovi_credits, 240); assert_eq!(costs.total_cost.usd_cents_rounded_up, 125); assert_eq!(costs.total_cost.usd_cents_rounded_down, 124); assert!((costs.total_cost.usd_cents_fractional - (24000.0 / 193.0)).abs() < 1e-9); } + /// The base and surcharge parts each carry their own USD conversions. + #[test] + fn parts_have_their_own_usd_conversions() { + let costs = with_video_ref(r720(5)).calculate_costs(); + + // Base: 200 credits; 20000/193 = 103.63. + assert_eq!(costs.base_cost.usd_cents_rounded_up, 104); + assert_eq!(costs.base_cost.usd_cents_rounded_down, 103); + assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 193.0)).abs() < 1e-9); + + // Surcharge: 40 credits; 4000/193 = 20.73. + let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge"); + assert_eq!(surcharge.kinovi_credits, 40); + assert_eq!(surcharge.usd_cents_rounded_up, 21); + assert_eq!(surcharge.usd_cents_rounded_down, 20); + assert!((surcharge.usd_cents_fractional - (4000.0 / 193.0)).abs() < 1e-9); + } + #[test] fn empty_video_reference_list_has_no_surcharge() { let mut request = r720(5); request.reference_video_urls = Some(vec![]); let costs = request.calculate_costs(); assert_eq!(costs.total_cost.kinovi_credits, 200); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert!(costs.video_reference_surcharge_cost.is_none()); } /// Surcharge is flat per generation regardless of how many reference @@ -592,8 +610,8 @@ mod tests { let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two))); // (200 base + 40 surcharge) × 2 = 480 credits let costs = request.calculate_costs(); - assert_eq!(costs.base_credits_cost, 400); - assert_eq!(costs.video_reference_surcharge_cost, Some(80)); + assert_eq!(costs.base_cost.kinovi_credits, 400); + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(80)); assert_eq!(costs.total_cost.kinovi_credits, 480); } } diff --git a/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0_fast.rs b/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0_fast.rs index f22a0e8811..118f4a1c86 100644 --- a/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0_fast.rs +++ b/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0_fast.rs @@ -110,16 +110,16 @@ impl GenerateSeedance2p0FastRequest { }; let duration = u64::from(self.duration_seconds); - let base_credits_cost = duration * credits_per_second * batch_multiplier; - let video_reference_surcharge_cost = if self.has_video_reference() { + let base_credits = duration * credits_per_second * batch_multiplier; + let maybe_video_reference_surcharge_credits = if self.has_video_reference() { Some(duration * video_reference_surcharge_per_second * batch_multiplier) } else { None }; KinoviSeedanceGenerationCost::from_base_and_surcharge( - base_credits_cost, - video_reference_surcharge_cost, + base_credits, + maybe_video_reference_surcharge_credits, ) } @@ -389,8 +389,8 @@ mod tests { assert_eq!(costs.total_cost.usd_cents_rounded_up, 37); assert_eq!(costs.total_cost.usd_cents_rounded_down, 36); assert!((costs.total_cost.usd_cents_fractional - (7000.0 / 193.0)).abs() < 1e-9); - assert_eq!(costs.base_credits_cost, 70); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert_eq!(costs.base_cost.kinovi_credits, 70); + assert!(costs.video_reference_surcharge_cost.is_none()); } #[test] @@ -401,8 +401,8 @@ mod tests { assert_eq!(costs.total_cost.usd_cents_rounded_up, 73); assert_eq!(costs.total_cost.usd_cents_rounded_down, 72); assert!((costs.total_cost.usd_cents_fractional - (14000.0 / 193.0)).abs() < 1e-9); - assert_eq!(costs.base_credits_cost, 140); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert_eq!(costs.base_cost.kinovi_credits, 140); + assert!(costs.video_reference_surcharge_cost.is_none()); } #[test] @@ -413,8 +413,8 @@ mod tests { assert_eq!(costs.total_cost.usd_cents_rounded_up, 218); assert_eq!(costs.total_cost.usd_cents_rounded_down, 217); assert!((costs.total_cost.usd_cents_fractional - (42000.0 / 193.0)).abs() < 1e-9); - assert_eq!(costs.base_credits_cost, 420); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert_eq!(costs.base_cost.kinovi_credits, 420); + assert!(costs.video_reference_surcharge_cost.is_none()); } #[test] @@ -425,8 +425,8 @@ mod tests { assert_eq!(costs.total_cost.usd_cents_rounded_up, 109); assert_eq!(costs.total_cost.usd_cents_rounded_down, 108); assert!((costs.total_cost.usd_cents_fractional - (21000.0 / 193.0)).abs() < 1e-9); - assert_eq!(costs.base_credits_cost, 210); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert_eq!(costs.base_cost.kinovi_credits, 210); + assert!(costs.video_reference_surcharge_cost.is_none()); } #[test] @@ -437,8 +437,8 @@ mod tests { assert_eq!(costs.total_cost.usd_cents_rounded_up, 146); assert_eq!(costs.total_cost.usd_cents_rounded_down, 145); assert!((costs.total_cost.usd_cents_fractional - (28000.0 / 193.0)).abs() < 1e-9); - assert_eq!(costs.base_credits_cost, 280); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert_eq!(costs.base_cost.kinovi_credits, 280); + assert!(costs.video_reference_surcharge_cost.is_none()); } /// The deprecated shims return the corresponding struct fields. @@ -487,8 +487,8 @@ mod tests { for (make, duration, base, surcharge) in cases { let costs = with_video_ref(make(*duration)).calculate_costs(); - assert_eq!(costs.base_credits_cost, *base, "base for {duration}s"); - assert_eq!(costs.video_reference_surcharge_cost, Some(*surcharge), "surcharge for {duration}s"); + assert_eq!(costs.base_cost.kinovi_credits, *base, "base for {duration}s"); + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(*surcharge), "surcharge for {duration}s"); assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total for {duration}s"); } } @@ -497,21 +497,39 @@ mod tests { fn surcharge_includes_usd_cents() { // 720p 5s + video ref = 140 + 30 = 170 credits; 17000/193 = 88.08 → 89¢ let costs = with_video_ref(r720(5)).calculate_costs(); - assert_eq!(costs.base_credits_cost, 140); - assert_eq!(costs.video_reference_surcharge_cost, Some(30)); + assert_eq!(costs.base_cost.kinovi_credits, 140); + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(30)); assert_eq!(costs.total_cost.kinovi_credits, 170); assert_eq!(costs.total_cost.usd_cents_rounded_up, 89); assert_eq!(costs.total_cost.usd_cents_rounded_down, 88); assert!((costs.total_cost.usd_cents_fractional - (17000.0 / 193.0)).abs() < 1e-9); } + /// The base and surcharge parts each carry their own USD conversions. + #[test] + fn parts_have_their_own_usd_conversions() { + let costs = with_video_ref(r720(5)).calculate_costs(); + + // Base: 140 credits; 14000/193 = 72.54. + assert_eq!(costs.base_cost.usd_cents_rounded_up, 73); + assert_eq!(costs.base_cost.usd_cents_rounded_down, 72); + assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 193.0)).abs() < 1e-9); + + // Surcharge: 30 credits; 3000/193 = 15.54. + let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge"); + assert_eq!(surcharge.kinovi_credits, 30); + assert_eq!(surcharge.usd_cents_rounded_up, 16); + assert_eq!(surcharge.usd_cents_rounded_down, 15); + assert!((surcharge.usd_cents_fractional - (3000.0 / 193.0)).abs() < 1e-9); + } + #[test] fn empty_video_reference_list_has_no_surcharge() { let mut request = r720(5); request.reference_video_urls = Some(vec![]); let costs = request.calculate_costs(); assert_eq!(costs.total_cost.kinovi_credits, 140); - assert_eq!(costs.video_reference_surcharge_cost, None); + assert!(costs.video_reference_surcharge_cost.is_none()); } /// Surcharge is flat per generation regardless of how many reference @@ -532,8 +550,8 @@ mod tests { let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two))); // (140 base + 30 surcharge) × 2 = 340 credits let costs = request.calculate_costs(); - assert_eq!(costs.base_credits_cost, 280); - assert_eq!(costs.video_reference_surcharge_cost, Some(60)); + assert_eq!(costs.base_cost.kinovi_credits, 280); + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(60)); assert_eq!(costs.total_cost.kinovi_credits, 340); } }