diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/cost.rs index 8bb3d975a5..dfc4e62703 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/cost.rs @@ -2,6 +2,7 @@ use kinovi_web_client::generate::video::generate_seedance_2p0_mini::{ GenerateSeedance2p0MiniRequest, KinoviSeedance2p0MiniBatchCount, KinoviSeedance2p0MiniOutputResolution, }; +use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState; @@ -66,8 +67,10 @@ impl KinoviSeedance2p0MiniCostState { bitrate: None, }; - let costs = pricing_request.calculate_costs(); - // Mini credits can be fractional (e.g. 480p odd durations land on 37.5). + // Enterprise tier: what generations actually cost us (our discounted + // per-model credit rate at our bulk credit purchase rate). + let costs = pricing_request.calculate_enterprise_costs(); + // Mini credits can be fractional (e.g. 480p + video ref at 4s = 15.2). // The router's credit field is an integer, so round to the nearest credit; // the USD cents (the authoritative charge) are already rounded up. let cost_in_credits = costs.total_cost.kinovi_credits.round() as u64; @@ -108,18 +111,20 @@ mod tests { #[test] fn without_video_reference() { - assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 13); - assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 16); - assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 31); - assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 47); + // Enterprise 3 credits/s. + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 5); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 7); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 13); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 19); } #[test] fn with_video_reference() { - assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, true), 16); - assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, true), 20); - assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, true), 40); - assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, true), 59); + // Enterprise 3.8 credits/s (3 base + 0.8 surcharge). + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, true), 7); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, true), 8); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, true), 16); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, true), 24); } } @@ -128,18 +133,20 @@ mod tests { #[test] fn without_video_reference() { - assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, false), 33); - assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 42); - assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, false), 83); - assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 124); + // Enterprise 8 credits/s. + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, false), 14); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 17); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, false), 33); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 50); } #[test] fn with_video_reference() { - assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, true), 40); - assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, true), 50); - assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, true), 99); - assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, true), 149); + // Enterprise 9.6 credits/s (8 base + 1.6 surcharge). + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, true), 16); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, true), 20); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, true), 40); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, true), 60); } } @@ -150,17 +157,18 @@ mod tests { #[test] fn credits_480p() { - // 7.5 credits/s; odd durations are fractional (37.5 → 38, 112.5 → 113). - assert_eq!(credits(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 30); - assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 38); - assert_eq!(credits(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 75); - assert_eq!(credits(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 113); + // Enterprise 3 credits/s (whole at every duration). + assert_eq!(credits(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 12); + assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 15); + assert_eq!(credits(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 30); + assert_eq!(credits(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 45); } #[test] fn credits_720p() { - assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 100); - assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 300); + // Enterprise 8 credits/s. + assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 40); + assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 120); } } @@ -210,7 +218,7 @@ mod tests { assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP))); assert_eq!(cost.duration_seconds, 5); assert!(!cost.has_video_reference); - assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(42)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(17)); } #[test] @@ -218,7 +226,7 @@ mod tests { let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, true); let cost = KinoviSeedance2p0MiniCostState::from_request(&req); assert!(cost.has_video_reference); - assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(20)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(8)); } #[test] @@ -226,7 +234,7 @@ mod tests { let req = make_request_state(None, 5, KinoviBatchCount::One, false); let cost = KinoviSeedance2p0MiniCostState::from_request(&req); assert!(cost.resolution.is_none()); - assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(42)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(17)); } } @@ -240,7 +248,7 @@ mod tests { let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false); let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft); assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP))); - assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(16)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(7)); } #[test] @@ -248,16 +256,16 @@ mod tests { let draft = make_draft(5, 1, Some(RouterResolution::SevenTwentyP), true); let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft); assert!(cost.has_video_reference); - assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(50)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(20)); } #[test] fn from_draft_batch_8() { - // Mini supports up to batch 8: 720p 5s × 8 = 800 credits → 80000/243 → 330¢. + // Mini supports up to batch 8: 720p 5s × 8 = 320 credits → 32000/243.16 → 132¢. let draft = make_draft(5, 8, Some(RouterResolution::SevenTwentyP), false); let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft); assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Eight))); - assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(330)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(132)); } } diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0_mini.rs b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0_mini.rs index 14b357b16a..e35dfd3e3b 100644 --- a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0_mini.rs +++ b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0_mini.rs @@ -1,6 +1,9 @@ use crate::creds::kinovi_web_session::KinoviWebSession; use crate::error::kinovi_web_error::KinoviWebError; use crate::pricing::cost::kinovi_seedance_fractional_generation_cost::KinoviSeedanceFractionalGenerationCost; +use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; +use crate::pricing::kinovi_pricing_rate::KinoviPricingRate; +use crate::pricing::kinovi_pricing_tier::KinoviPricingTier; use crate::requests::kinovi_host::KinoviHost; use crate::requests::workflow_run_task::workflow_run_task::{ workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw, @@ -78,52 +81,81 @@ pub enum KinoviSeedance2p0MiniBitrate { // // Seedance 2.0 Mini credit pricing (per second of OUTPUT duration): // -// | Resolution | Credits/sec | Video-ref surcharge/sec | -// |------------|-------------|-------------------------| -// | 480p | 7.5 | 2 | -// | 720p | 20 | 4 | +// | Resolution | Consumer credits/sec | Enterprise credits/sec | +// |------------|----------------------|------------------------| +// | 480p | 7.5 | 3 | +// | 720p | 20 | 8 | // -// 480p credits can be FRACTIONAL (7.5/sec lands on half-credits at odd -// durations, e.g. 5s = 37.5). Default resolution (None) is 720p. Batch count -// multiplies the total. Attaching reference VIDEOS adds the surcharge; -// reference images and audio are free. +// Attaching reference VIDEOS adds a per-output-second surcharge; reference +// images and audio are free. // -// Credit package: 525,000 credits for $2,159.0909 (~243 credits/$1). +// | Resolution | Consumer surcharge/sec | Enterprise surcharge/sec | +// |------------|------------------------|--------------------------| +// | 480p | 2 | 0.8 | +// | 720p | 4 | 1.6 | +// +// The enterprise rates come from the negotiated combined prices: 480p +// 7.5 → 3 credits/sec without a reference video and 9.5 → 3.8 with one; +// 720p 20 → 8 and 24 → 9.6. The surcharge is the difference +// (3.8 − 3 = 0.8; 9.6 − 8 = 1.6). +// +// 480p consumer credits can be FRACTIONAL (7.5/sec lands on half-credits at +// odd durations, e.g. 5s = 37.5). Default resolution (None) is 720p. Batch +// count multiplies the total. + +/// Per-second base credit rates by resolution. +const SEEDANCE_2P0_MINI_480P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 7.5, + maybe_enterprise_credits: Some(3.0), +}; +const SEEDANCE_2P0_MINI_720P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 20.0, + maybe_enterprise_credits: Some(8.0), +}; + +/// Per-second video-reference surcharge rates by resolution. Flat per +/// generation regardless of how many reference videos are attached. +const SEEDANCE_2P0_MINI_480P_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 2.0, + maybe_enterprise_credits: Some(0.8), +}; +const SEEDANCE_2P0_MINI_720P_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 4.0, + maybe_enterprise_credits: Some(1.6), +}; + +impl KinoviCostCalculatorTrait for GenerateSeedance2p0MiniRequest { + type Cost = KinoviSeedanceFractionalGenerationCost; -impl GenerateSeedance2p0MiniRequest { /// Calculate the cost of this generation request, in Kinovi credits - /// (possibly fractional) and USD cents. - pub fn calculate_costs(&self) -> KinoviSeedanceFractionalGenerationCost { - let credits_per_second: f64 = match self.output_resolution { - Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => 7.5, - Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => 20.0, + /// (possibly fractional) and USD cents, at the given pricing tier. + fn calculate_costs(&self, tier: KinoviPricingTier) -> KinoviSeedanceFractionalGenerationCost { + let base_rate = match self.output_resolution { + Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => SEEDANCE_2P0_MINI_480P, + Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => SEEDANCE_2P0_MINI_720P, + }; + let surcharge_rate = match self.output_resolution { + Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => SEEDANCE_2P0_MINI_480P_VIDEO_REF_SURCHARGE, + Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => SEEDANCE_2P0_MINI_720P_VIDEO_REF_SURCHARGE, }; - let video_reference_surcharge_per_second: f64 = if self.has_video_reference() { - match self.output_resolution { - Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => 2.0, - Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => 4.0, - } - } else { - 0.0 - }; - - let batch_multiplier = f64::from(self.batch_multiplier()); - let duration = f64::from(self.duration_seconds); - - let base_credits = duration * credits_per_second * batch_multiplier; + let output_seconds = f64::from(self.duration_seconds) * f64::from(self.batch_multiplier()); + let base_credits = base_rate.credits(tier) * output_seconds; let maybe_video_reference_surcharge_credits = if self.has_video_reference() { - Some(duration * video_reference_surcharge_per_second * batch_multiplier) + Some(surcharge_rate.credits(tier) * output_seconds) } else { None }; - KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge( + KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier( + tier, base_credits, maybe_video_reference_surcharge_credits, ) } +} +impl GenerateSeedance2p0MiniRequest { fn has_video_reference(&self) -> bool { self.reference_video_urls .as_ref() @@ -248,7 +280,348 @@ mod tests { mod pricing_tests { use super::*; - const FLOAT_TOLERANCE: f64 = 1e-9; + /// Expected fractional cents are written to 4 decimal places. + const FLOAT_TOLERANCE: f64 = 0.0001; + + // ── Consumer pricing tables ── + // + // The standard published rates: 480p 7.5/s (+2/s video ref), 720p 20/s + // (+4/s). 480p produces fractional credits at odd durations (37.5, + // 112.5, …). Cents convert at 192.98 credits/$1, rounded up. + + mod consumer_pricing_tables { + use super::*; + + #[test] + fn table_480p() { + assert_eq!(consumer_credits(&r480(4)), 30.0); + assert_eq!(consumer_credits(&r480(5)), 37.5); + assert_eq!(consumer_credits(&r480(10)), 75.0); + assert_eq!(consumer_credits(&r480(15)), 112.5); + + assert_eq!(consumer_cents(&r480(4)), 16); + assert_eq!(consumer_cents(&r480(5)), 20); + assert_eq!(consumer_cents(&r480(10)), 39); + assert_eq!(consumer_cents(&r480(15)), 59); + } + + #[test] + fn table_480p_with_video_reference() { + // 9.5 credits/sec combined (7.5 base + 2 surcharge). + assert_eq!(consumer_credits(&with_video_ref(r480(4))), 38.0); + assert_eq!(consumer_credits(&with_video_ref(r480(5))), 47.5); + assert_eq!(consumer_credits(&with_video_ref(r480(10))), 95.0); + assert_eq!(consumer_credits(&with_video_ref(r480(15))), 142.5); + + assert_eq!(consumer_cents(&with_video_ref(r480(4))), 20); + assert_eq!(consumer_cents(&with_video_ref(r480(5))), 25); + assert_eq!(consumer_cents(&with_video_ref(r480(10))), 50); + assert_eq!(consumer_cents(&with_video_ref(r480(15))), 74); + } + + #[test] + fn table_720p() { + assert_eq!(consumer_credits(&r720(4)), 80.0); + assert_eq!(consumer_credits(&r720(5)), 100.0); + assert_eq!(consumer_credits(&r720(10)), 200.0); + assert_eq!(consumer_credits(&r720(15)), 300.0); + + assert_eq!(consumer_cents(&r720(4)), 42); + assert_eq!(consumer_cents(&r720(5)), 52); + assert_eq!(consumer_cents(&r720(10)), 104); + assert_eq!(consumer_cents(&r720(15)), 156); + } + + #[test] + fn table_720p_with_video_reference() { + // 24 credits/sec combined (20 base + 4 surcharge). + assert_eq!(consumer_credits(&with_video_ref(r720(4))), 96.0); + assert_eq!(consumer_credits(&with_video_ref(r720(5))), 120.0); + assert_eq!(consumer_credits(&with_video_ref(r720(10))), 240.0); + assert_eq!(consumer_credits(&with_video_ref(r720(15))), 360.0); + + assert_eq!(consumer_cents(&with_video_ref(r720(4))), 50); + assert_eq!(consumer_cents(&with_video_ref(r720(5))), 63); + assert_eq!(consumer_cents(&with_video_ref(r720(10))), 125); + assert_eq!(consumer_cents(&with_video_ref(r720(15))), 187); + } + + #[test] + fn explicit_720p_same_as_default() { + let default = r720(5).calculate_consumer_costs(); + let explicit = build_request(5, Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP), None).calculate_consumer_costs(); + assert_eq!(default, explicit); + } + } + + // ── Enterprise pricing tables (60% discount on every rate) ── + // + // 480p 3/s (+0.8/s video ref), 720p 8/s (+1.6/s). Cents convert at the + // bulk rate of 243.16 credits/$1, rounded up. + + mod enterprise_pricing_tables { + use super::*; + + #[test] + fn table_480p() { + assert_eq!(enterprise_credits(&r480(4)), 12.0); + assert_eq!(enterprise_credits(&r480(5)), 15.0); + assert_eq!(enterprise_credits(&r480(10)), 30.0); + assert_eq!(enterprise_credits(&r480(15)), 45.0); + + assert_eq!(enterprise_cents(&r480(4)), 5); + assert_eq!(enterprise_cents(&r480(5)), 7); + assert_eq!(enterprise_cents(&r480(10)), 13); + assert_eq!(enterprise_cents(&r480(15)), 19); + } + + #[test] + fn table_480p_with_video_reference() { + // 3.8 credits/sec combined (3 base + 0.8 surcharge). + assert_eq!(enterprise_credits(&with_video_ref(r480(4))), 15.2); + assert_eq!(enterprise_credits(&with_video_ref(r480(5))), 19.0); + assert_eq!(enterprise_credits(&with_video_ref(r480(10))), 38.0); + assert_eq!(enterprise_credits(&with_video_ref(r480(15))), 57.0); + + assert_eq!(enterprise_cents(&with_video_ref(r480(4))), 7); + assert_eq!(enterprise_cents(&with_video_ref(r480(5))), 8); + assert_eq!(enterprise_cents(&with_video_ref(r480(10))), 16); + assert_eq!(enterprise_cents(&with_video_ref(r480(15))), 24); + } + + #[test] + fn table_720p() { + assert_eq!(enterprise_credits(&r720(4)), 32.0); + assert_eq!(enterprise_credits(&r720(5)), 40.0); + assert_eq!(enterprise_credits(&r720(10)), 80.0); + assert_eq!(enterprise_credits(&r720(15)), 120.0); + + assert_eq!(enterprise_cents(&r720(4)), 14); + assert_eq!(enterprise_cents(&r720(5)), 17); + assert_eq!(enterprise_cents(&r720(10)), 33); + assert_eq!(enterprise_cents(&r720(15)), 50); + } + + #[test] + fn table_720p_with_video_reference() { + // 9.6 credits/sec combined (8 base + 1.6 surcharge). + assert_eq!(enterprise_credits(&with_video_ref(r720(4))), 38.4); + assert_eq!(enterprise_credits(&with_video_ref(r720(5))), 48.0); + assert_eq!(enterprise_credits(&with_video_ref(r720(10))), 96.0); + assert_eq!(enterprise_credits(&with_video_ref(r720(15))), 144.0); + + assert_eq!(enterprise_cents(&with_video_ref(r720(4))), 16); + assert_eq!(enterprise_cents(&with_video_ref(r720(5))), 20); + assert_eq!(enterprise_cents(&with_video_ref(r720(10))), 40); + assert_eq!(enterprise_cents(&with_video_ref(r720(15))), 60); + } + + #[test] + fn base_and_surcharge_breakdown_5s() { + let costs = with_video_ref(r720(5)).calculate_enterprise_costs(); + assert_eq!(costs.base_cost.kinovi_credits, 40.0); // 5s × 8 + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(8.0)); // 5s × 1.6 + assert_eq!(costs.total_cost.kinovi_credits, 48.0); // 5s × 9.6 + + assert_eq!(costs.total_cost.usd_cents_rounded_up, 20); + assert_eq!(costs.total_cost.usd_cents_rounded_down, 19); + assert!((costs.total_cost.usd_cents_fractional - 19.7401).abs() < FLOAT_TOLERANCE); + } + } + + // ── Video-reference surcharge ── + + mod video_reference_surcharge_tests { + use super::*; + + /// The full base + surcharge breakdown at both tiers. Asserts every + /// part: base, surcharge, and the derived total. + #[test] + fn base_and_surcharge_tables() { + // (tier, request builder, duration, base credits, surcharge credits) + let cases: &[(KinoviPricingTier, fn(u8) -> GenerateSeedance2p0MiniRequest, u8, f64, f64)] = &[ + // Consumer 480p: 7.5/s base, +2/s surcharge + (KinoviPricingTier::Consumer, r480, 5, 37.5, 10.0), + (KinoviPricingTier::Consumer, r480, 10, 75.0, 20.0), + // Consumer 720p: 20/s base, +4/s surcharge + (KinoviPricingTier::Consumer, r720, 5, 100.0, 20.0), + (KinoviPricingTier::Consumer, r720, 10, 200.0, 40.0), + // Enterprise 480p: 3/s base, +0.8/s surcharge + (KinoviPricingTier::Enterprise, r480, 5, 15.0, 4.0), + (KinoviPricingTier::Enterprise, r480, 10, 30.0, 8.0), + // Enterprise 720p: 8/s base, +1.6/s surcharge + (KinoviPricingTier::Enterprise, r720, 5, 40.0, 8.0), + (KinoviPricingTier::Enterprise, r720, 10, 80.0, 16.0), + ]; + + for (tier, make, duration, base, surcharge) in cases { + let costs = with_video_ref(make(*duration)).calculate_costs(*tier); + assert_eq!(costs.base_cost.kinovi_credits, *base, "base at {duration}s ({tier:?})"); + assert_eq!( + costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), + Some(*surcharge), + "surcharge at {duration}s ({tier:?})", + ); + assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total at {duration}s ({tier:?})"); + } + } + + #[test] + fn no_video_reference_has_no_surcharge() { + for costs in [r480(5).calculate_consumer_costs(), r480(5).calculate_enterprise_costs()] { + assert!(costs.video_reference_surcharge_cost.is_none()); + assert_eq!(costs.total_cost, costs.base_cost); + } + } + + #[test] + fn empty_video_reference_list_has_no_surcharge() { + let mut request = r720(5); + request.reference_video_urls = Some(vec![]); + for costs in [request.calculate_consumer_costs(), request.calculate_enterprise_costs()] { + assert!(costs.video_reference_surcharge_cost.is_none()); + } + } + + /// The surcharge applies per generated video, so batches multiply it. + #[test] + fn batch_multiplies_surcharge() { + let request = with_video_ref(build_request( + 5, + Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), + Some(KinoviSeedance2p0MiniBatchCount::Two), + )); + + // Consumer: (37.5 base + 10 surcharge) × 2. + let consumer = request.calculate_consumer_costs(); + assert_eq!(consumer.base_cost.kinovi_credits, 75.0); + assert_eq!(consumer.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(20.0)); + assert_eq!(consumer.total_cost.kinovi_credits, 95.0); + + // Enterprise: (15 base + 4 surcharge) × 2. + let enterprise = request.calculate_enterprise_costs(); + assert_eq!(enterprise.base_cost.kinovi_credits, 30.0); + assert_eq!(enterprise.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(8.0)); + assert_eq!(enterprise.total_cost.kinovi_credits, 38.0); + } + } + + // ── Batch multiplier ── + + mod batch_tests { + use super::*; + + #[test] + fn batch_scales_total_linearly_at_both_tiers() { + let by_count = [ + (KinoviSeedance2p0MiniBatchCount::One, 1.0), + (KinoviSeedance2p0MiniBatchCount::Two, 2.0), + (KinoviSeedance2p0MiniBatchCount::Three, 3.0), + (KinoviSeedance2p0MiniBatchCount::Four, 4.0), + (KinoviSeedance2p0MiniBatchCount::Five, 5.0), + (KinoviSeedance2p0MiniBatchCount::Six, 6.0), + (KinoviSeedance2p0MiniBatchCount::Seven, 7.0), + (KinoviSeedance2p0MiniBatchCount::Eight, 8.0), + ]; + for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] { + let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits; + for (count, multiplier) in by_count { + let credits = build_request(5, None, Some(count)).calculate_costs(tier).total_cost.kinovi_credits; + assert_eq!(credits, base * multiplier, "batch {count:?} at {tier:?}"); + } + } + } + + #[test] + fn batch_eight_480p_with_video_reference() { + let request = with_video_ref(build_request( + 5, + Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), + Some(KinoviSeedance2p0MiniBatchCount::Eight), + )); + // Consumer: (37.5 + 10) × 8 = 380. Enterprise: (15 + 4) × 8 = 152. + assert_eq!(consumer_credits(&request), 380.0); + assert_eq!(enterprise_credits(&request), 152.0); + assert_eq!(enterprise_cents(&request), 63); + } + } + + // ── Tier dispatch ── + + #[test] + fn convenience_methods_match_explicit_tier() { + let request = with_video_ref(r480(5)); + assert_eq!(request.calculate_consumer_costs(), request.calculate_costs(KinoviPricingTier::Consumer)); + assert_eq!(request.calculate_enterprise_costs(), request.calculate_costs(KinoviPricingTier::Enterprise)); + } + + // ── Defaults & invariants ── + + #[test] + fn default_resolution_is_720p() { + let default = consumer_credits(&r720(5)); + let explicit = consumer_credits(&build_request(5, Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP), None)); + assert_eq!(default, explicit); + assert_eq!(default, 100.0); + } + + #[test] + fn cost_scales_with_duration() { + let c4 = enterprise_credits(&r720(4)); + let c10 = enterprise_credits(&r720(10)); + let c15 = enterprise_credits(&r720(15)); + assert!(c4 < c10); + assert!(c10 < c15); + } + + #[test] + fn resolution_480p_cheaper_than_720p_at_both_tiers() { + for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] { + for dur in 4..=15u8 { + let c480 = r480(dur).calculate_costs(tier).total_cost.kinovi_credits; + let c720 = r720(dur).calculate_costs(tier).total_cost.kinovi_credits; + assert!(c480 < c720, "480p should be cheaper than 720p at {dur}s ({tier:?})"); + } + } + } + + #[test] + fn enterprise_is_cheaper_than_consumer() { + for request in [r480(5), r720(5), with_video_ref(r480(5)), with_video_ref(r720(5))] { + let consumer = request.calculate_consumer_costs().total_cost; + let enterprise = request.calculate_enterprise_costs().total_cost; + assert!(enterprise.kinovi_credits < consumer.kinovi_credits); + assert!(enterprise.usd_cents_fractional < consumer.usd_cents_fractional); + } + } + + #[test] + fn aspect_ratio_does_not_affect_credits() { + let baseline = consumer_credits(&r720(5)); + let ratios = [ + KinoviSeedance2p0MiniAspectRatio::Landscape16x9, + KinoviSeedance2p0MiniAspectRatio::UltraWide21x9, + KinoviSeedance2p0MiniAspectRatio::Portrait9x16, + KinoviSeedance2p0MiniAspectRatio::Square1x1, + KinoviSeedance2p0MiniAspectRatio::Standard4x3, + KinoviSeedance2p0MiniAspectRatio::Portrait3x4, + ]; + for ar in ratios { + let mut request = r720(5); + request.aspect_ratio = Some(ar); + assert_eq!(consumer_credits(&request), baseline, "{ar:?}"); + } + } + + #[test] + fn high_bitrate_does_not_affect_credits() { + let baseline = consumer_credits(&r720(5)); + let mut high = r720(5); + high.bitrate = Some(KinoviSeedance2p0MiniBitrate::High); + assert_eq!(consumer_credits(&high), baseline); + } + + // ── Helpers ── fn build_request( duration_seconds: u8, @@ -285,239 +658,20 @@ mod tests { request } - // ── Full pricing table (credits) ── - // - // The exact table provided by Kinovi for Seedance 2.0 Mini. 480p produces - // fractional credits at odd durations (37.5, 112.5, …). - - mod kinovi_pricing_table { - use super::*; - - #[test] - fn table_480p_without_video_reference() { - assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 30.0); - assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 37.5); - assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 75.0); - assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 112.5); - } - - #[test] - fn table_720p_without_video_reference() { - assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 80.0); - assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 100.0); - assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 200.0); - assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 300.0); - } - - #[test] - fn table_480p_with_video_reference() { - assert_eq!(with_video_ref(r480(4)).calculate_costs().total_cost.kinovi_credits, 38.0); - assert_eq!(with_video_ref(r480(5)).calculate_costs().total_cost.kinovi_credits, 47.5); - assert_eq!(with_video_ref(r480(10)).calculate_costs().total_cost.kinovi_credits, 95.0); - assert_eq!(with_video_ref(r480(15)).calculate_costs().total_cost.kinovi_credits, 142.5); - } - - #[test] - fn table_720p_with_video_reference() { - assert_eq!(with_video_ref(r720(4)).calculate_costs().total_cost.kinovi_credits, 96.0); - assert_eq!(with_video_ref(r720(5)).calculate_costs().total_cost.kinovi_credits, 120.0); - assert_eq!(with_video_ref(r720(10)).calculate_costs().total_cost.kinovi_credits, 240.0); - assert_eq!(with_video_ref(r720(15)).calculate_costs().total_cost.kinovi_credits, 360.0); - } + fn consumer_credits(request: &GenerateSeedance2p0MiniRequest) -> f64 { + request.calculate_consumer_costs().total_cost.kinovi_credits } - // ── Base / surcharge breakdown ── - - mod video_reference_surcharge { - use super::*; - - /// The surcharge is the difference between the with-ref total and the - /// base: 480p +2/s, 720p +4/s. - #[test] - fn surcharge_table() { - // (request builder, duration, base credits, surcharge credits) - let cases: &[(fn(u8) -> GenerateSeedance2p0MiniRequest, u8, f64, f64)] = &[ - // 480p: 7.5/s base, +2/s surcharge - (r480, 4, 30.0, 8.0), - (r480, 5, 37.5, 10.0), - (r480, 10, 75.0, 20.0), - (r480, 15, 112.5, 30.0), - // 720p: 20/s base, +4/s surcharge - (r720, 4, 80.0, 16.0), - (r720, 5, 100.0, 20.0), - (r720, 10, 200.0, 40.0), - (r720, 15, 300.0, 60.0), - ]; - - for (make, duration, base, surcharge) in cases { - let costs = with_video_ref(make(*duration)).calculate_costs(); - assert_eq!(costs.base_cost.kinovi_credits, *base, "base at {duration}s"); - assert_eq!( - costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), - Some(*surcharge), - "surcharge at {duration}s", - ); - assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total at {duration}s"); - } - } - - #[test] - fn no_video_reference_has_no_surcharge() { - let costs = r480(5).calculate_costs(); - assert!(costs.video_reference_surcharge_cost.is_none()); - assert_eq!(costs.total_cost, costs.base_cost); - } - - #[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, 100.0); - assert!(costs.video_reference_surcharge_cost.is_none()); - } - - /// The surcharge applies per generated video, so batches multiply it. - #[test] - fn batch_multiplies_surcharge() { - let request = with_video_ref(build_request( - 5, - Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), - Some(KinoviSeedance2p0MiniBatchCount::Two), - )); - // (37.5 base + 10 surcharge) × 2 - let costs = request.calculate_costs(); - assert_eq!(costs.base_cost.kinovi_credits, 75.0); - assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(20.0)); - assert_eq!(costs.total_cost.kinovi_credits, 95.0); - } + fn consumer_cents(request: &GenerateSeedance2p0MiniRequest) -> u64 { + request.calculate_consumer_costs().total_cost.usd_cents_rounded_up } - // ── USD cents conversion ── - - mod usd_cents { - use super::*; - - #[test] - fn fractional_480p_5s() { - // 37.5 credits; 3750/243 = 15.43 → 16¢ up, 15¢ down. - let costs = r480(5).calculate_costs(); - assert_eq!(costs.total_cost.kinovi_credits, 37.5); - assert_eq!(costs.total_cost.usd_cents_rounded_up, 16); - assert_eq!(costs.total_cost.usd_cents_rounded_down, 15); - assert!((costs.total_cost.usd_cents_fractional - (3750.0 / 243.0)).abs() < FLOAT_TOLERANCE); - } - - #[test] - fn whole_720p_5s() { - // 100 credits; 10000/243 = 41.15 → 42¢ up, 41¢ down. - let costs = r720(5).calculate_costs(); - assert_eq!(costs.total_cost.kinovi_credits, 100.0); - assert_eq!(costs.total_cost.usd_cents_rounded_up, 42); - assert_eq!(costs.total_cost.usd_cents_rounded_down, 41); - assert!((costs.total_cost.usd_cents_fractional - (10000.0 / 243.0)).abs() < FLOAT_TOLERANCE); - } - - #[test] - fn fractional_480p_15s_with_video_reference() { - // 142.5 credits; 14250/243 = 58.64 → 59¢ up, 58¢ down. - let costs = with_video_ref(r480(15)).calculate_costs(); - assert_eq!(costs.total_cost.kinovi_credits, 142.5); - assert_eq!(costs.total_cost.usd_cents_rounded_up, 59); - assert_eq!(costs.total_cost.usd_cents_rounded_down, 58); - assert!((costs.total_cost.usd_cents_fractional - (14250.0 / 243.0)).abs() < FLOAT_TOLERANCE); - } + fn enterprise_credits(request: &GenerateSeedance2p0MiniRequest) -> f64 { + request.calculate_enterprise_costs().total_cost.kinovi_credits } - // ── Batch multiplier ── - - mod batch_tests { - use super::*; - - #[test] - fn batch_scales_total_linearly() { - let base = r720(5).calculate_costs().total_cost.kinovi_credits; - let by_count = [ - (KinoviSeedance2p0MiniBatchCount::One, 1.0), - (KinoviSeedance2p0MiniBatchCount::Two, 2.0), - (KinoviSeedance2p0MiniBatchCount::Three, 3.0), - (KinoviSeedance2p0MiniBatchCount::Four, 4.0), - (KinoviSeedance2p0MiniBatchCount::Five, 5.0), - (KinoviSeedance2p0MiniBatchCount::Six, 6.0), - (KinoviSeedance2p0MiniBatchCount::Seven, 7.0), - (KinoviSeedance2p0MiniBatchCount::Eight, 8.0), - ]; - for (count, multiplier) in by_count { - let credits = build_request(5, None, Some(count)).calculate_costs().total_cost.kinovi_credits; - assert_eq!(credits, base * multiplier, "batch {count:?}"); - } - } - - #[test] - fn batch_eight_480p_with_video_reference() { - let costs = with_video_ref(build_request( - 5, - Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), - Some(KinoviSeedance2p0MiniBatchCount::Eight), - )).calculate_costs(); - // (37.5 + 10) × 8 = 380 - assert_eq!(costs.total_cost.kinovi_credits, 380.0); - } - } - - // ── Defaults & invariants ── - - #[test] - fn default_resolution_is_720p() { - let default = r720(5).calculate_costs().total_cost.kinovi_credits; - let explicit = build_request(5, Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP), None) - .calculate_costs().total_cost.kinovi_credits; - assert_eq!(default, explicit); - assert_eq!(default, 100.0); - } - - #[test] - fn cost_scales_with_duration() { - let c4 = r720(4).calculate_costs().total_cost.kinovi_credits; - let c10 = r720(10).calculate_costs().total_cost.kinovi_credits; - let c15 = r720(15).calculate_costs().total_cost.kinovi_credits; - assert!(c4 < c10); - assert!(c10 < c15); - } - - #[test] - fn resolution_480p_cheaper_than_720p() { - for dur in 4..=15u8 { - let c480 = r480(dur).calculate_costs().total_cost.kinovi_credits; - let c720 = r720(dur).calculate_costs().total_cost.kinovi_credits; - assert!(c480 < c720, "480p should be cheaper than 720p at {dur}s"); - } - } - - #[test] - fn aspect_ratio_does_not_affect_credits() { - let baseline = r720(5).calculate_costs().total_cost.kinovi_credits; - let ratios = [ - KinoviSeedance2p0MiniAspectRatio::Landscape16x9, - KinoviSeedance2p0MiniAspectRatio::UltraWide21x9, - KinoviSeedance2p0MiniAspectRatio::Portrait9x16, - KinoviSeedance2p0MiniAspectRatio::Square1x1, - KinoviSeedance2p0MiniAspectRatio::Standard4x3, - KinoviSeedance2p0MiniAspectRatio::Portrait3x4, - ]; - for ar in ratios { - let mut req = r720(5); - req.aspect_ratio = Some(ar); - assert_eq!(req.calculate_costs().total_cost.kinovi_credits, baseline, "{ar:?}"); - } - } - - #[test] - fn high_bitrate_does_not_affect_credits() { - let baseline = r720(5).calculate_costs().total_cost.kinovi_credits; - let mut high = r720(5); - high.bitrate = Some(KinoviSeedance2p0MiniBitrate::High); - assert_eq!(high.calculate_costs().total_cost.kinovi_credits, baseline); + fn enterprise_cents(request: &GenerateSeedance2p0MiniRequest) -> u64 { + request.calculate_enterprise_costs().total_cost.usd_cents_rounded_up } } diff --git a/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_seedance_fractional_generation_cost.rs b/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_seedance_fractional_generation_cost.rs index 88dccde36c..08374ec243 100644 --- a/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_seedance_fractional_generation_cost.rs +++ b/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_seedance_fractional_generation_cost.rs @@ -63,21 +63,6 @@ impl KinoviSeedanceFractionalGenerationCost { } } - /// Build a cost at the historical single-rate enterprise conversion. - /// Still used by Seedance 2.0 Mini, which hasn't migrated to tiered - /// pricing yet. - pub fn from_base_and_surcharge( - base_credits: f64, - maybe_video_reference_surcharge_credits: Option, - ) -> Self { - let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0.0); - Self { - 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(KinoviFractionalGenerationCost::from_kinovi_credits), - } - } } #[cfg(test)] @@ -86,24 +71,37 @@ mod tests { #[test] fn without_surcharge_total_equals_base() { - let cost = KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge(37.5, None); + let cost = KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier( + KinoviPricingTier::Enterprise, 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 = KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge(37.5, Some(10.0)); + fn with_surcharge_sums_into_total_at_the_enterprise_rate() { + // 37.5 base + 10 surcharge = 47.5 total; 4750/243.16 = 19.5344 → 20¢. + let cost = KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier( + KinoviPricingTier::Enterprise, 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 consumer_tier_converts_at_the_consumer_rate() { + // 47.5 credits; 4750/192.98 = 24.6139 → 25¢. + let cost = KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier( + KinoviPricingTier::Consumer, 37.5, Some(10.0), + ); + assert_eq!(cost.total_cost.usd_cents_rounded_up, 25); + assert_eq!(cost.total_cost.usd_cents_rounded_down, 24); + } }