diff --git a/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs b/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs index f3f4046194..aeaa51b15b 100644 --- a/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs +++ b/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs @@ -54,6 +54,7 @@ pub enum KinoviSeedance2p0OutputResolution { FourEightyP, SevenTwentyP, TenEightyP, + FourK, } #[derive(Debug, Clone, Copy)] @@ -93,6 +94,8 @@ impl GenerateSeedance2p0Request { Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 15, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 40, Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 90, + // TODO(2026-06-24): Confirm 4K rate with Kinovi. Placeholder = 4× the 1080p rate. + Some(KinoviSeedance2p0OutputResolution::FourK) => 360, }; // Video-reference surcharge, billed per second of OUTPUT duration @@ -103,6 +106,7 @@ impl GenerateSeedance2p0Request { // | 480p | 4 | // | 720p | 8 | // | 1080p | 18 | + // | 4K | 72 | // // NB: Assumed flat per generation regardless of how many reference // videos are attached (Kinovi's pricing page only shows one). @@ -111,6 +115,8 @@ impl GenerateSeedance2p0Request { Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 4, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 8, Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 18, + // TODO(2026-06-24): Confirm 4K surcharge with Kinovi. Placeholder = 4× the 1080p surcharge. + Some(KinoviSeedance2p0OutputResolution::FourK) => 72, } } else { 0 @@ -222,6 +228,7 @@ fn map_output_resolution(res: KinoviSeedance2p0OutputResolution) -> KinoviOutput KinoviSeedance2p0OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP, KinoviSeedance2p0OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP, KinoviSeedance2p0OutputResolution::TenEightyP => KinoviOutputResolutionRaw::TenEightyP, + KinoviSeedance2p0OutputResolution::FourK => KinoviOutputResolutionRaw::FourK, } } @@ -288,6 +295,43 @@ mod tests { build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None) } + fn r4k(dur: u8) -> GenerateSeedance2p0Request { + build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourK), None) + } + + // ── 4K (Seedance 2.0 only) ── + // + // Placeholder pricing: 360 credits/sec base (4× 1080p), +72/sec video-ref + // surcharge. Update these alongside the rates if Kinovi publishes official + // 4K pricing. + + #[test] + fn four_k_base_cost() { + assert_eq!(r4k(5).calculate_costs().total_cost.kinovi_credits, 1800); + assert_eq!(r4k(6).calculate_costs().total_cost.kinovi_credits, 2160); + } + + #[test] + fn four_k_more_expensive_than_1080p() { + let c1080 = r1080(5).calculate_costs().total_cost.kinovi_credits; + let c4k = r4k(5).calculate_costs().total_cost.kinovi_credits; + assert!(c4k > c1080, "4K ({c4k}) should cost more than 1080p ({c1080})"); + } + + #[test] + fn four_k_video_reference_surcharge() { + let mut req = r4k(5); + req.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]); + let costs = req.calculate_costs(); + // base 5×360 = 1800, surcharge 5×72 = 360, total = 2160 + assert_eq!(costs.base_cost.kinovi_credits, 1800); + assert_eq!( + costs.video_reference_surcharge_cost.as_ref().map(|c| c.kinovi_credits), + Some(360), + ); + assert_eq!(costs.total_cost.kinovi_credits, 2160); + } + // ── Comprehensive per-resolution coverage ── // // Seedance 2.0: 480p 15/s (+4/s video ref), 720p 40/s (+8/s), 1080p 90/s (+18/s). @@ -1093,6 +1137,42 @@ mod tests { assert_eq!(1, 2); Ok(()) } + + /// 4K with image references (Seedance 2.0 only), 5-second clip. + #[tokio::test] + #[ignore] + async fn test_4k_image_references() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let img1 = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?; + let img2 = upload_test_image(&session, test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL).await?; + let img3 = upload_test_image(&session, test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL).await?; + + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "The dog in @1 explores the scenery in @3 and meets the friendly man in @2. Cinematic 4K detail.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9), + output_resolution: Some(KinoviSeedance2p0OutputResolution::FourK), + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec![img1, img2, img3]), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("4K image ref — 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(()) + } } mod video_reference { diff --git a/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/workflow_run_task.rs b/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/workflow_run_task.rs index 0747ce5456..163ef6e0bb 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/workflow_run_task.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/workflow_run_task.rs @@ -111,12 +111,15 @@ impl WorkflowRunTaskRequest { /// Pricing is per-second × batch count, with the per-second rate /// depending on model type and output resolution: /// - /// | Model | 480p | 720p | 1080p | - /// |--------------|------|------|-------| - /// | Pro | 15 | 40 | 90 | - /// | Fast | 10 | 28 | n/a | - /// | HappyHorse | 15 | 40 | 90 | + /// | Model | 480p | 720p | 1080p | 4K | + /// |--------------|------|------|-------|-----| + /// | Pro | 15 | 40 | 90 | 360 | + /// | Fast | 10 | 28 | n/a | n/a | + /// | HappyHorse | 15 | 40 | 90 | 360 | /// TODO(bt,2026-04-23): Not sure pricing for Happy Horse is correct here. + /// TODO(2026-06-24): Confirm the official 4K credit rate with Kinovi. The 360 + /// placeholder is the pixel-proportional estimate (4× the 1080p rate). 4K is + /// Seedance 2.0 (Pro) only; Fast does not support it. /// /// Input mode (text, keyframe, reference) has no effect on cost. /// Aspect ratio (`resolution` field) has no effect on cost. @@ -127,6 +130,8 @@ impl WorkflowRunTaskRequest { (KinoviModelTypeRaw::Seedance2Pro, None) | (KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 40, (KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::TenEightyP)) => 90, + // TODO(2026-06-24): Confirm 4K rate with Kinovi. Placeholder = 4× the 1080p rate. + (KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::FourK)) => 360, // Seedance 2.0 Fast (KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::FourEightyP)) => 10, @@ -134,6 +139,8 @@ impl WorkflowRunTaskRequest { | (KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 28, // NB: 1080p not officially supported for Fast, but price as 720p if requested (KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::TenEightyP)) => 28, + // NB: 4K is NOT supported for Fast; price defensively at the 720p rate if ever requested. + (KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::FourK)) => 28, // TODO(bt,2026-04-23): Not sure pricing for Happy Horse is correct here. // Happy Horse 1.0 — same credit rates as Seedance 2.0 Pro @@ -141,6 +148,8 @@ impl WorkflowRunTaskRequest { (KinoviModelTypeRaw::HappyHorse1p0, None) | (KinoviModelTypeRaw::HappyHorse1p0, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 40, (KinoviModelTypeRaw::HappyHorse1p0, Some(KinoviOutputResolutionRaw::TenEightyP)) => 90, + // Mirrors Pro's 4K placeholder rate. + (KinoviModelTypeRaw::HappyHorse1p0, Some(KinoviOutputResolutionRaw::FourK)) => 360, }; let per_video = u32::from(self.duration_seconds) * credits_per_second; @@ -220,6 +229,9 @@ pub enum KinoviOutputResolutionRaw { SevenTwentyP, /// 1080p TenEightyP, + /// 4K. Only supported by Seedance 2.0 (Pro). Seedance 2.0 Fast does NOT + /// offer 4K — requesting it for Fast is not valid upstream. + FourK, } impl KinoviOutputResolutionRaw { @@ -230,6 +242,7 @@ impl KinoviOutputResolutionRaw { Self::FourEightyP => Some("480p"), Self::SevenTwentyP => None, // Default — omit from request Self::TenEightyP => Some("1080p"), + Self::FourK => Some("4k"), } } } @@ -545,6 +558,20 @@ mod tests { assert_eq!(pro_res(15, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 1350); } + #[test] + fn spot_check_pro_4k() { + // 4K is Seedance 2.0 (Pro) only. Placeholder rate: 360 credits/sec (4× 1080p). + assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourK).estimate_credits(), 1800); + assert_eq!(pro_res(6, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourK).estimate_credits(), 2160); + } + + #[test] + fn pro_4k_more_expensive_than_1080p() { + let c1080 = pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(); + let c4k = pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourK).estimate_credits(); + assert!(c4k > c1080, "4K ({}) should cost more than 1080p ({})", c4k, c1080); + } + #[test] fn spot_check_fast_720p() { assert_eq!(fast(5, KinoviBatchCountRaw::One).estimate_credits(), 140); @@ -806,6 +833,53 @@ mod tests { } } + // ── Output-resolution serialization ── + // + // `outputResolution` is sent only when non-default; 720p (the default) omits + // the field. 4K serializes to the literal "4k". + + mod output_resolution_serialization_tests { + use super::*; + + #[test] + fn four_k_maps_to_4k() { + assert_eq!(KinoviOutputResolutionRaw::FourK.as_api_str(), Some("4k")); + } + + #[test] + fn four_k_serializes_on_the_wire() { + let api_params = base_api_params(KinoviOutputResolutionRaw::FourK.as_api_str()); + let json = serde_json::to_string(&api_params).unwrap(); + assert!(json.contains(r#""outputResolution":"4k""#), "expected outputResolution in {json}"); + } + + #[test] + fn default_720p_omits_output_resolution() { + assert_eq!(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str(), None); + let api_params = base_api_params(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str()); + let json = serde_json::to_string(&api_params).unwrap(); + assert!(!json.contains("outputResolution"), "expected no outputResolution in {json}"); + } + + fn base_api_params(output_resolution: Option<&'static str>) -> ApiParams { + ApiParams { + prompt: "a corgi".to_string(), + resolution: "1280x720".to_string(), + content_mode: "normal", + model: "seedance-20", + duration: "5s".to_string(), + mode: "reference", + output_resolution, + face_blur_mode: None, + character_ids: None, + uploaded_urls: None, + audio_urls: None, + batch_count: None, + bitrate_mode: None, + } + } + } + mod real_requests { use super::*; @@ -1641,6 +1715,69 @@ mod tests { assert_eq!(1, 2, "Inspect output above"); Ok(()) } + + /// 4K with image references (Seedance 2.0 Pro only), 5-second sample. + #[tokio::test] + #[ignore] + async fn test_4k_image_references() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + // Upload a few reference images first, then drive a 5-second 4K job. + let image_urls_to_upload = [ + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL, + test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL, + ]; + + let mut uploaded_urls = Vec::new(); + for (i, source_url) in image_urls_to_upload.iter().enumerate() { + let image_bytes = crate::test_utils::http_download::http_download_to_bytes(source_url).await?; + let ext = if source_url.ends_with(".png") { "png" } else { "jpg" }; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: ext.to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + println!("Uploaded ref image {}: {}", i + 1, upload_result.public_url); + uploaded_urls.push(upload_result.public_url); + } + + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: "The dog in @1 explores the scenery in @3 near the building in @2. Cinematic 4K detail.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(uploaded_urls), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: Some(KinoviOutputResolutionRaw::FourK), + }, + }; + let result = workflow_run_task(args).await?; + println!("Seedance 2.0 @ 4K (image refs) — 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(()) + } } mod seedance_2_fast {