Compare commits

...
Author SHA1 Message Date
Brandon Thomas 1a09b4dd95 pricing 2026-06-25 07:38:42 -04:00
Brandon Thomas d400571ca0 wire shape changed 2026-06-24 20:16:32 -04:00
Brandon Thomas 78f4c5079c seedance mini client 2026-06-24 19:37:59 -04:00
8 changed files with 1301 additions and 86 deletions
@@ -0,0 +1,85 @@
use crate::cost::constants::CREDITS_PER_DOLLAR;
/// The cost of a Kinovi generation that bills FRACTIONAL credits, in both
/// native credits and USD cents.
///
/// This is the fractional-credit counterpart of
/// [`crate::cost::kinovi_generation_cost::KinoviGenerationCost`] (which uses
/// `u64`). Some Kinovi models bill non-integer credits — e.g. Seedance 2.0
/// Mini at 480p bills 7.5 credits/sec, landing on half-credits like 37.5 at
/// odd durations. Any model with a fractional credit rate uses this type;
/// whole-credit models use the cheaper `u64` `KinoviGenerationCost`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinoviFractionalGenerationCost {
/// The cost of the generation in Kinovi credits. May be fractional (e.g.
/// 37.5 for Seedance 2.0 Mini 480p at 5 seconds).
pub kinovi_credits: f64,
/// Estimated cost in cents, rounded up to the nearest whole cent.
/// This does not account for discounts, prorations, etc.
pub usd_cents_rounded_up: u64,
/// Estimated cost in cents, rounded down to the nearest whole cent.
pub usd_cents_rounded_down: u64,
/// Estimated cost in cents without rounding (the exact fractional value).
pub usd_cents_fractional: f64,
}
impl KinoviFractionalGenerationCost {
/// Build a cost from a (possibly fractional) Kinovi credit amount, deriving
/// the USD cents via the credit-package rate.
///
/// The conversion is done on integer hundredths-of-a-credit, so the integer
/// cent results are exact (no floating-point drift). Credit amounts are
/// rounded to the nearest hundredth of a credit, which covers every Kinovi
/// rate seen so far (e.g. Mini's half-credits).
pub fn from_kinovi_credits(kinovi_credits: f64) -> Self {
// usd_cents == credits × 100 / CREDITS_PER_DOLLAR == hundredths / CREDITS_PER_DOLLAR.
let hundredths_of_credit = (kinovi_credits * 100.0).round() as u64;
Self {
kinovi_credits,
usd_cents_rounded_up: hundredths_of_credit.div_ceil(CREDITS_PER_DOLLAR),
usd_cents_rounded_down: hundredths_of_credit / CREDITS_PER_DOLLAR,
usd_cents_fractional: hundredths_of_credit as f64 / CREDITS_PER_DOLLAR as f64,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn fractional_credits_convert_to_usd() {
// 37.5 credits; 3750/243 = 15.4320 cents.
let cost = KinoviFractionalGenerationCost::from_kinovi_credits(37.5);
assert_eq!(cost.kinovi_credits, 37.5);
assert_eq!(cost.usd_cents_rounded_up, 16);
assert_eq!(cost.usd_cents_rounded_down, 15);
assert!((cost.usd_cents_fractional - (3750.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn whole_credits_convert_to_usd() {
// 100 credits; 10000/243 = 41.1522 cents.
let cost = KinoviFractionalGenerationCost::from_kinovi_credits(100.0);
assert_eq!(cost.kinovi_credits, 100.0);
assert_eq!(cost.usd_cents_rounded_up, 42);
assert_eq!(cost.usd_cents_rounded_down, 41);
assert!((cost.usd_cents_fractional - (10000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn rounded_bounds_bracket_the_fractional_value() {
for half_credits in [0u64, 1, 15, 75, 95, 200, 225, 600, 720] {
let credits = half_credits as f64 / 2.0;
let cost = KinoviFractionalGenerationCost::from_kinovi_credits(credits);
assert!(cost.usd_cents_rounded_down as f64 <= cost.usd_cents_fractional);
assert!(cost.usd_cents_fractional <= cost.usd_cents_rounded_up as f64);
assert!(cost.usd_cents_rounded_up - cost.usd_cents_rounded_down <= 1);
}
}
}
@@ -3,6 +3,11 @@ use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
/// The cost of a Kinovi Seedance generation, with the video-reference
/// surcharge broken out from the base price.
///
/// Used by the WHOLE-credit Seedance video models (regular + Fast). Seedance
/// 2.0 Mini bills fractional credits and has its own `f64` twin,
/// [`crate::cost::kinovi_seedance_mini_generation_cost::KinoviSeedanceMiniGenerationCost`]
/// — see that file for why they aren't merged.
///
/// `total_cost` covers base + surcharge. NB: the total's USD conversions are
/// computed from the SUMMED credits (rounded once), so they may differ by a
/// cent from adding the parts' rounded USD values together.
@@ -0,0 +1,91 @@
use crate::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
// ─────────────────────────────────────────────────────────────────────────────
// WHY THIS TYPE EXISTS (don't merge it into KinoviSeedanceGenerationCost):
//
// This is the Mini twin of `KinoviSeedanceGenerationCost`. It is structurally
// identical (base + optional video-reference surcharge = total) and prices the
// SAME way conceptually — but its credits are fractional
// (`KinoviFractionalGenerationCost`, `f64`) rather than whole
// (`KinoviGenerationCost`, `u64`).
//
// Seedance 2.0 Mini at 480p bills 7.5 credits/sec, so odd durations land on
// FRACTIONAL credits (5s = 37.5, 15s = 112.5). Every OTHER whole-credit
// Seedance video model uses `KinoviSeedanceGenerationCost` (u64 credits).
//
// Making the shared `u64` credit type fractional would ripple `f64` through all
// of those whole-credit models AND `artcraft_router` (which stores credits as
// `u64`), forcing hundreds of integer-literal edits for one model's benefit.
// Keeping Mini's fractional credits in their own small type is the cheaper,
// better-isolated choice. The inner `KinoviFractionalGenerationCost` is generic
// (not Mini-specific) — other fractional-credit models can reuse it.
// ─────────────────────────────────────────────────────────────────────────────
/// The cost of a Kinovi Seedance 2.0 Mini generation, with the video-reference
/// surcharge broken out from the base price.
///
/// Mirrors [`crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost`],
/// but with FRACTIONAL credits (see the module note above).
///
/// `total_cost` covers base + surcharge. NB: the total's USD conversions are
/// computed from the SUMMED credits (rounded once), so they may differ by a
/// cent from adding the parts' rounded USD values together.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinoviSeedanceMiniGenerationCost {
/// The full cost (base + any surcharge), in credits and USD cents.
pub total_cost: KinoviFractionalGenerationCost,
/// The base generation cost (resolution rate × output duration × batch
/// count), excluding any surcharges.
pub base_cost: KinoviFractionalGenerationCost,
/// The video-reference surcharge, when one or more reference videos are
/// attached. `None` when no reference videos are attached.
pub video_reference_surcharge_cost: Option<KinoviFractionalGenerationCost>,
}
impl KinoviSeedanceMiniGenerationCost {
/// Build a cost from base credits plus an optional video-reference
/// surcharge (both in Kinovi credits, possibly fractional). The per-part
/// and total USD conversions are derived.
pub fn from_base_and_surcharge(
base_credits: f64,
maybe_video_reference_surcharge_credits: Option<f64>,
) -> Self {
let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0.0);
Self {
total_cost: KinoviFractionalGenerationCost::from_kinovi_credits(total_credits),
base_cost: KinoviFractionalGenerationCost::from_kinovi_credits(base_credits),
video_reference_surcharge_cost: maybe_video_reference_surcharge_credits
.map(KinoviFractionalGenerationCost::from_kinovi_credits),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn without_surcharge_total_equals_base() {
let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, None);
assert!(cost.video_reference_surcharge_cost.is_none());
assert_eq!(cost.base_cost.kinovi_credits, 37.5);
assert_eq!(cost.total_cost, cost.base_cost);
}
#[test]
fn with_surcharge_sums_into_total() {
// 480p 5s with video ref: 37.5 base + 10 surcharge = 47.5 total.
let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, Some(10.0));
assert_eq!(cost.base_cost.kinovi_credits, 37.5);
assert_eq!(
cost.video_reference_surcharge_cost.map(|c| c.kinovi_credits),
Some(10.0),
);
assert_eq!(cost.total_cost.kinovi_credits, 47.5);
// 47.5 credits; 4750/243 = 19.5473 → 20¢.
assert_eq!(cost.total_cost.usd_cents_rounded_up, 20);
assert_eq!(cost.total_cost.usd_cents_rounded_down, 19);
}
}
@@ -1,3 +1,5 @@
pub mod constants;
pub mod kinovi_fractional_generation_cost;
pub mod kinovi_generation_cost;
pub mod kinovi_seedance_generation_cost;
pub mod kinovi_seedance_mini_generation_cost;
@@ -0,0 +1,755 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::cost::kinovi_seedance_mini_generation_cost::KinoviSeedanceMiniGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw,
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs,
WorkflowRunTaskRequest,
};
// ── Args ──
pub struct GenerateSeedance2p0MiniArgs<'a> {
pub request: GenerateSeedance2p0MiniRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateSeedance2p0MiniRequest {
pub prompt: String,
pub aspect_ratio: Option<KinoviSeedance2p0MiniAspectRatio>,
pub output_resolution: Option<KinoviSeedance2p0MiniOutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p0MiniBatchCount>,
pub start_frame_url: Option<String>,
pub end_frame_url: Option<String>,
pub reference_image_urls: Option<Vec<String>>,
pub reference_video_urls: Option<Vec<String>>,
pub reference_audio_urls: Option<Vec<String>>,
pub character_ids: Option<Vec<String>>,
pub use_face_blur_hack: Option<bool>,
/// Output video bitrate. None defaults to "normal"; `High` requests a
/// higher bitrate. Does not affect cost.
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0MiniAspectRatio {
Landscape16x9,
UltraWide21x9,
Portrait9x16,
Square1x1,
Standard4x3,
Portrait3x4,
}
/// Output resolution. Mini supports only 480p and 720p (no 1080p / 4K).
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0MiniOutputResolution {
FourEightyP,
SevenTwentyP,
}
/// Number of videos to generate in one request. Mini allows 1–8.
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0MiniBatchCount {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0MiniBitrate {
High,
}
// ── Pricing ──
//
// Seedance 2.0 Mini credit pricing (per second of OUTPUT duration):
//
// | Resolution | Credits/sec | Video-ref surcharge/sec |
// |------------|-------------|-------------------------|
// | 480p | 7.5 | 2 |
// | 720p | 20 | 4 |
//
// 480p credits can be FRACTIONAL (7.5/sec lands on half-credits at odd
// durations, e.g. 5s = 37.5). Default resolution (None) is 720p. Batch count
// multiplies the total. Attaching reference VIDEOS adds the surcharge;
// reference images and audio are free.
//
// Credit package: 525,000 credits for $2,159.0909 (~243 credits/$1).
impl GenerateSeedance2p0MiniRequest {
/// Calculate the cost of this generation request, in Kinovi credits
/// (possibly fractional) and USD cents.
pub fn calculate_costs(&self) -> KinoviSeedanceMiniGenerationCost {
let credits_per_second: f64 = match self.output_resolution {
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => 7.5,
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => 20.0,
};
let video_reference_surcharge_per_second: f64 = if self.has_video_reference() {
match self.output_resolution {
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => 2.0,
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => 4.0,
}
} else {
0.0
};
let batch_multiplier = f64::from(self.batch_multiplier());
let duration = f64::from(self.duration_seconds);
let base_credits = duration * credits_per_second * batch_multiplier;
let maybe_video_reference_surcharge_credits = if self.has_video_reference() {
Some(duration * video_reference_surcharge_per_second * batch_multiplier)
} else {
None
};
KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(
base_credits,
maybe_video_reference_surcharge_credits,
)
}
fn has_video_reference(&self) -> bool {
self.reference_video_urls
.as_ref()
.is_some_and(|urls| !urls.is_empty())
}
fn batch_multiplier(&self) -> u8 {
match self.batch_count {
None | Some(KinoviSeedance2p0MiniBatchCount::One) => 1,
Some(KinoviSeedance2p0MiniBatchCount::Two) => 2,
Some(KinoviSeedance2p0MiniBatchCount::Three) => 3,
Some(KinoviSeedance2p0MiniBatchCount::Four) => 4,
Some(KinoviSeedance2p0MiniBatchCount::Five) => 5,
Some(KinoviSeedance2p0MiniBatchCount::Six) => 6,
Some(KinoviSeedance2p0MiniBatchCount::Seven) => 7,
Some(KinoviSeedance2p0MiniBatchCount::Eight) => 8,
}
}
}
// ── Response ──
pub struct GenerateSeedance2p0MiniResponse {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn generate_seedance_2p0_mini(
args: GenerateSeedance2p0MiniArgs<'_>,
) -> Result<GenerateSeedance2p0MiniResponse, Seedance2ProError> {
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
request: to_raw_request(args.request),
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSeedance2p0MiniResponse {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
task_ids: raw_response.task_ids,
order_ids: raw_response.order_ids,
})
}
// ── Mapping helpers ──
fn to_raw_request(req: GenerateSeedance2p0MiniRequest) -> WorkflowRunTaskRequest {
WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2Mini,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
batch_count: map_batch_count(req.batch_count),
duration_seconds: req.duration_seconds,
start_frame_url: req.start_frame_url,
end_frame_url: req.end_frame_url,
reference_image_urls: req.reference_image_urls,
reference_video_urls: req.reference_video_urls,
reference_audio_urls: req.reference_audio_urls,
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.bitrate),
}
}
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0MiniAspectRatio>) -> KinoviAspectRatioRaw {
match ar {
Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
Some(KinoviSeedance2p0MiniAspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
Some(KinoviSeedance2p0MiniAspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
Some(KinoviSeedance2p0MiniAspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
Some(KinoviSeedance2p0MiniAspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
None => KinoviAspectRatioRaw::Landscape16x9,
}
}
fn map_output_resolution(res: KinoviSeedance2p0MiniOutputResolution) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p0MiniOutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p0MiniOutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
}
}
fn map_batch_count(bc: Option<KinoviSeedance2p0MiniBatchCount>) -> KinoviBatchCountRaw {
match bc {
Some(KinoviSeedance2p0MiniBatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviSeedance2p0MiniBatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p0MiniBatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p0MiniBatchCount::Four) => KinoviBatchCountRaw::Four,
Some(KinoviSeedance2p0MiniBatchCount::Five) => KinoviBatchCountRaw::Five,
Some(KinoviSeedance2p0MiniBatchCount::Six) => KinoviBatchCountRaw::Six,
Some(KinoviSeedance2p0MiniBatchCount::Seven) => KinoviBatchCountRaw::Seven,
Some(KinoviSeedance2p0MiniBatchCount::Eight) => KinoviBatchCountRaw::Eight,
}
}
fn map_bitrate(bitrate: Option<KinoviSeedance2p0MiniBitrate>) -> Option<KinoviBitrateRaw> {
match bitrate {
Some(KinoviSeedance2p0MiniBitrate::High) => Some(KinoviBitrateRaw::High),
None => None,
}
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
mod pricing_tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
fn build_request(
duration_seconds: u8,
output_resolution: Option<KinoviSeedance2p0MiniOutputResolution>,
batch_count: Option<KinoviSeedance2p0MiniBatchCount>,
) -> GenerateSeedance2p0MiniRequest {
GenerateSeedance2p0MiniRequest {
prompt: String::new(),
aspect_ratio: None,
output_resolution,
batch_count,
duration_seconds,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
}
}
fn r480(dur: u8) -> GenerateSeedance2p0MiniRequest {
build_request(dur, Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), None)
}
fn r720(dur: u8) -> GenerateSeedance2p0MiniRequest {
build_request(dur, None, None)
}
fn with_video_ref(mut request: GenerateSeedance2p0MiniRequest) -> GenerateSeedance2p0MiniRequest {
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
request
}
// ── Full pricing table (credits) ──
//
// The exact table provided by Kinovi for Seedance 2.0 Mini. 480p produces
// fractional credits at odd durations (37.5, 112.5, …).
mod kinovi_pricing_table {
use super::*;
#[test]
fn table_480p_without_video_reference() {
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 30.0);
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 37.5);
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 75.0);
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 112.5);
}
#[test]
fn table_720p_without_video_reference() {
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 80.0);
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 100.0);
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 200.0);
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 300.0);
}
#[test]
fn table_480p_with_video_reference() {
assert_eq!(with_video_ref(r480(4)).calculate_costs().total_cost.kinovi_credits, 38.0);
assert_eq!(with_video_ref(r480(5)).calculate_costs().total_cost.kinovi_credits, 47.5);
assert_eq!(with_video_ref(r480(10)).calculate_costs().total_cost.kinovi_credits, 95.0);
assert_eq!(with_video_ref(r480(15)).calculate_costs().total_cost.kinovi_credits, 142.5);
}
#[test]
fn table_720p_with_video_reference() {
assert_eq!(with_video_ref(r720(4)).calculate_costs().total_cost.kinovi_credits, 96.0);
assert_eq!(with_video_ref(r720(5)).calculate_costs().total_cost.kinovi_credits, 120.0);
assert_eq!(with_video_ref(r720(10)).calculate_costs().total_cost.kinovi_credits, 240.0);
assert_eq!(with_video_ref(r720(15)).calculate_costs().total_cost.kinovi_credits, 360.0);
}
}
// ── Base / surcharge breakdown ──
mod video_reference_surcharge {
use super::*;
/// The surcharge is the difference between the with-ref total and the
/// base: 480p +2/s, 720p +4/s.
#[test]
fn surcharge_table() {
// (request builder, duration, base credits, surcharge credits)
let cases: &[(fn(u8) -> GenerateSeedance2p0MiniRequest, u8, f64, f64)] = &[
// 480p: 7.5/s base, +2/s surcharge
(r480, 4, 30.0, 8.0),
(r480, 5, 37.5, 10.0),
(r480, 10, 75.0, 20.0),
(r480, 15, 112.5, 30.0),
// 720p: 20/s base, +4/s surcharge
(r720, 4, 80.0, 16.0),
(r720, 5, 100.0, 20.0),
(r720, 10, 200.0, 40.0),
(r720, 15, 300.0, 60.0),
];
for (make, duration, base, surcharge) in cases {
let costs = with_video_ref(make(*duration)).calculate_costs();
assert_eq!(costs.base_cost.kinovi_credits, *base, "base at {duration}s");
assert_eq!(
costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits),
Some(*surcharge),
"surcharge at {duration}s",
);
assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total at {duration}s");
}
}
#[test]
fn no_video_reference_has_no_surcharge() {
let costs = r480(5).calculate_costs();
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost, costs.base_cost);
}
#[test]
fn empty_video_reference_list_has_no_surcharge() {
let mut request = r720(5);
request.reference_video_urls = Some(vec![]);
let costs = request.calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 100.0);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// The surcharge applies per generated video, so batches multiply it.
#[test]
fn batch_multiplies_surcharge() {
let request = with_video_ref(build_request(
5,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
Some(KinoviSeedance2p0MiniBatchCount::Two),
));
// (37.5 base + 10 surcharge) × 2
let costs = request.calculate_costs();
assert_eq!(costs.base_cost.kinovi_credits, 75.0);
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(20.0));
assert_eq!(costs.total_cost.kinovi_credits, 95.0);
}
}
// ── USD cents conversion ──
mod usd_cents {
use super::*;
#[test]
fn fractional_480p_5s() {
// 37.5 credits; 3750/243 = 15.43 → 16¢ up, 15¢ down.
let costs = r480(5).calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 37.5);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 16);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 15);
assert!((costs.total_cost.usd_cents_fractional - (3750.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn whole_720p_5s() {
// 100 credits; 10000/243 = 41.15 → 42¢ up, 41¢ down.
let costs = r720(5).calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 100.0);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 42);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 41);
assert!((costs.total_cost.usd_cents_fractional - (10000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn fractional_480p_15s_with_video_reference() {
// 142.5 credits; 14250/243 = 58.64 → 59¢ up, 58¢ down.
let costs = with_video_ref(r480(15)).calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 142.5);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 59);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 58);
assert!((costs.total_cost.usd_cents_fractional - (14250.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
// ── Batch multiplier ──
mod batch_tests {
use super::*;
#[test]
fn batch_scales_total_linearly() {
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let by_count = [
(KinoviSeedance2p0MiniBatchCount::One, 1.0),
(KinoviSeedance2p0MiniBatchCount::Two, 2.0),
(KinoviSeedance2p0MiniBatchCount::Three, 3.0),
(KinoviSeedance2p0MiniBatchCount::Four, 4.0),
(KinoviSeedance2p0MiniBatchCount::Five, 5.0),
(KinoviSeedance2p0MiniBatchCount::Six, 6.0),
(KinoviSeedance2p0MiniBatchCount::Seven, 7.0),
(KinoviSeedance2p0MiniBatchCount::Eight, 8.0),
];
for (count, multiplier) in by_count {
let credits = build_request(5, None, Some(count)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(credits, base * multiplier, "batch {count:?}");
}
}
#[test]
fn batch_eight_480p_with_video_reference() {
let costs = with_video_ref(build_request(
5,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
Some(KinoviSeedance2p0MiniBatchCount::Eight),
)).calculate_costs();
// (37.5 + 10) × 8 = 380
assert_eq!(costs.total_cost.kinovi_credits, 380.0);
}
}
// ── Defaults & invariants ──
#[test]
fn default_resolution_is_720p() {
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP), None)
.calculate_costs().total_cost.kinovi_credits;
assert_eq!(default, explicit);
assert_eq!(default, 100.0);
}
#[test]
fn cost_scales_with_duration() {
let c4 = r720(4).calculate_costs().total_cost.kinovi_credits;
let c10 = r720(10).calculate_costs().total_cost.kinovi_credits;
let c15 = r720(15).calculate_costs().total_cost.kinovi_credits;
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn resolution_480p_cheaper_than_720p() {
for dur in 4..=15u8 {
let c480 = r480(dur).calculate_costs().total_cost.kinovi_credits;
let c720 = r720(dur).calculate_costs().total_cost.kinovi_credits;
assert!(c480 < c720, "480p should be cheaper than 720p at {dur}s");
}
}
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let ratios = [
KinoviSeedance2p0MiniAspectRatio::Landscape16x9,
KinoviSeedance2p0MiniAspectRatio::UltraWide21x9,
KinoviSeedance2p0MiniAspectRatio::Portrait9x16,
KinoviSeedance2p0MiniAspectRatio::Square1x1,
KinoviSeedance2p0MiniAspectRatio::Standard4x3,
KinoviSeedance2p0MiniAspectRatio::Portrait3x4,
];
for ar in ratios {
let mut req = r720(5);
req.aspect_ratio = Some(ar);
assert_eq!(req.calculate_costs().total_cost.kinovi_credits, baseline, "{ar:?}");
}
}
#[test]
fn high_bitrate_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let mut high = r720(5);
high.bitrate = Some(KinoviSeedance2p0MiniBitrate::High);
assert_eq!(high.calculate_costs().total_cost.kinovi_credits, baseline);
}
}
// ── Request shape (mapping to the raw Kinovi request) ──
mod request_shape_tests {
use super::*;
fn sample() -> GenerateSeedance2p0MiniRequest {
GenerateSeedance2p0MiniRequest {
prompt: "a corgi".to_string(),
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3),
output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
duration_seconds: 10,
batch_count: Some(KinoviSeedance2p0MiniBatchCount::Eight),
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec!["https://example.com/a.png".to_string()]),
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: Some(KinoviSeedance2p0MiniBitrate::High),
}
}
#[test]
fn maps_to_mini_model_type() {
let raw = to_raw_request(sample());
assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2Mini));
}
#[test]
fn maps_resolution_aspect_and_batch() {
let raw = to_raw_request(sample());
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape4x3));
assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::FourEightyP)));
assert!(matches!(raw.batch_count, KinoviBatchCountRaw::Eight));
assert!(matches!(raw.bitrate, Some(KinoviBitrateRaw::High)));
}
#[test]
fn passes_through_prompt_duration_and_references() {
let raw = to_raw_request(sample());
assert_eq!(raw.prompt, "a corgi");
assert_eq!(raw.duration_seconds, 10);
assert_eq!(raw.reference_image_urls, Some(vec!["https://example.com/a.png".to_string()]));
}
#[test]
fn default_resolution_maps_to_none() {
let mut req = sample();
req.output_resolution = None;
let raw = to_raw_request(req);
assert!(raw.output_resolution.is_none());
}
#[test]
fn all_batch_counts_map() {
let expected: &[(KinoviSeedance2p0MiniBatchCount, u8)] = &[
(KinoviSeedance2p0MiniBatchCount::One, 1),
(KinoviSeedance2p0MiniBatchCount::Two, 2),
(KinoviSeedance2p0MiniBatchCount::Three, 3),
(KinoviSeedance2p0MiniBatchCount::Four, 4),
(KinoviSeedance2p0MiniBatchCount::Five, 5),
(KinoviSeedance2p0MiniBatchCount::Six, 6),
(KinoviSeedance2p0MiniBatchCount::Seven, 7),
(KinoviSeedance2p0MiniBatchCount::Eight, 8),
];
for (count, n) in expected {
let mut req = sample();
req.batch_count = Some(*count);
let raw = to_raw_request(req);
// The mapped raw batch matches the requested count's multiplier.
let mut probe = build_probe();
probe.batch_count = Some(*count);
assert_eq!(probe.batch_multiplier(), *n, "{count:?}");
// And the raw enum is the corresponding variant.
assert!(!matches!(raw.model_type, KinoviModelTypeRaw::Seedance2Pro));
}
}
fn build_probe() -> GenerateSeedance2p0MiniRequest {
GenerateSeedance2p0MiniRequest {
prompt: String::new(),
aspect_ratio: None,
output_resolution: None,
duration_seconds: 5,
batch_count: None,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
}
}
}
// ── Live API tests (manual; require Kinovi cookies) ──
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
mod live {
use super::*;
#[tokio::test]
#[ignore]
async fn test_text_to_video_720p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0MiniRequest {
prompt: "A giant evil teddy bear steps on people in the city.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}).await?;
println!("mini t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_text_to_video_480p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0MiniRequest {
prompt: "A snowman fights a bear".to_string(),
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3),
output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
batch_count: None,
duration_seconds: 10,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}).await?;
println!("mini t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_reference_to_video_720p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0MiniRequest {
prompt: "Girl walks around in the city, giving a historical tour".to_string(),
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::UltraWide21x9),
output_resolution: None,
batch_count: None,
duration_seconds: 15,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260624/1782342102687-53fe5496.png".to_string(),
]),
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}).await?;
println!("mini reference-to-video 720p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_video_reference_480p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0MiniRequest {
prompt: "Change @video1 to night time.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9),
output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}).await?;
println!("mini video ref 480p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
}
@@ -1,3 +1,4 @@
pub mod generate_happy_horse_1p0;
pub mod generate_seedance_2p0;
pub mod generate_seedance_2p0_fast;
pub mod generate_seedance_2p0_mini;
@@ -22,12 +22,27 @@ pub (super) struct BatchRequestJson {
#[derive(Serialize, Debug)]
pub (super) struct ApiParams {
pub prompt: String,
pub resolution: String,
/// Aspect ratio as pixel dimensions (e.g. "1280x720"). Most Kinovi
/// models carry the aspect ratio here. None for models that use the
/// `aspectRatio` field instead (Seedance 2.0 Mini).
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio as a ratio string (e.g. "16:9"). Used by Seedance 2.0
/// Mini instead of `resolution`. None for models that use `resolution`.
#[serde(rename = "aspectRatio", skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<&'static str>,
#[serde(rename = "contentMode")]
pub content_mode: &'static str,
pub model: &'static str,
pub duration: String,
pub mode: &'static str,
/// Generation mode ("keyframe" / "reference"). Omitted for models that use
/// `happyhorseMode` instead (Happy Horse).
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<&'static str>,
/// Happy Horse generation mode ("t2v" / "i2v"). Used instead of `mode` by
/// Happy Horse; None for every other model.
#[serde(rename = "happyhorseMode", skip_serializing_if = "Option::is_none")]
pub happyhorse_mode: Option<&'static str>,
#[serde(rename = "outputResolution", skip_serializing_if = "Option::is_none")]
pub output_resolution: Option<&'static str>,
#[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")]
@@ -125,14 +125,16 @@ pub enum KinoviAspectRatioRaw {
}
impl KinoviAspectRatioRaw {
fn as_str(&self) -> &'static str {
/// The aspect ratio as a ratio string (e.g. "16:9"), used by models that
/// send an `aspectRatio` field (Seedance 2.0 Mini) instead of `resolution`.
fn as_aspect_ratio_str(&self) -> &'static str {
match self {
Self::Landscape16x9 => "1280x720",
Self::UltraWide21x9 => "1280x540",
Self::Portrait9x16 => "720x1280",
Self::Square1x1 => "720x720",
Self::Landscape4x3 => "960x720",
Self::Portrait3x4 => "720x960",
Self::Landscape16x9 => "16:9",
Self::UltraWide21x9 => "21:9",
Self::Portrait9x16 => "9:16",
Self::Square1x1 => "1:1",
Self::Landscape4x3 => "4:3",
Self::Portrait3x4 => "3:4",
}
}
}
@@ -169,7 +171,12 @@ impl KinoviOutputResolutionRaw {
pub enum KinoviBatchCountRaw {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
impl KinoviBatchCountRaw {
@@ -177,7 +184,12 @@ impl KinoviBatchCountRaw {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
Self::Five => 5,
Self::Six => 6,
Self::Seven => 7,
Self::Eight => 8,
}
}
}
@@ -189,6 +201,8 @@ pub enum KinoviModelTypeRaw {
Seedance2Pro,
/// Seedance 2.0 Fast (lower quality, faster).
Seedance2Fast,
/// Seedance 2.0 Mini (cheapest; 480p/720p only).
Seedance2Mini,
/// Happy Horse 1.0.
HappyHorse1p0,
}
@@ -198,9 +212,32 @@ impl KinoviModelTypeRaw {
match self {
Self::Seedance2Pro => "seedance-20",
Self::Seedance2Fast => "seedance2-fast",
Self::Seedance2Mini => "seedance2.0-mini",
Self::HappyHorse1p0 => "happyhorse1.0",
}
}
/// The tRPC `businessType` for this model. Seedance 2.0 Mini uses its
/// own business type; every other model uses the shared one.
fn business_type(&self) -> &'static str {
match self {
Self::Seedance2Mini => "seedance20-mini-video-generation",
Self::HappyHorse1p0 => "happyhorse-video-generation",
Self::Seedance2Pro | Self::Seedance2Fast => "wan22-video-generation",
}
}
/// Whether the aspect ratio is sent in an `aspectRatio` field (true)
/// rather than the `resolution` field. Mini and Happy Horse use `aspectRatio`.
fn uses_aspect_ratio_field(&self) -> bool {
matches!(self, Self::Seedance2Mini | Self::HappyHorse1p0)
}
/// Whether this model uses Happy Horse's `happyhorseMode` (t2v/i2v)
/// instead of the standard `mode` (keyframe/reference).
fn uses_happyhorse_mode(&self) -> bool {
matches!(self, Self::HappyHorse1p0)
}
}
/// Output video bitrate. When omitted, defaults to "standard".
@@ -245,79 +282,7 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
info!("Requesting video from Seedance2Pro (v2): {:?}", req);
let has_reference_images = req.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let has_reference_videos = req.reference_video_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let has_reference_audio = req.reference_audio_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let has_characters = req.character_ids.as_ref().is_some_and(|ids| !ids.is_empty());
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters;
let video_input_mode = if is_reference_mode { "reference" } else { "keyframe" };
let uploaded_urls: Option<Vec<String>> = if is_reference_mode {
let mut urls = Vec::new();
if let Some(video_urls) = req.reference_video_urls {
urls.extend(video_urls);
}
if let Some(image_urls) = req.reference_image_urls {
urls.extend(image_urls);
}
if urls.is_empty() { None } else { Some(urls) }
} else {
let mut urls = Vec::new();
if let Some(url) = req.start_frame_url {
urls.push(url);
}
if let Some(url) = req.end_frame_url {
urls.push(url);
}
if urls.is_empty() { None } else { Some(urls) }
};
let audio_urls: Option<Vec<String>> = if has_reference_audio {
req.reference_audio_urls
} else {
None
};
let face_blur_mode = match req.use_face_blur_hack {
Some(true) => Some("on"),
Some(false) => Some("off"),
None => None,
};
let batch_count_value = req.batch_count.as_u8();
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
let duration = format!("{}s", req.duration_seconds);
info!(
"Generating video (v2): mode={}, resolution={}, duration={}, batch={}",
video_input_mode, req.aspect_ratio.as_str(), duration, batch_count_value
);
let request_body = BatchRequest {
zero: BatchRequestInner {
json: BatchRequestJson {
business_type: "wan22-video-generation",
api_params: ApiParams {
prompt: req.prompt,
resolution: req.aspect_ratio.as_str().to_string(),
content_mode: "normal",
model: req.model_type.as_api_str(),
duration,
mode: video_input_mode,
output_resolution: req.output_resolution.and_then(|r| r.as_api_str()),
face_blur_mode,
character_ids: req.character_ids,
uploaded_urls,
audio_urls,
batch_count,
bitrate_mode: req.bitrate.and_then(|bitrate| bitrate.as_api_str()),
},
},
},
};
let request_body = build_batch_request(req);
info!("Seedance2pro request (v2): {:?}", request_body);
@@ -388,6 +353,107 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
})
}
/// Build the tRPC request body for a workflow run-task call.
///
/// Most models carry the aspect ratio as pixel dimensions in `resolution`.
/// Seedance 2.0 Mini instead sends a ratio string in an `aspectRatio` field
/// and a different `businessType`.
fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
let has_reference_images = req.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let has_reference_videos = req.reference_video_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let has_reference_audio = req.reference_audio_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let has_characters = req.character_ids.as_ref().is_some_and(|ids| !ids.is_empty());
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters;
let video_input_mode = if is_reference_mode { "reference" } else { "keyframe" };
let uploaded_urls: Option<Vec<String>> = if is_reference_mode {
let mut urls = Vec::new();
if let Some(video_urls) = req.reference_video_urls {
urls.extend(video_urls);
}
if let Some(image_urls) = req.reference_image_urls {
urls.extend(image_urls);
}
if urls.is_empty() { None } else { Some(urls) }
} else {
let mut urls = Vec::new();
if let Some(url) = req.start_frame_url {
urls.push(url);
}
if let Some(url) = req.end_frame_url {
urls.push(url);
}
if urls.is_empty() { None } else { Some(urls) }
};
let audio_urls: Option<Vec<String>> = if has_reference_audio {
req.reference_audio_urls
} else {
None
};
let face_blur_mode = match req.use_face_blur_hack {
Some(true) => Some("on"),
Some(false) => Some("off"),
None => None,
};
let batch_count_value = req.batch_count.as_u8();
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
let duration = format!("{}s", req.duration_seconds);
// The aspect ratio is sent as a ratio string (e.g. "16:9"). Seedance Pro/Fast
// carry it in the `resolution` field; Mini and Happy Horse use `aspectRatio`.
let aspect_ratio_value = req.aspect_ratio.as_aspect_ratio_str();
let (resolution, aspect_ratio) = if req.model_type.uses_aspect_ratio_field() {
(None, Some(aspect_ratio_value))
} else {
(Some(aspect_ratio_value.to_string()), None)
};
// Happy Horse uses `happyhorseMode` (t2v/i2v) instead of the standard `mode`
// (keyframe/reference). i2v applies whenever an input image/video is attached.
let (mode, happyhorse_mode) = if req.model_type.uses_happyhorse_mode() {
let hh = if uploaded_urls.is_some() { "i2v" } else { "t2v" };
(None, Some(hh))
} else {
(Some(video_input_mode), None)
};
info!(
"Generating video (v2): mode={}, model={}, duration={}, batch={}",
video_input_mode, req.model_type.as_api_str(), duration, batch_count_value
);
BatchRequest {
zero: BatchRequestInner {
json: BatchRequestJson {
business_type: req.model_type.business_type(),
api_params: ApiParams {
prompt: req.prompt,
resolution,
aspect_ratio,
content_mode: "normal",
model: req.model_type.as_api_str(),
duration,
mode,
happyhorse_mode,
output_resolution: req.output_resolution.and_then(|r| r.as_api_str()),
face_blur_mode,
character_ids: req.character_ids,
uploaded_urls,
audio_urls,
batch_count,
bitrate_mode: req.bitrate.and_then(|bitrate| bitrate.as_api_str()),
},
},
},
}
}
#[cfg(test)]
mod tests {
use std::fs;
@@ -427,11 +493,13 @@ mod tests {
fn base_api_params(bitrate_mode: Option<&'static str>) -> ApiParams {
ApiParams {
prompt: "a corgi".to_string(),
resolution: "1280x720".to_string(),
resolution: Some("16:9".to_string()),
aspect_ratio: None,
content_mode: "normal",
model: "seedance-20",
duration: "5s".to_string(),
mode: "keyframe",
mode: Some("keyframe"),
happyhorse_mode: None,
output_resolution: None,
face_blur_mode: None,
character_ids: None,
@@ -474,11 +542,13 @@ mod tests {
fn base_api_params(output_resolution: Option<&'static str>) -> ApiParams {
ApiParams {
prompt: "a corgi".to_string(),
resolution: "1280x720".to_string(),
resolution: Some("16:9".to_string()),
aspect_ratio: None,
content_mode: "normal",
model: "seedance-20",
duration: "5s".to_string(),
mode: "reference",
mode: Some("reference"),
happyhorse_mode: None,
output_resolution,
face_blur_mode: None,
character_ids: None,
@@ -490,6 +560,197 @@ mod tests {
}
}
// ── Seedance 2.0 Mini request shape ──
//
// Mini differs from the other models on the wire: a
// `seedance20-mini-video-generation` businessType, a `seedance2.0-mini`
// model, and an `aspectRatio` ratio string (e.g. "16:9") in place of the
// pixel-dimension `resolution` field.
mod mini_request_shape_tests {
use super::*;
fn mini_request(
aspect_ratio: KinoviAspectRatioRaw,
output_resolution: Option<KinoviOutputResolutionRaw>,
batch_count: KinoviBatchCountRaw,
) -> WorkflowRunTaskRequest {
WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2Mini,
prompt: "a corgi".to_string(),
aspect_ratio,
output_resolution,
duration_seconds: 5,
batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
}
}
#[test]
fn mini_uses_aspect_ratio_field_and_business_type() {
let body = build_batch_request(mini_request(
KinoviAspectRatioRaw::Landscape4x3,
Some(KinoviOutputResolutionRaw::FourEightyP),
KinoviBatchCountRaw::One,
));
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""businessType":"seedance20-mini-video-generation""#), "{json}");
assert!(json.contains(r#""model":"seedance2.0-mini""#), "{json}");
assert!(json.contains(r#""aspectRatio":"4:3""#), "{json}");
assert!(json.contains(r#""outputResolution":"480p""#), "{json}");
// Mini does NOT send the pixel-dimension `resolution` field.
assert!(!json.contains(r#""resolution":"#), "{json}");
}
#[test]
fn mini_720p_omits_output_resolution() {
let body = build_batch_request(mini_request(
KinoviAspectRatioRaw::Landscape16x9,
None,
KinoviBatchCountRaw::One,
));
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}");
assert!(!json.contains("outputResolution"), "{json}");
}
#[test]
fn mini_batch_count_eight_serializes() {
let body = build_batch_request(mini_request(
KinoviAspectRatioRaw::Landscape16x9,
None,
KinoviBatchCountRaw::Eight,
));
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""batchCount":8"#), "{json}");
}
#[test]
fn non_mini_still_uses_resolution_field() {
let mut req = mini_request(KinoviAspectRatioRaw::Landscape16x9, None, KinoviBatchCountRaw::One);
req.model_type = KinoviModelTypeRaw::Seedance2Pro;
let body = build_batch_request(req);
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""businessType":"wan22-video-generation""#), "{json}");
assert!(json.contains(r#""resolution":"16:9""#), "{json}");
assert!(!json.contains("aspectRatio"), "{json}");
}
#[test]
fn model_strings_and_business_types() {
assert_eq!(KinoviModelTypeRaw::Seedance2Mini.as_api_str(), "seedance2.0-mini");
assert_eq!(KinoviModelTypeRaw::Seedance2Mini.business_type(), "seedance20-mini-video-generation");
assert_eq!(KinoviModelTypeRaw::Seedance2Pro.business_type(), "wan22-video-generation");
assert_eq!(KinoviModelTypeRaw::Seedance2Fast.business_type(), "wan22-video-generation");
assert_eq!(KinoviModelTypeRaw::HappyHorse1p0.business_type(), "happyhorse-video-generation");
}
#[test]
fn aspect_ratio_strings() {
assert_eq!(KinoviAspectRatioRaw::Landscape16x9.as_aspect_ratio_str(), "16:9");
assert_eq!(KinoviAspectRatioRaw::UltraWide21x9.as_aspect_ratio_str(), "21:9");
assert_eq!(KinoviAspectRatioRaw::Portrait9x16.as_aspect_ratio_str(), "9:16");
assert_eq!(KinoviAspectRatioRaw::Square1x1.as_aspect_ratio_str(), "1:1");
assert_eq!(KinoviAspectRatioRaw::Landscape4x3.as_aspect_ratio_str(), "4:3");
assert_eq!(KinoviAspectRatioRaw::Portrait3x4.as_aspect_ratio_str(), "3:4");
}
}
// ── 2026-06-24 request-shape change ──
//
// The aspect ratio is now a ratio string ("16:9"). Seedance Pro/Fast keep it
// in `resolution`; Happy Horse moved to its own `businessType`, an
// `aspectRatio` field, and a `happyhorseMode` (t2v/i2v) in place of `mode`.
mod wire_shape_change_tests {
use super::*;
fn request(
model_type: KinoviModelTypeRaw,
aspect_ratio: KinoviAspectRatioRaw,
start_frame_url: Option<String>,
) -> WorkflowRunTaskRequest {
WorkflowRunTaskRequest {
model_type,
prompt: "a corgi".to_string(),
aspect_ratio,
output_resolution: None,
duration_seconds: 5,
batch_count: KinoviBatchCountRaw::One,
start_frame_url,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
}
}
#[test]
fn seedance_pro_resolution_is_a_ratio_string() {
let body = build_batch_request(request(
KinoviModelTypeRaw::Seedance2Pro, KinoviAspectRatioRaw::Landscape16x9, None));
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""businessType":"wan22-video-generation""#), "{json}");
assert!(json.contains(r#""resolution":"16:9""#), "{json}");
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
assert!(!json.contains("aspectRatio"), "{json}");
assert!(!json.contains("1280x720"), "{json}");
assert!(!json.contains("happyhorseMode"), "{json}");
}
#[test]
fn seedance_fast_resolution_is_a_ratio_string() {
let body = build_batch_request(request(
KinoviModelTypeRaw::Seedance2Fast, KinoviAspectRatioRaw::Portrait3x4, None));
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""model":"seedance2-fast""#), "{json}");
assert!(json.contains(r#""resolution":"3:4""#), "{json}");
}
#[test]
fn happy_horse_text_to_video_shape() {
let body = build_batch_request(request(
KinoviModelTypeRaw::HappyHorse1p0, KinoviAspectRatioRaw::Portrait9x16, None));
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""businessType":"happyhorse-video-generation""#), "{json}");
assert!(json.contains(r#""model":"happyhorse1.0""#), "{json}");
assert!(json.contains(r#""happyhorseMode":"t2v""#), "{json}");
assert!(json.contains(r#""aspectRatio":"9:16""#), "{json}");
// Happy Horse omits the standard `mode` and the `resolution` field.
assert!(!json.contains(r#""mode":"#), "{json}");
assert!(!json.contains(r#""resolution":"#), "{json}");
}
#[test]
fn happy_horse_image_to_video_uses_i2v() {
let body = build_batch_request(request(
KinoviModelTypeRaw::HappyHorse1p0, KinoviAspectRatioRaw::Landscape16x9,
Some("https://example.com/start.png".to_string())));
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""happyhorseMode":"i2v""#), "{json}");
assert!(json.contains(r#""uploadedUrls":["https://example.com/start.png"]"#), "{json}");
assert!(!json.contains(r#""mode":"#), "{json}");
}
#[test]
fn business_types() {
assert_eq!(KinoviModelTypeRaw::Seedance2Pro.business_type(), "wan22-video-generation");
assert_eq!(KinoviModelTypeRaw::Seedance2Fast.business_type(), "wan22-video-generation");
assert_eq!(KinoviModelTypeRaw::Seedance2Mini.business_type(), "seedance20-mini-video-generation");
assert_eq!(KinoviModelTypeRaw::HappyHorse1p0.business_type(), "happyhorse-video-generation");
}
}
mod real_requests {
use super::*;