diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/seedance2pro/execute_seedance2pro_seedance2p0.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/seedance2pro/execute_seedance2pro_seedance2p0.rs index 334c79a82..d0b1d47d9 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/seedance2pro/execute_seedance2pro_seedance2p0.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/seedance2pro/execute_seedance2pro_seedance2p0.rs @@ -52,6 +52,8 @@ pub async fn execute_seedance2pro_seedance2p0( Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload { order_id: response.order_id, task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/seedance2pro/execute_seedance2pro_seedance2p0_fast.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/seedance2pro/execute_seedance2pro_seedance2p0_fast.rs index e23de19eb..1f3d85fa9 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/seedance2pro/execute_seedance2pro_seedance2p0_fast.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/seedance2pro/execute_seedance2pro_seedance2p0_fast.rs @@ -49,5 +49,7 @@ pub async fn execute_seedance2pro_seedance2p0_fast( Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload { order_id: response.order_id, task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_response.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_response.rs index f71d5b7a6..d9e8ba911 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_response.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_response.rs @@ -16,6 +16,8 @@ pub struct MuapiVideoResponsePayload { pub struct Seedance2proVideoResponsePayload { pub order_id: String, pub task_id: String, + pub maybe_order_ids: Option>, + pub maybe_task_ids: Option>, } #[derive(Clone, Debug)] diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/mod.rs index dcd09c164..d862928fb 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/mod.rs @@ -2,4 +2,4 @@ pub mod providers; pub mod video_generation_request; pub mod video_generation_draft; pub mod video_generation_draft_or_request; -mod video_generation_draft_context; \ No newline at end of file +pub mod video_generation_draft_context; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0/request.rs index 07482b3a7..ee4287663 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0/request.rs @@ -27,6 +27,8 @@ impl KinoviSeedance2p0RequestState { Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload { order_id: response.order_id, task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, })) } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/request.rs index cc03067f2..44e96a52c 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/request.rs @@ -27,6 +27,8 @@ impl KinoviSeedance2p0FastRequestState { Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload { order_id: response.order_id, task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, })) } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft.rs index 71bb928d3..822fc668a 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft.rs @@ -1,4 +1,4 @@ -use crate::client::router_client::RouterClient; +use crate::api::provider::Provider; use crate::errors::artcraft_router_error::ArtcraftRouterError; use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState; @@ -19,6 +19,13 @@ pub enum VideoGenerationDraftRequest { impl VideoGenerationDraftRequest { + pub fn get_provider(&self) -> Provider { + match self { + Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro, + Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro, + } + } + /// Return a cost estimate to fulfill the request. pub fn estimate_cost(&self) -> Result { match self { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft_or_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft_or_request.rs index bf5b0eefa..2427a17be 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft_or_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft_or_request.rs @@ -1,3 +1,4 @@ +use crate::api::provider::Provider; use crate::errors::artcraft_router_error::ArtcraftRouterError; use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest; @@ -10,6 +11,13 @@ pub enum VideoGenerationDraftOrRequest { impl VideoGenerationDraftOrRequest { + pub fn get_provider(&self) -> Provider { + match self { + Self::Draft(draft) => draft.get_provider(), + Self::Request(request) => request.get_provider(), + } + } + pub fn estimate_cost(&self) -> Result { match self { VideoGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(), diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs index e5f782830..4dfe600fc 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs @@ -1,3 +1,4 @@ +use crate::api::provider::Provider; use crate::client::router_client::RouterClient; use crate::errors::artcraft_router_error::ArtcraftRouterError; use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; @@ -21,6 +22,15 @@ pub enum VideoGenerationRequest { impl VideoGenerationRequest { + pub fn get_provider(&self) -> Provider { + match self { + Self::ArtcraftSeedance2p0(_) => Provider::Artcraft, + Self::ArtcraftSeedance2p0Fast(_) => Provider::Artcraft, + Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro, + Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro, + } + } + /// Return a cost estimate to fulfill the request. pub fn estimate_cost(&self) -> Result { match self { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs index c69b43bee..9b065b2cf 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs @@ -6,10 +6,11 @@ use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost use artcraft_api_defs::omni_gen::cost_response::omni_gen_video_cost_response::OmniGenVideoCostResponse; use artcraft_router::api::provider::Provider; use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError; +use artcraft_router::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; use log::warn; use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; -use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::distill_helper::hydrate_to_router_request::hydrate_to_router_request; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request; use crate::state::server_state::ServerState; /// Estimate the cost of a video generation. @@ -29,27 +30,39 @@ pub async fn omni_gen_video_cost_handler( request: Json, _server_state: web::Data>, ) -> Result, AdvancedCommonWebError> { - let mut generate_request = hydrate_to_router_request(&request)?; - generate_request.provider = Provider::Artcraft; + let mut builder = hydrate_to_router_request(&request)?; + builder.provider = Provider::Artcraft; - let plan = match generate_request.build() { - Ok(plan) => plan, - Err(ArtcraftRouterError::UnsupportedModel(_)) => { - generate_request.provider = Provider::Fal; - generate_request.build() - .map_err(|e| { - warn!("Failed to build Fal cost plan: {}", e); - AdvancedCommonWebError::from_error(e) - })? - } - Err(e) => { - warn!("Failed to build cost plan: {}", e); - return Err(AdvancedCommonWebError::from_error(e)); - } + let estimate = if builder.use_new_builder() { + builder.build2() + .map_err(|e| { + warn!("Failed to build2 cost estimate: {}", e); + AdvancedCommonWebError::from_error(e) + })? + .estimate_cost() + .map_err(|e| { + warn!("Failed to estimate cost (v2): {}", e); + AdvancedCommonWebError::from_error(e) + })? + } else { + let plan = match builder.build() { + Ok(plan) => plan, + Err(ArtcraftRouterError::UnsupportedModel(_)) => { + builder.provider = Provider::Fal; + builder.build() + .map_err(|e| { + warn!("Failed to build Fal cost plan: {}", e); + AdvancedCommonWebError::from_error(e) + })? + } + Err(e) => { + warn!("Failed to build cost plan: {}", e); + return Err(AdvancedCommonWebError::from_error(e)); + } + }; + plan.estimate_costs() }; - let estimate = plan.estimate_costs(); - Ok(Json(OmniGenVideoCostResponse { success: true, cost_in_credits: estimate.cost_in_credits, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/bill_wallet.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/bill_wallet.rs new file mode 100644 index 000000000..61946ec2a --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/bill_wallet.rs @@ -0,0 +1,44 @@ +//! Shared wallet billing logic for both video generation pipelines. + +use log::info; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::users::UserToken; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; + +use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error; +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; + +pub struct BillWalletResult { + pub apriori_job_token: InferenceJobToken, + pub maybe_wallet_ledger_entry_token: Option, +} + +/// Generate an apriori job token and bill the user's wallet for the given cost. +/// +/// If cost is 0, no wallet deduction is made but the apriori token is still generated. +pub async fn bill_wallet( + user_token: &UserToken, + cost: u64, + mysql_connection: &mut sqlx::pool::PoolConnection, +) -> Result { + let apriori_job_token = InferenceJobToken::generate(); + + info!("Charging wallet: {} credits", cost); + + let maybe_wallet_ledger_entry_token = if cost > 0 { + let deduction_result = attempt_wallet_deduction_else_common_web_error( + user_token, + Some(apriori_job_token.as_str()), + cost, + mysql_connection, + ).await?; + Some(deduction_result.ledger_entry_token) + } else { + None + }; + + Ok(BillWalletResult { + apriori_job_token, + maybe_wallet_ledger_entry_token, + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/build_router_client.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/build_router_client.rs new file mode 100644 index 000000000..b3d6d64c6 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/build_router_client.rs @@ -0,0 +1,36 @@ +//! Build a RouterClient for the given provider from server state. + +use artcraft_router::api::provider::Provider; +use artcraft_router::client::router_client::RouterClient; +use artcraft_router::client::router_fal_client::RouterFalClient; +use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient; +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::state::server_state::ServerState; + +pub fn build_router_client( + provider: Provider, + server_state: &ServerState, +) -> Result { + match provider { + Provider::Seedance2Pro => { + let session = Seedance2ProSession::from_cookies_string( + server_state.seedance2pro.cookies.clone() + ); + Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))) + } + Provider::Fal => { + let fal_client = RouterFalClient::new( + server_state.fal.api_key.clone(), + server_state.fal.webhook_url.clone(), + ); + Ok(RouterClient::Fal(fal_client)) + } + other => { + Err(AdvancedCommonWebError::server_error_with_message( + &format!("Unsupported provider for video generation: {:?}", other), + )) + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/distill_helper/hydrate_to_router_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/hydrate_router_request.rs similarity index 95% rename from crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/distill_helper/hydrate_to_router_request.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/hydrate_router_request.rs index 988c1c90c..2aef127a6 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/distill_helper/hydrate_to_router_request.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/hydrate_router_request.rs @@ -14,6 +14,53 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonA use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; +pub fn hydrate_to_router_request( + request: &OmniGenVideoCostAndGenerateRequest, +) -> Result { + let api_model = request.model + .as_ref() + .ok_or_else(|| AdvancedCommonWebError::BadInputWithSimpleMessage( + "model is required".to_string(), + ))?; + + let model = convert_model(api_model)?; + + let aspect_ratio = request.aspect_ratio + .as_ref() + .map(convert_aspect_ratio) + .transpose()?; + + let resolution = request.resolution + .as_ref() + .map(convert_resolution) + .transpose()?; + + Ok(GenerateVideoRequestBuilder { + model, + provider: Provider::Artcraft, + prompt: request.prompt.clone(), + negative_prompt: request.negative_prompt.clone(), + start_frame: request.start_frame_image_media_token.clone() + .map(ImageRef::MediaFileToken), + end_frame: request.end_frame_image_media_token.clone() + .map(ImageRef::MediaFileToken), + reference_images: request.reference_image_media_tokens.clone() + .map(ImageListRef::MediaFileTokens), + reference_videos: request.reference_video_media_tokens.clone() + .map(VideoListRef::MediaFileTokens), + reference_audio: request.reference_audio_media_tokens.clone() + .map(AudioListRef::MediaFileTokens), + reference_character_tokens: None, + resolution, + aspect_ratio, + duration_seconds: request.duration_seconds, + video_batch_count: request.video_batch_count, + generate_audio: request.generate_audio, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + idempotency_token: request.idempotency_token.clone(), + }) +} + fn convert_model( model: &CommonVideoModelEnum, ) -> Result { @@ -46,47 +93,3 @@ fn convert_resolution( ) }) } - -pub fn hydrate_to_router_request( - request: &OmniGenVideoCostAndGenerateRequest, -) -> Result { - let api_model = request.model.as_ref() - .ok_or_else(|| AdvancedCommonWebError::BadInputWithSimpleMessage( - "model is required".to_string(), - ))?; - - let model = convert_model(api_model)?; - - let aspect_ratio = request.aspect_ratio.as_ref() - .map(convert_aspect_ratio) - .transpose()?; - - let resolution = request.resolution.as_ref() - .map(convert_resolution) - .transpose()?; - - Ok(GenerateVideoRequestBuilder { - model, - provider: Provider::Artcraft, - prompt: request.prompt.clone(), - negative_prompt: request.negative_prompt.clone(), - start_frame: request.start_frame_image_media_token.clone() - .map(ImageRef::MediaFileToken), - end_frame: request.end_frame_image_media_token.clone() - .map(ImageRef::MediaFileToken), - reference_images: request.reference_image_media_tokens.clone() - .map(ImageListRef::MediaFileTokens), - reference_videos: request.reference_video_media_tokens.clone() - .map(VideoListRef::MediaFileTokens), - reference_audio: request.reference_audio_media_tokens.clone() - .map(AudioListRef::MediaFileTokens), - reference_character_tokens: None, - resolution, - aspect_ratio, - duration_seconds: request.duration_seconds, - video_batch_count: request.video_batch_count, - generate_audio: request.generate_audio, - request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, - idempotency_token: request.idempotency_token.clone(), - }) -} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/mod.rs new file mode 100644 index 000000000..903ea1091 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/mod.rs @@ -0,0 +1,5 @@ +pub mod bill_wallet; +pub mod build_router_client; +pub(crate) mod hydrate_router_request; +pub mod pipeline_result; +pub mod resolve_kinovi_character_ids; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/pipeline_result.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/pipeline_result.rs new file mode 100644 index 000000000..e427a68e3 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/pipeline_result.rs @@ -0,0 +1,9 @@ +use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse; + +use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::BillWalletResult; + +/// Both pipelines produce this, then the shared suffix handles DB writes. +pub struct PipelineResult { + pub billing: BillWalletResult, + pub response: GenerateVideoResponse, +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_helper/resolve_kinovi_character_ids.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/resolve_kinovi_character_ids.rs similarity index 73% rename from crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_helper/resolve_kinovi_character_ids.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/resolve_kinovi_character_ids.rs index d45f25eac..47abda21b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_helper/resolve_kinovi_character_ids.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/resolve_kinovi_character_ids.rs @@ -1,5 +1,7 @@ //! Resolve character tokens to Kinovi character IDs for prompting. +use std::collections::HashMap; + use log::warn; use sqlx::MySql; @@ -8,14 +10,14 @@ use tokens::tokens::characters::CharacterToken; use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; -/// Resolve character tokens to Kinovi character IDs for prompting. +/// Resolve character tokens to a map of CharacterToken → Kinovi character ID. /// /// Looks up the characters, filters to active ones with kinovi IDs, and warns about /// any that are missing or inactive (but doesn't fail the request). pub async fn resolve_kinovi_character_ids( maybe_tokens: Option<&[CharacterToken]>, connection: &mut sqlx::pool::PoolConnection, -) -> Result>, AdvancedCommonWebError> { +) -> Result>, AdvancedCommonWebError> { let tokens = match maybe_tokens { None => return Ok(None), Some(tokens) if tokens.is_empty() => return Ok(None), @@ -38,10 +40,12 @@ pub async fn resolve_kinovi_character_ids( } } - let ids: Vec = characters.iter() - .filter(|c| c.is_active) - .filter_map(|c| c.kinovi_character_id.clone()) - .collect(); + let map: HashMap = characters.iter() + .filter(|c| c.is_active) + .filter_map(|c| { + c.kinovi_character_id.as_ref().map(|kid| (c.token.clone(), kid.clone())) + }) + .collect(); - if ids.is_empty() { Ok(None) } else { Ok(Some(ids)) } + if map.is_empty() { Ok(None) } else { Ok(Some(map)) } } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/insert_fal_job.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/insert_fal_job.rs new file mode 100644 index 000000000..a9f5cc25e --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/insert_fal_job.rs @@ -0,0 +1,53 @@ +use log::warn; + +use enums::common::visibility::Visibility; +use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory; +use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{ + insert_generic_inference_job_for_fal_queue_with_apriori_job_token, + InsertGenericInferenceForFalWithAprioriJobTokenArgs, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; + +use super::shared_job_args::SharedJobArgs; + +pub struct InsertFalJobArgs<'a, 'tx> { + pub external_job_id: &'a str, + pub shared: SharedJobArgs<'a, 'tx>, +} + +pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result { + let InsertFalJobArgs { + external_job_id, + shared, + } = args; + + let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token( + InsertGenericInferenceForFalWithAprioriJobTokenArgs { + apriori_job_token: shared.apriori_job_token, + uuid_idempotency_token: shared.idempotency_token, + maybe_external_third_party_id: external_job_id, + fal_category: FalCategory::VideoGeneration, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + creator_set_visibility: Visibility::Public, + mysql_executor: &mut **shared.transaction, + maybe_inference_args: None, + starting_job_status_override: None, + maybe_frontend_failure_category: None, + maybe_failure_reason: None, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => Ok(token), + Err(err) => { + warn!("Error inserting fal inference job: {:?}", err); + Err(AdvancedCommonWebError::from_error(err)) + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/insert_seedance2pro_jobs.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/insert_seedance2pro_jobs.rs new file mode 100644 index 000000000..8346d7890 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/insert_seedance2pro_jobs.rs @@ -0,0 +1,76 @@ +use log::{error, warn}; + +use enums::common::visibility::Visibility; +use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{ + insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token, + InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; + +use super::shared_job_args::SharedJobArgs; + +pub struct InsertSeedance2proJobsArgs<'a, 'tx> { + pub primary_order_id: &'a str, + pub maybe_additional_order_ids: Option<&'a [String]>, + pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>, + pub shared: SharedJobArgs<'a, 'tx>, +} + +pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result { + let InsertSeedance2proJobsArgs { + primary_order_id, + maybe_additional_order_ids, + maybe_wallet_ledger_entry_token, + mut shared, + } = args; + + // Build a deduplicated list with primary_order_id first. + let mut all_order_ids = vec![primary_order_id.to_string()]; + if let Some(additional) = maybe_additional_order_ids { + for id in additional { + if id != primary_order_id { + all_order_ids.push(id.clone()); + } + } + } + + let mut all_job_tokens: Vec = Vec::with_capacity(all_order_ids.len()); + + for (i, order_id) in all_order_ids.iter().enumerate() { + let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() }; + let idempotency_str = if i == 0 { shared.idempotency_token.to_string() } else { format!("{}-batch-{}", shared.idempotency_token, i) }; + + let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token( + InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs { + apriori_job_token: &job_token, + uuid_idempotency_token: &idempotency_str, + maybe_external_third_party_id: order_id, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_wallet_ledger_entry_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + creator_set_visibility: Visibility::Public, + mysql_executor: &mut **shared.transaction, + maybe_inference_args: None, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => all_job_tokens.push(token), + Err(err) => { + warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err); + if i == 0 { return Err(AdvancedCommonWebError::from_error(err)); } + } + } + } + + all_job_tokens.first().cloned().ok_or_else(|| { + error!("No inference job token was created"); + AdvancedCommonWebError::server_error_with_message("No inference job token was created") + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/mod.rs new file mode 100644 index 000000000..751cbf87e --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/mod.rs @@ -0,0 +1,3 @@ +pub mod insert_fal_job; +pub mod insert_seedance2pro_jobs; +pub mod shared_job_args; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/shared_job_args.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/shared_job_args.rs new file mode 100644 index 000000000..52fff46b8 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/insert_db_job/shared_job_args.rs @@ -0,0 +1,14 @@ +use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::prompts::PromptToken; +use tokens::tokens::users::UserToken; + +pub struct SharedJobArgs<'a, 'tx> { + pub apriori_job_token: &'a InferenceJobToken, + pub idempotency_token: &'a str, + pub user_token: &'a UserToken, + pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>, + pub maybe_prompt_token: Option<&'a PromptToken>, + pub ip_address: &'a str, + pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>, +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs index 4cf243587..f4a3071ce 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs @@ -1,7 +1,5 @@ pub mod omni_gen_video_generate_handler; +pub mod helpers; pub mod pipeline_v1; -mod request_helper; - -#[cfg(test)] -mod tests; pub mod pipeline_v2; +pub mod insert_db_job; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs index 156ea77d4..581c578a9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs @@ -9,37 +9,31 @@ use url::Url; use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse; -use artcraft_router::api::provider::Provider; +use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse; use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType; use enums::by_table::prompts::prompt_type::PromptType; +use enums::common::generation::common_generation_mode::CommonGenerationMode; use enums::common::generation::common_model_type::CommonModelType; use enums::common::generation::common_video_model::CommonVideoModel; use enums::common::generation_provider::GenerationProvider; -use enums::common::visibility::Visibility; use http_server_common::request::get_request_ip::get_request_ip; -use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory; -use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{ - insert_generic_inference_job_for_fal_queue_with_apriori_job_token, - InsertGenericInferenceForFalWithAprioriJobTokenArgs, -}; -use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{ - insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token, - InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs, -}; use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{ insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem, }; use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs}; -use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; -use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error; use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test; -use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::distill_video_request::distill_video_request; -use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::execute::execute_generation::execute_generation; -use crate::http_server::endpoints::omni_gen::generate::video::request_helper::resolve_kinovi_character_ids::resolve_kinovi_character_ids; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request; +use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs}; +use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs}; +use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs; +use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::run_pipeline_v1::{run_pipeline_v1, RunPipelineV1Args}; +use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args}; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::resolve_kinovi_character_ids::resolve_kinovi_character_ids; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::state::server_state::ServerState; use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map; @@ -63,7 +57,7 @@ pub async fn omni_gen_video_generate_handler( request: Json, server_state: web::Data>, ) -> Result, AdvancedCommonWebError> { - + info!("request: {:?}", request); payments_error_test(&request.prompt.as_deref().unwrap_or(""))?; @@ -112,8 +106,6 @@ pub async fn omni_gen_video_generate_handler( })?; // ==================== RESOLVE MEDIA TOKENS ==================== // - // Look up media file tokens BEFORE distilling. distill_video_request takes - // a pre-computed `MediaFileToken -> Url` map and does no I/O of its own. let media_file_hydration_map: Option> = { let mut all_tokens: Vec = Vec::new(); @@ -157,73 +149,64 @@ pub async fn omni_gen_video_generate_handler( } }; - // ==================== RESOLVE CHARACTER TOKENS ==================== // + let media_file_to_url_map: Option> = + media_file_hydration_map.as_ref().map(|map| { + map.iter().map(|(k, v)| (k.clone(), v.to_string())).collect() + }); - let kinovi_character_ids = resolve_kinovi_character_ids( - request.reference_character_tokens.as_deref(), - &mut mysql_connection, - ).await?; + // ==================== RESOLVE CHARACTERS ==================== // - // ==================== DETERMINE PROVIDER ==================== // - - let execution_provider = match request.model { - Some(CommonVideoModel::Seedance2p0) => Provider::Seedance2Pro, - Some(CommonVideoModel::Seedance2p0Fast) => Provider::Seedance2Pro, - _ => Provider::Fal, - }; - - // ==================== DISTILL ==================== // - - let distilled = distill_video_request(&request, media_file_hydration_map.as_ref(), execution_provider)?; - - info!("distilled plan: {:?}", distilled.plan); - - // ==================== BILLING ==================== // - - let cost = distilled.cost.cost_in_credits.unwrap_or(0); - - info!("Charging wallet: {} credits", cost); - - let apriori_job_token = InferenceJobToken::generate(); - - let maybe_deduction_result = if cost > 0 { - Some(attempt_wallet_deduction_else_common_web_error( - user_token, - Some(apriori_job_token.as_str()), - cost, + let kinovi_character_id_map: Option> = + resolve_kinovi_character_ids( + request.reference_character_tokens.as_deref(), &mut mysql_connection, - ).await?) - } else { - None + ).await?; + + // ==================== HYDRATE ROUTER REQUEST ==================== // + + let router_builder = hydrate_to_router_request(&request)?; + + // ==================== PIPELINE DISPATCH ==================== // + + let use_v2 = match request.model { + Some(CommonVideoModel::Seedance2p0) => true, + Some(CommonVideoModel::Seedance2p0Fast) => true, + _ => false, }; - // ==================== EXECUTE GENERATION ==================== // + let pipeline_result = if use_v2 { + run_pipeline_v2(RunPipelineV2Args { + router_builder: &router_builder, + server_state: &server_state, + mysql_connection: &mut mysql_connection, + user_token, + media_file_to_url_map: &media_file_to_url_map, + kinovi_character_id_map: &kinovi_character_id_map, + }).await? + } else { + run_pipeline_v1(RunPipelineV1Args { + request: &request, + router_builder: &router_builder, + server_state: &server_state, + mysql_connection: &mut mysql_connection, + user_token, + media_url_map: &media_file_hydration_map, + kinovi_character_id_map: &kinovi_character_id_map, + }).await? + }; - let gen_result = execute_generation( - &distilled, - &request, - &server_state, - media_file_hydration_map.as_ref(), - kinovi_character_ids, - maybe_deduction_result.as_ref().map(|d| &d.ledger_entry_token), - &mut mysql_connection, - ).await?; - - // ==================== DB TRANSACTION ==================== // + // ==================== WRITE RESULT ==================== // let ip_address = get_request_ip(&http_request); - let mut transaction = mysql_connection - .begin() - .await - .map_err(|err| { - error!("Error starting MySQL transaction: {:?}", err); - AdvancedCommonWebError::from_error(err) - })?; + let mut transaction = mysql_connection.begin().await.map_err(|err| { + error!("Error starting MySQL transaction: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; // -- Prompt -- - let prompt_result = insert_prompt(InsertPromptArgs { + let prompt_token = match insert_prompt(InsertPromptArgs { maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(user_token), @@ -232,18 +215,16 @@ pub async fn omni_gen_video_generate_handler( maybe_positive_prompt: request.prompt.as_deref(), maybe_negative_prompt: request.negative_prompt.as_deref(), maybe_other_args: None, - maybe_generation_mode: Some(gen_result.generation_mode), - maybe_aspect_ratio: None, - maybe_resolution: None, + maybe_generation_mode: Some(determine_generation_mode(&request)), + maybe_aspect_ratio: request.aspect_ratio, + maybe_resolution: request.resolution, maybe_batch_count: request.video_batch_count.map(|c| c as u8), maybe_generate_audio: request.generate_audio, maybe_duration_seconds: request.duration_seconds.map(|d| d as u32), creator_ip_address: &ip_address, mysql_executor: &mut *transaction, phantom: Default::default(), - }).await; - - let prompt_token = match prompt_result { + }).await { Ok(token) => Some(token), Err(err) => { warn!("Error inserting prompt: {:?}", err); @@ -262,14 +243,12 @@ pub async fn omni_gen_video_generate_handler( context_semantic_type: PromptContextSemanticType::VidStartFrame, }); } - if let Some(media_token) = &request.end_frame_image_media_token { context_items.push(PromptContextItem { media_token: media_token.clone(), context_semantic_type: PromptContextSemanticType::VidEndFrame, }); } - if let Some(ref_tokens) = &request.reference_image_media_tokens { for media_token in ref_tokens { context_items.push(PromptContextItem { @@ -278,7 +257,6 @@ pub async fn omni_gen_video_generate_handler( }); } } - if let Some(ref_tokens) = &request.reference_video_media_tokens { for media_token in ref_tokens { context_items.push(PromptContextItem { @@ -301,92 +279,47 @@ pub async fn omni_gen_video_generate_handler( // -- Inference job -- - let maybe_ledger_entry_token = maybe_deduction_result.as_ref() - .map(|d| &d.ledger_entry_token); - - let job_token = if gen_result.is_seedance2pro { - // Seedance2Pro path: insert one job per order_id (batch support). - let fallback_ids = vec![gen_result.external_job_id.clone()]; - let order_ids = gen_result.maybe_seedance_order_ids - .as_deref() - .unwrap_or(&fallback_ids); - - let mut all_job_tokens: Vec = Vec::with_capacity(order_ids.len()); - - for (i, order_id) in order_ids.iter().enumerate() { - let job_token = if i == 0 { - apriori_job_token.clone() - } else { - InferenceJobToken::generate() - }; - - let idempotency_str = if i == 0 { - idempotency_token.clone() - } else { - format!("{}-batch-{}", idempotency_token, i) - }; - - let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token( - InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs { - apriori_job_token: &job_token, - uuid_idempotency_token: &idempotency_str, - maybe_external_third_party_id: order_id, - maybe_inference_args: None, - maybe_prompt_token: prompt_token.as_ref(), - maybe_wallet_ledger_entry_token: maybe_ledger_entry_token, - maybe_creator_user_token: Some(user_token), + let job_token = match &pipeline_result.response { + GenerateVideoResponse::Seedance2Pro(payload) => { + insert_seedance2pro_jobs(InsertSeedance2proJobsArgs { + primary_order_id: &payload.order_id, + maybe_additional_order_ids: payload.maybe_order_ids.as_deref(), + maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(), + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.billing.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, maybe_avt_token: maybe_avt_token.as_ref(), - creator_ip_address: &ip_address, - creator_set_visibility: Visibility::Public, - mysql_executor: &mut *transaction, - phantom: Default::default(), - } - ).await; - - match db_result { - Ok(token) => all_job_tokens.push(token), - Err(err) => { - warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err); - if i == 0 { - return Err(AdvancedCommonWebError::from_error(err)); - } - } - } + maybe_prompt_token: prompt_token.as_ref(), + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await? } - - all_job_tokens.first().cloned().ok_or_else(|| { - error!("No inference job token was created"); - AdvancedCommonWebError::server_error_with_message("No inference job token was created") - })? - } else { - // Fal / other providers path. - // TODO: Pass maybe_wallet_ledger_entry_token to fal jobs once the fal insert supports it. - let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token( - InsertGenericInferenceForFalWithAprioriJobTokenArgs { - apriori_job_token: &apriori_job_token, - uuid_idempotency_token: &idempotency_token, - maybe_external_third_party_id: &gen_result.external_job_id, - fal_category: FalCategory::VideoGeneration, - maybe_inference_args: None, - maybe_prompt_token: prompt_token.as_ref(), - maybe_creator_user_token: Some(user_token), - maybe_avt_token: maybe_avt_token.as_ref(), - creator_ip_address: &ip_address, - creator_set_visibility: Visibility::Public, - mysql_executor: &mut *transaction, - starting_job_status_override: None, - maybe_frontend_failure_category: None, - maybe_failure_reason: None, - phantom: Default::default(), - } - ).await; - - match db_result { - Ok(token) => token, - Err(err) => { - warn!("Error inserting inference job: {:?}", err); - return Err(AdvancedCommonWebError::from_error(err)); - } + GenerateVideoResponse::Fal(payload) => { + let external_id = payload.request_id.as_deref().ok_or_else(|| { + error!("Fal generation response missing request_id"); + AdvancedCommonWebError::server_error_with_message("Fal generation response missing request_id") + })?; + insert_fal_job(InsertFalJobArgs { + external_job_id: external_id, + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.billing.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, + maybe_avt_token: maybe_avt_token.as_ref(), + maybe_prompt_token: prompt_token.as_ref(), + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await? + } + GenerateVideoResponse::Artcraft(payload) => { + payload.inference_job_token.clone() + } + other => { + error!("Unexpected generation response variant: {:?}", other); + return Err(AdvancedCommonWebError::server_error_with_message("Unexpected generation response")); } }; @@ -400,3 +333,23 @@ pub async fn omni_gen_video_generate_handler( inference_job_token: job_token, })) } + +fn determine_generation_mode(request: &OmniGenVideoCostAndGenerateRequest) -> CommonGenerationMode { + let has_keyframe = request.start_frame_image_media_token.is_some() + || request.end_frame_image_media_token.is_some(); + + if has_keyframe { + return CommonGenerationMode::Keyframe; + } + + let has_reference = request.reference_image_media_tokens.as_ref().is_some_and(|t| !t.is_empty()) + || request.reference_video_media_tokens.as_ref().is_some_and(|t| !t.is_empty()) + || request.reference_audio_media_tokens.as_ref().is_some_and(|t| !t.is_empty()) + || request.reference_character_tokens.as_ref().is_some_and(|t| !t.is_empty()); + + if has_reference { + return CommonGenerationMode::Reference; + } + + CommonGenerationMode::Text +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/distill_helper/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/distill_helper/mod.rs index 706ab177f..ca33f3690 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/distill_helper/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/distill_helper/mod.rs @@ -1,3 +1,2 @@ -pub(crate) mod hydrate_to_router_request; pub(crate) mod request_to_costs; pub(crate) mod request_to_plan; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/distill_video_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/distill_video_request.rs deleted file mode 100644 index 56c67abe5..000000000 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/distill_video_request.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Distillation step for the omni-gen video endpoints. -//! -//! Takes a raw [`OmniGenVideoCostAndGenerateRequest`] plus a (pre-computed) -//! `MediaFileToken -> Url` map and produces a fully self-contained -//! [`DistilledVideoRequest`] holding: -//! - the (private) router request, kept for inspection in tests / debugging -//! - the [`VideoGenerationCostEstimate`] (Artcraft provider, what we bill on) -//! - the [`VideoGenerationPlan`] (execution provider, what we actually execute) - -use std::collections::HashMap; - -use log::warn; -use url::Url; - -use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; -use artcraft_router::api::audio_list_ref::AudioListRef; -use artcraft_router::api::image_list_ref::ImageListRef; -use artcraft_router::api::image_ref::ImageRef; -use artcraft_router::api::provider::Provider; -use artcraft_router::api::video_list_ref::VideoListRef; -use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; -use artcraft_router::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; -use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan; -use tokens::tokens::media_files::MediaFileToken; - -use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; - -use super::distill_helper::hydrate_to_router_request::hydrate_to_router_request; - -/// Self-contained, owned representation of a fully-distilled omni-gen video -/// request: the router request, the bill-on cost estimate, and the executable -/// plan, all in one place. -pub struct DistilledVideoRequest { - request: GenerateVideoRequestBuilder, - - /// Cost estimate as computed by the Artcraft provider — this is what we bill on. - pub cost: VideoGenerationCostEstimate, - - /// Execution plan as computed by the execution provider — what we hand to the router. - pub plan: VideoGenerationPlan, - - /// The provider used for execution (Fal, Seedance2Pro, etc.). - pub execution_provider: Provider, -} - -impl DistilledVideoRequest { - /// Borrow the execution plan. - pub fn plan(&self) -> &VideoGenerationPlan { - &self.plan - } - - /// Borrow the underlying router request. Useful for tests / debugging. - #[allow(dead_code)] - pub(crate) fn request(&self) -> &GenerateVideoRequestBuilder { - &self.request - } -} - -/// Build a [`DistilledVideoRequest`] from a raw API request and a pre-computed -/// `MediaFileToken -> Url` hydration map. -pub fn distill_video_request( - request: &OmniGenVideoCostAndGenerateRequest, - media_file_hydration_map: Option<&HashMap>, - execution_provider: Provider, -) -> Result { - // 1. Convert the raw API request into a router request. - let initial = hydrate_to_router_request(request)?; - - // 2. Cost estimate (Artcraft provider). - // `initial` already has media fields in token form (from hydrate_to_router_request), - // which is exactly what the Artcraft cost builder needs. Just swap the provider. - let cost: VideoGenerationCostEstimate = { - let cost_request = GenerateVideoRequestBuilder { - provider: Provider::Artcraft, - ..initial.clone() - }; - let cost_plan = cost_request.build().map_err(|e| { - warn!("Failed to build cost plan during video distillation: {}", e); - AdvancedCommonWebError::from_error(e) - })?; - cost_plan.estimate_costs() - }; - - // 3. Resolve media tokens to URLs for the execution request. - let start_frame_url = resolve_single_media_token( - request.start_frame_image_media_token.as_ref(), - media_file_hydration_map, - )?; - let end_frame_url = resolve_single_media_token( - request.end_frame_image_media_token.as_ref(), - media_file_hydration_map, - )?; - let reference_image_urls = resolve_media_token_list( - request.reference_image_media_tokens.as_ref(), - media_file_hydration_map, - )?; - let reference_video_urls = resolve_media_token_list( - request.reference_video_media_tokens.as_ref(), - media_file_hydration_map, - )?; - let reference_audio_urls = resolve_media_token_list( - request.reference_audio_media_tokens.as_ref(), - media_file_hydration_map, - )?; - - // 4. Build the execution request with resolved URLs. - let exec_request = GenerateVideoRequestBuilder { - model: initial.model, - provider: execution_provider, - prompt: initial.prompt, - negative_prompt: initial.negative_prompt, - start_frame: start_frame_url.map(ImageRef::Url), - end_frame: end_frame_url.map(ImageRef::Url), - reference_images: reference_image_urls.map(ImageListRef::Urls), - reference_videos: reference_video_urls.map(VideoListRef::Urls), - reference_audio: reference_audio_urls.map(AudioListRef::Urls), - reference_character_tokens: None, - resolution: initial.resolution, - aspect_ratio: initial.aspect_ratio, - duration_seconds: initial.duration_seconds, - video_batch_count: initial.video_batch_count, - generate_audio: initial.generate_audio, - request_mismatch_mitigation_strategy: initial.request_mismatch_mitigation_strategy, - idempotency_token: initial.idempotency_token, - }; - - // 5. Build the execution plan. - let plan = exec_request.build().map_err(|e| { - warn!("Failed to build video generation plan during distillation: {}", e); - AdvancedCommonWebError::from_error(e) - })?; - - Ok(DistilledVideoRequest { - request: exec_request, - cost, - plan, - execution_provider, - }) -} - -/// Resolve a single optional media token to its URL string. -fn resolve_single_media_token( - token: Option<&MediaFileToken>, - hydration_map: Option<&HashMap>, -) -> Result, AdvancedCommonWebError> { - let token = match token { - Some(t) => t, - None => return Ok(None), - }; - - let map = hydration_map.ok_or_else(|| { - AdvancedCommonWebError::BadInputWithSimpleMessage( - "media token supplied but no hydration map was provided".to_string(), - ) - })?; - - match map.get(token) { - Some(url) => Ok(Some(url.to_string())), - None => Err(AdvancedCommonWebError::BadInputWithSimpleMessage(format!( - "Media token not found in hydration map: {:?}", - token - ))), - } -} - -/// Resolve a list of media tokens to their URL strings. -fn resolve_media_token_list( - tokens: Option<&Vec>, - hydration_map: Option<&HashMap>, -) -> Result>, AdvancedCommonWebError> { - let tokens = match tokens { - Some(tokens) if !tokens.is_empty() => tokens, - _ => return Ok(None), - }; - - let map = hydration_map.ok_or_else(|| { - AdvancedCommonWebError::BadInputWithSimpleMessage( - "media tokens supplied but no hydration map was provided".to_string(), - ) - })?; - - let mut urls: Vec = Vec::with_capacity(tokens.len()); - for token in tokens { - match map.get(token) { - Some(url) => urls.push(url.to_string()), - None => { - return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(format!( - "Media token not found in hydration map: {:?}", - token - ))); - } - } - } - - Ok(Some(urls)) -} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/execute_fal.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/execute_fal.rs index 2bbb94bb1..f4ef9eec1 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/execute_fal.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/execute_fal.rs @@ -2,54 +2,27 @@ use log::warn; -use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; -use artcraft_router::client::router_client::RouterClient; -use artcraft_router::client::router_fal_client::RouterFalClient; +use artcraft_router::api::provider::Provider; use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse; -use enums::common::generation::common_generation_mode::CommonGenerationMode; +use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan; use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client; use crate::state::server_state::ServerState; -use super::super::distill_video_request::DistilledVideoRequest; -use super::execute_generation::GenerationResult; - /// Execute generation via Fal (the existing path). -pub(super) async fn execute_generation_fal( - distilled: &DistilledVideoRequest, - request: &OmniGenVideoCostAndGenerateRequest, +pub(crate) async fn execute_generation_fal( + plan: &VideoGenerationPlan, server_state: &ServerState, -) -> Result { - let fal_client = RouterFalClient::new( - server_state.fal.api_key.clone(), - server_state.fal.webhook_url.clone(), - ); - let router_client = RouterClient::Fal(fal_client); +) -> Result { + let router_client = build_router_client(Provider::Fal, server_state)?; - let generation_response = distilled.plan().generate_video(&router_client) + let response = plan.generate_video(&router_client) .await .map_err(|e| { warn!("Video generation failed (Fal): {:?}", e); AdvancedCommonWebError::from_error(e) })?; - let external_job_id = match &generation_response { - GenerateVideoResponse::Artcraft(p) => p.inference_job_token.as_str().to_string(), - GenerateVideoResponse::Muapi(p) => p.request_id.as_str().to_string(), - GenerateVideoResponse::Seedance2Pro(p) => p.order_id.clone(), - GenerateVideoResponse::Fal(p) => p.request_id.clone().unwrap_or_default(), - }; - - let generation_mode = if request.start_frame_image_media_token.is_some() { - CommonGenerationMode::Keyframe - } else { - CommonGenerationMode::Text - }; - - Ok(GenerationResult { - external_job_id, - is_seedance2pro: false, - maybe_seedance_order_ids: None, - generation_mode, - }) + Ok(response) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/execute_generation.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/execute_generation.rs deleted file mode 100644 index c91a67991..000000000 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/execute_generation.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Main entrypoint for video generation execution. -//! -//! Routes to the appropriate provider (Fal or Seedance2Pro/Kinovi) -//! based on the distilled request's `execution_provider`. - -use std::collections::HashMap; - -use url::Url; - -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 enums::common::generation::common_generation_mode::CommonGenerationMode; -use tokens::tokens::media_files::MediaFileToken; -use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; - -use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; -use crate::state::server_state::ServerState; - -use super::super::distill_video_request::DistilledVideoRequest; -use super::execute_fal::execute_generation_fal; -use super::execute_kinovi::execute_generation_kinovi; - -/// Result of a successful generation, regardless of provider. -pub struct GenerationResult { - /// The external job ID used to track the generation (first order_id for Seedance). - pub external_job_id: String, - - /// Whether this is a Seedance2Pro generation (changes DB insertion path). - pub is_seedance2pro: bool, - - /// For Seedance2Pro batch jobs, the list of all order IDs. - pub maybe_seedance_order_ids: Option>, - - /// The generation mode (Text, Keyframe, Reference). - pub generation_mode: CommonGenerationMode, -} - -/// Execute video generation via the appropriate provider. -pub async fn execute_generation( - distilled: &DistilledVideoRequest, - request: &OmniGenVideoCostAndGenerateRequest, - server_state: &ServerState, - media_file_hydration_map: Option<&HashMap>, - kinovi_character_ids: Option>, - maybe_wallet_ledger_entry_token: Option<&WalletLedgerEntryToken>, - mysql_connection: &mut sqlx::pool::PoolConnection, -) -> Result { - match distilled.execution_provider { - Provider::Seedance2Pro => { - execute_generation_kinovi( - distilled, - request, - server_state, - media_file_hydration_map, - kinovi_character_ids, - maybe_wallet_ledger_entry_token, - mysql_connection, - ).await - } - _ => { - execute_generation_fal( - distilled, - request, - server_state, - ).await - } - } -} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/execute_kinovi.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/execute_kinovi.rs index 5cfd02425..032cb51f6 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/execute_kinovi.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/execute_kinovi.rs @@ -9,10 +9,11 @@ use log::{error, info, warn}; use url::Url; use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +use artcraft_router::generate::generate_video::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload}; use enums::common::generation::common_aspect_ratio::CommonAspectRatio; -use enums::common::generation::common_generation_mode::CommonGenerationMode; use enums::common::generation::common_resolution::CommonResolution; use enums::common::generation::common_video_model::CommonVideoModel; + use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest, KinoviBatchCount, KinoviModelType, KinoviAspectRatio, KinoviOutputResolution}; use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{ @@ -30,18 +31,14 @@ use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refun use crate::state::server_state::ServerState; use crate::util::http_download_url_to_bytes::http_download_url_to_bytes; -use super::super::distill_video_request::DistilledVideoRequest; -use super::execute_generation::GenerationResult; - -pub(super) async fn execute_generation_kinovi( - _distilled: &DistilledVideoRequest, +pub(crate) async fn execute_generation_kinovi( request: &OmniGenVideoCostAndGenerateRequest, server_state: &ServerState, media_file_hydration_map: Option<&HashMap>, kinovi_character_ids: Option>, maybe_wallet_ledger_entry_token: Option<&WalletLedgerEntryToken>, mysql_connection: &mut sqlx::pool::PoolConnection, -) -> Result { +) -> Result { let result = execute_generation_kinovi_inner( request, server_state, @@ -66,7 +63,7 @@ async fn execute_generation_kinovi_inner( server_state: &ServerState, media_file_hydration_map: Option<&HashMap>, kinovi_character_ids: Option>, -) -> Result { +) -> Result { let session = Seedance2ProSession::from_cookies_string( server_state.seedance2pro.cookies.clone() ); @@ -97,23 +94,6 @@ async fn execute_generation_kinovi_inner( &session, hydration_map, request.reference_audio_media_tokens.as_deref(), ).await?; - // Determine generation mode. - let is_keyframe = request.start_frame_image_media_token.is_some() - || request.end_frame_image_media_token.is_some(); - let is_reference = request.reference_image_media_tokens.as_ref().map_or(false, |t| !t.is_empty()) - || request.reference_video_media_tokens.as_ref().map_or(false, |t| !t.is_empty()) - || request.reference_audio_media_tokens.as_ref().map_or(false, |t| !t.is_empty()); - - // TODO(bt): Move this logic to `artcraft_router` to the `execute` phase. - - let generation_mode = if is_keyframe { - CommonGenerationMode::Keyframe - } else if is_reference { - CommonGenerationMode::Reference - } else { - CommonGenerationMode::Text - }; - // Map aspect ratio / duration / batch from the request. let aspect_ratio = map_common_aspect_ratio_to_kinovi_resolution(request.aspect_ratio); @@ -172,17 +152,12 @@ async fn execute_generation_kinovi_inner( gen_response.task_id, gen_response.order_id ); - let order_ids: Vec = match gen_response.order_ids { - Some(ids) if !ids.is_empty() => ids, - _ => vec![gen_response.order_id.clone()], - }; - - Ok(GenerationResult { - external_job_id: gen_response.order_id, - is_seedance2pro: true, - maybe_seedance_order_ids: Some(order_ids), - generation_mode, - }) + Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload { + order_id: gen_response.order_id, + task_id: gen_response.task_id, + maybe_order_ids: gen_response.order_ids, + maybe_task_ids: gen_response.task_ids, + })) } // --- Upload helpers --- diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/mod.rs index b4f3645e6..1fdf742e5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/execute/mod.rs @@ -1,3 +1,2 @@ -mod execute_fal; -mod execute_kinovi; -pub mod execute_generation; +pub(crate) mod execute_fal; +pub(crate) mod execute_kinovi; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/mod.rs index abedd3fe8..297e127e9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/mod.rs @@ -1,3 +1,2 @@ -pub mod distill_helper; -pub mod distill_video_request; pub mod execute; +pub mod run_pipeline_v1; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/run_pipeline_v1.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/run_pipeline_v1.rs new file mode 100644 index 000000000..dbf5a6a44 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v1/run_pipeline_v1.rs @@ -0,0 +1,129 @@ +use std::collections::HashMap; + +use log::{info, warn}; +use url::Url; + +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +use artcraft_router::api::audio_list_ref::AudioListRef; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::image_ref::ImageRef; +use artcraft_router::api::provider::Provider; +use artcraft_router::api::video_list_ref::VideoListRef; +use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use enums::common::generation::common_video_model::CommonVideoModel; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::pipeline_result::PipelineResult; +use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::execute::execute_fal::execute_generation_fal; +use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::execute::execute_kinovi::execute_generation_kinovi; +use crate::state::server_state::ServerState; + +// ── Pipeline args ── + +pub struct RunPipelineV1Args<'a> { + pub request: &'a OmniGenVideoCostAndGenerateRequest, + pub router_builder: &'a GenerateVideoRequestBuilder, + pub server_state: &'a ServerState, + pub mysql_connection: &'a mut sqlx::pool::PoolConnection, + pub user_token: &'a UserToken, + pub media_url_map: &'a Option>, + pub kinovi_character_id_map: &'a Option>, +} + +// ── Pipeline entrypoint ── + +pub async fn run_pipeline_v1(args: RunPipelineV1Args<'_>) -> Result { + let RunPipelineV1Args { + request, + router_builder, + server_state, + mysql_connection, + user_token, + media_url_map, + kinovi_character_id_map, + } = args; + + // v1 execute_generation expects a flat Vec of kinovi IDs + let kinovi_character_ids: Option> = kinovi_character_id_map + .as_ref() + .map(|map| map.values().cloned().collect()); + + let execution_provider = match request.model { + Some(CommonVideoModel::Seedance2p0) => Provider::Seedance2Pro, + Some(CommonVideoModel::Seedance2p0Fast) => Provider::Seedance2Pro, + _ => Provider::Fal, + }; + + // ── Cost estimate (Artcraft provider — what we bill on) ── + + let cost = { + let cost_request = GenerateVideoRequestBuilder { + provider: Provider::Artcraft, + ..router_builder.clone() + }; + let cost_plan = cost_request.build().map_err(|e| { + warn!("Failed to build cost plan during video distillation: {}", e); + AdvancedCommonWebError::from_error(e) + })?; + cost_plan.estimate_costs() + }; + + // ── Build the execution request (clone builder, override provider + media URLs) ── + + let empty_map = HashMap::new(); + let url_map = media_url_map.as_ref().unwrap_or(&empty_map); + + let mut exec_request = router_builder.clone(); + exec_request.provider = execution_provider; + + exec_request.start_frame = lookup_url(url_map, request.start_frame_image_media_token.as_ref()).map(ImageRef::Url); + exec_request.end_frame = lookup_url(url_map, request.end_frame_image_media_token.as_ref()).map(ImageRef::Url); + exec_request.reference_images = lookup_urls(url_map, request.reference_image_media_tokens.as_ref()).map(ImageListRef::Urls); + exec_request.reference_videos = lookup_urls(url_map, request.reference_video_media_tokens.as_ref()).map(VideoListRef::Urls); + exec_request.reference_audio = lookup_urls(url_map, request.reference_audio_media_tokens.as_ref()).map(AudioListRef::Urls); + + let plan = exec_request.build().map_err(|e| { + warn!("Failed to build video generation plan during distillation: {}", e); + AdvancedCommonWebError::from_error(e) + })?; + + info!("v1 distilled plan: {:?}", plan); + + // ── Bill wallet ── + + let cost_in_credits = cost.cost_in_credits.unwrap_or(0); + let billing = bill_wallet(user_token, cost_in_credits, mysql_connection).await?; + + // ── Execute generation via the appropriate provider ── + + let response = match execution_provider { + Provider::Seedance2Pro => { + execute_generation_kinovi( + request, server_state, + media_url_map.as_ref(), kinovi_character_ids, + billing.maybe_wallet_ledger_entry_token.as_ref(), mysql_connection, + ).await? + } + _ => { + execute_generation_fal(&plan, server_state).await? + } + }; + + Ok(PipelineResult { billing, response }) +} + +// ── Helpers ── + +fn lookup_url(map: &HashMap, token: Option<&MediaFileToken>) -> Option { + token.and_then(|t| map.get(t)).map(|u| u.to_string()) +} + +fn lookup_urls(map: &HashMap, tokens: Option<&Vec>) -> Option> { + tokens + .filter(|v| !v.is_empty()) + .map(|tokens| tokens.iter().filter_map(|t| map.get(t).map(|u| u.to_string())).collect()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/mod.rs index e69de29bb..f207b2129 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/mod.rs @@ -0,0 +1 @@ +pub mod run_pipeline_v2; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/run_pipeline_v2.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/run_pipeline_v2.rs new file mode 100644 index 000000000..620f2b2f4 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/run_pipeline_v2.rs @@ -0,0 +1,147 @@ +use std::collections::HashMap; + +use log::{error, info, warn}; +use sqlx::pool::PoolConnection; +use artcraft_router::api::common_video_model::CommonVideoModel; +use artcraft_router::api::provider::Provider; +use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse; +use artcraft_router::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext; +use artcraft_router::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::pipeline_result::PipelineResult; +use crate::state::server_state::ServerState; + +pub struct RunPipelineV2Args<'a> { + pub router_builder: &'a GenerateVideoRequestBuilder, + pub server_state: &'a ServerState, + pub mysql_connection: &'a mut PoolConnection, + pub user_token: &'a UserToken, + pub media_file_to_url_map: &'a Option>, + pub kinovi_character_id_map: &'a Option>, +} + +pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result { + let RunPipelineV2Args { + router_builder, + server_state, + mysql_connection, + user_token, + media_file_to_url_map, + kinovi_character_id_map, + } = args; + + let provider = match router_builder.model { + CommonVideoModel::Seedance2p0 => Provider::Seedance2Pro, + CommonVideoModel::Seedance2p0Fast => Provider::Seedance2Pro, + _ => Provider::Fal, + }; + + // 1. Build execution request + let mut exec_builder = router_builder.clone(); + exec_builder.provider = provider; + + let draft_or_request = exec_builder.build2() + .map_err(|e| { + warn!("Failed to build2 for v2 pipeline: {}", e); + AdvancedCommonWebError::from_error(e) + })?; + + // 2. Calculate cost (swap provider to Artcraft for billing) + let mut cost_builder = router_builder.clone(); + cost_builder.provider = Provider::Artcraft; + + let cost = cost_builder.build2() + .map_err(|e| { + warn!("Failed to build2 cost estimate for v2: {}", e); + AdvancedCommonWebError::from_error(e) + })? + .estimate_cost() + .map_err(|e| { + warn!("Failed to estimate cost for v2: {}", e); + AdvancedCommonWebError::from_error(e) + })? + .cost_in_credits + .unwrap_or(0); + + info!("v2 estimated cost: {} credits", cost); + + // 3. Bill wallet + let billing = bill_wallet(user_token, cost, mysql_connection).await?; + + // 4. Upload media (if draft) and generate video. + // The entire block is wrapped so Kinovi failures trigger a refund. + let result = upload_and_generate( + draft_or_request, + server_state, + media_file_to_url_map.as_ref(), + kinovi_character_id_map.as_ref(), + ).await; + + // 5. On failure, refund wallet for Kinovi requests. + if let Err(ref err) = result { + if matches!(provider, Provider::Seedance2Pro) { + if let Some(ledger_entry_token) = billing.maybe_wallet_ledger_entry_token.as_ref() { + warn!("Kinovi v2 generation failed, issuing refund for {}: {:?}", ledger_entry_token.as_str(), err); + + let result = refund_wallet_after_api_failure(ledger_entry_token, mysql_connection).await; + + if let Err(refund_err) = result { + error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err); + } + } + } + } + + let response = result?; + + info!("v2 generation response: {:?}", response); + + Ok(PipelineResult { billing, response }) +} + +/// Finalize the draft (uploading media if needed), then send the generation request. +/// +/// This is the block that gets refunded on failure for Kinovi providers. +async fn upload_and_generate( + draft_or_request: VideoGenerationDraftOrRequest, + server_state: &ServerState, + media_file_urls_by_token: Option<&HashMap>, + kinovi_character_ids: Option<&HashMap>, +) -> Result { + + let provider = draft_or_request.get_provider(); + let client = build_router_client(provider, server_state)?; + + let video_request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(request) => request, + VideoGenerationDraftOrRequest::Draft(draft) => { + let draft_context = VideoGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: media_file_urls_by_token, + character_token_to_kinovi_id_map: kinovi_character_ids, + }; + + draft.finalize(draft_context) + .await + .map_err(|err| { + warn!("Failed to finalize v2 draft: {:?}", err); + AdvancedCommonWebError::from_error(err) + })? + } + }; + + video_request.send_request(&client) + .await + .map_err(|err| { + warn!("v2 video generation failed: {:?}", err); + AdvancedCommonWebError::from_error(err) + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_helper/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_helper/mod.rs deleted file mode 100644 index 7a8859620..000000000 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_helper/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod resolve_kinovi_character_ids; 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 deleted file mode 100644 index 56c79b849..000000000 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/kinovi/mod.rs +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index f537f3e68..000000000 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/kinovi/test_kinovi_seedance_2p0.rs +++ /dev/null @@ -1,551 +0,0 @@ -//! 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::pipeline_v1::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.request.start_frame_url.is_none()); - assert!(plan.request.end_frame_url.is_none()); - assert!(plan.request.reference_image_urls.is_none()); - assert!(plan.request.reference_video_urls.is_none()); - assert!(plan.request.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.request.prompt, "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.request.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.request.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.request.start_frame_url.is_some()); - assert!(plan.request.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.request.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.request.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.request.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.request.start_frame_url.is_some()); - assert!(plan.request.end_frame_url.is_some()); - assert_eq!(plan.request.reference_image_urls.as_ref().map(|v| v.len()), Some(1)); - assert_eq!(plan.request.reference_video_urls.as_ref().map(|v| v.len()), Some(1)); - assert_eq!(plan.request.reference_audio_urls.as_ref().map(|v| v.len()), Some(1)); - assert_eq!(plan.request.prompt, "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.request.duration_seconds >= 4, "expected >= 4, got {}", plan.request.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.request.duration_seconds <= 15, "expected <= 15, got {}", plan.request.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. - assert!(plan.request.prompt.is_empty()); - } else { - panic!("wrong plan variant"); - } - } - - // -- Aspect ratio tests -- - - #[test] - fn aspect_ratio_wide_16x9() { - use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio; - let req = make_request( - Some("test"), Some(CommonAspectRatio::WideSixteenByNine), Some(5), None, - None, None, None, None, None, - ); - let d = distill_text(&req); - if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { - assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Landscape16x9)); - } else { panic!("wrong plan variant"); } - } - - #[test] - fn aspect_ratio_tall_9x16() { - use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio; - let req = make_request( - Some("test"), Some(CommonAspectRatio::TallNineBySixteen), Some(5), None, - None, None, None, None, None, - ); - let d = distill_text(&req); - if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { - assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Portrait9x16)); - } else { panic!("wrong plan variant"); } - } - - #[test] - fn aspect_ratio_square() { - use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio; - let req = make_request( - Some("test"), Some(CommonAspectRatio::Square), Some(5), None, - None, None, None, None, None, - ); - let d = distill_text(&req); - if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { - assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Square1x1)); - } else { panic!("wrong plan variant"); } - } - - #[test] - fn aspect_ratio_4x3() { - use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio; - let req = make_request( - Some("test"), Some(CommonAspectRatio::WideFourByThree), Some(5), None, - None, None, None, None, None, - ); - let d = distill_text(&req); - if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { - assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Standard4x3)); - } else { panic!("wrong plan variant"); } - } - - #[test] - fn aspect_ratio_3x4() { - use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio; - let req = make_request( - Some("test"), Some(CommonAspectRatio::TallThreeByFour), Some(5), None, - None, None, None, None, None, - ); - let d = distill_text(&req); - if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { - assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Portrait3x4)); - } else { panic!("wrong plan variant"); } - } - - #[test] - fn aspect_ratio_none_defaults_to_landscape() { - use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio; - let req = make_request( - Some("test"), None, Some(5), None, - None, None, None, None, None, - ); - let d = distill_text(&req); - if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { - assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Landscape16x9)); - } else { panic!("wrong plan variant"); } - } - - #[test] - fn aspect_ratio_auto_defaults_to_landscape() { - use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio; - let req = make_request( - Some("test"), Some(CommonAspectRatio::Auto), Some(5), None, - None, None, None, None, None, - ); - let d = distill_text(&req); - if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() { - assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Landscape16x9)); - } 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 deleted file mode 100644 index baf495bf2..000000000 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod test_seedance_1p5_pro; -mod test_veo_2; -mod test_veo_3; -mod kinovi; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_seedance_1p5_pro.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_seedance_1p5_pro.rs deleted file mode 100644 index e869e468f..000000000 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_seedance_1p5_pro.rs +++ /dev/null @@ -1,772 +0,0 @@ -//! Tests for omni-gen video against the Seedance 1.5 Pro model. -//! -//! Seedance 1.5 Pro supports both text-to-video and image-to-video (via -//! start_frame / end_frame). Pricing is based on resolution × duration × -//! audio, computed via a token-based formula (30 FPS, $2.40/M tokens with -//! audio, $1.20/M without). The special case 720p/5s/audio returns a flat 26¢. -//! -//! The execution plan (`FalSeedance1p5Pro`) carries mode (TextToVideo vs -//! ImageToVideo), aspect_ratio, resolution, duration, and generate_audio. - -#[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 enums::common::generation::common_aspect_ratio::CommonAspectRatio; - use enums::common::generation::common_resolution::CommonResolution; - use enums::common::generation::common_video_model::CommonVideoModel; - use tokens::tokens::media_files::MediaFileToken; - use url::Url; - - use artcraft_router::api::provider::Provider; - use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::distill_video_request::{ - distill_video_request, DistilledVideoRequest, - }; - - // ── Helpers ──────────────────────────────────────────────────────────────── - - fn make_request( - prompt: Option<&str>, - aspect_ratio: Option, - resolution: Option, - duration_seconds: Option, - generate_audio: Option, - start_frame_token: Option, - end_frame_token: Option, - ) -> OmniGenVideoCostAndGenerateRequest { - OmniGenVideoCostAndGenerateRequest { - idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()), - model: Some(CommonVideoModel::Seedance1p5Pro), - 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: None, - reference_video_media_tokens: None, - reference_audio_media_tokens: None, - reference_character_tokens: None, - resolution, - aspect_ratio, - quality: None, - duration_seconds, - video_batch_count: None, - generate_audio, - } - } - - fn fake_token(name: &str) -> (MediaFileToken, HashMap) { - let token = MediaFileToken::new_from_str(name); - let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap(); - let mut map = HashMap::new(); - map.insert(token.clone(), url); - (token, map) - } - - fn fake_two_tokens(a: &str, b: &str) -> (MediaFileToken, MediaFileToken, HashMap) { - let t1 = MediaFileToken::new_from_str(a); - let t2 = MediaFileToken::new_from_str(b); - let u1 = Url::parse(&format!("https://fake.example.com/{}.png", a)).unwrap(); - let u2 = Url::parse(&format!("https://fake.example.com/{}.png", b)).unwrap(); - let mut map = HashMap::new(); - map.insert(t1.clone(), u1); - map.insert(t2.clone(), u2); - (t1, t2, map) - } - - fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest { - let empty: HashMap = HashMap::new(); - distill_video_request(request, Some(&empty), Provider::Fal) - .expect("distill_video_request should succeed for Seedance 1.5 Pro (text)") - } - - fn distill_with_map( - request: &OmniGenVideoCostAndGenerateRequest, - hydration: &HashMap, - ) -> DistilledVideoRequest { - distill_video_request(request, Some(hydration), Provider::Fal) - .expect("distill_video_request should succeed for Seedance 1.5 Pro (image)") - } - - // ──────────────────────────────────────────────────────────────────────────── - // COST - // ──────────────────────────────────────────────────────────────────────────── - // - // Pricing matches `estimate_video_cost_artcraft_seedance1p5_pro`, which - // delegates to the fal token-based calculator: - // 720p/5s/audio = flat 26¢ - // other combos = ceil(H×W×30×dur / 1024 × $rate / 1M × 100) - // - // Default resolution = 720p, default duration = 5s, default audio = true. - mod cost { - use super::*; - - mod text { - use super::*; - - fn cost( - duration: Option, - resolution: Option, - audio: Option, - ) -> u64 { - let request = make_request(Some("p"), None, resolution, duration, audio, None, None); - distill_text(&request).cost.cost_in_usd_cents.unwrap() - } - - // ── Default (720p/5s/audio=true) = 26¢ ─────────────────────────────── - - #[test] - fn default_costs_26_cents() { - assert_eq!(cost(None, None, None), 26); - } - - #[test] - fn explicit_5s_720p_audio_costs_26() { - assert_eq!(cost(Some(5), Some(CommonResolution::SevenTwentyP), Some(true)), 26); - } - - // ── Duration scaling at default resolution ──────────────────────────── - - #[test] - fn four_seconds_costs_26() { - // 4s at 720p uses token calc: ceil(1280*720*30*4/1024 * 2.4/1M * 100) ≈ 26¢ - assert_eq!(cost(Some(4), None, None), 26); - } - - #[test] - fn ten_seconds_costs_65() { - assert_eq!(cost(Some(10), None, None), 65); - } - - #[test] - fn twelve_seconds_costs_78() { - assert_eq!(cost(Some(12), None, None), 78); - } - - // ── Audio toggle ────────────────────────────────────────────────────── - - #[test] - fn no_audio_5s_720p_costs_13() { - assert_eq!(cost(Some(5), Some(CommonResolution::SevenTwentyP), Some(false)), 13); - } - - #[test] - fn no_audio_10s_720p() { - // ceil(1280*720*30*10/1024 * 1.2/1M * 100) ≈ 33 - assert_eq!(cost(Some(10), Some(CommonResolution::SevenTwentyP), Some(false)), 33); - } - - // ── Resolution changes ──────────────────────────────────────────────── - - #[test] - fn ten_eighty_p_5s_audio() { - // ceil(1920*1080*30*5/1024 * 2.4/1M * 100) ≈ 73 - assert_eq!(cost(Some(5), Some(CommonResolution::TenEightyP), Some(true)), 73); - } - - #[test] - fn four_eighty_p_5s_audio() { - // ceil(640*480*30*5/1024 * 2.4/1M * 100) ≈ 11 - assert_eq!(cost(Some(5), Some(CommonResolution::FourEightyP), Some(true)), 11); - } - - // ── Cost is independent of aspect ratio ─────────────────────────────── - - #[test] - fn cost_independent_of_aspect_ratio() { - let ars = [ - None, - Some(CommonAspectRatio::Square), - Some(CommonAspectRatio::WideSixteenByNine), - Some(CommonAspectRatio::TallNineBySixteen), - Some(CommonAspectRatio::Auto), - ]; - for ar in ars { - let request = make_request(Some("p"), ar, None, None, None, None, None); - assert_eq!( - distill_text(&request).cost.cost_in_usd_cents.unwrap(), 26, - "expected 26¢ regardless of aspect ratio {:?}", ar, - ); - } - } - - // ── Metadata flags ──────────────────────────────────────────────────── - - #[test] - fn cost_metadata_flags_are_default() { - let d = distill_text(&make_request(Some("p"), None, None, None, None, None, None)); - assert!(!d.cost.is_free); - assert!(!d.cost.is_unlimited); - assert!(!d.cost.is_rate_limited); - assert!(!d.cost.has_watermark); - } - } - - mod image { - use super::*; - - // ── Image-to-video has the same pricing as text-to-video ────────────── - - #[test] - fn image_to_video_default_costs_26() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, None, None, Some(start), None); - assert_eq!(distill_with_map(&request, &map).cost.cost_in_usd_cents.unwrap(), 26); - } - - #[test] - fn image_to_video_matches_text_to_video_cost() { - let text = distill_text(&make_request(Some("p"), None, None, Some(10), Some(true), None, None)) - .cost.cost_in_usd_cents.unwrap(); - let (start, map) = fake_token("mf_start0000000000000000000000"); - let img = distill_with_map( - &make_request(Some("p"), None, None, Some(10), Some(true), Some(start), None), - &map, - ).cost.cost_in_usd_cents.unwrap(); - assert_eq!(text, img); - } - - #[test] - fn image_to_video_no_audio_costs_13() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, None, Some(false), Some(start), None); - assert_eq!(distill_with_map(&request, &map).cost.cost_in_usd_cents.unwrap(), 13); - } - } - } - - // ──────────────────────────────────────────────────────────────────────────── - // PLAN - // ──────────────────────────────────────────────────────────────────────────── - mod plan { - use super::*; - - use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan; - use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_seedance_1p5_pro::{ - FalSeedance1p5ProAspectRatio, FalSeedance1p5ProDuration, FalSeedance1p5ProMode, - FalSeedance1p5ProResolution, PlanFalSeedance1p5Pro, - }; - - fn with_plan( - request: &OmniGenVideoCostAndGenerateRequest, - hydration: &HashMap, - assertion: F, - ) { - let distilled = distill_with_map(request, hydration); - match distilled.plan() { - VideoGenerationPlan::FalSeedance1p5Pro(plan) => assertion(plan), - other => panic!("expected FalSeedance1p5Pro, got {:?}", other), - } - } - - fn with_text_plan( - request: &OmniGenVideoCostAndGenerateRequest, - assertion: F, - ) { - let empty = HashMap::new(); - with_plan(request, &empty, assertion); - } - - mod text { - use super::*; - - // ── Mode detection ──────────────────────────────────────────────────── - - #[test] - fn text_mode_is_text_to_video() { - with_text_plan(&make_request(Some("p"), None, None, None, None, None, None), |plan| { - assert!(matches!(plan.mode, FalSeedance1p5ProMode::TextToVideo)); - }); - } - - // ── Prompt passthrough ──────────────────────────────────────────────── - - #[test] - fn prompt_is_passed_through() { - with_text_plan(&make_request(Some("a horse on mars"), None, None, None, None, None, None), |plan| { - assert_eq!(plan.prompt, "a horse on mars"); - }); - } - - #[test] - fn missing_prompt_is_empty_string() { - with_text_plan(&make_request(None, None, None, None, None, None, None), |plan| { - assert_eq!(plan.prompt, ""); - }); - } - - // ── Aspect ratio mappings ───────────────────────────────────────────── - - #[test] - fn default_aspect_ratio_is_none() { - with_text_plan(&make_request(Some("p"), None, None, None, None, None, None), |plan| { - assert!(plan.aspect_ratio.is_none()); - }); - } - - #[test] - fn auto_yields_auto() { - for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::Auto)), "expected Auto for {:?}", ar); - }); - } - } - - #[test] - fn square_yields_square() { - for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::Square))); - }); - } - } - - #[test] - fn wide_16x9_yields_sixteen_by_nine() { - for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::SixteenByNine))); - }); - } - } - - #[test] - fn wide_4x3_yields_four_by_three() { - with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::WideFourByThree), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::FourByThree))); - }); - } - - #[test] - fn wide_21x9_yields_twenty_one_by_nine() { - with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::WideTwentyOneByNine), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::TwentyOneByNine))); - }); - } - - #[test] - fn tall_9x16_yields_nine_by_sixteen() { - for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::NineBySixteen))); - }); - } - } - - #[test] - fn tall_3x4_yields_three_by_four() { - with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::TallThreeByFour), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::ThreeByFour))); - }); - } - - // ── Unsupported aspect ratios fall back to nearest under PayMoreUpgrade - - #[test] - fn wide_5x4_falls_back_to_four_by_three() { - with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::WideFiveByFour), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::FourByThree))); - }); - } - - #[test] - fn wide_3x2_falls_back_to_four_by_three() { - with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::WideThreeByTwo), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::FourByThree))); - }); - } - - #[test] - fn tall_4x5_falls_back_to_three_by_four() { - with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::TallFourByFive), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::ThreeByFour))); - }); - } - - #[test] - fn tall_2x3_falls_back_to_three_by_four() { - with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::TallTwoByThree), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::ThreeByFour))); - }); - } - - #[test] - fn tall_9x21_falls_back_to_nine_by_sixteen() { - with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::TallNineByTwentyOne), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::NineBySixteen))); - }); - } - - // ── Duration mapping ────────────────────────────────────────────────── - - #[test] - fn default_duration_is_none() { - with_text_plan(&make_request(Some("p"), None, None, None, None, None, None), |plan| { - assert!(plan.duration.is_none()); - }); - } - - #[test] - fn duration_4_yields_four() { - with_text_plan(&make_request(Some("p"), None, None, Some(4), None, None, None), |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Four))); - }); - } - - #[test] - fn duration_5_yields_five() { - with_text_plan(&make_request(Some("p"), None, None, Some(5), None, None, None), |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Five))); - }); - } - - #[test] - fn duration_6_yields_six() { - with_text_plan(&make_request(Some("p"), None, None, Some(6), None, None, None), |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Six))); - }); - } - - #[test] - fn duration_7_yields_seven() { - with_text_plan(&make_request(Some("p"), None, None, Some(7), None, None, None), |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Seven))); - }); - } - - #[test] - fn duration_8_yields_eight() { - with_text_plan(&make_request(Some("p"), None, None, Some(8), None, None, None), |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Eight))); - }); - } - - #[test] - fn duration_9_yields_nine() { - with_text_plan(&make_request(Some("p"), None, None, Some(9), None, None, None), |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Nine))); - }); - } - - #[test] - fn duration_10_yields_ten() { - with_text_plan(&make_request(Some("p"), None, None, Some(10), None, None, None), |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Ten))); - }); - } - - #[test] - fn duration_11_yields_eleven() { - with_text_plan(&make_request(Some("p"), None, None, Some(11), None, None, None), |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Eleven))); - }); - } - - #[test] - fn duration_12_yields_twelve() { - with_text_plan(&make_request(Some("p"), None, None, Some(12), None, None, None), |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Twelve))); - }); - } - - #[test] - fn duration_above_12_clamps_to_twelve() { - with_text_plan(&make_request(Some("p"), None, None, Some(20), None, None, None), |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Twelve))); - }); - } - - // ── Resolution mapping ──────────────────────────────────────────────── - - #[test] - fn default_resolution_is_none() { - with_text_plan(&make_request(Some("p"), None, None, None, None, None, None), |plan| { - assert!(plan.resolution.is_none()); - }); - } - - #[test] - fn four_eighty_p_yields_four_eighty_p() { - with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::FourEightyP), None, None, None, None), |plan| { - assert!(matches!(plan.resolution, Some(FalSeedance1p5ProResolution::FourEightyP))); - }); - } - - #[test] - fn seven_twenty_p_yields_seven_twenty_p() { - with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::SevenTwentyP), None, None, None, None), |plan| { - assert!(matches!(plan.resolution, Some(FalSeedance1p5ProResolution::SevenTwentyP))); - }); - } - - #[test] - fn ten_eighty_p_yields_ten_eighty_p() { - with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, None, None), |plan| { - assert!(matches!(plan.resolution, Some(FalSeedance1p5ProResolution::TenEightyP))); - }); - } - - // ── Generate audio passthrough ──────────────────────────────────────── - - #[test] - fn default_audio_is_none() { - with_text_plan(&make_request(Some("p"), None, None, None, None, None, None), |plan| { - assert!(plan.generate_audio.is_none()); - }); - } - - #[test] - fn audio_true_passes_through() { - with_text_plan(&make_request(Some("p"), None, None, None, Some(true), None, None), |plan| { - assert_eq!(plan.generate_audio, Some(true)); - }); - } - - #[test] - fn audio_false_passes_through() { - with_text_plan(&make_request(Some("p"), None, None, None, Some(false), None, None), |plan| { - assert_eq!(plan.generate_audio, Some(false)); - }); - } - } - - mod image { - use super::*; - - // ── Mode detection ──────────────────────────────────────────────────── - - #[test] - fn start_frame_triggers_image_to_video() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, None, None, Some(start), None); - with_plan(&request, &map, |plan| { - match &plan.mode { - FalSeedance1p5ProMode::ImageToVideo { image_url, end_image_url } => { - assert!(image_url.starts_with("https://fake.example.com/")); - assert!(end_image_url.is_none()); - } - other => panic!("expected ImageToVideo, got {:?}", other), - } - }); - } - - #[test] - fn start_and_end_frame_both_hydrated() { - let (start, end, map) = fake_two_tokens( - "mf_start0000000000000000000000", - "mf_end00000000000000000000000000", - ); - let request = make_request(Some("p"), None, None, None, None, Some(start), Some(end)); - with_plan(&request, &map, |plan| { - match &plan.mode { - FalSeedance1p5ProMode::ImageToVideo { image_url, end_image_url } => { - assert!(image_url.contains("mf_start")); - assert!(end_image_url.as_ref().unwrap().contains("mf_end")); - } - other => panic!("expected ImageToVideo, got {:?}", other), - } - }); - } - - // ── Prompt passthrough in image mode ────────────────────────────────── - - #[test] - fn prompt_is_passed_through_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("a horse on mars"), None, None, None, None, Some(start), None); - with_plan(&request, &map, |plan| { - assert_eq!(plan.prompt, "a horse on mars"); - }); - } - - // ── Aspect ratio in image mode ──────────────────────────────────────── - - #[test] - fn aspect_ratio_passes_through_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start), None); - with_plan(&request, &map, |plan| { - assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::SixteenByNine))); - }); - } - - // ── Duration pass-throughs in image mode ──────────────────────────────── - - #[test] - fn duration_4_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, Some(4), None, Some(start), None); - with_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Four))); - }); - } - - #[test] - fn duration_5_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, Some(5), None, Some(start), None); - with_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Five))); - }); - } - - #[test] - fn duration_8_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, Some(8), None, Some(start), None); - with_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Eight))); - }); - } - - #[test] - fn duration_12_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, Some(12), None, Some(start), None); - with_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Twelve))); - }); - } - - #[test] - fn duration_above_12_clamps_to_twelve_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, Some(30), None, Some(start), None); - with_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Twelve))); - }); - } - - #[test] - fn default_duration_is_none_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, None, None, Some(start), None); - with_plan(&request, &map, |plan| { - assert!(plan.duration.is_none()); - }); - } - - // ── Resolution in image mode ────────────────────────────────────────── - - #[test] - fn resolution_passes_through_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, Some(start), None); - with_plan(&request, &map, |plan| { - assert!(matches!(plan.resolution, Some(FalSeedance1p5ProResolution::TenEightyP))); - }); - } - - // ── Audio in image mode ─────────────────────────────────────────────── - - #[test] - fn audio_passes_through_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, None, Some(false), Some(start), None); - with_plan(&request, &map, |plan| { - assert_eq!(plan.generate_audio, Some(false)); - }); - } - - // ── Media file mapping: exact 1:1 token→URL verification ────────────── - - #[test] - fn start_frame_url_matches_hydration_map_exactly() { - let token = MediaFileToken::new_from_str("mf_abc00000000000000000000000000"); - let expected_url = "https://cdn.example.com/images/abc123.png"; - let mut map = HashMap::new(); - map.insert(token.clone(), Url::parse(expected_url).unwrap()); - - let request = make_request(Some("p"), None, None, None, None, Some(token), None); - with_plan(&request, &map, |plan| { - match &plan.mode { - FalSeedance1p5ProMode::ImageToVideo { image_url, .. } => { - assert_eq!(image_url, expected_url); - } - other => panic!("expected ImageToVideo, got {:?}", other), - } - }); - } - - #[test] - fn start_and_end_frame_urls_match_hydration_map_exactly() { - let start_token = MediaFileToken::new_from_str("mf_start0000000000000000000000"); - let end_token = MediaFileToken::new_from_str("mf_end00000000000000000000000000"); - let start_url = "https://cdn.example.com/images/start_frame.png"; - let end_url = "https://cdn.example.com/images/end_frame.png"; - - let mut map = HashMap::new(); - map.insert(start_token.clone(), Url::parse(start_url).unwrap()); - map.insert(end_token.clone(), Url::parse(end_url).unwrap()); - - let request = make_request(Some("p"), None, None, None, None, Some(start_token), Some(end_token)); - with_plan(&request, &map, |plan| { - match &plan.mode { - FalSeedance1p5ProMode::ImageToVideo { image_url, end_image_url } => { - assert_eq!(image_url, start_url); - assert_eq!(end_image_url.as_deref().unwrap(), end_url); - } - other => panic!("expected ImageToVideo, got {:?}", other), - } - }); - } - - #[test] - fn hydration_map_with_extra_tokens_still_resolves_correctly() { - // Map has 4 tokens but only start_frame + end_frame are used by - // the request. The extras are ignored. - let start_token = MediaFileToken::new_from_str("mf_start0000000000000000000000"); - let end_token = MediaFileToken::new_from_str("mf_end00000000000000000000000000"); - let extra1 = MediaFileToken::new_from_str("mf_extra1000000000000000000000000"); - let extra2 = MediaFileToken::new_from_str("mf_extra2000000000000000000000000"); - - let start_url = "https://cdn.example.com/start.png"; - let end_url = "https://cdn.example.com/end.png"; - - let mut map = HashMap::new(); - map.insert(start_token.clone(), Url::parse(start_url).unwrap()); - map.insert(end_token.clone(), Url::parse(end_url).unwrap()); - map.insert(extra1, Url::parse("https://cdn.example.com/extra1.png").unwrap()); - map.insert(extra2, Url::parse("https://cdn.example.com/extra2.png").unwrap()); - - let request = make_request(Some("p"), None, None, None, None, Some(start_token), Some(end_token)); - with_plan(&request, &map, |plan| { - match &plan.mode { - FalSeedance1p5ProMode::ImageToVideo { image_url, end_image_url } => { - assert_eq!(image_url, start_url); - assert_eq!(end_image_url.as_deref().unwrap(), end_url); - } - other => panic!("expected ImageToVideo, got {:?}", other), - } - }); - } - - #[test] - fn start_frame_only_with_larger_hydration_map() { - // Map has 3 tokens, request only uses start_frame. - let start_token = MediaFileToken::new_from_str("mf_start0000000000000000000000"); - let other1 = MediaFileToken::new_from_str("mf_other1000000000000000000000000"); - let other2 = MediaFileToken::new_from_str("mf_other2000000000000000000000000"); - - let start_url = "https://cdn.example.com/keyframe.jpg"; - - let mut map = HashMap::new(); - map.insert(start_token.clone(), Url::parse(start_url).unwrap()); - map.insert(other1, Url::parse("https://cdn.example.com/other1.jpg").unwrap()); - map.insert(other2, Url::parse("https://cdn.example.com/other2.jpg").unwrap()); - - let request = make_request(Some("p"), None, None, None, None, Some(start_token), None); - with_plan(&request, &map, |plan| { - match &plan.mode { - FalSeedance1p5ProMode::ImageToVideo { image_url, end_image_url } => { - assert_eq!(image_url, start_url); - assert!(end_image_url.is_none()); - } - other => panic!("expected ImageToVideo, got {:?}", other), - } - }); - } - } - } -} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_2.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_2.rs deleted file mode 100644 index 4c2348ff6..000000000 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_2.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! Tests for omni-gen video against the Veo 2 model. -//! -//! Veo 2 supports both text-to-video and image-to-video (via start_frame). -//! -//! Pricing: $2.50 for the first 5 seconds, +$0.50 per additional second. -//! 5s → 250¢, 6s → 300¢, 7s → 350¢, 8s → 400¢ -//! -//! Supported aspect ratios: Auto, WideSixteenNine, TallNineSixteen. -//! Unsupported ratios fall back to Auto under PayMoreUpgrade. -//! -//! Supported durations: 5–8 seconds. Over-max clamps to 8s (PayMoreUpgrade). - -#[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 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 artcraft_router::api::provider::Provider; - use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::distill_video_request::{ - distill_video_request, DistilledVideoRequest, - }; - - // ── Helpers ──────────────────────────────────────────────────────────────── - - fn make_request( - prompt: Option<&str>, - aspect_ratio: Option, - duration_seconds: Option, - start_frame_token: Option, - ) -> OmniGenVideoCostAndGenerateRequest { - OmniGenVideoCostAndGenerateRequest { - idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()), - model: Some(CommonVideoModel::Veo2), - prompt: prompt.map(|s| s.to_string()), - negative_prompt: None, - start_frame_image_media_token: start_frame_token, - end_frame_image_media_token: None, - reference_image_media_tokens: None, - reference_video_media_tokens: None, - reference_audio_media_tokens: None, - reference_character_tokens: None, - resolution: None, - aspect_ratio, - quality: None, - duration_seconds, - video_batch_count: None, - generate_audio: None, - } - } - - fn fake_token(name: &str) -> (MediaFileToken, HashMap) { - let token = MediaFileToken::new_from_str(name); - let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap(); - let mut map = HashMap::new(); - map.insert(token.clone(), url); - (token, map) - } - - fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest { - let empty: HashMap = HashMap::new(); - distill_video_request(request, Some(&empty), Provider::Fal) - .expect("distill_video_request should succeed for Veo 2 (text)") - } - - fn distill_image( - request: &OmniGenVideoCostAndGenerateRequest, - hydration: &HashMap, - ) -> DistilledVideoRequest { - distill_video_request(request, Some(hydration), Provider::Fal) - .expect("distill_video_request should succeed for Veo 2 (image)") - } - - // ──────────────────────────────────────────────────────────────────────────── - // COST - // ──────────────────────────────────────────────────────────────────────────── - mod cost { - use super::*; - - mod text { - use super::*; - - fn cost(duration: Option) -> u64 { - let request = make_request(Some("p"), None, duration, None); - distill_text(&request).cost.cost_in_usd_cents.unwrap() - } - - #[test] - fn default_duration_costs_250() { assert_eq!(cost(None), 250); } - - #[test] - fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); } - - #[test] - fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); } - - #[test] - fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); } - - #[test] - fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); } - - #[test] - fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); } - - #[test] - fn cost_independent_of_aspect_ratio() { - let ars = [ - None, - Some(CommonAspectRatio::Auto), - Some(CommonAspectRatio::WideSixteenByNine), - Some(CommonAspectRatio::TallNineBySixteen), - Some(CommonAspectRatio::Square), - ]; - for ar in ars { - let request = make_request(Some("p"), ar, None, None); - assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 250); - } - } - - #[test] - fn cost_metadata_flags_are_default() { - let d = distill_text(&make_request(Some("p"), None, None, None)); - assert!(!d.cost.is_free); - assert!(!d.cost.is_unlimited); - assert!(!d.cost.is_rate_limited); - assert!(!d.cost.has_watermark); - } - } - - mod image { - use super::*; - - fn cost(duration: Option) -> u64 { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, duration, Some(start)); - distill_image(&request, &map).cost.cost_in_usd_cents.unwrap() - } - - #[test] - fn default_duration_costs_250() { assert_eq!(cost(None), 250); } - - #[test] - fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); } - - #[test] - fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); } - - #[test] - fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); } - - #[test] - fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); } - - #[test] - fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); } - - #[test] - fn image_and_text_cost_match() { - for dur in [5u16, 6, 7, 8] { - let text = distill_text(&make_request(Some("p"), None, Some(dur), None)) - .cost.cost_in_usd_cents.unwrap(); - let (start, map) = fake_token("mf_start0000000000000000000000"); - let img = distill_image( - &make_request(Some("p"), None, Some(dur), Some(start)), - &map, - ).cost.cost_in_usd_cents.unwrap(); - assert_eq!(text, img, "text/image cost diverged at dur={}", dur); - } - } - } - } - - // ──────────────────────────────────────────────────────────────────────────── - // PLAN - // ──────────────────────────────────────────────────────────────────────────── - mod plan { - use super::*; - - use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan; - use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{ - FalVeo2Mode, PlanFalVeo2, - }; - use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{ - Veo2AspectRatio, Veo2Duration, - }; - - fn with_text_plan( - request: &OmniGenVideoCostAndGenerateRequest, - assertion: F, - ) { - let distilled = distill_text(request); - match distilled.plan() { - VideoGenerationPlan::FalVeo2(plan) => assertion(plan), - other => panic!("expected FalVeo2, got {:?}", other), - } - } - - fn with_image_plan( - request: &OmniGenVideoCostAndGenerateRequest, - hydration: &HashMap, - assertion: F, - ) { - let distilled = distill_image(request, hydration); - match distilled.plan() { - VideoGenerationPlan::FalVeo2(plan) => assertion(plan), - other => panic!("expected FalVeo2, got {:?}", other), - } - } - - mod text { - use super::*; - - // ── Mode detection ──────────────────────────────────────────────────── - - #[test] - fn text_mode_is_text_to_video() { - with_text_plan(&make_request(Some("p"), None, None, None), |plan| { - assert!(matches!(plan.mode, FalVeo2Mode::TextToVideo)); - }); - } - - // ── Prompt passthrough ──────────────────────────────────────────────── - - #[test] - fn prompt_is_passed_through() { - with_text_plan(&make_request(Some("a horse on mars"), None, None, None), |plan| { - assert_eq!(plan.prompt, "a horse on mars"); - }); - } - - #[test] - fn missing_prompt_is_empty_string() { - with_text_plan(&make_request(None, None, None, None), |plan| { - assert_eq!(plan.prompt, ""); - }); - } - - // ── Aspect ratio mappings ───────────────────────────────────────────── - - #[test] - fn default_aspect_ratio_is_auto() { - with_text_plan(&make_request(Some("p"), None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto))); - }); - } - - #[test] - fn auto_yields_auto() { - for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto))); - }); - } - } - - #[test] - fn wide_16x9_yields_wide() { - for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::WideSixteenNine))); - }); - } - } - - #[test] - fn tall_9x16_yields_tall() { - for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::TallNineSixteen))); - }); - } - } - - #[test] - fn unsupported_aspect_ratio_falls_back_to_auto() { - for ar in [ - CommonAspectRatio::Square, CommonAspectRatio::SquareHd, - CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour, - ] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto))); - }); - } - } - - // ── Duration mappings ───────────────────────────────────────────────── - - #[test] - fn default_duration_is_default() { - with_text_plan(&make_request(Some("p"), None, None, None), |plan| { - assert!(matches!(plan.duration, Veo2Duration::Default)); - }); - } - - #[test] - fn duration_5_yields_five() { - with_text_plan(&make_request(Some("p"), None, Some(5), None), |plan| { - assert!(matches!(plan.duration, Veo2Duration::FiveSeconds)); - }); - } - - #[test] - fn duration_6_yields_six() { - with_text_plan(&make_request(Some("p"), None, Some(6), None), |plan| { - assert!(matches!(plan.duration, Veo2Duration::SixSeconds)); - }); - } - - #[test] - fn duration_7_yields_seven() { - with_text_plan(&make_request(Some("p"), None, Some(7), None), |plan| { - assert!(matches!(plan.duration, Veo2Duration::SevenSeconds)); - }); - } - - #[test] - fn duration_8_yields_eight() { - with_text_plan(&make_request(Some("p"), None, Some(8), None), |plan| { - assert!(matches!(plan.duration, Veo2Duration::EightSeconds)); - }); - } - - #[test] - fn duration_above_8_clamps_to_eight() { - with_text_plan(&make_request(Some("p"), None, Some(20), None), |plan| { - assert!(matches!(plan.duration, Veo2Duration::EightSeconds)); - }); - } - } - - mod image { - use super::*; - - // ── Mode detection / start frame hydration ──────────────────────────── - - #[test] - fn start_frame_triggers_image_to_video() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - match &plan.mode { - FalVeo2Mode::ImageToVideo { image_url } => { - assert!(image_url.starts_with("https://fake.example.com/")); - } - other => panic!("expected ImageToVideo, got {:?}", other), - } - }); - } - - #[test] - fn start_frame_url_matches_hydration_map_exactly() { - let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000"); - let expected_url = "https://cdn.example.com/frames/my_frame.png"; - let mut map = HashMap::new(); - map.insert(token.clone(), Url::parse(expected_url).unwrap()); - - let request = make_request(Some("p"), None, None, Some(token)); - with_image_plan(&request, &map, |plan| { - match &plan.mode { - FalVeo2Mode::ImageToVideo { image_url } => { - assert_eq!(image_url, expected_url); - } - other => panic!("expected ImageToVideo, got {:?}", other), - } - }); - } - - // ── Prompt passthrough in image mode ────────────────────────────────── - - #[test] - fn prompt_is_passed_through_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("a horse on mars"), None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert_eq!(plan.prompt, "a horse on mars"); - }); - } - - // ── Aspect ratio is None in image mode (i2v inherits source AR) ──────── - - #[test] - fn aspect_ratio_is_none_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(plan.aspect_ratio.is_none(), "image-to-video should not have aspect_ratio"); - }); - } - - #[test] - fn aspect_ratio_is_none_in_image_mode_even_with_tall() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(plan.aspect_ratio.is_none()); - }); - } - - // ── Duration pass-throughs in image mode ────────────────────────────── - - #[test] - fn duration_5_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, Some(5), Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Veo2Duration::FiveSeconds)); - }); - } - - #[test] - fn duration_8_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, Some(8), Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Veo2Duration::EightSeconds)); - }); - } - - #[test] - fn duration_above_8_clamps_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, Some(20), Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Veo2Duration::EightSeconds)); - }); - } - - #[test] - fn default_duration_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Veo2Duration::Default)); - }); - } - } - } -} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_3.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_3.rs deleted file mode 100644 index 14b83699e..000000000 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_3.rs +++ /dev/null @@ -1,590 +0,0 @@ -//! Tests for omni-gen video against the Veo 3 model. -//! -//! Veo 3 supports both text-to-video and image-to-video (via start_frame). -//! -//! Pricing: $0.20/sec (audio off) or $0.40/sec (audio on). -//! Default: 8s, audio off (artcraft legacy) → 160¢. -//! 4s no-audio → 80¢, 6s no-audio → 120¢, 8s no-audio → 160¢ -//! 4s audio → 160¢, 6s audio → 240¢, 8s audio → 320¢ -//! -//! Supported aspect ratios (text mode only): Default, 16:9, 9:16, Square. -//! Image-to-video inherits the source frame's aspect ratio. -//! -//! Supported resolutions: 720p, 1080p. -//! Supported durations: 4s, 6s, 8s. Over-max clamps to 8s (PayMoreUpgrade). - -#[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 enums::common::generation::common_aspect_ratio::CommonAspectRatio; - use enums::common::generation::common_resolution::CommonResolution; - use enums::common::generation::common_video_model::CommonVideoModel; - use tokens::tokens::media_files::MediaFileToken; - use url::Url; - - use artcraft_router::api::provider::Provider; - use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::distill_video_request::{ - distill_video_request, DistilledVideoRequest, - }; - - // ── Helpers ──────────────────────────────────────────────────────────────── - - fn make_request( - prompt: Option<&str>, - aspect_ratio: Option, - resolution: Option, - duration_seconds: Option, - generate_audio: Option, - start_frame_token: Option, - ) -> OmniGenVideoCostAndGenerateRequest { - OmniGenVideoCostAndGenerateRequest { - idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()), - model: Some(CommonVideoModel::Veo3), - prompt: prompt.map(|s| s.to_string()), - negative_prompt: None, - start_frame_image_media_token: start_frame_token, - end_frame_image_media_token: None, - reference_image_media_tokens: None, - reference_video_media_tokens: None, - reference_audio_media_tokens: None, - reference_character_tokens: None, - resolution, - aspect_ratio, - quality: None, - duration_seconds, - video_batch_count: None, - generate_audio, - } - } - - fn fake_token(name: &str) -> (MediaFileToken, HashMap) { - let token = MediaFileToken::new_from_str(name); - let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap(); - let mut map = HashMap::new(); - map.insert(token.clone(), url); - (token, map) - } - - fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest { - let empty: HashMap = HashMap::new(); - distill_video_request(request, Some(&empty), Provider::Fal) - .expect("distill should succeed for Veo 3 (text)") - } - - fn distill_image( - request: &OmniGenVideoCostAndGenerateRequest, - hydration: &HashMap, - ) -> DistilledVideoRequest { - distill_video_request(request, Some(hydration), Provider::Fal) - .expect("distill should succeed for Veo 3 (image)") - } - - // ──────────────────────────────────────────────────────────────────────────── - // COST - // ──────────────────────────────────────────────────────────────────────────── - // - // Artcraft cost path: always 8s (legacy). $0.20/sec no-audio, $0.40/sec audio. - // Default audio = false (artcraft legacy). - mod cost { - use super::*; - - mod text { - use super::*; - - fn cost(duration: Option, audio: Option) -> u64 { - let request = make_request(Some("p"), None, None, duration, audio, None); - distill_text(&request).cost.cost_in_usd_cents.unwrap() - } - - // ── No audio: $0.20/sec ────────────────────────────────────────────── - - #[test] - fn default_no_audio_costs_160() { assert_eq!(cost(None, None), 160); } // 8s × 20¢ - - #[test] - fn four_seconds_no_audio_costs_80() { assert_eq!(cost(Some(4), Some(false)), 80); } - - #[test] - fn six_seconds_no_audio_costs_120() { assert_eq!(cost(Some(6), Some(false)), 120); } - - #[test] - fn eight_seconds_no_audio_costs_160() { assert_eq!(cost(Some(8), Some(false)), 160); } - - // ── Audio on: $0.40/sec ─────────────────────────────────────────────── - - #[test] - fn four_seconds_audio_on_costs_160() { assert_eq!(cost(Some(4), Some(true)), 160); } - - #[test] - fn six_seconds_audio_on_costs_240() { assert_eq!(cost(Some(6), Some(true)), 240); } - - #[test] - fn eight_seconds_audio_on_costs_320() { assert_eq!(cost(Some(8), Some(true)), 320); } - - #[test] - fn default_duration_audio_on_costs_320() { assert_eq!(cost(None, Some(true)), 320); } - - // ── Over-max clamps to 8s ───────────────────────────────────────────── - - #[test] - fn duration_above_8_clamps_to_160_no_audio() { assert_eq!(cost(Some(20), Some(false)), 160); } - - #[test] - fn duration_above_8_clamps_to_320_audio() { assert_eq!(cost(Some(20), Some(true)), 320); } - - // ── Cost is independent of aspect ratio ─────────────────────────────── - - #[test] - fn cost_independent_of_aspect_ratio() { - for ar in [None, Some(CommonAspectRatio::Auto), Some(CommonAspectRatio::WideSixteenByNine), Some(CommonAspectRatio::TallNineBySixteen)] { - let request = make_request(Some("p"), ar, None, None, None, None); - assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 160); - } - } - - // ── Cost is independent of resolution ───────────────────────────────── - - #[test] - fn cost_independent_of_resolution() { - for res in [None, Some(CommonResolution::SevenTwentyP), Some(CommonResolution::TenEightyP)] { - let request = make_request(Some("p"), None, res, None, None, None); - assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 160); - } - } - - // ── Metadata flags ──────────────────────────────────────────────────── - - #[test] - fn cost_metadata_flags_are_default() { - let d = distill_text(&make_request(Some("p"), None, None, None, None, None)); - assert!(!d.cost.is_free); - assert!(!d.cost.is_unlimited); - assert!(!d.cost.is_rate_limited); - assert!(!d.cost.has_watermark); - } - } - - mod image { - use super::*; - - fn cost_img(duration: Option, audio: Option) -> u64 { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, duration, audio, Some(start)); - distill_image(&request, &map).cost.cost_in_usd_cents.unwrap() - } - - #[test] - fn default_costs_160() { assert_eq!(cost_img(None, None), 160); } - - #[test] - fn four_seconds_no_audio_costs_80() { assert_eq!(cost_img(Some(4), Some(false)), 80); } - - #[test] - fn six_seconds_audio_on_costs_240() { assert_eq!(cost_img(Some(6), Some(true)), 240); } - - #[test] - fn eight_seconds_audio_on_costs_320() { assert_eq!(cost_img(Some(8), Some(true)), 320); } - - #[test] - fn image_and_text_cost_match() { - let text = distill_text(&make_request(Some("p"), None, None, None, None, None)) - .cost.cost_in_usd_cents.unwrap(); - let (start, map) = fake_token("mf_start0000000000000000000000"); - let img = distill_image( - &make_request(Some("p"), None, None, None, None, Some(start)), - &map, - ).cost.cost_in_usd_cents.unwrap(); - assert_eq!(text, img); - } - } - } - - // ──────────────────────────────────────────────────────────────────────────── - // PLAN - // ──────────────────────────────────────────────────────────────────────────── - mod plan { - use super::*; - - use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan; - use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{ - FalVeo3Duration, FalVeo3I2vAspectRatio, FalVeo3Mode, FalVeo3Resolution, - FalVeo3T2vAspectRatio, PlanFalVeo3, - }; - - fn with_text_plan( - request: &OmniGenVideoCostAndGenerateRequest, - assertion: F, - ) { - let distilled = distill_text(request); - match distilled.plan() { - VideoGenerationPlan::FalVeo3(plan) => assertion(plan), - other => panic!("expected FalVeo3, got {:?}", other), - } - } - - fn with_image_plan( - request: &OmniGenVideoCostAndGenerateRequest, - hydration: &HashMap, - assertion: F, - ) { - let distilled = distill_image(request, hydration); - match distilled.plan() { - VideoGenerationPlan::FalVeo3(plan) => assertion(plan), - other => panic!("expected FalVeo3, got {:?}", other), - } - } - - mod text { - use super::*; - - // ── Mode detection ──────────────────────────────────────────────────── - - #[test] - fn text_mode_is_text_to_video() { - with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| { - assert!(matches!(plan.mode, FalVeo3Mode::TextToVideo)); - }); - } - - // ── Prompt passthrough ──────────────────────────────────────────────── - - #[test] - fn prompt_is_passed_through() { - with_text_plan(&make_request(Some("a horse on mars"), None, None, None, None, None), |plan| { - assert_eq!(plan.prompt, "a horse on mars"); - }); - } - - #[test] - fn missing_prompt_is_empty_string() { - with_text_plan(&make_request(None, None, None, None, None, None), |plan| { - assert_eq!(plan.prompt, ""); - }); - } - - // ── Aspect ratio mappings (text mode only) ──────────────────────────── - - #[test] - fn default_aspect_ratio_is_default() { - with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| { - assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default))); - }); - } - - #[test] - fn wide_16x9_yields_wide() { - for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| { - assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::WideSixteenNine))); - }); - } - } - - #[test] - fn tall_9x16_yields_tall() { - for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| { - assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::TallNineSixteen))); - }); - } - } - - #[test] - fn square_falls_back_to_default() { - // Veo 3 t2v has no Square — falls back to Default (16:9). - for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| { - assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default))); - }); - } - } - - #[test] - fn auto_falls_back_to_default() { - // Veo 3 t2v has no Auto — falls back to Default (16:9). - for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| { - assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default))); - }); - } - } - - #[test] - fn unsupported_aspect_ratio_falls_back_to_default() { - for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour, CommonAspectRatio::WideFiveByFour] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| { - assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default))); - }); - } - } - - // ── Duration mappings ───────────────────────────────────────────────── - - #[test] - fn default_duration_is_default() { - with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| { - assert!(matches!(plan.duration, FalVeo3Duration::Default)); - }); - } - - #[test] - fn duration_4_yields_four() { - with_text_plan(&make_request(Some("p"), None, None, Some(4), None, None), |plan| { - assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds)); - }); - } - - #[test] - fn duration_6_yields_six() { - with_text_plan(&make_request(Some("p"), None, None, Some(6), None, None), |plan| { - assert!(matches!(plan.duration, FalVeo3Duration::SixSeconds)); - }); - } - - #[test] - fn duration_8_yields_eight() { - with_text_plan(&make_request(Some("p"), None, None, Some(8), None, None), |plan| { - assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds)); - }); - } - - #[test] - fn duration_above_8_clamps_to_eight() { - with_text_plan(&make_request(Some("p"), None, None, Some(20), None, None), |plan| { - assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds)); - }); - } - - // ── Resolution mappings ─────────────────────────────────────────────── - - #[test] - fn default_resolution_is_default() { - with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| { - assert!(matches!(plan.resolution, FalVeo3Resolution::Default)); - }); - } - - #[test] - fn seven_twenty_p_yields_seven_twenty_p() { - with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::SevenTwentyP), None, None, None), |plan| { - assert!(matches!(plan.resolution, FalVeo3Resolution::SevenTwentyP)); - }); - } - - #[test] - fn ten_eighty_p_yields_ten_eighty_p() { - with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, None), |plan| { - assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP)); - }); - } - - // ── Generate audio passthrough ──────────────────────────────────────── - - #[test] - fn default_audio_is_true() { - // Fal plan defaults generate_audio to true. - with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| { - assert!(plan.generate_audio); - }); - } - - #[test] - fn audio_true_passes_through() { - with_text_plan(&make_request(Some("p"), None, None, None, Some(true), None), |plan| { - assert!(plan.generate_audio); - }); - } - - #[test] - fn audio_false_passes_through() { - with_text_plan(&make_request(Some("p"), None, None, None, Some(false), None), |plan| { - assert!(!plan.generate_audio); - }); - } - } - - mod image { - use super::*; - - // ── Mode detection / start frame hydration ──────────────────────────── - - #[test] - fn start_frame_triggers_image_to_video() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - match &plan.mode { - FalVeo3Mode::ImageToVideo { image_url } => { - assert!(image_url.starts_with("https://fake.example.com/")); - } - other => panic!("expected ImageToVideo, got {:?}", other), - } - }); - } - - #[test] - fn start_frame_url_matches_hydration_map_exactly() { - let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000"); - let expected_url = "https://cdn.example.com/frames/my_frame.png"; - let mut map = HashMap::new(); - map.insert(token.clone(), Url::parse(expected_url).unwrap()); - - let request = make_request(Some("p"), None, None, None, None, Some(token)); - with_image_plan(&request, &map, |plan| { - match &plan.mode { - FalVeo3Mode::ImageToVideo { image_url } => { - assert_eq!(image_url, expected_url); - } - other => panic!("expected ImageToVideo, got {:?}", other), - } - }); - } - - // ── Aspect ratio is None in image mode ──────────────────────────────── - - // ── Aspect ratio in image mode: uses i2v (has Auto, no Square) ───────── - - #[test] - fn t2v_aspect_ratio_is_none_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(plan.t2v_aspect_ratio.is_none()); - }); - } - - #[test] - fn i2v_default_aspect_ratio_is_auto() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto))); - }); - } - - #[test] - fn i2v_wide_16x9_yields_wide() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::WideSixteenNine))); - }); - } - - #[test] - fn i2v_tall_9x16_yields_tall() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::TallNineSixteen))); - }); - } - - #[test] - fn i2v_auto_yields_auto() { - for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(ar), None, None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto))); - }); - } - } - - #[test] - fn i2v_square_falls_back_to_auto() { - for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(ar), None, None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto))); - }); - } - } - - #[test] - fn i2v_unsupported_falls_back_to_auto() { - for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour] { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(ar), None, None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto))); - }); - } - } - - // ── Prompt passthrough in image mode ────────────────────────────────── - - #[test] - fn prompt_is_passed_through_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("a horse on mars"), None, None, None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert_eq!(plan.prompt, "a horse on mars"); - }); - } - - // ── Duration pass-throughs in image mode ────────────────────────────── - - #[test] - fn duration_4_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, Some(4), None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds)); - }); - } - - #[test] - fn duration_8_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, Some(8), None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds)); - }); - } - - #[test] - fn default_duration_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, FalVeo3Duration::Default)); - }); - } - - // ── Resolution in image mode ────────────────────────────────────────── - - #[test] - fn resolution_passes_through_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP)); - }); - } - - // ── Audio in image mode ─────────────────────────────────────────────── - - #[test] - fn audio_passes_through_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, None, Some(false), Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(!plan.generate_audio); - }); - } - - #[test] - fn default_audio_is_true_in_image_mode() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, None, None, Some(start)); - with_image_plan(&request, &map, |plan| { - assert!(plan.generate_audio); - }); - } - } - } -}