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 479e692b79..b9b697eb0c 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 @@ -8,6 +8,7 @@ use artcraft_router::api::router_provider::RouterProvider; use log::warn; use crate::http_server::common_responses::common_web_error::CommonWebError; 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::helpers::validate_image_required::validate_image_required; use crate::state::server_state::ServerState; /// Estimate the cost of a video generation. @@ -27,6 +28,8 @@ pub async fn omni_gen_video_cost_handler( request: Json, _server_state: web::Data>, ) -> Result, CommonWebError> { + validate_image_required(&request)?; + let mut builder = hydrate_to_router_request(&request)?; builder.provider = RouterProvider::Artcraft; 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 index 7108f22a3b..2c80241f67 100644 --- 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 @@ -4,3 +4,4 @@ pub(crate) mod hydrate_router_request; pub mod pipeline_result; pub mod resolve_kinovi_character_ids; pub mod resolve_media_tokens_to_urls; +pub mod validate_image_required; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/validate_image_required.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/validate_image_required.rs new file mode 100644 index 0000000000..f56e4e1d7f --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/validate_image_required.rs @@ -0,0 +1,156 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +use enums::common::generation::common_video_model::CommonVideoModel; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +/// xAI's `grok-imagine-video-1.5-preview` model rejects text-to-video at +/// the upstream API. Reject the request at the handler layer so callers see +/// a clean 400 with an actionable message and we don't charge their wallet +/// for an inference job we know will fail. +pub fn validate_image_required( + request: &OmniGenVideoCostAndGenerateRequest, +) -> Result<(), CommonWebError> { + match request.model { + Some(CommonVideoModel::GrokImagineVideo1p5) => {}, // Fall-through + _ => return Ok(()), + } + + let requires_image = matches!(request.model, Some(CommonVideoModel::GrokImagineVideo1p5)); + + if !requires_image { + return Ok(()); + } + + let has_start_frame = request.start_frame_image_media_token.is_some(); + let has_reference_images = request + .reference_image_media_tokens + .as_ref() + .is_some_and(|v| !v.is_empty()); + + if has_start_frame || has_reference_images { + return Ok(()); + } + + Err(CommonWebError::BadInputWithSimpleMessage( + "Image is required".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokens::tokens::media_files::MediaFileToken; + + fn base_request() -> OmniGenVideoCostAndGenerateRequest { + OmniGenVideoCostAndGenerateRequest { + idempotency_token: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()), + model: None, + prompt: Some("test".to_string()), + negative_prompt: None, + start_frame_image_media_token: None, + 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: None, + quality: None, + duration_seconds: Some(5), + video_batch_count: Some(1), + generate_audio: None, + } + } + + fn token(id: &str) -> MediaFileToken { + MediaFileToken::new(id.to_string()) + } + + #[test] + fn passes_when_model_is_none() { + let req = base_request(); + assert!(validate_image_required(&req).is_ok()); + } + + #[test] + fn passes_for_other_models_without_image() { + // Only v1.5 needs the gate; other models can do text-to-video. + let req = OmniGenVideoCostAndGenerateRequest { + model: Some(CommonVideoModel::Seedance2p0), + ..base_request() + }; + assert!(validate_image_required(&req).is_ok()); + } + + #[test] + fn rejects_v1p5_with_no_image_inputs() { + let req = OmniGenVideoCostAndGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + ..base_request() + }; + let err = validate_image_required(&req).expect_err("should reject"); + match err { + CommonWebError::BadInputWithSimpleMessage(msg) => { + assert_eq!(msg, "Image is required"); + } + other => panic!("expected BadInputWithSimpleMessage, got {:?}", other), + } + } + + #[test] + fn rejects_v1p5_with_empty_reference_list_and_no_start_frame() { + let req = OmniGenVideoCostAndGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + reference_image_media_tokens: Some(vec![]), + ..base_request() + }; + assert!(matches!( + validate_image_required(&req), + Err(CommonWebError::BadInputWithSimpleMessage(_)) + )); + } + + #[test] + fn accepts_v1p5_with_start_frame_only() { + let req = OmniGenVideoCostAndGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + start_frame_image_media_token: Some(token("mf_start")), + ..base_request() + }; + assert!(validate_image_required(&req).is_ok()); + } + + #[test] + fn accepts_v1p5_with_reference_images_only() { + let req = OmniGenVideoCostAndGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + reference_image_media_tokens: Some(vec![token("mf_a"), token("mf_b")]), + ..base_request() + }; + assert!(validate_image_required(&req).is_ok()); + } + + #[test] + fn accepts_v1p5_with_both_start_frame_and_reference_images() { + let req = OmniGenVideoCostAndGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + start_frame_image_media_token: Some(token("mf_start")), + reference_image_media_tokens: Some(vec![token("mf_a")]), + ..base_request() + }; + assert!(validate_image_required(&req).is_ok()); + } + + #[test] + fn end_frame_alone_does_not_count_as_image_input() { + // end_frame_image_media_token is technically an image, but xAI's v1.5 + // wire shape only uses start_frame / reference_images. end_frame + // shouldn't satisfy the gate. + let req = OmniGenVideoCostAndGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + end_frame_image_media_token: Some(token("mf_end")), + ..base_request() + }; + assert!(validate_image_required(&req).is_err()); + } +} 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 feab9a3dd8..96a6b6535b 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 @@ -32,6 +32,7 @@ use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test; 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::helpers::validate_image_required::validate_image_required; 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_gmicloud_job::{insert_gmicloud_job, InsertGmiCloudJobArgs}; use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs}; @@ -67,6 +68,10 @@ pub async fn omni_gen_video_generate_handler( info!("request: {:?}", request); + // Reject doomed combos (e.g. grok_imagine_video_1p5 without an image) + // before any billable or DB-mutating work — see helper for the rules. + validate_image_required(&request)?; + payments_error_test(&request.prompt.as_deref().unwrap_or(""))?; let debug_log_event_token = DebugLogEventToken::generate();