updating pricing tests (#1636)

This commit is contained in:
Brandon Thomas
2026-06-12 19:52:58 -04:00
committed by GitHub
parent 2818831a66
commit 189c5afc80
38 changed files with 1103 additions and 1084 deletions
+2
View File
@@ -38,6 +38,7 @@ script/socat_dev_database_connect.sh
venv/
# LLM Testing
/external/
/prompts/
/reports/
/requests/
@@ -102,3 +103,4 @@ windows/sqlite/sqlite3.lib
# ArtCraft Windows DLLs
crates/desktop/src-tauri/*.dll
windows_dlls/
@@ -11,9 +11,11 @@ use crate::generate::generate_image::providers::artcraft::midjourney_7::request:
/// | 3 | 19 | 19¢ | (interpolated; Kinovi doesn't expose 3)
/// | 4 | 25 | 25¢ |
///
/// Kinovi-side math: 12 Kinovi credits per Midjourney task; 193 Kinovi
/// credits per USD. Kinovi rounds to nearest cent, so batches 1/2/4 land
/// on 6/12/25. Batch 3 is `ceil((12 × 3 / 193) × 100) = 19`.
/// Kinovi-side math (historical, 22,000-credit package at 193 credits/$1):
/// 12 Kinovi credits per Midjourney task → batches 1/2/4 land on 6/12/25¢;
/// batch 3 is `ceil((12 × 3 / 193) × 100) = 19`. Under the current
/// 525,000-credit package (243 credits/$1) Kinovi costs only 5/10/20¢ —
/// user pricing is intentionally kept at the 193-derived values.
#[derive(Clone, Debug)]
pub struct ArtcraftMidjourney7CostState {
pub num_images: u16,
@@ -127,62 +129,6 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi for batches 1/2/4 ──
//
// Kinovi midjourney_7 returns 6/12/25¢ for batches 1/2/4. The artcraft
// provider must match those values byte-for-byte so users pay the same.
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(1);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 1: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(2);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 2: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(4);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 4: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
}
// ── Monotonicity ──
#[test]
@@ -82,56 +82,4 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi 7-niji ──
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(1);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 1: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(2);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 2: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(4);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 4: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
}
}
@@ -82,56 +82,4 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi v8 ──
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(1);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 1: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(2);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 2: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(4);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 4: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
}
}
@@ -86,27 +86,27 @@ mod tests {
}
}
// ── USD pricing (193 credits per dollar; rounded) ──
// ── USD pricing (243 credits per dollar; rounded up) ──
mod usd {
use super::*;
#[test]
fn batch_one_is_six_cents() {
// 12 / 193 × 100 = 6.21 → 6¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 7); // rounds UP
fn batch_one_is_five_cents() {
// 12/243 × 100 = 4.94 → 5¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 5); // rounds UP
}
#[test]
fn batch_two_is_twelve_cents() {
// 24 / 193 × 100 = 12.44 → 12¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 13); // rounds UP
fn batch_two_is_ten_cents() {
// 24/243 × 100 = 9.88 → 10¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 10); // rounds UP
}
#[test]
fn batch_four_is_twentyfive_cents() {
// 48 / 193 × 100 = 24.87 → 25¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 25);
fn batch_four_is_twenty_cents() {
// 48/243 × 100 = 19.75 → 20¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 20);
}
}
@@ -56,11 +56,11 @@ mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_six_cents() {
fn batch_one_is_twelve_credits_and_five_cents() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(7)); // 1200/193 = 6.22 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP
}
#[test]
@@ -68,7 +68,7 @@ mod tests {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(13)); // 2400/193 = 12.44 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
}
#[test]
@@ -76,7 +76,7 @@ mod tests {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(25));
assert_eq!(cost.cost_in_usd_cents, Some(20));
}
#[test]
@@ -56,11 +56,11 @@ mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_six_cents() {
fn batch_one_is_twelve_credits_and_five_cents() {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(7)); // 1200/193 = 6.22 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP
}
#[test]
@@ -68,7 +68,7 @@ mod tests {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(13)); // 2400/193 = 12.44 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
}
#[test]
@@ -76,6 +76,6 @@ mod tests {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(25));
assert_eq!(cost.cost_in_usd_cents, Some(20));
}
}
@@ -231,176 +231,6 @@ mod tests {
}
}
// ── Cross-provider margin: ArtCraft is consistently ~30% above GrokApi ──
mod small_margin {
use super::*;
use crate::api::router_aspect_ratio::RouterAspectRatio;
#[test]
fn small_margin() {
let resolutions = [
None,
Some(RouterResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP),
Some(RouterResolution::TenEightyP), // clamps to 720p on both sides
];
let durations: &[u16] = &[1, 4, 5, 8, 10, 12, 15];
let aspect_ratios = [
None,
Some(RouterAspectRatio::Auto),
Some(RouterAspectRatio::Square),
Some(RouterAspectRatio::WideSixteenByNine),
Some(RouterAspectRatio::TallNineBySixteen),
Some(RouterAspectRatio::WideFourByThree),
Some(RouterAspectRatio::TallThreeByFour),
Some(RouterAspectRatio::WideThreeByTwo),
Some(RouterAspectRatio::TallTwoByThree),
];
// (has_start_frame, num_reference_images). At most one of the two is
// non-default — start_frame and reference_images are mutually exclusive
// on the GrokApi wire (image-to-video vs reference-to-video), so we
// don't mix them here to keep the comparison apples-to-apples.
let image_combos: &[(bool, usize)] = &[
(false, 0), // text-to-video
(true, 0), // image-to-video, 1 source image
(false, 1), // reference-to-video, 1 ref image
(false, 2),
(false, 3),
];
let mut total_cases = 0;
let mut failures = Vec::new();
for &res in &resolutions {
for &dur in durations {
for &ar in &aspect_ratios {
for &(has_start, num_refs) in image_combos {
total_cases += 1;
let artcraft = artcraft_cents(res, dur, ar, has_start, num_refs);
let grokapi = grokapi_cents(res, dur, ar, has_start, num_refs);
// Margin requirement: artcraft / max(grokapi - 1, 1) >= 1.29.
// The (-1, 1) floor accounts for one cent of GrokApi-side
// ceiling-rounding noise (see test doc comment above).
let grokapi_compare = grokapi.saturating_sub(1).max(1);
let lhs = artcraft.saturating_mul(100);
let rhs = grokapi_compare.saturating_mul(129);
if lhs < rhs {
let pct = if grokapi == 0 { 0.0 }
else { (artcraft as f64 / grokapi as f64 - 1.0) * 100.0 };
failures.push(format!(
"res={:?} dur={}s ar={:?} start={} refs={} → \
artcraft={}¢ vs grokapi={}¢ (margin {:.1}%, want ≥ 29% after 1¢ tolerance)",
res, dur, ar, has_start, num_refs, artcraft, grokapi, pct,
));
}
// Defense in depth: ArtCraft must always cost at least as
// much as GrokApi, regardless of rounding noise.
assert!(
artcraft >= grokapi,
"ArtCraft ({artcraft}¢) cheaper than GrokApi ({grokapi}¢) for \
res={res:?} dur={dur}s ar={ar:?} start={has_start} refs={num_refs}",
);
}
}
}
}
if !failures.is_empty() {
let summary = failures.iter().take(10).cloned().collect::<Vec<_>>().join("\n ");
panic!(
"{} / {} (res × duration × aspect × image_combo) tuples failed \
the ≥29% (with 1¢ tolerance) margin check.\nFirst 10:\n {}",
failures.len(), total_cases, summary,
);
}
}
// ── Cross-provider helpers ──
fn artcraft_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
aspect_ratio: Option<RouterAspectRatio>,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
// ArtCraft's omni endpoint takes MediaFileTokens for image refs.
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::MediaFileTokens(
(0..num_reference_images)
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo,
provider: RouterProvider::Artcraft,
resolution,
aspect_ratio,
duration_seconds: Some(duration_seconds),
// Batch is fixed at 1 because GrokApi's per-request cost calculator
// doesn't multiply by batch (Grok returns one video per call); the
// markup test would compare apples to oranges otherwise.
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (artcraft) should succeed")
.estimate_cost()
.expect("estimate_cost (artcraft) should succeed")
.cost_in_usd_cents
.unwrap()
}
fn grokapi_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
aspect_ratio: Option<RouterAspectRatio>,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
// GrokApi (direct xAI) takes URLs for image refs.
Some(ImageRef::Url("https://example.com/start.png".to_string()))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::Urls(
(0..num_reference_images)
.map(|i| format!("https://example.com/ref_{i}.png"))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo,
provider: RouterProvider::GrokApi,
resolution,
aspect_ratio,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (grokapi) should succeed")
.estimate_cost()
.expect("estimate_cost (grokapi) should succeed")
.cost_in_usd_cents
.unwrap()
}
}
// ── Helpers ──
fn build_cost(
@@ -144,113 +144,6 @@ mod tests {
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ── Base cost parity with grok_api_client ─────────────────────────────
//
// The base cost (before ArtCraft markup, batch=1) must equal exactly what
// grok_api_client's calculator returns for an equivalent direct-Grok
// request. This protects against the artcraft and grok-api implementations
// silently drifting apart.
mod grok_api_parity {
use super::*;
// (resolution, duration, image_count)
//
// Durations are confined to [4, 15] because the ArtCraft pipeline clamps
// outside that range (see `plan_duration` in artcraft/build_common.rs).
// Every case carries exactly one image. v1.5 rejects text-to-video, AND
// the GrokApi build step collapses multi-image reference-to-video
// requests down to one `image` (promote-first; see grok_api side
// build.rs). So the only request shape where both sides agree on the
// input image count is the single-image case.
const PARITY_CASES: &[(Option<RouterResolution>, u16, u64)] = &[
// 480p variations
(Some(RouterResolution::FourEightyP), 4, 1),
(Some(RouterResolution::FourEightyP), 5, 1),
(Some(RouterResolution::FourEightyP), 8, 1),
(Some(RouterResolution::FourEightyP), 15, 1),
// 720p variations
(Some(RouterResolution::SevenTwentyP), 4, 1),
(Some(RouterResolution::SevenTwentyP), 5, 1),
(Some(RouterResolution::SevenTwentyP), 8, 1),
(Some(RouterResolution::SevenTwentyP), 15, 1),
// None defaults to 720p in our `from_request`, which matches the
// assumption Grok's calculator makes when fed a 720p request.
(None, 5, 1),
];
#[test]
fn base_cost_for_one_video_matches_grok_api_client() {
for &(router_res, duration, image_count) in PARITY_CASES {
let artcraft = artcraft_state(router_res, duration, image_count, 1);
let grok_cents = grok_cents(router_res, duration, image_count);
assert_eq!(
artcraft.base_cost_in_cents_for_one_video(),
grok_cents,
"parity mismatch at res={router_res:?} dur={duration} images={image_count}: \
artcraft_base={} grok={}",
artcraft.base_cost_in_cents_for_one_video(),
grok_cents,
);
}
}
#[test]
fn batch_one_base_total_equals_one_video_base() {
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1);
assert_eq!(s.base_cost_in_cents_for_batch(), s.base_cost_in_cents_for_one_video());
}
#[test]
fn batch_scales_base_linearly() {
// The ArtCraft pipeline only accepts batch counts of 1, 2, or 4 (see
// `plan_batch_count` in artcraft/build_common.rs); anything else gets
// clamped, so we can only meaningfully test those values.
let per_one = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1)
.base_cost_in_cents_for_one_video();
for batch in [1u16, 2, 4] {
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, batch);
assert_eq!(
s.base_cost_in_cents_for_batch(),
per_one * batch as u64,
"batch={batch}",
);
}
}
/// Builder-driven parity — exercises the full pipeline (build2 →
/// estimate_cost) on both providers and compares ArtCraft's base cost
/// (final / 1.05, accounting for ceiling) against GrokApi's direct
/// cost.
///
/// We verify the markup rather than the base here because the public
/// `estimate_cost()` returns the marked-up number; the explicit
/// `base_cost_in_cents_*` API is tested above.
#[test]
fn end_to_end_markup_is_at_least_five_percent_over_grok() {
// Single-image cases only — see PARITY_CASES comment above for why
// multi-image rows can't be apples-to-apples between the two sides.
let cases: &[(Option<RouterResolution>, u16, bool, usize)] = &[
(Some(RouterResolution::FourEightyP), 5, true, 0),
(Some(RouterResolution::FourEightyP), 10, true, 0),
(Some(RouterResolution::SevenTwentyP), 5, true, 0),
(Some(RouterResolution::SevenTwentyP), 8, true, 0),
(Some(RouterResolution::SevenTwentyP),15, true, 0),
];
for &(res, duration, has_start, refs) in cases {
let artcraft_cents = artcraft_end_to_end_cents(res, duration, 1, has_start, refs);
let grok_cents = grok_cents(res, duration, has_start as u64 + refs as u64);
let want_minimum = grok_cents.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
assert_eq!(
artcraft_cents, want_minimum,
"res={res:?} dur={duration} start={has_start} refs={refs}: \
artcraft={artcraft_cents}¢ vs ceil(grok({grok_cents})×1.05)={want_minimum}¢",
);
}
}
}
// ── Markup correctness ─────────────────────────────────────────────────
mod markup {
@@ -484,42 +377,6 @@ mod tests {
}
}
fn artcraft_end_to_end_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
batch_count: u16,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::MediaFileTokens(
(0..num_reference_images)
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(batch_count),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
fn build_cost_via_builder(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -542,40 +399,4 @@ mod tests {
.expect("estimate_cost should succeed")
}
/// Reference value: what GrokApi's own pipeline would charge for one
/// equivalent video. Used by parity tests.
///
/// The Grok build step prefers `start_frame` over `reference_images` when
/// both are supplied and drops the latter (see grok_api/grok_imagine_video_1p5/build.rs).
/// To keep parity with the ArtCraft input-image *count*, we put all source
/// images in `reference_images` only — the cost calculator counts the total
/// either way.
fn grok_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
image_count: u64,
) -> u64 {
let reference_images = if image_count >= 1 {
Some(ImageListRef::Urls(
(0..image_count).map(|i| format!("https://example.com/ref_{i}.png")).collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::GrokApi,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame: None,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (grokapi) should succeed")
.estimate_cost()
.expect("estimate_cost (grokapi) should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -14,13 +14,16 @@ use crate::generate::generate_video::providers::artcraft::happy_horse_1p0::reque
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package (historical): 22,000 credits for $114 (~193 credits/$1).
// The upstream package is now 525,000 credits for $2,159.0909 (~243 credits/$1),
// but user pricing is intentionally kept at the 193-derived rates.
//
// ArtCraft credits: 100 credits = $1.00, so ArtCraft credits = USD cents.
// We compute USD cents from upstream credits ÷ credits_per_dollar × 100.
const UPSTREAM_CREDITS_PER_SECOND_720P: u32 = 33;
const UPSTREAM_CREDITS_PER_SECOND_1080P: u32 = 66;
// Frozen at the historical rate so user pricing doesn't shift with the package deal.
const UPSTREAM_CREDITS_PER_DOLLAR: f64 = 193.0;
pub struct ArtcraftHappyHorse1p0CostState {
@@ -201,56 +204,6 @@ mod tests {
}
}
// ── Cross-check with Kinovi ──
mod cross_check_with_kinovi {
use super::*;
#[test]
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(RouterResolution::SevenTwentyP),
None,
Some(RouterResolution::TenEightyP),
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
model: RouterVideoModel::HappyHorse1p0,
provider: RouterProvider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi_cost = kinovi.build2()
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost");
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost), while the artcraft user-facing price
// here rounds to nearest — so kinovi may exceed artcraft by at
// most one cent.
let artcraft_cents = artcraft_cost.cost_in_usd_cents.expect("artcraft cents");
let kinovi_cents = kinovi_cost.cost_in_usd_cents.expect("kinovi cents");
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"USD cents mismatch: res={:?} dur={}s batch={} (artcraft={}, kinovi={})",
res, dur, batch, artcraft_cents, kinovi_cents,
);
}
}
}
}
}
// ── Helpers ──
fn build_cost(
@@ -18,6 +18,10 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0::request:
///
/// We keep these as f64 because per-second rates are fractional; rounding
/// happens once at the end after multiplying by duration × batch.
///
/// NB: Derivation is historical (193 credits/$1 package). The upstream
/// package is now 525,000 credits for $2,159.0909 (~243 credits/$1), but
/// user pricing is intentionally kept at these 193-derived rates.
const CENTS_PER_SECOND_480P: f64 = 7.772;
const CENTS_PER_SECOND_720P: f64 = 16.0;
const CENTS_PER_SECOND_1080P: f64 = 46.632;
@@ -214,57 +218,6 @@ mod tests {
}
}
// -- Cross-check with Kinovi via builder --
mod cross_check_with_kinovi {
use super::*;
#[test]
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(RouterResolution::FourEightyP),
Some(RouterResolution::TenEightyP),
// NB: These are underpriced (for now)
//Some(RouterResolution::SevenTwentyP),
//None,
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
provider: RouterProvider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi_cost = kinovi.build2()
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost");
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost), while the artcraft user-facing price
// here rounds to nearest — so kinovi may exceed artcraft by at
// most one cent.
let artcraft_cents = artcraft_cost.cost_in_usd_cents.expect("artcraft cents");
let kinovi_cents = kinovi_cost.cost_in_usd_cents.expect("kinovi cents");
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"USD cents mismatch: res={:?} dur={}s batch={} (artcraft={}, kinovi={})",
res, dur, batch, artcraft_cents, kinovi_cents,
);
}
}
}
}
}
// -- Helpers --
/// Build a cost estimate end-to-end: GenerateVideoRequestBuilder → build2 → estimate_cost.
@@ -154,6 +154,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -125,6 +125,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -154,6 +154,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -125,6 +125,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -13,6 +13,8 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
/// USD cents per second by resolution, derived from upstream Fast rates:
/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s
/// (historical 193 credits/$1 derivation; the upstream package is now
/// ~243 credits/$1 but user pricing is intentionally unchanged)
/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s
///
/// We keep these as f64 because per-second rates are fractional; rounding
@@ -184,50 +186,6 @@ mod tests {
}
}
// -- Cross-check with Kinovi Fast via builder --
//mod cross_check_with_kinovi {
// use super::*;
//
// #[test]
// fn artcraft_fast_matches_kinovi_fast_all_combos() {
// let resolutions = [
// Some(RouterResolution::FourEightyP),
// Some(RouterResolution::SevenTwentyP),
// None,
// ];
// let durations: [u16; 4] = [4, 5, 10, 15];
// let batches: [u16; 3] = [1, 2, 4];
//
// for res in &resolutions {
// for dur in &durations {
// for batch in &batches {
// let artcraft_cost = build_cost(*res, *dur, *batch);
//
// let kinovi = GenerateVideoRequestBuilder {
// model: RouterVideoModel::Seedance2p0Fast,
// provider: RouterProvider::Seedance2Pro,
// resolution: *res,
// duration_seconds: Some(*dur),
// video_batch_count: Some(*batch),
// ..Default::default()
// };
// let kinovi_cost = kinovi.build2()
// .expect("kinovi build2")
// .estimate_cost()
// .expect("kinovi estimate_cost");
//
// assert_eq!(
// artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
// "USD cents mismatch: res={:?} dur={}s batch={}",
// res, dur, batch,
// );
// }
// }
// }
// }
//}
// -- Helpers --
fn build_cost(
@@ -159,6 +159,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -144,6 +144,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -98,10 +98,8 @@ mod tests {
#[test]
fn usd_cents_720p() {
// 165 credits / 193 * 100 = 85.49 → 85¢
assert_eq!(usd_cents(None, 5, None), 86); // 16500/193 = 85.49 -> rounds UP
// 495 credits / 193 * 100 = 256.48 → 256¢
assert_eq!(usd_cents(None, 15, None), 257); // 49500/193 = 256.48 -> rounds UP
assert_eq!(usd_cents(None, 5, None), 68); // 16500/243 = 67.90 -> rounds UP
assert_eq!(usd_cents(None, 15, None), 204); // 49500/243 = 203.70 -> rounds UP
}
}
@@ -131,10 +129,10 @@ mod tests {
#[test]
fn usd_cents_1080p() {
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
// 330 credits / 193 * 100 = 170.98 → 171¢
assert_eq!(usd_cents(r, 5, None), 171);
// 990 credits / 193 * 100 = 512.95 → 513¢
assert_eq!(usd_cents(r, 15, None), 513);
// 330 credits; 33000/243 = 135.80 → rounds UP to 136¢
assert_eq!(usd_cents(r, 5, None), 136);
// 990 credits; 99000/243 = 407.41 → rounds UP to 408¢
assert_eq!(usd_cents(r, 15, None), 408);
}
}
@@ -238,7 +236,7 @@ mod tests {
);
let cost = KinoviHappyHorse1p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_credits, Some(330));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(171));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(136));
}
}
@@ -66,8 +66,8 @@ impl KinoviSeedance2p0CostState {
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = costs.total_cost.kinovi_credits;
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -103,26 +103,26 @@ mod tests {
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 104);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 125);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 146);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 208);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 311);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 66);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 99);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 116);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 165);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 247);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 166);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 208);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 622);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 132);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 165);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 494);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 332);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 415);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 1244);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 264);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 330);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 988);
}
}
@@ -133,20 +133,20 @@ mod tests {
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 32);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 39);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 78);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 117);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 25);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 31);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 62);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 93);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 78);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 62);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 156);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 124);
}
}
@@ -157,20 +157,20 @@ mod tests {
#[test]
fn cost_1080p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 187);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 234);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 467);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 700);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 149);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 186);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 371);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 556);
}
#[test]
fn cost_1080p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 467);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 371);
}
#[test]
fn cost_1080p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 933);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 741);
}
}
@@ -219,10 +219,10 @@ mod tests {
};
let without = base.estimate_cost();
let with = KinoviSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
// 720p surcharge is +8 credits/s: 200 -> 240 credits (24000/193 = 124.35 -> 125 cents).
// 720p surcharge is +8 credits/s: 200 -> 240 credits (24000/243 = 98.77 -> 99 cents).
assert_eq!(without.cost_in_credits, Some(200));
assert_eq!(with.cost_in_credits, Some(240));
assert_eq!(with.cost_in_usd_cents, Some(125));
assert_eq!(with.cost_in_usd_cents, Some(99));
}
// ── from_request() tests ──
@@ -238,7 +238,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
}
#[test]
@@ -246,21 +246,21 @@ mod tests {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
}
#[test]
fn from_request_1080p_batch_2() {
let req = make_request_state(Some(KinoviOutputResolution::TenEightyP), 5, KinoviBatchCount::Two, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(467));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(371));
}
#[test]
@@ -270,7 +270,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(125));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
}
#[test]
@@ -294,7 +294,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
}
#[test]
@@ -302,7 +302,7 @@ mod tests {
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
}
#[test]
@@ -311,7 +311,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::TenEightyP)));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Four)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(933));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(741));
}
#[test]
@@ -321,7 +321,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(125));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
}
#[test]
@@ -337,7 +337,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(622));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(494));
}
}
@@ -66,8 +66,8 @@ impl KinoviSeedance2p0FastCostState {
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = costs.total_cost.kinovi_credits;
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -100,51 +100,51 @@ mod tests {
// ── Direct estimate_cost() tests ──
// These must match the reference implementation values exactly.
// -- 720p (28 credits/sec, 193 credits/$1) --
// -- 720p (28 credits/sec, 243 credits/$1) --
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 59);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 146);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 218);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 47);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 58);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 116);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 173);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 146);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 116);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 291);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 231);
}
}
// -- 480p (14 credits/sec, 193 credits/$1) --
// -- 480p (14 credits/sec, 243 credits/$1) --
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 30);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 37);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 109);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 24);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 29);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 58);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 87);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 73);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 58);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 146);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 116);
}
}
@@ -191,10 +191,10 @@ mod tests {
};
let without = base.estimate_cost();
let with = KinoviSeedance2p0FastCostState { has_video_reference: true, ..base }.estimate_cost();
// Fast 720p surcharge is +6 credits/s: 140 -> 170 credits (17000/193 = 88.08 -> 89 cents).
// Fast 720p surcharge is +6 credits/s: 140 -> 170 credits (17000/243 = 69.96 -> 70 cents).
assert_eq!(without.cost_in_credits, Some(140));
assert_eq!(with.cost_in_credits, Some(170));
assert_eq!(with.cost_in_usd_cents, Some(89));
assert_eq!(with.cost_in_usd_cents, Some(70));
}
// ── from_request() tests ──
@@ -210,7 +210,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
}
#[test]
@@ -218,14 +218,14 @@ mod tests {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(cost.resolution.is_none());
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(37));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
}
#[test]
@@ -235,7 +235,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(89));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
}
#[test]
@@ -259,7 +259,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
}
#[test]
@@ -267,7 +267,7 @@ mod tests {
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(37));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
}
#[test]
@@ -277,7 +277,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(89));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
}
#[test]
@@ -293,7 +293,7 @@ mod tests {
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(436));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(346));
}
}
@@ -0,0 +1,68 @@
Cost table built on 2026-06-11
Seedance 2.0 (~$0.19/sec)
Seedance 2.0 Fast (~$0.13/sec)
Without Video Uploads
=====================
### Seedance 2.0
4 sec 480p = 60 credits
5 sec 480p = 75 credits
10 sec 480p = 150 credits
15 sec 480p = 225 credits
4 sec 720p = 160 credits
5 sec 720p = 200 credits
10 sec 720p = 400 credits
15 sec 720p = 600 credits
4 sec 1080p = 360 credits
5 sec 1080p = 450 credits
10 sec 1080p = 900 credits
15 sec 1080p = 1350 credits
### Seedance 2.0 Fast
fast 4 sec 480p = 56 credits
fast 5 sec 480p = 70 credits
fast 10 sec 480p = 140 credits
fast 15 sec 480p = 210 credits
fast 4 sec 720p = 112 credits
fast 5 sec 720p = 140 credits
fast 10 sec 720p = 280 credits
fast 15 sec 720p = 420 credits
With Video Uploads
==================
### Seedance 2.0 With Video Refs
4 sec 480p + 10 sec video ref = 60 credits + 16 credits
5 sec 480p + 10 sec video ref = 75 credits + 20 credits
10 sec 480p + 10 sec video ref = 150 credits + 40 credits
15 sec 480p + 10 sec video ref = 225 credits + 60 credits
4 sec 720p + 10 sec video ref = 160 credits + 32 credits
5 sec 720p + 10 sec video ref = 200 credits + 40 credits
10 sec 720p + 10 sec video ref = 400 credits + 80 credits
15 sec 720p + 10 sec video ref = 600 credits + 120 credits
4 sec 1080p + 10 sec video ref = 360 credits + 72 credits
5 sec 1080p + 10 sec video ref = 450 credits + 90 credits
10 sec 1080p + 10 sec video ref = 900 credits + 180 credits
15 sec 1080p + 10 sec video ref = 1350 credits + 270 credits
### Seedance 2.0 Fast With Video Refs
fast 4 sec 480p + 10 sec video ref = 56 credits + 16 credits
fast 5 sec 480p + 10 sec video ref = 70 credits + 20 credits
fast 10 sec 480p + 10 sec video ref = 140 credits + 40 credits
fast 15 sec 480p + 10 sec video ref = 210 credits + 60 credits
fast 4 sec 720p + 10 sec video ref = 112 credits + 24 credits
fast 5 sec 720p + 10 sec video ref = 140 credits + 30 credits
fast 10 sec 720p + 10 sec video ref = 280 credits + 60 credits
fast 15 sec 720p + 10 sec video ref = 420 credits + 90 credits
@@ -0,0 +1,9 @@
/// Kinovi credits per US dollar.
///
/// Current package: 525,000 credits for $2,159.0909 (~243.16 credits/$1,
/// rounded down to 243).
///
/// Historical packages, for reference:
/// - 500,000 credits for $2,159.09 (~231.58 credits/$1, rounded down to 231)
/// - 22,000 credits for $114 (~192.98 credits/$1, rounded to 193)
pub const CREDITS_PER_DOLLAR: u64 = 243;
@@ -0,0 +1,90 @@
use crate::cost::constants::CREDITS_PER_DOLLAR;
/// The cost of a Kinovi generation, in both native credits and USD cents.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinoviGenerationCost {
/// The cost of the generation in Kinovi credits.
pub kinovi_credits: u64,
/// Estimated cost in cents, rounded up to the nearest whole cent.
/// This does not account for discounts, prorations, etc.
/// This is always rounded up when fractional.
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 KinoviGenerationCost {
/// Build a cost from a Kinovi credit amount, deriving the USD cents via
/// the credit-package rate.
pub fn from_kinovi_credits(kinovi_credits: u64) -> Self {
let total_hundredths = kinovi_credits * 100;
Self {
kinovi_credits,
// Integer ceiling / floor of (credits * 100 / CREDITS_PER_DOLLAR).
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,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn zero_credits_is_zero_cents() {
let cost = KinoviGenerationCost::from_kinovi_credits(0);
assert_eq!(cost.kinovi_credits, 0);
assert_eq!(cost.usd_cents_rounded_up, 0);
assert_eq!(cost.usd_cents_rounded_down, 0);
assert!((cost.usd_cents_fractional - 0.0).abs() < FLOAT_TOLERANCE);
}
#[test]
fn exact_dollar_boundaries_do_not_round() {
// 243 credits = exactly $1.00 — all three representations agree.
let cost = KinoviGenerationCost::from_kinovi_credits(243);
assert_eq!(cost.kinovi_credits, 243);
assert_eq!(cost.usd_cents_rounded_up, 100);
assert_eq!(cost.usd_cents_rounded_down, 100);
assert!((cost.usd_cents_fractional - 100.0).abs() < FLOAT_TOLERANCE);
let cost = KinoviGenerationCost::from_kinovi_credits(486);
assert_eq!(cost.usd_cents_rounded_up, 200);
assert_eq!(cost.usd_cents_rounded_down, 200);
assert!((cost.usd_cents_fractional - 200.0).abs() < FLOAT_TOLERANCE);
}
#[test]
fn fractional_cents_round_in_both_directions() {
// 244 credits = 100.411... cents.
let cost = KinoviGenerationCost::from_kinovi_credits(244);
assert_eq!(cost.usd_cents_rounded_up, 101);
assert_eq!(cost.usd_cents_rounded_down, 100);
assert!((cost.usd_cents_fractional - (24400.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// 1 credit = 0.432... cents.
let cost = KinoviGenerationCost::from_kinovi_credits(1);
assert_eq!(cost.usd_cents_rounded_up, 1);
assert_eq!(cost.usd_cents_rounded_down, 0);
assert!((cost.usd_cents_fractional - (100.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn rounded_bounds_bracket_the_fractional_value() {
for credits in [0, 1, 12, 75, 200, 243, 244, 450, 1350] {
let cost = KinoviGenerationCost::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);
}
}
}
@@ -0,0 +1,111 @@
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 + 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 (resolution rate × output duration × batch
/// count), excluding any surcharges.
pub base_cost: KinoviGenerationCost,
/// 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<KinoviGenerationCost>,
}
impl KinoviSeedanceGenerationCost {
/// Build a cost from base credits plus an optional video-reference
/// surcharge (both in Kinovi credits). The per-part and total USD
/// conversions are derived.
pub fn from_base_and_surcharge(
base_credits: u64,
maybe_video_reference_surcharge_credits: Option<u64>,
) -> Self {
let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0);
Self {
total_cost: KinoviGenerationCost::from_kinovi_credits(total_credits),
base_cost: KinoviGenerationCost::from_kinovi_credits(base_credits),
video_reference_surcharge_cost: maybe_video_reference_surcharge_credits
.map(KinoviGenerationCost::from_kinovi_credits),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn without_surcharge() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, None);
assert!(cost.video_reference_surcharge_cost.is_none());
// Base: 200 credits; 20000/243 = 82.30.
assert_eq!(cost.base_cost.kinovi_credits, 200);
assert_eq!(cost.base_cost.usd_cents_rounded_up, 83);
assert_eq!(cost.base_cost.usd_cents_rounded_down, 82);
assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 243.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));
// Base: 200 credits; 20000/243 = 82.30.
assert_eq!(cost.base_cost.kinovi_credits, 200);
assert_eq!(cost.base_cost.usd_cents_rounded_up, 83);
assert_eq!(cost.base_cost.usd_cents_rounded_down, 82);
assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 40 credits; 4000/243 = 16.46.
let surcharge = cost.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 40);
assert_eq!(surcharge.usd_cents_rounded_up, 17);
assert_eq!(surcharge.usd_cents_rounded_down, 16);
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 240 credits; 24000/243 = 98.77.
assert_eq!(cost.total_cost.kinovi_credits, 240);
assert_eq!(cost.total_cost.usd_cents_rounded_up, 99);
assert_eq!(cost.total_cost.usd_cents_rounded_down, 98);
assert!((cost.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
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_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 83 + 17 = 100, but the total (240 credits) rounds
// once to 99 — one cent less than summing the rounded parts.
assert_eq!(cost.base_cost.usd_cents_rounded_up + surcharge.usd_cents_rounded_up, 100);
assert_eq!(cost.total_cost.usd_cents_rounded_up, 99);
// …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);
}
}
@@ -0,0 +1,3 @@
pub mod constants;
pub mod kinovi_generation_cost;
pub mod kinovi_seedance_generation_cost;
@@ -1,61 +0,0 @@
/// Kinovi credits per US dollar.
/// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
const CREDITS_PER_DOLLAR: u64 = 193;
/// The cost of a Kinovi generation, in both native credits and USD cents.
pub struct KinoviGenerationCost {
/// The cost of the generation in Kinovi credits.
pub kinovi_credits: u64,
/// Estimated cost in cents, rounded up to the nearest whole cent.
/// This does not account for discounts, prorations, etc.
/// This is always rounded up when fractional.
pub usd_cents_rounded_up: u64,
}
impl KinoviGenerationCost {
/// Build a cost from a Kinovi credit amount, deriving the USD cents via
/// the credit-package rate (always rounding up fractional cents).
pub fn from_kinovi_credits(kinovi_credits: u64) -> Self {
// Integer ceiling of (credits * 100 / CREDITS_PER_DOLLAR).
let usd_cents_rounded_up = (kinovi_credits * 100).div_ceil(CREDITS_PER_DOLLAR);
Self {
kinovi_credits,
usd_cents_rounded_up,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_credits_is_zero_cents() {
let cost = KinoviGenerationCost::from_kinovi_credits(0);
assert_eq!(cost.kinovi_credits, 0);
assert_eq!(cost.usd_cents_rounded_up, 0);
}
#[test]
fn exact_dollar_boundaries_do_not_round() {
// 193 credits = exactly $1.00.
let cost = KinoviGenerationCost::from_kinovi_credits(193);
assert_eq!(cost.kinovi_credits, 193);
assert_eq!(cost.usd_cents_rounded_up, 100);
let cost = KinoviGenerationCost::from_kinovi_credits(386);
assert_eq!(cost.usd_cents_rounded_up, 200);
}
#[test]
fn fractional_cents_round_up() {
// 194 credits = 100.518... cents -> 101.
let cost = KinoviGenerationCost::from_kinovi_credits(194);
assert_eq!(cost.usd_cents_rounded_up, 101);
// 1 credit = 0.518... cents -> 1.
let cost = KinoviGenerationCost::from_kinovi_credits(1);
assert_eq!(cost.usd_cents_rounded_up, 1);
}
}
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
@@ -288,13 +288,17 @@ mod tests {
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
fn usd_cents_batch_one_is_five() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
fn usd_cents_batch_four_is_twenty() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Pricing must match the inner module byte-for-byte (sanity that no
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
@@ -286,13 +286,17 @@ mod tests {
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
fn usd_cents_batch_one_is_five() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
fn usd_cents_batch_four_is_twenty() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Pricing must match the inner module exactly.
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
@@ -283,13 +283,17 @@ mod tests {
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
fn usd_cents_batch_one_is_five() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
fn usd_cents_batch_four_is_twenty() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Pricing must match the inner module exactly.
@@ -1,3 +1,2 @@
pub mod cost;
pub mod image;
pub mod video;
@@ -1,6 +1,6 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
@@ -63,7 +63,7 @@ pub enum KinoviHappyHorse1p0BatchCount {
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateHappyHorse1p0Request {
/// Estimate the credit cost for this generation request.
@@ -334,42 +334,52 @@ mod tests {
/// (USD cents are always rounded UP when fractional.)
#[test]
fn costs_720p_5s() {
// 33 credits/s × 5s = 165 credits; 16500/193 = 85.49 → rounds UP to 86¢
// 33 credits/s × 5s = 165 credits; 16500/243 = 67.90 → rounds UP to 68¢
let costs = r720(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 165);
assert_eq!(costs.usd_cents_rounded_up, 86);
assert_eq!(costs.usd_cents_rounded_up, 68);
assert_eq!(costs.usd_cents_rounded_down, 67);
assert!((costs.usd_cents_fractional - (16500.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_1080p_5s() {
// 66 credits/s × 5s = 330 credits; 33000/193 = 170.98 → 171¢
// 66 credits/s × 5s = 330 credits; 33000/243 = 135.80 → 136¢
let costs = r1080(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 330);
assert_eq!(costs.usd_cents_rounded_up, 171);
assert_eq!(costs.usd_cents_rounded_up, 136);
assert_eq!(costs.usd_cents_rounded_down, 135);
assert!((costs.usd_cents_fractional - (33000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_720p_15s() {
// 33 credits/s × 15s = 495 credits; 49500/193 = 256.48 → rounds UP to 257¢
// 33 credits/s × 15s = 495 credits; 49500/243 = 203.70 → rounds UP to 204¢
let costs = r720(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 495);
assert_eq!(costs.usd_cents_rounded_up, 257);
assert_eq!(costs.usd_cents_rounded_up, 204);
assert_eq!(costs.usd_cents_rounded_down, 203);
assert!((costs.usd_cents_fractional - (49500.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_1080p_15s() {
// 66 credits/s × 15s = 990 credits; 99000/193 = 512.95 → 513¢
// 66 credits/s × 15s = 990 credits; 99000/243 = 407.41 → 408¢
let costs = r1080(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 990);
assert_eq!(costs.usd_cents_rounded_up, 513);
assert_eq!(costs.usd_cents_rounded_up, 408);
assert_eq!(costs.usd_cents_rounded_down, 407);
assert!((costs.usd_cents_fractional - (99000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_batch_2_720p_5s() {
// 165 credits × 2 = 330 credits; 33000/193 = 170.98 → 171¢
// 165 credits × 2 = 330 credits; 33000/243 = 135.80 → 136¢
let costs = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).calculate_costs();
assert_eq!(costs.kinovi_credits, 330);
assert_eq!(costs.usd_cents_rounded_up, 171);
assert_eq!(costs.usd_cents_rounded_up, 136);
assert_eq!(costs.usd_cents_rounded_down, 135);
assert!((costs.usd_cents_fractional - (33000.0 / 243.0)).abs() < 1e-9);
}
/// The deprecated shims return the corresponding struct fields.
@@ -1,6 +1,6 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
@@ -72,7 +72,7 @@ pub enum KinoviSeedance2p0BatchCount {
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateSeedance2p0Request {
/// Calculate the cost of this generation request, in Kinovi credits and
@@ -80,7 +80,7 @@ impl GenerateSeedance2p0Request {
///
/// Attaching reference VIDEOS adds a per-output-second surcharge (see the
/// pricing table below). Reference images and audio are free.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
pub fn calculate_costs(&self) -> KinoviSeedanceGenerationCost {
let credits_per_second: u64 = match self.output_resolution {
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 15,
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 40,
@@ -108,15 +108,24 @@ impl GenerateSeedance2p0Request {
0
};
let per_video = u64::from(self.duration_seconds)
* (credits_per_second + video_reference_surcharge_per_second);
let batch_multiplier: u64 = match self.batch_count {
None | Some(KinoviSeedance2p0BatchCount::One) => 1,
Some(KinoviSeedance2p0BatchCount::Two) => 2,
Some(KinoviSeedance2p0BatchCount::Four) => 4,
};
KinoviGenerationCost::from_kinovi_credits(per_video * batch_multiplier)
let duration = u64::from(self.duration_seconds);
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,
maybe_video_reference_surcharge_credits,
)
}
fn has_video_reference(&self) -> bool {
@@ -128,14 +137,14 @@ impl GenerateSeedance2p0Request {
/// Estimate the credit cost for this generation request.
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_credits(&self) -> u32 {
self.calculate_costs().kinovi_credits as u32
self.calculate_costs().total_cost.kinovi_credits as u32
}
/// Estimate the USD cost in cents for this generation request.
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
self.calculate_costs().usd_cents_rounded_up
self.calculate_costs().total_cost.usd_cents_rounded_up
}
}
@@ -262,6 +271,171 @@ mod tests {
build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None)
}
// ── Comprehensive per-resolution coverage ──
//
// Seedance 2.0: 480p 15/s (+4/s video ref), 720p 40/s (+8/s), 1080p 90/s (+18/s).
// Every field and subfield is asserted: total_cost, base_cost,
// video_reference_surcharge_cost × (kinovi_credits, usd_cents_rounded_up,
// usd_cents_rounded_down, usd_cents_fractional). All cases use a 5 second
// duration with no batching.
mod comprehensive {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
mod resolution_480p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r480(5).calculate_costs();
// Base: 15 credits/s × 5s = 75 credits; 7500/243 = 30.8642.
assert_eq!(costs.base_cost.kinovi_credits, 75);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 30);
assert!((costs.base_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 75);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 30);
assert!((costs.total_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r480(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 15 credits/s × 5s = 75 credits; 7500/243 = 30.8642.
assert_eq!(costs.base_cost.kinovi_credits, 75);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 30);
assert!((costs.base_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 4 credits/s × 5s = 20 credits; 2000/243 = 8.2305.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 20);
assert_eq!(surcharge.usd_cents_rounded_up, 9);
assert_eq!(surcharge.usd_cents_rounded_down, 8);
assert!((surcharge.usd_cents_fractional - (2000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 75 + 20 = 95 credits; 9500/243 = 39.0947.
assert_eq!(costs.total_cost.kinovi_credits, 95);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 40);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 39);
assert!((costs.total_cost.usd_cents_fractional - (9500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
mod resolution_720p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r720(5).calculate_costs();
// Base: 40 credits/s × 5s = 200 credits; 20000/243 = 82.3045.
assert_eq!(costs.base_cost.kinovi_credits, 200);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 200);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 82);
assert!((costs.total_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r720(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 40 credits/s × 5s = 200 credits; 20000/243 = 82.3045.
assert_eq!(costs.base_cost.kinovi_credits, 200);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 8 credits/s × 5s = 40 credits; 4000/243 = 16.4609.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 40);
assert_eq!(surcharge.usd_cents_rounded_up, 17);
assert_eq!(surcharge.usd_cents_rounded_down, 16);
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 200 + 40 = 240 credits; 24000/243 = 98.7654.
assert_eq!(costs.total_cost.kinovi_credits, 240);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 99);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 98);
assert!((costs.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
mod resolution_1080p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r1080(5).calculate_costs();
// Base: 90 credits/s × 5s = 450 credits; 45000/243 = 185.1852.
assert_eq!(costs.base_cost.kinovi_credits, 450);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 185);
assert!((costs.base_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 450);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 185);
assert!((costs.total_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r1080(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 90 credits/s × 5s = 450 credits; 45000/243 = 185.1852.
assert_eq!(costs.base_cost.kinovi_credits, 450);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 185);
assert!((costs.base_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 18 credits/s × 5s = 90 credits; 9000/243 = 37.0370.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 90);
assert_eq!(surcharge.usd_cents_rounded_up, 38);
assert_eq!(surcharge.usd_cents_rounded_down, 37);
assert!((surcharge.usd_cents_fractional - (9000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 450 + 90 = 540 credits; 54000/243 = 222.2222.
assert_eq!(costs.total_cost.kinovi_credits, 540);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 223);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 222);
assert!((costs.total_cost.usd_cents_fractional - (54000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
}
// ── 480p credits (15 credits/sec) ──
mod credits_480p {
@@ -269,19 +443,19 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r480(3).calculate_costs().kinovi_credits, 45);
assert_eq!(r480(4).calculate_costs().kinovi_credits, 60);
assert_eq!(r480(5).calculate_costs().kinovi_credits, 75);
assert_eq!(r480(6).calculate_costs().kinovi_credits, 90);
assert_eq!(r480(7).calculate_costs().kinovi_credits, 105);
assert_eq!(r480(8).calculate_costs().kinovi_credits, 120);
assert_eq!(r480(9).calculate_costs().kinovi_credits, 135);
assert_eq!(r480(10).calculate_costs().kinovi_credits, 150);
assert_eq!(r480(11).calculate_costs().kinovi_credits, 165);
assert_eq!(r480(12).calculate_costs().kinovi_credits, 180);
assert_eq!(r480(13).calculate_costs().kinovi_credits, 195);
assert_eq!(r480(14).calculate_costs().kinovi_credits, 210);
assert_eq!(r480(15).calculate_costs().kinovi_credits, 225);
assert_eq!(r480(3).calculate_costs().total_cost.kinovi_credits, 45);
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 60);
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 75);
assert_eq!(r480(6).calculate_costs().total_cost.kinovi_credits, 90);
assert_eq!(r480(7).calculate_costs().total_cost.kinovi_credits, 105);
assert_eq!(r480(8).calculate_costs().total_cost.kinovi_credits, 120);
assert_eq!(r480(9).calculate_costs().total_cost.kinovi_credits, 135);
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 150);
assert_eq!(r480(11).calculate_costs().total_cost.kinovi_credits, 165);
assert_eq!(r480(12).calculate_costs().total_cost.kinovi_credits, 180);
assert_eq!(r480(13).calculate_costs().total_cost.kinovi_credits, 195);
assert_eq!(r480(14).calculate_costs().total_cost.kinovi_credits, 210);
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 225);
}
}
@@ -292,25 +466,25 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r720(3).calculate_costs().kinovi_credits, 120);
assert_eq!(r720(4).calculate_costs().kinovi_credits, 160);
assert_eq!(r720(5).calculate_costs().kinovi_credits, 200);
assert_eq!(r720(6).calculate_costs().kinovi_credits, 240);
assert_eq!(r720(7).calculate_costs().kinovi_credits, 280);
assert_eq!(r720(8).calculate_costs().kinovi_credits, 320);
assert_eq!(r720(9).calculate_costs().kinovi_credits, 360);
assert_eq!(r720(10).calculate_costs().kinovi_credits, 400);
assert_eq!(r720(11).calculate_costs().kinovi_credits, 440);
assert_eq!(r720(12).calculate_costs().kinovi_credits, 480);
assert_eq!(r720(13).calculate_costs().kinovi_credits, 520);
assert_eq!(r720(14).calculate_costs().kinovi_credits, 560);
assert_eq!(r720(15).calculate_costs().kinovi_credits, 600);
assert_eq!(r720(3).calculate_costs().total_cost.kinovi_credits, 120);
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 160);
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 200);
assert_eq!(r720(6).calculate_costs().total_cost.kinovi_credits, 240);
assert_eq!(r720(7).calculate_costs().total_cost.kinovi_credits, 280);
assert_eq!(r720(8).calculate_costs().total_cost.kinovi_credits, 320);
assert_eq!(r720(9).calculate_costs().total_cost.kinovi_credits, 360);
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 400);
assert_eq!(r720(11).calculate_costs().total_cost.kinovi_credits, 440);
assert_eq!(r720(12).calculate_costs().total_cost.kinovi_credits, 480);
assert_eq!(r720(13).calculate_costs().total_cost.kinovi_credits, 520);
assert_eq!(r720(14).calculate_costs().total_cost.kinovi_credits, 560);
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 600);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).calculate_costs().kinovi_credits;
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).calculate_costs().total_cost.kinovi_credits;
assert_eq!(default, explicit);
}
}
@@ -322,19 +496,19 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r1080(3).calculate_costs().kinovi_credits, 270);
assert_eq!(r1080(4).calculate_costs().kinovi_credits, 360);
assert_eq!(r1080(5).calculate_costs().kinovi_credits, 450);
assert_eq!(r1080(6).calculate_costs().kinovi_credits, 540);
assert_eq!(r1080(7).calculate_costs().kinovi_credits, 630);
assert_eq!(r1080(8).calculate_costs().kinovi_credits, 720);
assert_eq!(r1080(9).calculate_costs().kinovi_credits, 810);
assert_eq!(r1080(10).calculate_costs().kinovi_credits, 900);
assert_eq!(r1080(11).calculate_costs().kinovi_credits, 990);
assert_eq!(r1080(12).calculate_costs().kinovi_credits, 1080);
assert_eq!(r1080(13).calculate_costs().kinovi_credits, 1170);
assert_eq!(r1080(14).calculate_costs().kinovi_credits, 1260);
assert_eq!(r1080(15).calculate_costs().kinovi_credits, 1350);
assert_eq!(r1080(3).calculate_costs().total_cost.kinovi_credits, 270);
assert_eq!(r1080(4).calculate_costs().total_cost.kinovi_credits, 360);
assert_eq!(r1080(5).calculate_costs().total_cost.kinovi_credits, 450);
assert_eq!(r1080(6).calculate_costs().total_cost.kinovi_credits, 540);
assert_eq!(r1080(7).calculate_costs().total_cost.kinovi_credits, 630);
assert_eq!(r1080(8).calculate_costs().total_cost.kinovi_credits, 720);
assert_eq!(r1080(9).calculate_costs().total_cost.kinovi_credits, 810);
assert_eq!(r1080(10).calculate_costs().total_cost.kinovi_credits, 900);
assert_eq!(r1080(11).calculate_costs().total_cost.kinovi_credits, 990);
assert_eq!(r1080(12).calculate_costs().total_cost.kinovi_credits, 1080);
assert_eq!(r1080(13).calculate_costs().total_cost.kinovi_credits, 1170);
assert_eq!(r1080(14).calculate_costs().total_cost.kinovi_credits, 1260);
assert_eq!(r1080(15).calculate_costs().total_cost.kinovi_credits, 1350);
}
}
@@ -345,39 +519,39 @@ mod tests {
#[test]
fn batch_1_is_base() {
let base = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0BatchCount::One)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0BatchCount::One)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(base, explicit);
}
#[test]
fn batch_2_doubles() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_1080p() {
let base = r1080(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().kinovi_credits;
let base = r1080(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_480p() {
let base = r480(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().kinovi_credits;
let base = r480(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
@@ -390,9 +564,9 @@ mod tests {
#[test]
fn cost_scales_with_duration() {
let c3 = r720(3).calculate_costs().kinovi_credits;
let c10 = r720(10).calculate_costs().kinovi_credits;
let c15 = r720(15).calculate_costs().kinovi_credits;
let c3 = r720(3).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!(c3 < c10);
assert!(c10 < c15);
}
@@ -400,9 +574,9 @@ mod tests {
#[test]
fn resolution_ordering() {
for dur in 3..=15u8 {
let c480 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None).calculate_costs().kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().kinovi_credits;
let c1080 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None).calculate_costs().kinovi_credits;
let c480 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None).calculate_costs().total_cost.kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().total_cost.kinovi_credits;
let c1080 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None).calculate_costs().total_cost.kinovi_credits;
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
assert!(c720 < c1080, "720p should be cheaper than 1080p at {}s", dur);
}
@@ -418,50 +592,74 @@ mod tests {
/// (USD cents are always rounded UP when fractional.)
#[test]
fn costs_480p_5s() {
// 15 credits/s × 5s = 75 credits; 7500/193 = 38.86 → 39¢
// 15 credits/s × 5s = 75 credits; 7500/243 = 30.86 → 31¢
let costs = r480(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 75);
assert_eq!(costs.usd_cents_rounded_up, 39);
assert_eq!(costs.total_cost.kinovi_credits, 75);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 30);
assert!((costs.total_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 75);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_5s() {
// 40 credits/s × 5s = 200 credits; 20000/193 = 103.63 → 104¢
// 40 credits/s × 5s = 200 credits; 20000/243 = 82.30 → 83¢
let costs = r720(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 200);
assert_eq!(costs.usd_cents_rounded_up, 104);
assert_eq!(costs.total_cost.kinovi_credits, 200);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 82);
assert!((costs.total_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 200);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_1080p_5s() {
// 90 credits/s × 5s = 450 credits; 45000/193 = 233.16 → rounds UP to 234¢
// 90 credits/s × 5s = 450 credits; 45000/243 = 185.19 → rounds UP to 186¢
let costs = r1080(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 450);
assert_eq!(costs.usd_cents_rounded_up, 234);
assert_eq!(costs.total_cost.kinovi_credits, 450);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 185);
assert!((costs.total_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 450);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_15s() {
// 40 credits/s × 15s = 600 credits; 60000/193 = 310.88 → 311¢
// 40 credits/s × 15s = 600 credits; 60000/243 = 246.91 → 247¢
let costs = r720(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 600);
assert_eq!(costs.usd_cents_rounded_up, 311);
assert_eq!(costs.total_cost.kinovi_credits, 600);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 247);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 246);
assert!((costs.total_cost.usd_cents_fractional - (60000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 600);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_1080p_15s() {
// 90 credits/s × 15s = 1350 credits; 135000/193 = 699.48 → rounds UP to 700¢
// 90 credits/s × 15s = 1350 credits; 135000/243 = 555.56 → rounds UP to 556¢
let costs = r1080(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 1350);
assert_eq!(costs.usd_cents_rounded_up, 700);
assert_eq!(costs.total_cost.kinovi_credits, 1350);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 556);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 555);
assert!((costs.total_cost.usd_cents_fractional - (135000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 1350);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_batch_4_720p_5s() {
// 200 credits × 4 = 800 credits; 80000/193 = 414.51 → 415¢
// 200 credits × 4 = 800 credits; 80000/243 = 329.22 → 330¢
let costs = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs();
assert_eq!(costs.kinovi_credits, 800);
assert_eq!(costs.usd_cents_rounded_up, 415);
assert_eq!(costs.total_cost.kinovi_credits, 800);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 330);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 329);
assert!((costs.total_cost.usd_cents_fractional - (80000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 800);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// The deprecated shims return the corresponding struct fields.
@@ -470,8 +668,8 @@ mod tests {
fn deprecated_methods_delegate() {
let request = r720(5);
let costs = request.calculate_costs();
assert_eq!(u64::from(request.estimate_credits()), costs.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.usd_cents_rounded_up);
assert_eq!(u64::from(request.estimate_credits()), costs.total_cost.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.total_cost.usd_cents_rounded_up);
}
}
@@ -489,47 +687,74 @@ mod tests {
}
/// The full base + surcharge table from Kinovi's pricing page
/// ("With Video Uploads", 10 sec video ref).
/// ("With Video Uploads", 10 sec video ref). Asserts every field:
/// base, surcharge, and the derived total.
#[test]
fn kinovi_pricing_table_with_video_reference() {
let cases: &[(fn(u8) -> GenerateSeedance2p0Request, u8, u64)] = &[
// (request, duration, base credits, surcharge credits)
let cases: &[(fn(u8) -> GenerateSeedance2p0Request, u8, u64, u64)] = &[
// 480p: base + 4/s
(r480, 4, 60 + 16),
(r480, 5, 75 + 20),
(r480, 10, 150 + 40),
(r480, 15, 225 + 60),
(r480, 4, 60, 16),
(r480, 5, 75, 20),
(r480, 10, 150, 40),
(r480, 15, 225, 60),
// 720p: base + 8/s
(r720, 4, 160 + 32),
(r720, 5, 200 + 40),
(r720, 10, 400 + 80),
(r720, 15, 600 + 120),
(r720, 4, 160, 32),
(r720, 5, 200, 40),
(r720, 10, 400, 80),
(r720, 15, 600, 120),
// 1080p: base + 18/s
(r1080, 4, 360 + 72),
(r1080, 5, 450 + 90),
(r1080, 10, 900 + 180),
(r1080, 15, 1350 + 270),
(r1080, 4, 360, 72),
(r1080, 5, 450, 90),
(r1080, 10, 900, 180),
(r1080, 15, 1350, 270),
];
for (make, duration, expected_credits) in cases {
for (make, duration, base, surcharge) in cases {
let costs = with_video_ref(make(*duration)).calculate_costs();
assert_eq!(costs.kinovi_credits, *expected_credits,
"duration {duration}s should cost {expected_credits} credits with a video reference");
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");
}
}
#[test]
fn surcharge_includes_usd_cents() {
// 720p 5s + video ref = 240 credits; 24000/193 = 124.35 → 125¢
// 720p 5s + video ref = 200 + 40 = 240 credits; 24000/243 = 98.77 → 99¢
let costs = with_video_ref(r720(5)).calculate_costs();
assert_eq!(costs.kinovi_credits, 240);
assert_eq!(costs.usd_cents_rounded_up, 125);
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, 99);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 98);
assert!((costs.total_cost.usd_cents_fractional - (24000.0 / 243.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/243 = 82.30.
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < 1e-9);
// Surcharge: 40 credits; 4000/243 = 16.46.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 40);
assert_eq!(surcharge.usd_cents_rounded_up, 17);
assert_eq!(surcharge.usd_cents_rounded_down, 16);
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn empty_video_reference_list_has_no_surcharge() {
let mut request = r720(5);
request.reference_video_urls = Some(vec![]);
assert_eq!(request.calculate_costs().kinovi_credits, 200);
let costs = request.calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 200);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// Surcharge is flat per generation regardless of how many reference
@@ -541,7 +766,7 @@ mod tests {
"https://example.com/a.mp4".to_string(),
"https://example.com/b.mp4".to_string(),
]);
assert_eq!(request.calculate_costs().kinovi_credits, 240);
assert_eq!(request.calculate_costs().total_cost.kinovi_credits, 240);
}
/// The surcharge applies per generated video, so batches multiply it.
@@ -549,7 +774,10 @@ mod tests {
fn batch_multiplies_surcharge() {
let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)));
// (200 base + 40 surcharge) × 2 = 480 credits
assert_eq!(request.calculate_costs().kinovi_credits, 480);
let costs = request.calculate_costs();
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);
}
}
@@ -557,7 +785,7 @@ mod tests {
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().kinovi_credits;
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let ratios = [
KinoviSeedance2p0AspectRatio::Landscape16x9,
@@ -584,7 +812,7 @@ mod tests {
use_face_blur_hack: None,
};
assert_eq!(
req.calculate_costs().kinovi_credits, baseline,
req.calculate_costs().total_cost.kinovi_credits, baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
@@ -1,6 +1,6 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
@@ -70,7 +70,7 @@ pub enum KinoviSeedance2p0FastBatchCount {
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateSeedance2p0FastRequest {
/// Calculate the cost of this generation request, in Kinovi credits and
@@ -78,7 +78,7 @@ impl GenerateSeedance2p0FastRequest {
///
/// Attaching reference VIDEOS adds a per-output-second surcharge (see the
/// pricing table below). Reference images and audio are free.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
pub fn calculate_costs(&self) -> KinoviSeedanceGenerationCost {
let credits_per_second: u64 = match self.output_resolution {
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP) => 14,
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP) | None => 28,
@@ -103,15 +103,24 @@ impl GenerateSeedance2p0FastRequest {
0
};
let per_video = u64::from(self.duration_seconds)
* (credits_per_second + video_reference_surcharge_per_second);
let batch_multiplier: u64 = match self.batch_count {
None | Some(KinoviSeedance2p0FastBatchCount::One) => 1,
Some(KinoviSeedance2p0FastBatchCount::Two) => 2,
Some(KinoviSeedance2p0FastBatchCount::Four) => 4,
};
KinoviGenerationCost::from_kinovi_credits(per_video * batch_multiplier)
let duration = u64::from(self.duration_seconds);
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,
maybe_video_reference_surcharge_credits,
)
}
fn has_video_reference(&self) -> bool {
@@ -123,14 +132,14 @@ impl GenerateSeedance2p0FastRequest {
/// Estimate the credit cost for this generation request.
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_credits(&self) -> u32 {
self.calculate_costs().kinovi_credits as u32
self.calculate_costs().total_cost.kinovi_credits as u32
}
/// Estimate the USD cost in cents for this generation request.
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
self.calculate_costs().usd_cents_rounded_up
self.calculate_costs().total_cost.usd_cents_rounded_up
}
}
@@ -252,6 +261,121 @@ mod tests {
build_request(dur, None, None)
}
// ── Comprehensive per-resolution coverage ──
//
// Seedance 2.0 Fast: 480p 14/s (+4/s video ref), 720p 28/s (+6/s). No 1080p.
// Every field and subfield is asserted: total_cost, base_cost,
// video_reference_surcharge_cost × (kinovi_credits, usd_cents_rounded_up,
// usd_cents_rounded_down, usd_cents_fractional). All cases use a 5 second
// duration with no batching.
mod comprehensive {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
mod resolution_480p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r480(5).calculate_costs();
// Base: 14 credits/s × 5s = 70 credits; 7000/243 = 28.8066.
assert_eq!(costs.base_cost.kinovi_credits, 70);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 28);
assert!((costs.base_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 28);
assert!((costs.total_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r480(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 14 credits/s × 5s = 70 credits; 7000/243 = 28.8066.
assert_eq!(costs.base_cost.kinovi_credits, 70);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 28);
assert!((costs.base_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 4 credits/s × 5s = 20 credits; 2000/243 = 8.2305.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 20);
assert_eq!(surcharge.usd_cents_rounded_up, 9);
assert_eq!(surcharge.usd_cents_rounded_down, 8);
assert!((surcharge.usd_cents_fractional - (2000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 70 + 20 = 90 credits; 9000/243 = 37.0370.
assert_eq!(costs.total_cost.kinovi_credits, 90);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 38);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 37);
assert!((costs.total_cost.usd_cents_fractional - (9000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
mod resolution_720p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r720(5).calculate_costs();
// Base: 28 credits/s × 5s = 140 credits; 14000/243 = 57.6132.
assert_eq!(costs.base_cost.kinovi_credits, 140);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 140);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 57);
assert!((costs.total_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r720(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 28 credits/s × 5s = 140 credits; 14000/243 = 57.6132.
assert_eq!(costs.base_cost.kinovi_credits, 140);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 6 credits/s × 5s = 30 credits; 3000/243 = 12.3457.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 30);
assert_eq!(surcharge.usd_cents_rounded_up, 13);
assert_eq!(surcharge.usd_cents_rounded_down, 12);
assert!((surcharge.usd_cents_fractional - (3000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 140 + 30 = 170 credits; 17000/243 = 69.9588.
assert_eq!(costs.total_cost.kinovi_credits, 170);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 69);
assert!((costs.total_cost.usd_cents_fractional - (17000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
}
// ── 480p credits (14 credits/sec) ──
mod credits_480p {
@@ -259,19 +383,19 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r480(3).calculate_costs().kinovi_credits, 42);
assert_eq!(r480(4).calculate_costs().kinovi_credits, 56);
assert_eq!(r480(5).calculate_costs().kinovi_credits, 70);
assert_eq!(r480(6).calculate_costs().kinovi_credits, 84);
assert_eq!(r480(7).calculate_costs().kinovi_credits, 98);
assert_eq!(r480(8).calculate_costs().kinovi_credits, 112);
assert_eq!(r480(9).calculate_costs().kinovi_credits, 126);
assert_eq!(r480(10).calculate_costs().kinovi_credits, 140);
assert_eq!(r480(11).calculate_costs().kinovi_credits, 154);
assert_eq!(r480(12).calculate_costs().kinovi_credits, 168);
assert_eq!(r480(13).calculate_costs().kinovi_credits, 182);
assert_eq!(r480(14).calculate_costs().kinovi_credits, 196);
assert_eq!(r480(15).calculate_costs().kinovi_credits, 210);
assert_eq!(r480(3).calculate_costs().total_cost.kinovi_credits, 42);
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 56);
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 70);
assert_eq!(r480(6).calculate_costs().total_cost.kinovi_credits, 84);
assert_eq!(r480(7).calculate_costs().total_cost.kinovi_credits, 98);
assert_eq!(r480(8).calculate_costs().total_cost.kinovi_credits, 112);
assert_eq!(r480(9).calculate_costs().total_cost.kinovi_credits, 126);
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 140);
assert_eq!(r480(11).calculate_costs().total_cost.kinovi_credits, 154);
assert_eq!(r480(12).calculate_costs().total_cost.kinovi_credits, 168);
assert_eq!(r480(13).calculate_costs().total_cost.kinovi_credits, 182);
assert_eq!(r480(14).calculate_costs().total_cost.kinovi_credits, 196);
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 210);
}
}
@@ -282,25 +406,25 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r720(3).calculate_costs().kinovi_credits, 84);
assert_eq!(r720(4).calculate_costs().kinovi_credits, 112);
assert_eq!(r720(5).calculate_costs().kinovi_credits, 140);
assert_eq!(r720(6).calculate_costs().kinovi_credits, 168);
assert_eq!(r720(7).calculate_costs().kinovi_credits, 196);
assert_eq!(r720(8).calculate_costs().kinovi_credits, 224);
assert_eq!(r720(9).calculate_costs().kinovi_credits, 252);
assert_eq!(r720(10).calculate_costs().kinovi_credits, 280);
assert_eq!(r720(11).calculate_costs().kinovi_credits, 308);
assert_eq!(r720(12).calculate_costs().kinovi_credits, 336);
assert_eq!(r720(13).calculate_costs().kinovi_credits, 364);
assert_eq!(r720(14).calculate_costs().kinovi_credits, 392);
assert_eq!(r720(15).calculate_costs().kinovi_credits, 420);
assert_eq!(r720(3).calculate_costs().total_cost.kinovi_credits, 84);
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 112);
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 140);
assert_eq!(r720(6).calculate_costs().total_cost.kinovi_credits, 168);
assert_eq!(r720(7).calculate_costs().total_cost.kinovi_credits, 196);
assert_eq!(r720(8).calculate_costs().total_cost.kinovi_credits, 224);
assert_eq!(r720(9).calculate_costs().total_cost.kinovi_credits, 252);
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 280);
assert_eq!(r720(11).calculate_costs().total_cost.kinovi_credits, 308);
assert_eq!(r720(12).calculate_costs().total_cost.kinovi_credits, 336);
assert_eq!(r720(13).calculate_costs().total_cost.kinovi_credits, 364);
assert_eq!(r720(14).calculate_costs().total_cost.kinovi_credits, 392);
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 420);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP), None).calculate_costs().kinovi_credits;
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP), None).calculate_costs().total_cost.kinovi_credits;
assert_eq!(default, explicit);
}
}
@@ -312,30 +436,30 @@ mod tests {
#[test]
fn batch_1_is_base() {
let base = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::One)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::One)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(base, explicit);
}
#[test]
fn batch_2_doubles() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_480p() {
let base = r480(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().kinovi_credits;
let base = r480(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
@@ -348,9 +472,9 @@ mod tests {
#[test]
fn cost_scales_with_duration() {
let c3 = r720(3).calculate_costs().kinovi_credits;
let c10 = r720(10).calculate_costs().kinovi_credits;
let c15 = r720(15).calculate_costs().kinovi_credits;
let c3 = r720(3).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!(c3 < c10);
assert!(c10 < c15);
}
@@ -358,8 +482,8 @@ mod tests {
#[test]
fn resolution_ordering() {
for dur in 3..=15u8 {
let c480 = build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None).calculate_costs().kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().kinovi_credits;
let c480 = build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None).calculate_costs().total_cost.kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().total_cost.kinovi_credits;
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
}
}
@@ -374,42 +498,62 @@ mod tests {
/// (USD cents are always rounded UP when fractional.)
#[test]
fn costs_480p_5s() {
// 14 credits/s × 5s = 70 credits; 7000/193 = 36.27 → rounds UP to 37¢
// 14 credits/s × 5s = 70 credits; 7000/243 = 28.81 → rounds UP to 29¢
let costs = r480(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 70);
assert_eq!(costs.usd_cents_rounded_up, 37);
assert_eq!(costs.total_cost.kinovi_credits, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 28);
assert!((costs.total_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 70);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_5s() {
// 28 credits/s × 5s = 140 credits; 14000/193 = 72.54 → 73¢
// 28 credits/s × 5s = 140 credits; 14000/243 = 57.61 → 58¢
let costs = r720(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 140);
assert_eq!(costs.usd_cents_rounded_up, 73);
assert_eq!(costs.total_cost.kinovi_credits, 140);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 57);
assert!((costs.total_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 140);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_15s() {
// 28 credits/s × 15s = 420 credits; 42000/193 = 217.62 → 218¢
// 28 credits/s × 15s = 420 credits; 42000/243 = 172.84 → 173¢
let costs = r720(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 420);
assert_eq!(costs.usd_cents_rounded_up, 218);
assert_eq!(costs.total_cost.kinovi_credits, 420);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 173);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 172);
assert!((costs.total_cost.usd_cents_fractional - (42000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 420);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_480p_15s() {
// 14 credits/s × 15s = 210 credits; 21000/193 = 108.81 → 109¢
// 14 credits/s × 15s = 210 credits; 21000/243 = 86.42 → 87¢
let costs = r480(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 210);
assert_eq!(costs.usd_cents_rounded_up, 109);
assert_eq!(costs.total_cost.kinovi_credits, 210);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 87);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 86);
assert!((costs.total_cost.usd_cents_fractional - (21000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 210);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_batch_2_720p_5s() {
// 140 credits × 2 = 280 credits; 28000/193 = 145.07 → 146¢
// 140 credits × 2 = 280 credits; 28000/243 = 115.23 → 116¢
let costs = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs();
assert_eq!(costs.kinovi_credits, 280);
assert_eq!(costs.usd_cents_rounded_up, 146);
assert_eq!(costs.total_cost.kinovi_credits, 280);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 116);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 115);
assert!((costs.total_cost.usd_cents_fractional - (28000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 280);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// The deprecated shims return the corresponding struct fields.
@@ -418,8 +562,8 @@ mod tests {
fn deprecated_methods_delegate() {
let request = r720(5);
let costs = request.calculate_costs();
assert_eq!(u64::from(request.estimate_credits()), costs.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.usd_cents_rounded_up);
assert_eq!(u64::from(request.estimate_credits()), costs.total_cost.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.total_cost.usd_cents_rounded_up);
}
}
@@ -438,42 +582,69 @@ mod tests {
}
/// The full base + surcharge table from Kinovi's pricing page
/// ("Seedance 2.0 Fast With Video Ref", 10 sec video ref).
/// ("Seedance 2.0 Fast With Video Ref", 10 sec video ref). Asserts
/// every field: base, surcharge, and the derived total.
#[test]
fn kinovi_pricing_table_with_video_reference() {
let cases: &[(fn(u8) -> GenerateSeedance2p0FastRequest, u8, u64)] = &[
// (request, duration, base credits, surcharge credits)
let cases: &[(fn(u8) -> GenerateSeedance2p0FastRequest, u8, u64, u64)] = &[
// 480p: base + 4/s
(r480, 4, 56 + 16),
(r480, 5, 70 + 20),
(r480, 10, 140 + 40),
(r480, 15, 210 + 60),
(r480, 4, 56, 16),
(r480, 5, 70, 20),
(r480, 10, 140, 40),
(r480, 15, 210, 60),
// 720p: base + 6/s
(r720, 4, 112 + 24),
(r720, 5, 140 + 30),
(r720, 10, 280 + 60),
(r720, 15, 420 + 90),
(r720, 4, 112, 24),
(r720, 5, 140, 30),
(r720, 10, 280, 60),
(r720, 15, 420, 90),
];
for (make, duration, expected_credits) in cases {
for (make, duration, base, surcharge) in cases {
let costs = with_video_ref(make(*duration)).calculate_costs();
assert_eq!(costs.kinovi_credits, *expected_credits,
"duration {duration}s should cost {expected_credits} credits with a video reference");
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");
}
}
#[test]
fn surcharge_includes_usd_cents() {
// 720p 5s + video ref = 170 credits; 17000/193 = 88.08 → 89¢
// 720p 5s + video ref = 140 + 30 = 170 credits; 17000/243 = 69.96 → 70¢
let costs = with_video_ref(r720(5)).calculate_costs();
assert_eq!(costs.kinovi_credits, 170);
assert_eq!(costs.usd_cents_rounded_up, 89);
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, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 69);
assert!((costs.total_cost.usd_cents_fractional - (17000.0 / 243.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/243 = 57.61.
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < 1e-9);
// Surcharge: 30 credits; 3000/243 = 12.35.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 30);
assert_eq!(surcharge.usd_cents_rounded_up, 13);
assert_eq!(surcharge.usd_cents_rounded_down, 12);
assert!((surcharge.usd_cents_fractional - (3000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn empty_video_reference_list_has_no_surcharge() {
let mut request = r720(5);
request.reference_video_urls = Some(vec![]);
assert_eq!(request.calculate_costs().kinovi_credits, 140);
let costs = request.calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 140);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// Surcharge is flat per generation regardless of how many reference
@@ -485,7 +656,7 @@ mod tests {
"https://example.com/a.mp4".to_string(),
"https://example.com/b.mp4".to_string(),
]);
assert_eq!(request.calculate_costs().kinovi_credits, 170);
assert_eq!(request.calculate_costs().total_cost.kinovi_credits, 170);
}
/// The surcharge applies per generated video, so batches multiply it.
@@ -493,7 +664,10 @@ mod tests {
fn batch_multiplies_surcharge() {
let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)));
// (140 base + 30 surcharge) × 2 = 340 credits
assert_eq!(request.calculate_costs().kinovi_credits, 340);
let costs = request.calculate_costs();
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);
}
}
@@ -501,7 +675,7 @@ mod tests {
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().kinovi_credits;
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let ratios = [
KinoviSeedance2p0FastAspectRatio::Landscape16x9,
@@ -528,7 +702,7 @@ mod tests {
use_face_blur_hack: None,
};
assert_eq!(
req.calculate_costs().kinovi_credits, baseline,
req.calculate_costs().total_cost.kinovi_credits, baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
@@ -2,6 +2,7 @@
pub (crate) mod test_utils;
// User API
pub mod cost;
pub mod creds;
pub mod error;
pub mod generate;
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
@@ -22,7 +22,7 @@ const CREDITS_PER_MIDJOURNEY_TASK: u32 = 12;
/// Newer Kinovi credit-package rate. Used to convert credits to USD for the
/// Midjourney image generation flow.
///
/// 22,000 credits / $114 = 192.98 ≈ 193 credits per dollar.
/// 500,000 credits / $2,159.09 = 231.58 ≈ 243 credits per dollar (rounded down).
/// Kinovi only currently exposes a single Midjourney resolution preset.
const MIDJOURNEY_RESOLUTION: &str = "1k";
@@ -416,20 +416,26 @@ mod tests {
#[test]
fn usd_cents_batch_one() {
// 12 / 193 × 100 = 6.21¢ → 6¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
// 12/243 × 100 = 4.94¢ → 5¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_two() {
// 24 / 193 × 100 = 12.43¢ → 12¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_up, 13); // 2400/193 = 12.44 -> rounds UP
// 24/243 × 100 = 9.88¢ → 10¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_up, 10); // 2400/243 = 9.88 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_down, 9);
assert!((make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_fractional - (2400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four() {
// 48 / 193 × 100 = 24.87¢ → 25¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
// 48/243 × 100 = 19.75¢ → 20¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Batch 4 should be exactly 4× the credit cost of batch 1, and the USD
@@ -141,7 +141,7 @@ impl KinoviGenerateVideoRequest {
/// Credits per dollar for billing conversion.
///
/// Legacy 720p pricing uses the original Kinovi credit package rates.
/// All other model/resolution combos use the newer rate: 22,000 credits / $114.
/// All other model/resolution combos use the newer rate: 525,000 credits / $2,159.0909.
fn credits_per_dollar(&self) -> f64 {
match (self.model_type, self.output_resolution) {
// Legacy: Seedance 2.0 Pro @ 720p — 25,000 credits for $99.99
@@ -152,8 +152,8 @@ impl KinoviGenerateVideoRequest {
(KinoviModelType::Seedance2Fast, None)
| (KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::SevenTwentyP)) => 220.0,
// New pricing: 22,000 credits for $114 (~192.98 credits/$1)
_ => 193.0,
// New pricing: 525,000 credits for $2,159.0909 (~243.16 credits/$1)
_ => 243.0,
}
}
@@ -669,7 +669,7 @@ mod tests {
#[test]
fn usd_cents_legacy_fast_720p() {
// 220 credits/$1: 140 credits = 63.6 → 64¢
// 220 credits/$1: 140 credits = 57.61 → 58¢
assert_eq!(fast(5, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 64);
assert_eq!(fast(10, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 127);
}
@@ -678,24 +678,24 @@ mod tests {
#[test]
fn usd_cents_new_pro_480p() {
// 192.98 credits/$1: 75 credits = 38.86 → 39¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 39);
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 78);
// 243 credits/$1: 75 credits = 30.86 → 31¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 31);
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 62);
}
#[test]
fn usd_cents_new_pro_1080p() {
// 192.98 credits/$1: 450 credits = 233.18 → 233¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 233);
// 900 credits = 466.36 → 466¢
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 466);
// 243 credits/$1: 450 credits = 185.19 → 185¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 185);
// 900 credits = 370.37 → 370¢
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 370);
}
#[test]
fn usd_cents_new_fast_480p() {
// 192.98 credits/$1: 50 credits = 25.91 → 26¢
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 26);
assert_eq!(fast_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 52);
// 243 credits/$1: 50 credits = 20.58 → 21¢
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 21);
assert_eq!(fast_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 41);
}
// ── credits_per_dollar: verify the rates directly ──
@@ -711,11 +711,11 @@ mod tests {
#[test]
fn credits_per_dollar_new_rate() {
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 243.0);
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 243.0);
// Fast 1080p (not officially supported) also uses new rate
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 243.0);
}
}
@@ -150,7 +150,7 @@ impl WorkflowRunTaskRequest {
/// Credits per dollar for billing conversion.
///
/// Legacy 720p pricing uses the original Kinovi credit package rates.
/// All other model/resolution combos use the newer rate: 22,000 credits / $114.
/// All other model/resolution combos use the newer rate: 525,000 credits / $2,159.0909.
fn credits_per_dollar(&self) -> f64 {
match (self.model_type, self.output_resolution) {
// Legacy: Seedance 2.0 Pro @ 720p — 25,000 credits for $99.99
@@ -161,8 +161,8 @@ impl WorkflowRunTaskRequest {
(KinoviModelTypeRaw::Seedance2Fast, None)
| (KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 220.0,
// New pricing: 22,000 credits for $114 (~192.98 credits/$1)
_ => 193.0,
// New pricing: 525,000 credits for $2,159.0909 (~243.16 credits/$1)
_ => 243.0,
}
}
@@ -661,20 +661,20 @@ mod tests {
#[test]
fn usd_cents_new_pro_480p() {
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 39);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 78);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 31);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 62);
}
#[test]
fn usd_cents_new_pro_1080p() {
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 233);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 466);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 185);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 370);
}
#[test]
fn usd_cents_new_fast_480p() {
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 26);
assert_eq!(fast_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 52);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 21);
assert_eq!(fast_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 41);
}
#[test]
@@ -687,10 +687,10 @@ mod tests {
#[test]
fn credits_per_dollar_new_rate() {
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
}
// ── Happy Horse 1.0 ──
@@ -724,17 +724,17 @@ mod tests {
#[test]
fn happy_horse_uses_new_pricing_rate() {
// All Happy Horse resolutions use the new 193 credits/$1 rate (not legacy)
assert_eq!(happy_horse(5, KinoviBatchCountRaw::One).credits_per_dollar(), 193.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).credits_per_dollar(), 193.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
// All Happy Horse resolutions use the new 243 credits/$1 rate (not legacy)
assert_eq!(happy_horse(5, KinoviBatchCountRaw::One).credits_per_dollar(), 243.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).credits_per_dollar(), 243.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
}
#[test]
fn happy_horse_usd_cents_1080p() {
// 193 credits/$1: 360 credits (4s×90) = 186.53 → 187¢
assert_eq!(happy_horse_res(4, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 187);
// 243 credits/$1: 360 credits (4s×90) = 148.15 → 148¢
assert_eq!(happy_horse_res(4, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 148);
}
}