From 198aed0f916b0f3c1810701083bfbfceed7602bb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 15 Apr 2026 13:13:15 -0400 Subject: [PATCH] add seedance 2.0 tests --- .../generate/video/tests/kinovi/mod.rs | 1 + .../tests/kinovi/test_kinovi_seedance_2p0.rs | 459 ++++++++++++++++++ .../omni_gen/generate/video/tests/mod.rs | 1 + 3 files changed, 461 insertions(+) create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/kinovi/mod.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/kinovi/test_kinovi_seedance_2p0.rs diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/kinovi/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/kinovi/mod.rs new file mode 100644 index 0000000000..56c79b849b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/kinovi/mod.rs @@ -0,0 +1 @@ +mod test_kinovi_seedance_2p0; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/kinovi/test_kinovi_seedance_2p0.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/kinovi/test_kinovi_seedance_2p0.rs new file mode 100644 index 0000000000..840073193d --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/kinovi/test_kinovi_seedance_2p0.rs @@ -0,0 +1,459 @@ +//! Tests for omni-gen video against the Seedance 2.0 model (Kinovi/Seedance2Pro provider). +//! +//! Seedance 2.0 is special: it uses the Seedance2Pro/Kinovi provider for execution +//! instead of Fal. The cost estimation uses Artcraft provider with media-token-form +//! references, while the execution plan uses URL-form references. +//! +//! Pricing: 16¢ per second × batch count. +//! - 4s/batch 1 = 64¢, 5s/batch 1 = 80¢, 15s/batch 1 = 240¢ +//! - Batch 2 = 2×, Batch 4 = 4× + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + use artcraft_router::api::provider::Provider; + use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan; + use enums::common::generation::common_aspect_ratio::CommonAspectRatio; + use enums::common::generation::common_video_model::CommonVideoModel; + use tokens::tokens::media_files::MediaFileToken; + use url::Url; + + use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{ + distill_video_request, DistilledVideoRequest, + }; + + // ── Helpers ──────────────────────────────────────────────────────────────── + + fn make_request( + prompt: Option<&str>, + aspect_ratio: Option, + duration_seconds: Option, + video_batch_count: Option, + start_frame_token: Option, + end_frame_token: Option, + reference_image_tokens: Option>, + reference_video_tokens: Option>, + reference_audio_tokens: Option>, + ) -> OmniGenVideoCostAndGenerateRequest { + OmniGenVideoCostAndGenerateRequest { + idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()), + model: Some(CommonVideoModel::Seedance2p0), + prompt: prompt.map(|s| s.to_string()), + negative_prompt: None, + start_frame_image_media_token: start_frame_token, + end_frame_image_media_token: end_frame_token, + reference_image_media_tokens: reference_image_tokens, + reference_video_media_tokens: reference_video_tokens, + reference_audio_media_tokens: reference_audio_tokens, + reference_character_tokens: None, + resolution: None, + aspect_ratio, + quality: None, + duration_seconds, + video_batch_count, + generate_audio: None, + } + } + + fn text_only_request( + duration_seconds: Option, + video_batch_count: Option, + ) -> OmniGenVideoCostAndGenerateRequest { + make_request( + Some("a cat dancing"), + None, + duration_seconds, + video_batch_count, + None, None, None, None, None, + ) + } + + fn fake_token(name: &str) -> MediaFileToken { + MediaFileToken::new_from_str(name) + } + + fn build_hydration_map(tokens: &[&MediaFileToken]) -> HashMap { + tokens.iter().enumerate().map(|(i, t)| { + let url = Url::parse(&format!("https://cdn.example.com/media/{}.png", i)).unwrap(); + ((*t).clone(), url) + }).collect() + } + + fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest { + let empty: HashMap = HashMap::new(); + distill_video_request(request, Some(&empty), Provider::Seedance2Pro) + .expect("distill should succeed for Seedance 2.0 (text)") + } + + fn distill_with_map( + request: &OmniGenVideoCostAndGenerateRequest, + hydration: &HashMap, + ) -> DistilledVideoRequest { + distill_video_request(request, Some(hydration), Provider::Seedance2Pro) + .expect("distill should succeed for Seedance 2.0 (with media)") + } + + // ═══════════════════════════════════════════════════════════════════════════ + // COST TESTS + // ═══════════════════════════════════════════════════════════════════════════ + + mod cost_tests { + use super::*; + + #[test] + fn cost_is_present() { + let d = distill_text(&text_only_request(Some(5), None)); + assert!(d.cost.cost_in_credits.is_some()); + assert!(d.cost.cost_in_usd_cents.is_some()); + } + + #[test] + fn cost_is_not_free() { + let d = distill_text(&text_only_request(Some(5), None)); + assert!(!d.cost.is_free); + } + + // Pricing: 16¢/sec × batch_count + + #[test] + fn cost_4s_batch_1() { + let d = distill_text(&text_only_request(Some(4), Some(1))); + assert_eq!(d.cost.cost_in_usd_cents, Some(64)); + } + + #[test] + fn cost_5s_batch_1() { + let d = distill_text(&text_only_request(Some(5), Some(1))); + assert_eq!(d.cost.cost_in_usd_cents, Some(80)); + } + + #[test] + fn cost_6s_batch_1() { + let d = distill_text(&text_only_request(Some(6), Some(1))); + assert_eq!(d.cost.cost_in_usd_cents, Some(96)); + } + + #[test] + fn cost_10s_batch_1() { + let d = distill_text(&text_only_request(Some(10), Some(1))); + assert_eq!(d.cost.cost_in_usd_cents, Some(160)); + } + + #[test] + fn cost_15s_batch_1() { + let d = distill_text(&text_only_request(Some(15), Some(1))); + assert_eq!(d.cost.cost_in_usd_cents, Some(240)); + } + + #[test] + fn cost_5s_batch_2() { + let d = distill_text(&text_only_request(Some(5), Some(2))); + assert_eq!(d.cost.cost_in_usd_cents, Some(160)); + } + + #[test] + fn cost_5s_batch_4() { + let d = distill_text(&text_only_request(Some(5), Some(4))); + assert_eq!(d.cost.cost_in_usd_cents, Some(320)); + } + + #[test] + fn cost_15s_batch_4() { + let d = distill_text(&text_only_request(Some(15), Some(4))); + assert_eq!(d.cost.cost_in_usd_cents, Some(960)); + } + + #[test] + fn cost_same_with_or_without_start_frame() { + // Cost should not change based on media presence — it's duration × batch. + let text_d = distill_text(&text_only_request(Some(5), Some(1))); + + let start_token = fake_token("start_frame"); + let map = build_hydration_map(&[&start_token]); + let req = make_request( + Some("a cat"), None, Some(5), Some(1), + Some(start_token), None, None, None, None, + ); + let frame_d = distill_with_map(&req, &map); + + assert_eq!(text_d.cost.cost_in_usd_cents, frame_d.cost.cost_in_usd_cents); + } + + #[test] + fn cost_estimation_uses_media_tokens_not_urls() { + // The Artcraft cost plan should use token-form references. + // If it used URLs, the Artcraft plan builder would reject them. + // This test passes if distillation succeeds at all — the Artcraft + // builder would error out on URL-form refs. + let start_token = fake_token("start_frame"); + let ref_img = fake_token("ref_img_1"); + let ref_vid = fake_token("ref_vid_1"); + let ref_aud = fake_token("ref_aud_1"); + + let map = build_hydration_map(&[&start_token, &ref_img, &ref_vid, &ref_aud]); + + let req = make_request( + Some("test"), None, Some(5), Some(1), + Some(start_token), None, + Some(vec![ref_img]), + Some(vec![ref_vid]), + Some(vec![ref_aud]), + ); + + // If this succeeds, the Artcraft cost path handled tokens correctly. + let d = distill_with_map(&req, &map); + assert!(d.cost.cost_in_credits.is_some()); + } + + #[test] + fn default_duration_produces_valid_cost() { + // When duration_seconds is None, the model should use a default. + let d = distill_text(&text_only_request(None, None)); + assert!(d.cost.cost_in_usd_cents.unwrap_or(0) > 0); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // GENERATION PLAN TESTS + // ═══════════════════════════════════════════════════════════════════════════ + + mod generation_plan_tests { + use super::*; + + #[test] + fn provider_is_seedance2pro() { + let d = distill_text(&text_only_request(Some(5), None)); + assert!( + matches!(d.execution_provider, Provider::Seedance2Pro), + "expected Seedance2Pro provider, got {:?}", d.execution_provider + ); + } + + #[test] + fn plan_is_seedance2pro_variant() { + let d = distill_text(&text_only_request(Some(5), None)); + assert!( + matches!(d.plan(), VideoGenerationPlan::Seedance2proSeedance2p0(_)), + "expected Seedance2proSeedance2p0 plan variant, got {:?}", d.plan() + ); + } + + #[test] + fn text_only_has_no_media_urls() { + let d = distill_text(&text_only_request(Some(5), None)); + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + assert!(plan.start_frame_url.is_none()); + assert!(plan.end_frame_url.is_none()); + assert!(plan.reference_image_urls.is_none()); + assert!(plan.reference_video_urls.is_none()); + assert!(plan.reference_audio_urls.is_none()); + } else { + panic!("wrong plan variant"); + } + } + + #[test] + fn text_only_prompt_is_passed_through() { + let d = distill_text(&text_only_request(Some(5), None)); + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + assert_eq!(plan.prompt.as_deref(), Some("a cat dancing")); + } else { + panic!("wrong plan variant"); + } + } + + #[test] + fn start_frame_uses_url_not_token() { + let start_token = fake_token("start_frame"); + let map = build_hydration_map(&[&start_token]); + + let req = make_request( + Some("test"), None, Some(5), None, + Some(start_token), None, None, None, None, + ); + let d = distill_with_map(&req, &map); + + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + let url = plan.start_frame_url.as_ref().expect("start_frame_url should be set"); + assert!(url.starts_with("https://"), "expected URL, got: {}", url); + assert!(!url.contains("m_"), "URL should not look like a media token"); + } else { + panic!("wrong plan variant"); + } + } + + #[test] + fn end_frame_uses_url_not_token() { + let end_token = fake_token("end_frame"); + let map = build_hydration_map(&[&end_token]); + + let req = make_request( + Some("test"), None, Some(5), None, + None, Some(end_token), None, None, None, + ); + let d = distill_with_map(&req, &map); + + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + let url = plan.end_frame_url.as_ref().expect("end_frame_url should be set"); + assert!(url.starts_with("https://"), "expected URL, got: {}", url); + } else { + panic!("wrong plan variant"); + } + } + + #[test] + fn start_and_end_frame_both_resolved() { + let start = fake_token("sf"); + let end = fake_token("ef"); + let map = build_hydration_map(&[&start, &end]); + + let req = make_request( + Some("test"), None, Some(5), None, + Some(start), Some(end), None, None, None, + ); + let d = distill_with_map(&req, &map); + + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + assert!(plan.start_frame_url.is_some()); + assert!(plan.end_frame_url.is_some()); + } else { + panic!("wrong plan variant"); + } + } + + #[test] + fn reference_images_use_urls() { + let img1 = fake_token("img1"); + let img2 = fake_token("img2"); + let map = build_hydration_map(&[&img1, &img2]); + + let req = make_request( + Some("test"), None, Some(5), None, + None, None, + Some(vec![img1, img2]), + None, None, + ); + let d = distill_with_map(&req, &map); + + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + let urls = plan.reference_image_urls.as_ref().expect("reference_image_urls should be set"); + assert_eq!(urls.len(), 2); + for url in urls { + assert!(url.starts_with("https://"), "expected URL, got: {}", url); + } + } else { + panic!("wrong plan variant"); + } + } + + #[test] + fn reference_videos_use_urls() { + let vid1 = fake_token("vid1"); + let map = build_hydration_map(&[&vid1]); + + let req = make_request( + Some("test"), None, Some(5), None, + None, None, None, + Some(vec![vid1]), + None, + ); + let d = distill_with_map(&req, &map); + + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + let urls = plan.reference_video_urls.as_ref().expect("reference_video_urls should be set"); + assert_eq!(urls.len(), 1); + assert!(urls[0].starts_with("https://")); + } else { + panic!("wrong plan variant"); + } + } + + #[test] + fn reference_audio_use_urls() { + let aud1 = fake_token("aud1"); + let map = build_hydration_map(&[&aud1]); + + let req = make_request( + Some("test"), None, Some(5), None, + None, None, None, None, + Some(vec![aud1]), + ); + let d = distill_with_map(&req, &map); + + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + let urls = plan.reference_audio_urls.as_ref().expect("reference_audio_urls should be set"); + assert_eq!(urls.len(), 1); + assert!(urls[0].starts_with("https://")); + } else { + panic!("wrong plan variant"); + } + } + + #[test] + fn all_reference_types_together() { + let sf = fake_token("sf"); + let ef = fake_token("ef"); + let img = fake_token("img"); + let vid = fake_token("vid"); + let aud = fake_token("aud"); + let map = build_hydration_map(&[&sf, &ef, &img, &vid, &aud]); + + let req = make_request( + Some("full test"), None, Some(8), Some(2), + Some(sf), Some(ef), + Some(vec![img]), + Some(vec![vid]), + Some(vec![aud]), + ); + let d = distill_with_map(&req, &map); + + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + assert!(plan.start_frame_url.is_some()); + assert!(plan.end_frame_url.is_some()); + assert_eq!(plan.reference_image_urls.as_ref().map(|v| v.len()), Some(1)); + assert_eq!(plan.reference_video_urls.as_ref().map(|v| v.len()), Some(1)); + assert_eq!(plan.reference_audio_urls.as_ref().map(|v| v.len()), Some(1)); + assert!(plan.prompt.as_deref() == Some("full test")); + } else { + panic!("wrong plan variant"); + } + } + + #[test] + fn duration_is_clamped_to_valid_range() { + // Duration below 4 should clamp to 4. + let d = distill_text(&text_only_request(Some(1), None)); + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + assert!(plan.duration_seconds >= 4, "expected >= 4, got {}", plan.duration_seconds); + } else { + panic!("wrong plan variant"); + } + + // Duration above 15 should clamp to 15. + let d = distill_text(&text_only_request(Some(99), None)); + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + assert!(plan.duration_seconds <= 15, "expected <= 15, got {}", plan.duration_seconds); + } else { + panic!("wrong plan variant"); + } + } + + #[test] + fn missing_prompt_defaults_to_empty() { + let req = make_request( + None, None, Some(5), None, + None, None, None, None, None, + ); + let d = distill_text(&req); + if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { + // Prompt should be None or empty string. + let prompt = plan.prompt.as_deref().unwrap_or(""); + assert!(prompt.is_empty() || plan.prompt.is_none()); + } else { + panic!("wrong plan variant"); + } + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mod.rs index a9bf0559a5..baf495bf29 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mod.rs @@ -1,3 +1,4 @@ mod test_seedance_1p5_pro; mod test_veo_2; mod test_veo_3; +mod kinovi;