add binding

This commit is contained in:
Hanashi committed 2026-08-07 16:46:17 -04:00
1 parent d598896e40
commit fab358e25e
3 files changed
+937 -20

No files matched your search

@@ -0,0 +1,738 @@
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviModelTypeRaw,
KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest,
};
// ── Args ──
pub struct GenerateSeedance2p5Args<'a> {
pub request: GenerateSeedance2p5Request,
pub session: &'a KinoviWebSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
/// Seedance 2.5 supports keyframe (image-to-video) and reference
/// (text-to-video / reference-to-video) modalities, and 480p/720p output.
///
/// On the wire it reuses the 2.5 *Preview* business type
/// (`seedance25-preview-video-generation`) with model `seedance2-5`;
/// text-to-video is sent as mode "reference".
#[derive(Clone, Debug)]
pub struct GenerateSeedance2p5Request {
pub prompt: String,
pub modality: KinoviSeedance2p5Modality,
pub output_resolution: Option<KinoviSeedance2p5OutputResolution>,
pub duration_seconds: u8,
/// CALCULATION-ONLY (never sent on the wire): the total seconds of
/// reference video input, summed across all reference videos. When video
/// references are attached, Kinovi bills the input seconds on top of the
/// output duration — see the pricing notes on [`calculate_costs`].
///
/// [`calculate_costs`]: GenerateSeedance2p5Request::calculate_costs
pub total_input_seconds: Option<u8>,
/// Controls `faceBlurMode`: true sends "on"; false or None sends "off"
/// (the model always sends the field, like 2.5 Preview).
pub use_face_blur_hack: Option<bool>,
}
// ── Modality ──
/// The generation modality. The aspect ratio choice lives here because the
/// two modalities support disjoint aspect ratio sets.
#[derive(Clone, Debug)]
pub enum KinoviSeedance2p5Modality {
/// Image-to-video (start frame + optional end frame).
///
/// The aspect ratio is always "Adaptive" — the only choice the API allows
/// in this modality, expressed on the wire by omitting the `aspectRatio`
/// field entirely — so there is nothing to configure.
Keyframe {
start_frame_url: String,
end_frame_url: Option<String>,
},
/// Text-to-video / reference-to-video. "Adaptive" is NOT supported here;
/// `None` defaults to 16:9.
Reference {
aspect_ratio: Option<KinoviSeedance2p5AspectRatio>,
/// Reference images, referenced in prompts as @image1, @image2, etc.
reference_image_urls: Option<Vec<String>>,
/// Reference videos, referenced in prompts as @video1, @video2, etc.
reference_video_urls: Option<Vec<String>>,
/// Reference audio, referenced in prompts as @audio1, @audio2, etc.
reference_audio_urls: Option<Vec<String>>,
},
}
// ── Enums ──
/// Aspect ratios for the reference (text/reference-to-video) modality.
/// There is deliberately no "Adaptive" variant: Adaptive is keyframe-only
/// (and there it is the only choice).
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p5AspectRatio {
Landscape16x9,
UltraWide21x9,
Portrait9x16,
Square1x1,
Standard4x3,
Portrait3x4,
}
/// Output resolution. Seedance 2.5 supports only 480p and 720p for now.
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p5OutputResolution {
FourEightyP,
SevenTwentyP,
}
// ── Pricing ──
//
// Seedance 2.5 credit pricing (whole credits per billed second):
//
// | Resolution | No video refs | With video refs |
// |------------|---------------|-----------------|
// | 480p | 26 | 16 |
// | 720p | 59 | 35 |
//
// Without video references (text-to-video, keyframe, and reference mode with
// only image/audio references), billed seconds = output duration.
//
// With video references, the rate drops but the billed seconds are the
// output duration PLUS the total seconds of reference video input
// (`total_input_seconds`, summed across all reference videos). E.g. a 30s
// output with a 10s video reference bills as 40 seconds; a 30s output with
// two 7s references (14s total input) bills as 44 seconds.
//
// Default resolution (None) is 720p.
// Credit package: 525,000 credits for $2,159.0909 (~243 credits/$1).
impl GenerateSeedance2p5Request {
/// Calculate the cost of this generation request, in Kinovi credits and
/// USD cents.
pub fn calculate_costs(&self) -> KinoviFractionalGenerationCost {
let has_video_references = matches!(
&self.modality,
KinoviSeedance2p5Modality::Reference { reference_video_urls: Some(urls), .. } if !urls.is_empty()
);
let (credits_per_second, billed_seconds) = if has_video_references {
let rate = match self.output_resolution {
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => 16u64,
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => 35u64,
};
let seconds = u64::from(self.duration_seconds)
+ u64::from(self.total_input_seconds.unwrap_or(0));
(rate, seconds)
} else {
let rate = match self.output_resolution {
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => 26u64,
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => 59u64,
};
(rate, u64::from(self.duration_seconds))
};
let total_credits = credits_per_second * billed_seconds;
KinoviFractionalGenerationCost::from_kinovi_credits(total_credits as f64)
}
}
// ── Response ──
pub struct GenerateSeedance2p5Response {
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_2p5(
args: GenerateSeedance2p5Args<'_>,
) -> Result<GenerateSeedance2p5Response, KinoviWebError> {
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
request: to_raw_request(args.request),
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSeedance2p5Response {
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: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
let (
aspect_ratio,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
) = match req.modality {
KinoviSeedance2p5Modality::Keyframe { start_frame_url, end_frame_url } => (
// Keyframe is always "Adaptive"; the raw layer omits the aspect ratio
// for 2.5 keyframe requests, so this placeholder never hits the wire.
KinoviAspectRatioRaw::Landscape16x9,
Some(start_frame_url),
end_frame_url,
None,
None,
None,
),
KinoviSeedance2p5Modality::Reference {
aspect_ratio,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
} => (
map_aspect_ratio(aspect_ratio),
None,
None,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
),
};
WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2p5,
prompt: req.prompt,
aspect_ratio,
output_resolution: req.output_resolution.map(map_output_resolution),
duration_seconds: req.duration_seconds,
batch_count: KinoviBatchCountRaw::One,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: None,
}
}
fn map_aspect_ratio(ar: Option<KinoviSeedance2p5AspectRatio>) -> KinoviAspectRatioRaw {
match ar {
Some(KinoviSeedance2p5AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
Some(KinoviSeedance2p5AspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
Some(KinoviSeedance2p5AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
Some(KinoviSeedance2p5AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
Some(KinoviSeedance2p5AspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
Some(KinoviSeedance2p5AspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
None => KinoviAspectRatioRaw::Landscape16x9,
}
}
fn map_output_resolution(res: KinoviSeedance2p5OutputResolution) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p5OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p5OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
}
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::kinovi_web_session::KinoviWebSession;
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;
// ── No video references: output-duration billing ──
//
// 480p = 26 credits/sec, 720p = 59 credits/sec.
mod without_video_references {
use super::*;
#[test]
fn table_480p() {
assert_eq!(t2v_480(1).calculate_costs().kinovi_credits, 26.0);
assert_eq!(t2v_480(5).calculate_costs().kinovi_credits, 130.0);
assert_eq!(t2v_480(10).calculate_costs().kinovi_credits, 260.0);
assert_eq!(t2v_480(15).calculate_costs().kinovi_credits, 390.0);
assert_eq!(t2v_480(30).calculate_costs().kinovi_credits, 780.0);
}
#[test]
fn table_720p() {
assert_eq!(t2v_720(1).calculate_costs().kinovi_credits, 59.0);
assert_eq!(t2v_720(5).calculate_costs().kinovi_credits, 295.0);
assert_eq!(t2v_720(10).calculate_costs().kinovi_credits, 590.0);
assert_eq!(t2v_720(15).calculate_costs().kinovi_credits, 885.0);
assert_eq!(t2v_720(30).calculate_costs().kinovi_credits, 1770.0);
}
#[test]
fn default_resolution_is_720p() {
let default = t2v_720(10).calculate_costs();
let mut explicit = t2v_720(10);
explicit.output_resolution = Some(KinoviSeedance2p5OutputResolution::SevenTwentyP);
assert_eq!(default, explicit.calculate_costs());
}
#[test]
fn keyframe_modality_uses_no_reference_rates() {
let mut request = keyframe_request(10);
request.output_resolution = Some(KinoviSeedance2p5OutputResolution::FourEightyP);
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
let request_720 = keyframe_request(10);
assert_eq!(request_720.calculate_costs().kinovi_credits, 590.0);
}
#[test]
fn image_and_audio_references_do_not_change_the_rate() {
let mut request = t2v_480(10);
set_reference_urls(
&mut request,
Some(vec!["https://example.com/a.png".to_string(), "https://example.com/b.jpg".to_string()]),
None,
Some(vec!["https://example.com/ref.wav".to_string()]),
);
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
}
#[test]
fn empty_video_reference_list_counts_as_no_references() {
let mut request = t2v_480(10);
set_reference_urls(&mut request, None, Some(Vec::new()), None);
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
}
#[test]
fn total_input_seconds_is_ignored_without_video_references() {
let mut request = t2v_480(10);
request.total_input_seconds = Some(60);
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
}
}
// ── With video references: input seconds are billed too ──
//
// 480p = 16 credits/sec, 720p = 35 credits/sec, over
// (output duration + total_input_seconds) billed seconds.
mod with_video_references {
use super::*;
#[test]
fn thirty_second_output_with_ten_second_reference_bills_forty_seconds() {
// The canonical example: 30s output + 10s of input video = 40 billed seconds.
let request = video_ref_480(30, Some(10));
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 40) as f64);
let request = video_ref_720(30, Some(10));
assert_eq!(request.calculate_costs().kinovi_credits, (35 * 40) as f64);
}
#[test]
fn two_seven_second_references_bill_forty_four_seconds() {
// Two 7s references (14s total input) + 30s output = 44 billed seconds.
let mut request = video_ref_480(30, Some(14));
set_reference_urls(
&mut request,
None,
Some(vec![
"https://example.com/ref1.mp4".to_string(),
"https://example.com/ref2.mp4".to_string(),
]),
None,
);
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 44) as f64);
}
#[test]
fn table_480p_with_ten_input_seconds() {
assert_eq!(video_ref_480(5, Some(10)).calculate_costs().kinovi_credits, (16 * 15) as f64);
assert_eq!(video_ref_480(10, Some(10)).calculate_costs().kinovi_credits, (16 * 20) as f64);
assert_eq!(video_ref_480(30, Some(10)).calculate_costs().kinovi_credits, (16 * 40) as f64);
}
#[test]
fn table_720p_with_ten_input_seconds() {
assert_eq!(video_ref_720(5, Some(10)).calculate_costs().kinovi_credits, (35 * 15) as f64);
assert_eq!(video_ref_720(10, Some(10)).calculate_costs().kinovi_credits, (35 * 20) as f64);
assert_eq!(video_ref_720(30, Some(10)).calculate_costs().kinovi_credits, (35 * 40) as f64);
}
#[test]
fn missing_total_input_seconds_bills_output_duration_only() {
assert_eq!(video_ref_480(10, None).calculate_costs().kinovi_credits, 160.0);
assert_eq!(video_ref_720(10, None).calculate_costs().kinovi_credits, 350.0);
}
#[test]
fn mixed_references_still_use_video_reference_rates() {
let mut request = video_ref_480(10, Some(5));
set_reference_urls(
&mut request,
Some(vec!["https://example.com/a.png".to_string()]),
Some(vec!["https://example.com/ref.mp4".to_string()]),
Some(vec!["https://example.com/ref.wav".to_string()]),
);
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 15) as f64);
}
}
// ── USD cents conversion ──
//
// usd_cents = credits × 100 / 243, computed on integer hundredths.
mod usd_cents {
use super::*;
#[test]
fn cents_480p_5s() {
// 130 credits; 13000/243 = 53.4979 cents.
let cost = t2v_480(5).calculate_costs();
assert_eq!(cost.usd_cents_rounded_up, 54);
assert_eq!(cost.usd_cents_rounded_down, 53);
assert!((cost.usd_cents_fractional - (13000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn cents_720p_30s() {
// 1770 credits; 177000/243 = 728.3951 cents.
let cost = t2v_720(30).calculate_costs();
assert_eq!(cost.usd_cents_rounded_up, 729);
assert_eq!(cost.usd_cents_rounded_down, 728);
assert!((cost.usd_cents_fractional - (177000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn cents_720p_video_ref_40_billed_seconds() {
// 35 × 40 = 1400 credits; 140000/243 = 576.1317 cents.
let cost = video_ref_720(30, Some(10)).calculate_costs();
assert_eq!(cost.usd_cents_rounded_up, 577);
assert_eq!(cost.usd_cents_rounded_down, 576);
assert!((cost.usd_cents_fractional - (140000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
// ── Relative pricing ──
mod relative_tests {
use super::*;
#[test]
fn video_reference_rate_is_cheaper_per_second() {
// 16 < 26 and 35 < 59: the with-references rate is lower per billed
// second (the input seconds are where the money goes).
assert!(video_ref_480(10, None).calculate_costs().kinovi_credits
< t2v_480(10).calculate_costs().kinovi_credits);
assert!(video_ref_720(10, None).calculate_costs().kinovi_credits
< t2v_720(10).calculate_costs().kinovi_credits);
}
#[test]
fn cost_scales_linearly_with_duration() {
for duration in [1u8, 5, 10, 30] {
assert_eq!(
t2v_480(duration).calculate_costs().kinovi_credits,
(26 * u64::from(duration)) as f64,
);
}
}
}
// ── Aspect ratio doesn't affect cost ──
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = t2v_720(10).calculate_costs().kinovi_credits;
let ratios = [
KinoviSeedance2p5AspectRatio::Landscape16x9,
KinoviSeedance2p5AspectRatio::UltraWide21x9,
KinoviSeedance2p5AspectRatio::Portrait9x16,
KinoviSeedance2p5AspectRatio::Square1x1,
KinoviSeedance2p5AspectRatio::Standard4x3,
KinoviSeedance2p5AspectRatio::Portrait3x4,
];
for ar in &ratios {
let mut request = t2v_720(10);
request.modality = KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(*ar),
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
};
assert_eq!(
request.calculate_costs().kinovi_credits, baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
}
// ── Helpers ──
fn text_to_video_request(
duration_seconds: u8,
output_resolution: Option<KinoviSeedance2p5OutputResolution>,
) -> GenerateSeedance2p5Request {
GenerateSeedance2p5Request {
prompt: String::new(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
},
output_resolution,
duration_seconds,
total_input_seconds: None,
use_face_blur_hack: None,
}
}
fn t2v_480(duration: u8) -> GenerateSeedance2p5Request {
text_to_video_request(duration, Some(KinoviSeedance2p5OutputResolution::FourEightyP))
}
fn t2v_720(duration: u8) -> GenerateSeedance2p5Request {
text_to_video_request(duration, None)
}
fn keyframe_request(duration: u8) -> GenerateSeedance2p5Request {
GenerateSeedance2p5Request {
prompt: String::new(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://example.com/start.png".to_string(),
end_frame_url: Some("https://example.com/end.png".to_string()),
},
output_resolution: None,
duration_seconds: duration,
total_input_seconds: None,
use_face_blur_hack: None,
}
}
fn video_ref_480(duration: u8, total_input_seconds: Option<u8>) -> GenerateSeedance2p5Request {
let mut request = t2v_480(duration);
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
request.total_input_seconds = total_input_seconds;
request
}
fn video_ref_720(duration: u8, total_input_seconds: Option<u8>) -> GenerateSeedance2p5Request {
let mut request = t2v_720(duration);
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
request.total_input_seconds = total_input_seconds;
request
}
fn set_reference_urls(
request: &mut GenerateSeedance2p5Request,
images: Option<Vec<String>>,
videos: Option<Vec<String>>,
audio: Option<Vec<String>>,
) {
let KinoviSeedance2p5Modality::Reference {
reference_image_urls,
reference_video_urls,
reference_audio_urls,
..
} = &mut request.modality else {
panic!("set_reference_urls requires the Reference modality");
};
*reference_image_urls = images;
*reference_video_urls = videos;
*reference_audio_urls = audio;
}
}
mod raw_mapping_tests {
use super::*;
#[test]
fn keyframe_maps_frames_and_no_references() {
let raw = to_raw_request(GenerateSeedance2p5Request {
prompt: "Car drives into the sunset".to_string(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://example.com/start.png".to_string(),
end_frame_url: Some("https://example.com/end.png".to_string()),
},
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
duration_seconds: 8,
total_input_seconds: None,
use_face_blur_hack: None,
});
assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2p5));
assert_eq!(raw.start_frame_url.as_deref(), Some("https://example.com/start.png"));
assert_eq!(raw.end_frame_url.as_deref(), Some("https://example.com/end.png"));
assert!(raw.reference_image_urls.is_none());
assert!(raw.reference_video_urls.is_none());
assert!(raw.reference_audio_urls.is_none());
}
#[test]
fn reference_maps_references_and_no_frames() {
let raw = to_raw_request(GenerateSeedance2p5Request {
prompt: "The t-rex @image1 eats the banana".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
reference_image_urls: Some(vec!["https://example.com/a.png".to_string()]),
reference_video_urls: Some(vec!["https://example.com/ref.mp4".to_string()]),
reference_audio_urls: Some(vec!["https://example.com/ref.wav".to_string()]),
},
output_resolution: None,
duration_seconds: 8,
total_input_seconds: Some(7),
use_face_blur_hack: None,
});
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::UltraWide21x9));
assert!(raw.start_frame_url.is_none());
assert!(raw.end_frame_url.is_none());
assert_eq!(raw.reference_image_urls.as_deref().map(|urls| urls.len()), Some(1));
assert_eq!(raw.reference_video_urls.as_deref().map(|urls| urls.len()), Some(1));
assert_eq!(raw.reference_audio_urls.as_deref().map(|urls| urls.len()), Some(1));
}
#[test]
fn reference_aspect_ratio_defaults_to_16x9() {
let raw = to_raw_request(GenerateSeedance2p5Request {
prompt: "Lightning hits a building".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
},
output_resolution: None,
duration_seconds: 5,
total_input_seconds: None,
use_face_blur_hack: None,
});
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape16x9));
}
}
mod real_requests {
use super::*;
#[tokio::test]
#[ignore]
async fn test_text_to_video_480p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
prompt: "Lightning hits a building".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9),
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
},
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
duration_seconds: 5,
total_input_seconds: None,
use_face_blur_hack: None,
},
}).await?;
println!("t2v 480p — 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, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_keyframe_to_video_adaptive_480p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
prompt: "Car drives into the sunset".to_string(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(),
end_frame_url: Some("https://static.seedance2-pro.com/materials/20260807/1786128493608-d708413a.png".to_string()),
},
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
duration_seconds: 8,
total_input_seconds: None,
use_face_blur_hack: None,
},
}).await?;
println!("keyframe 480p — 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, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_reference_to_video_480p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
prompt: "The t-rex @image1 eats the banana @image2".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260807/1786128628611-d6ca7afd.png".to_string(),
"https://static.seedance2-pro.com/materials/20260807/1786128638331-5395ff1c.jpg".to_string(),
]),
reference_video_urls: None,
reference_audio_urls: None,
},
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
duration_seconds: 8,
total_input_seconds: None,
use_face_blur_hack: None,
},
}).await?;
println!("reference 480p — 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, "Inspect output above");
Ok(())
}
fn test_session() -> AnyhowResult<KinoviWebSession> {
let cookies = get_test_cookies()?;
Ok(KinoviWebSession::from_cookies_string(cookies))
}
}
}
@@ -2,4 +2,5 @@ pub mod generate_happy_horse_1p0;
pub mod generate_seedance_2p0;
pub mod generate_seedance_2p0_fast;
pub mod generate_seedance_2p0_mini;
pub mod generate_seedance_2p5;
pub mod generate_seedance_2p5_preview;
@@ -216,6 +216,8 @@ pub enum KinoviModelTypeRaw {
Seedance2Mini,
/// Seedance 2.5 Preview (480p/720p only).
Seedance2p5Preview,
/// Seedance 2.5 (480p/720p only).
Seedance2p5,
/// Happy Horse 1.0.
HappyHorse1p0,
}
@@ -227,27 +229,29 @@ impl KinoviModelTypeRaw {
Self::Seedance2Fast => "seedance2-fast",
Self::Seedance2Mini => "seedance2.0-mini",
Self::Seedance2p5Preview => "seedance2-5-preview",
Self::Seedance2p5 => "seedance2-5",
Self::HappyHorse1p0 => "happyhorse1.0",
}
}
/// The tRPC `businessType` for this model. Seedance 2.0 Mini and 2.5
/// Preview use their own business types; every other model uses the
/// shared one.
/// The tRPC `businessType` for this model. Seedance 2.0 Mini and the 2.5
/// family use their own business types; every other model uses the shared
/// one. (Seedance 2.5 reuses the *preview* business type on the wire —
/// only the `model` string differs.)
fn business_type(&self) -> &'static str {
match self {
Self::Seedance2Mini => "seedance20-mini-video-generation",
Self::Seedance2p5Preview => "seedance25-preview-video-generation",
Self::Seedance2p5Preview | Self::Seedance2p5 => "seedance25-preview-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, 2.5 Preview, and Happy Horse
/// use `aspectRatio`.
/// rather than the `resolution` field. Mini, the 2.5 family, and Happy
/// Horse use `aspectRatio`.
fn uses_aspect_ratio_field(&self) -> bool {
matches!(self, Self::Seedance2Mini | Self::Seedance2p5Preview | Self::HappyHorse1p0)
matches!(self, Self::Seedance2Mini | Self::Seedance2p5Preview | Self::Seedance2p5 | Self::HappyHorse1p0)
}
/// Whether this model uses Happy Horse's `happyhorseMode` (t2v/i2v)
@@ -258,10 +262,11 @@ impl KinoviModelTypeRaw {
/// Whether reference URLs are sent in split `imageUrls` / `videoUrls`
/// fields — with `uploadedUrls` mirroring just the images — rather than
/// one combined `uploadedUrls` list. Seedance 2.5 Preview uses the split
/// shape.
/// one combined `uploadedUrls` list. The 2.5 family uses the split shape.
/// (In 2.5 keyframe mode the start/end frames go in `imageUrls` with
/// `uploadedUrls` mirroring them.)
fn uses_split_reference_url_fields(&self) -> bool {
matches!(self, Self::Seedance2p5Preview)
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
}
/// Whether `mode` is always "reference", even with no references attached.
@@ -270,11 +275,25 @@ impl KinoviModelTypeRaw {
matches!(self, Self::Seedance2p5Preview)
}
/// Whether `mode` is "keyframe" only when a start/end frame is attached,
/// and "reference" otherwise (including plain text-to-video). Seedance 2.5
/// sends text-to-video as mode "reference".
fn uses_reference_mode_unless_keyframes(&self) -> bool {
matches!(self, Self::Seedance2p5)
}
/// Whether keyframe mode omits the aspect ratio entirely: Seedance 2.5
/// keyframe (image-to-video) only supports "Adaptive", which is expressed
/// by sending no `aspectRatio` field at all.
fn omits_aspect_ratio_in_keyframe_mode(&self) -> bool {
matches!(self, Self::Seedance2p5)
}
/// Whether the default field values (`outputResolution: "720p"`,
/// `faceBlurMode: "off"`) are always sent explicitly rather than omitted.
/// Seedance 2.5 Preview always sends both.
/// The 2.5 family always sends both.
fn always_sends_default_fields(&self) -> bool {
matches!(self, Self::Seedance2p5Preview)
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
}
}
@@ -446,20 +465,41 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters;
let video_input_mode = if is_reference_mode || req.model_type.always_uses_reference_mode() {
let has_keyframes = req.start_frame_url.is_some() || req.end_frame_url.is_some();
let video_input_mode = if req.model_type.always_uses_reference_mode() {
"reference"
} else if req.model_type.uses_reference_mode_unless_keyframes() {
// Seedance 2.5: text-to-video is sent as "reference"; "keyframe" only
// when a start/end frame is attached.
if has_keyframes { "keyframe" } else { "reference" }
} else if is_reference_mode {
"reference"
} else {
"keyframe"
};
// Reference URLs. Seedance 2.5 Preview sends split `imageUrls` /
// `videoUrls` fields with `uploadedUrls` mirroring just the images; other
// Reference URLs. The 2.5 family sends split `imageUrls` / `videoUrls`
// fields with `uploadedUrls` mirroring just the images (in 2.5 keyframe
// mode the start/end frames ride in `imageUrls` + `uploadedUrls`); other
// models combine videos + images into `uploadedUrls` in reference mode, or
// send the start/end frames there in keyframe mode.
let (image_urls, video_urls, uploaded_urls): (Option<Vec<String>>, Option<Vec<String>>, Option<Vec<String>>) = if req.model_type.uses_split_reference_url_fields() {
let image_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
let video_urls = req.reference_video_urls.filter(|urls| !urls.is_empty());
(image_urls.clone(), video_urls, image_urls)
if video_input_mode == "keyframe" {
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);
}
let urls = if urls.is_empty() { None } else { Some(urls) };
(urls.clone(), None, urls)
} else {
let image_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
let video_urls = req.reference_video_urls.filter(|urls| !urls.is_empty());
(image_urls.clone(), video_urls, image_urls)
}
} else if is_reference_mode {
let mut urls = Vec::new();
if let Some(video_urls) = req.reference_video_urls {
@@ -506,9 +546,13 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
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`.
// carry it in the `resolution` field; Mini, the 2.5 family, and Happy Horse
// use `aspectRatio`. Seedance 2.5 keyframe mode only supports "Adaptive",
// which is expressed by omitting the field entirely.
let aspect_ratio_value = req.aspect_ratio.as_aspect_ratio_str();
let (resolution, aspect_ratio) = if req.model_type.uses_aspect_ratio_field() {
let (resolution, aspect_ratio) = if video_input_mode == "keyframe" && req.model_type.omits_aspect_ratio_in_keyframe_mode() {
(None, None)
} else if req.model_type.uses_aspect_ratio_field() {
(None, Some(aspect_ratio_value))
} else {
(Some(aspect_ratio_value.to_string()), None)
@@ -893,6 +937,140 @@ mod tests {
}
}
// ── Seedance 2.5 request shape ──
//
// Seedance 2.5 reuses the *preview* businessType
// (`seedance25-preview-video-generation`) with model `seedance2-5`. Unlike
// Preview it has a keyframe (image-to-video) mode: `mode` is "keyframe"
// only when a start/end frame is attached (text-to-video goes out as
// "reference"), keyframe requests omit `aspectRatio` entirely (the only
// choice is "Adaptive"), and the frames ride in `imageUrls` with
// `uploadedUrls` mirroring them. Reference mode matches Preview's split
// URL shape. Mirrors
// external/requests/sites/kinovi.ai/2026-08-07-seedance2p5/.
mod seedance_2p5_shape_tests {
use super::*;
fn base_request(prompt: &str, duration_seconds: u8) -> WorkflowRunTaskRequest {
WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2p5,
prompt: prompt.to_string(),
aspect_ratio: KinoviAspectRatioRaw::Landscape16x9,
output_resolution: Some(KinoviOutputResolutionRaw::FourEightyP),
duration_seconds,
batch_count: KinoviBatchCountRaw::One,
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 text_to_video_matches_observed_request() {
// Mirrors 1_request_text_to_video.txt: mode "reference", aspectRatio
// "16:9", 480p, 5s, no URL fields.
let body = build_batch_request(base_request("Lightning hits a building", 5));
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""businessType":"seedance25-preview-video-generation""#), "{json}");
assert!(json.contains(r#""model":"seedance2-5""#), "{json}");
assert!(json.contains(r#""mode":"reference""#), "{json}");
assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}");
assert!(json.contains(r#""duration":"5s""#), "{json}");
assert!(json.contains(r#""outputResolution":"480p""#), "{json}");
assert!(json.contains(r#""faceBlurMode":"off""#), "{json}");
assert!(json.contains(r#""contentMode":"normal""#), "{json}");
// No ratio-string `resolution` field, and no URL fields.
assert!(!json.contains(r#""resolution":"#), "{json}");
assert!(!json.contains("imageUrls"), "{json}");
assert!(!json.contains("videoUrls"), "{json}");
assert!(!json.contains("uploadedUrls"), "{json}");
assert!(!json.contains("audioUrls"), "{json}");
}
#[test]
fn keyframe_with_both_frames_matches_observed_request() {
// Mirrors 2_request_keyframe_to_video.txt: mode "keyframe", NO
// aspectRatio (Adaptive), frames in imageUrls + uploadedUrls.
let mut request = base_request("Car drives into the sunset", 8);
request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.png".to_string());
request.end_frame_url = Some("https://static.seedance2-pro.com/materials/end.png".to_string());
let body = build_batch_request(request);
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
// Adaptive: neither the `aspectRatio` nor `resolution` field is sent.
assert!(!json.contains("aspectRatio"), "{json}");
assert!(!json.contains(r#""resolution":"#), "{json}");
let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.png","https://static.seedance2-pro.com/materials/end.png"]"#;
assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}");
assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}");
assert!(!json.contains("videoUrls"), "{json}");
}
#[test]
fn keyframe_with_start_frame_only_matches_observed_request() {
// Mirrors 4_request_keyframe_to_video_2.txt: a single start frame.
let mut request = base_request("The cars drive by", 5);
request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.jpg".to_string());
let body = build_batch_request(request);
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
assert!(!json.contains("aspectRatio"), "{json}");
let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.jpg"]"#;
assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}");
assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}");
}
#[test]
fn reference_with_all_reference_types_matches_observed_request() {
// Mirrors 3_request_reference_to_video.txt: mode "reference",
// aspectRatio "21:9", split imageUrls / videoUrls / audioUrls with
// uploadedUrls mirroring just the images.
let mut request = base_request("The t-rex @image1 eats the banana @image2", 8);
request.aspect_ratio = KinoviAspectRatioRaw::UltraWide21x9;
request.reference_image_urls = Some(vec![
"https://static.seedance2-pro.com/materials/a.png".to_string(),
"https://static.seedance2-pro.com/materials/b.jpg".to_string(),
]);
request.reference_video_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.mp4".to_string()]);
request.reference_audio_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.wav".to_string()]);
let body = build_batch_request(request);
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""mode":"reference""#), "{json}");
assert!(json.contains(r#""aspectRatio":"21:9""#), "{json}");
let expected_images = r#"["https://static.seedance2-pro.com/materials/a.png","https://static.seedance2-pro.com/materials/b.jpg"]"#;
assert!(json.contains(&format!(r#""imageUrls":{expected_images}"#)), "{json}");
assert!(json.contains(&format!(r#""uploadedUrls":{expected_images}"#)), "{json}");
assert!(json.contains(r#""videoUrls":["https://static.seedance2-pro.com/materials/ref.mp4"]"#), "{json}");
assert!(json.contains(r#""audioUrls":["https://static.seedance2-pro.com/materials/ref.wav"]"#), "{json}");
}
#[test]
fn default_720p_and_face_blur_off_are_sent_explicitly() {
let mut request = base_request("a corgi", 5);
request.output_resolution = None;
let body = build_batch_request(request);
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""outputResolution":"720p""#), "{json}");
assert!(json.contains(r#""faceBlurMode":"off""#), "{json}");
}
#[test]
fn model_string_and_business_type() {
assert_eq!(KinoviModelTypeRaw::Seedance2p5.as_api_str(), "seedance2-5");
assert_eq!(KinoviModelTypeRaw::Seedance2p5.business_type(), "seedance25-preview-video-generation");
}
}
// ── 2026-06-24 request-shape change ──
//
// The aspect ratio is now a ratio string ("16:9"). Seedance Pro/Fast keep it