diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs new file mode 100644 index 0000000000..8b24a89b0e --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs @@ -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, +} + +// ── 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, + + 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, + + /// 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, +} + +// ── 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, + }, + + /// Text-to-video / reference-to-video. "Adaptive" is NOT supported here; + /// `None` defaults to 16:9. + Reference { + aspect_ratio: Option, + /// Reference images, referenced in prompts as @image1, @image2, etc. + reference_image_urls: Option>, + /// Reference videos, referenced in prompts as @video1, @video2, etc. + reference_video_urls: Option>, + /// Reference audio, referenced in prompts as @audio1, @audio2, etc. + reference_audio_urls: Option>, + }, +} + +// ── 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>, + pub order_ids: Option>, +} + +// ── Entry point ── + +pub async fn generate_seedance_2p5( + args: GenerateSeedance2p5Args<'_>, +) -> Result { + 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) -> 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, + ) -> 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) -> 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) -> 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>, + videos: Option>, + audio: Option>, + ) { + 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 { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/mod.rs b/crates/api_clients/kinovi_web_client/src/generate/video/mod.rs index 8e8cce0ca1..025c602431 100644 --- a/crates/api_clients/kinovi_web_client/src/generate/video/mod.rs +++ b/crates/api_clients/kinovi_web_client/src/generate/video/mod.rs @@ -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; diff --git a/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/workflow_run_task.rs b/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/workflow_run_task.rs index 3b63540acb..41ec7d85e9 100644 --- a/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/workflow_run_task.rs +++ b/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/workflow_run_task.rs @@ -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>, Option>, Option>) = 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