From 75788d95253096ac65fecc70daeeb700f9fed0a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 1 Jun 2026 19:08:25 -0400 Subject: [PATCH] kling 1.6 pro, 2.5 turbo pro, 2.6 pro -> web omni api / artcraft_router / fal_client (#1597) --- .../generate_video/generate_video_response.rs | 8 + .../providers/fal/kling_1_6_pro/build.rs | 386 +++++++++++++++--- .../providers/fal/kling_1_6_pro/cost.rs | 111 ++++- .../providers/fal/kling_1_6_pro/request.rs | 165 +++++++- .../providers/fal/kling_2_1_master/request.rs | 2 + .../providers/fal/kling_2_1_pro/request.rs | 2 + .../fal/kling_2_5_turbo_pro/build.rs | 69 ++-- .../providers/fal/kling_2_5_turbo_pro/cost.rs | 33 +- .../fal/kling_2_5_turbo_pro/request.rs | 154 +++++-- .../providers/fal/kling_2_6_pro/build.rs | 131 +++--- .../providers/fal/kling_2_6_pro/cost.rs | 76 ++-- .../providers/fal/kling_2_6_pro/request.rs | 156 +++++-- .../providers/fal/kling_3p0_pro/request.rs | 2 + .../fal/kling_3p0_standard/request.rs | 2 + .../fal/seedance_1p0_lite/request.rs | 2 + .../providers/fal/seedance_1p5_pro/request.rs | 2 + .../providers/fal/sora_2/request.rs | 2 + .../providers/fal/sora_2_pro/request.rs | 2 + .../providers/fal/veo_2/request.rs | 2 + .../providers/fal/veo_3/request.rs | 2 + .../providers/fal/veo_3_fast/request.rs | 2 + .../providers/fal/veo_3p1/request.rs | 2 + .../providers/fal/veo_3p1_fast/request.rs | 2 + .../kling_1p6_pro_elements_to_video/api.rs | 257 ++++++++++++ .../kling_1p6_pro_elements_to_video/cost.rs | 261 ++++++++++++ .../kling_1p6_pro_elements_to_video/mod.rs | 3 + .../raw_request.rs | 37 ++ .../src/requests/api/video/elements/mod.rs | 1 + .../image/kling_1p6_pro_image_to_video/api.rs | 236 +++++++++++ .../kling_1p6_pro_image_to_video/cost.rs | 236 +++++++++++ .../image/kling_1p6_pro_image_to_video/mod.rs | 3 + .../raw_request.rs | 51 +++ .../kling_2p5_turbo_pro_image_to_video/api.rs | 324 +++++++++++++++ .../cost.rs | 215 ++++++++++ .../kling_2p5_turbo_pro_image_to_video/mod.rs | 3 + .../raw_request.rs | 40 ++ .../image/kling_2p6_pro_image_to_video/api.rs | 311 ++++++++++++++ .../kling_2p6_pro_image_to_video/cost.rs | 314 ++++++++++++++ .../image/kling_2p6_pro_image_to_video/mod.rs | 3 + .../raw_request.rs | 45 ++ .../src/requests/api/video/image/mod.rs | 3 + .../fal_client/src/requests/api/video/mod.rs | 1 + .../text/kling_1p6_pro_text_to_video/api.rs | 225 ++++++++++ .../text/kling_1p6_pro_text_to_video/cost.rs | 236 +++++++++++ .../text/kling_1p6_pro_text_to_video/mod.rs | 3 + .../raw_request.rs | 38 ++ .../kling_2p5_turbo_pro_text_to_video/api.rs | 335 +++++++++++++++ .../kling_2p5_turbo_pro_text_to_video/cost.rs | 261 ++++++++++++ .../kling_2p5_turbo_pro_text_to_video/mod.rs | 3 + .../raw_request.rs | 38 ++ .../text/kling_2p6_pro_text_to_video/api.rs | 358 ++++++++++++++++ .../text/kling_2p6_pro_text_to_video/cost.rs | 247 +++++++++++ .../text/kling_2p6_pro_text_to_video/mod.rs | 3 + .../raw_request.rs | 42 ++ .../src/requests/api/video/text/mod.rs | 3 + .../src/configs/omni_gen/video_models.rs | 58 +++ 56 files changed, 5205 insertions(+), 304 deletions(-) create mode 100644 crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/raw_request.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/elements/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/raw_request.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/raw_request.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/raw_request.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/raw_request.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/raw_request.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/raw_request.rs 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 95dac85b13..1a95d729d7 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 @@ -22,6 +22,14 @@ pub struct FalVideoResponsePayload { pub request_id: Option, pub gateway_request_id: Option, + /// Queue-mode status URL (fal's queue/polling flow). `None` for webhook + /// dispatch — the webhook callback drives status updates instead. + pub maybe_status_url: Option, + + /// Queue-mode response URL (fal's queue/polling flow). `None` for webhook + /// dispatch. + pub maybe_response_url: Option, + /// The outbound request that was sent to Fal. /// Stored as a trait object so any Request type can be captured. /// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print. diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/build.rs index 3c2169cf26..0e25c31959 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/build.rs @@ -1,40 +1,111 @@ -use fal_client::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{ - Kling1p6ProAspectRatio, Kling1p6ProDuration, Kling1p6ProRequest, +use fal_client::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{ + Kling1p6ProElementsToVideoAspectRatio, Kling1p6ProElementsToVideoDuration, + Kling1p6ProElementsToVideoRequest, +}; +use fal_client::requests::api::video::image::kling_1p6_pro_image_to_video::api::{ + Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration, + Kling1p6ProImageToVideoRequest, +}; +use fal_client::requests::api::video::text::kling_1p6_pro_text_to_video::api::{ + Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration, + Kling1p6ProTextToVideoRequest, }; use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::image_list_ref::ImageListRef; use crate::api::image_ref::ImageRef; use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; use crate::errors::artcraft_router_error::ArtcraftRouterError; use crate::errors::client_error::ClientError; use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; -use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::FalKling16ProRequestState; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::{ + FalKling16ProMode, FalKling16ProRequestState, +}; use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanDuration { + Five, + Ten, +} + pub fn build_fal_kling_1_6_pro( builder: GenerateVideoRequestBuilder, ) -> Result { let strategy = builder.request_mismatch_mitigation_strategy; - let image_url = require_url(builder.start_frame.clone(), "start_frame", "Kling 1.6 Pro requires a starting frame")?; - let end_frame_image_url = optional_url(builder.end_frame.clone())?; let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; let duration = plan_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); - let request = Kling1p6ProRequest { - image_url, - end_frame_image_url, - prompt: builder.prompt.clone().unwrap_or_default(), - duration, - aspect_ratio, + let reference_image_urls = resolve_reference_image_urls(builder.reference_images.clone())?; + let start_image_url = optional_url(builder.start_frame.clone())?; + let end_image_url = optional_url(builder.end_frame.clone())?; + + // Dispatch: reference_images → elements; start_frame → image; else → text. + // Elements-to-video has no concept of start/end frame, so passing both with + // reference_images is rejected unless the strategy is lenient. + let mode = if !reference_image_urls.is_empty() { + if (start_image_url.is_some() || end_image_url.is_some()) + && matches!(strategy, RequestMismatchMitigationStrategy::ErrorOut) + { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "Kling 1.6 Pro elements-to-video cannot also accept start_frame or end_frame".to_string(), + })); + } + FalKling16ProMode::ElementsToVideo(Kling1p6ProElementsToVideoRequest { + prompt, + input_image_urls: reference_image_urls, + negative_prompt, + duration: duration.map(to_elements_duration), + aspect_ratio: aspect_ratio.map(to_elements_aspect_ratio), + }) + } else if let Some(image_url) = start_image_url { + FalKling16ProMode::ImageToVideo(Kling1p6ProImageToVideoRequest { + prompt, + image_url, + end_image_url, + negative_prompt, + duration: duration.map(to_i2v_duration), + aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio), + cfg_scale: None, + }) + } else { + if end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Kling 1.6 Pro requires a start_frame when end_frame is provided".to_string(), + })); + } + FalKling16ProMode::TextToVideo(Kling1p6ProTextToVideoRequest { + prompt, + negative_prompt, + duration: duration.map(to_t2v_duration), + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + cfg_scale: None, + }) }; Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro( - FalKling16ProRequestState { request }, + FalKling16ProRequestState { mode }, ))) } +// ── Shared URL helpers ── +// +// `optional_url` is also imported by other Kling router modules (e.g. +// kling_3p0_pro) to avoid copy/paste. Preserve the signature. + pub(crate) fn require_url( image_ref: Option, field: &'static str, @@ -62,21 +133,35 @@ pub(crate) fn optional_url(image_ref: Option) -> Result } } +fn resolve_reference_image_urls( + reference_images: Option, +) -> Result, ArtcraftRouterError> { + match reference_images { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +// ── Plan helpers ── + fn plan_aspect_ratio( aspect_ratio: Option, strategy: RequestMismatchMitigationStrategy, -) -> Result { - use Kling1p6ProAspectRatio as Ar; +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; match aspect_ratio { - None => Ok(Ar::WideSixteenNine), + None => Ok(None), - Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square), - Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine), - Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen), + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k) - | Some(RouterAspectRatio::Auto4k) => Ok(Ar::WideSixteenNine), + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::SixteenByNine)), Some(other) => match strategy { RequestMismatchMitigationStrategy::ErrorOut => { @@ -85,7 +170,7 @@ fn plan_aspect_ratio( value: format!("{:?}", other), })) } - _ => Ok(Ar::WideSixteenNine), + _ => Ok(Some(Ar::SixteenByNine)), }, } } @@ -93,11 +178,11 @@ fn plan_aspect_ratio( fn plan_duration( duration_seconds: Option, strategy: RequestMismatchMitigationStrategy, -) -> Result { +) -> Result, ArtcraftRouterError> { match duration_seconds { - None => Ok(Kling1p6ProDuration::Default), - Some(5) => Ok(Kling1p6ProDuration::FiveSeconds), - Some(10) => Ok(Kling1p6ProDuration::TenSeconds), + None => Ok(None), + Some(5) => Ok(Some(PlanDuration::Five)), + Some(10) => Ok(Some(PlanDuration::Ten)), Some(other) => match strategy { RequestMismatchMitigationStrategy::ErrorOut => { Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { @@ -105,12 +190,57 @@ fn plan_duration( value: format!("{}", other), })) } - RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Kling1p6ProDuration::TenSeconds), - RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Kling1p6ProDuration::FiveSeconds), + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Ten)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Five)), }, } } +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling1p6ProTextToVideoAspectRatio { + match a { + PlanAspectRatio::Square => Kling1p6ProTextToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling1p6ProTextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling1p6ProTextToVideoAspectRatio::NineBySixteen, + } +} + +fn to_t2v_duration(d: PlanDuration) -> Kling1p6ProTextToVideoDuration { + match d { + PlanDuration::Five => Kling1p6ProTextToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling1p6ProTextToVideoDuration::TenSeconds, + } +} + +fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Kling1p6ProImageToVideoAspectRatio { + match a { + PlanAspectRatio::Square => Kling1p6ProImageToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling1p6ProImageToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling1p6ProImageToVideoAspectRatio::NineBySixteen, + } +} + +fn to_i2v_duration(d: PlanDuration) -> Kling1p6ProImageToVideoDuration { + match d { + PlanDuration::Five => Kling1p6ProImageToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling1p6ProImageToVideoDuration::TenSeconds, + } +} + +fn to_elements_aspect_ratio(a: PlanAspectRatio) -> Kling1p6ProElementsToVideoAspectRatio { + match a { + PlanAspectRatio::Square => Kling1p6ProElementsToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling1p6ProElementsToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling1p6ProElementsToVideoAspectRatio::NineBySixteen, + } +} + +fn to_elements_duration(d: PlanDuration) -> Kling1p6ProElementsToVideoDuration { + match d { + PlanDuration::Five => Kling1p6ProElementsToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling1p6ProElementsToVideoDuration::TenSeconds, + } +} + #[cfg(test)] mod tests { use crate::api::router_video_model::RouterVideoModel; @@ -123,44 +253,194 @@ mod tests { model: RouterVideoModel::Kling16Pro, provider: RouterProvider::Fal, prompt: Some("test".to_string()), - start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())), ..Default::default() } } - #[test] - fn missing_start_frame_errors() { - let mut b = base_builder(); - b.start_frame = None; - assert!(build_fal_kling_1_6_pro(b).is_err()); + // ── Mode dispatch ── + + mod mode_dispatch { + use super::*; + + #[test] + fn no_frames_picks_t2v() { + let result = build_fal_kling_1_6_pro(base_builder()).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + assert!(matches!(s.mode, FalKling16ProMode::TextToVideo(_))); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + assert!(matches!(s.mode, FalKling16ProMode::ImageToVideo(_))); + } + + #[test] + fn reference_images_picks_elements() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()])); + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + assert!(matches!(s.mode, FalKling16ProMode::ElementsToVideo(_))); + } + + #[test] + fn elements_with_start_frame_errors_in_strict_mode() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()])); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_kling_1_6_pro(b).is_err()); + } + + #[test] + fn elements_with_start_frame_drops_frame_in_lenient_mode() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()])); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + assert!(matches!(s.mode, FalKling16ProMode::ElementsToVideo(_))); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_kling_1_6_pro(b).is_err()); + } } - #[test] - fn end_frame_is_optional() { - let mut b = base_builder(); - b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); - assert!(build_fal_kling_1_6_pro(b).is_ok()); + // ── Field passthrough ── + + mod field_passthrough { + use super::*; + + #[test] + fn t2v_prompt_and_aspect_propagate() { + let mut b = base_builder(); + b.prompt = Some("custom prompt".to_string()); + b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); + b.duration_seconds = Some(10); + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + let FalKling16ProMode::TextToVideo(req) = s.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.prompt, "custom prompt"); + assert!(matches!(req.aspect_ratio, Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen))); + assert!(matches!(req.duration, Some(Kling1p6ProTextToVideoDuration::TenSeconds))); + } + + #[test] + fn i2v_start_and_end_frames_propagate() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + let FalKling16ProMode::ImageToVideo(req) = s.mode else { + panic!("expected ImageToVideo"); + }; + assert_eq!(req.image_url, "https://example.com/start.png"); + assert_eq!(req.end_image_url.as_deref(), Some("https://example.com/end.png")); + } + + #[test] + fn elements_input_image_urls_propagate() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + ])); + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + let FalKling16ProMode::ElementsToVideo(req) = s.mode else { + panic!("expected ElementsToVideo"); + }; + assert_eq!(req.input_image_urls.len(), 2); + } } - #[test] - fn full_combinatorial_pass() { - let aspect_ratios = [None, Some(RouterAspectRatio::Square), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen), Some(RouterAspectRatio::Auto)]; - let durations = [None, Some(5u16), Some(10)]; - let mut combos = 0; - for &aspect_ratio in &aspect_ratios { - for &duration in &durations { - for include_end in [false, true] { - let mut b = base_builder(); - b.aspect_ratio = aspect_ratio; - b.duration_seconds = duration; - if include_end { - b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + // ── Validation ── + + mod validation { + use super::*; + + #[test] + fn media_file_token_rejected_for_start_frame() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::MediaFileToken( + tokens::tokens::media_files::MediaFileToken::new_from_str("mf_xxx"), + )); + assert!(matches!( + build_fal_kling_1_6_pro(b), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn duration_out_of_range_errors_in_strict_mode() { + let mut b = base_builder(); + b.duration_seconds = Some(7); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_kling_1_6_pro(b).is_err()); + } + + #[test] + fn duration_out_of_range_upgrades() { + let mut b = base_builder(); + b.duration_seconds = Some(7); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + let FalKling16ProMode::TextToVideo(req) = s.mode else { + panic!("expected TextToVideo"); + }; + assert!(matches!(req.duration, Some(Kling1p6ProTextToVideoDuration::TenSeconds))); + } + + #[test] + fn full_combinatorial_pass() { + let aspect_ratios = [None, Some(RouterAspectRatio::Square), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen), Some(RouterAspectRatio::Auto)]; + let durations = [None, Some(5u16), Some(10)]; + let mut combos = 0; + for &aspect_ratio in &aspect_ratios { + for &duration in &durations { + for include_end in [false, true] { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.aspect_ratio = aspect_ratio; + b.duration_seconds = duration; + if include_end { + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + } + assert!(build_fal_kling_1_6_pro(b).is_ok()); + combos += 1; } - assert!(build_fal_kling_1_6_pro(b).is_ok()); - combos += 1; } } + assert_eq!(combos, 5 * 3 * 2); } - assert_eq!(combos, 5 * 3 * 2); } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/cost.rs index be4697e297..8ab1f40627 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/cost.rs @@ -1,27 +1,32 @@ -use fal_client::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::Kling1p6ProDuration; +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; -use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::FalKling16ProRequestState; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::{ + FalKling16ProMode, FalKling16ProRequestState, +}; #[derive(Clone, Debug)] pub struct FalKling16ProCostState { - pub is_ten_seconds: bool, + pub cost_in_usd_cents: u64, } impl FalKling16ProCostState { pub fn from_request(request: &FalKling16ProRequestState) -> Self { - Self { - is_ten_seconds: matches!(request.request.duration, Kling1p6ProDuration::TenSeconds), - } + // Cost math is owned by fal_client's per-endpoint `FalRequestCostCalculator` + // implementations. The router state mirrors whichever calculator matches + // the dispatched mode so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalKling16ProMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalKling16ProMode::ImageToVideo(req) => req.calculate_cost_in_cents(), + FalKling16ProMode::ElementsToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } } pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { - // Mirrors fal_client kling_v1p6_pro: 5s = 48¢, 10s = 95¢ (Default → 5s). - let cost_in_usd_cents: u64 = if self.is_ten_seconds { 95 } else { 48 }; - VideoGenerationCostEstimate { - cost_in_credits: Some(cost_in_usd_cents), - cost_in_usd_cents: Some(cost_in_usd_cents), + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), is_free: false, is_unlimited: false, is_rate_limited: false, @@ -33,29 +38,95 @@ impl FalKling16ProCostState { #[cfg(test)] mod tests { - use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; use crate::api::image_ref::ImageRef; use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; - fn cost_cents(duration_seconds: Option) -> u64 { - let b = GenerateVideoRequestBuilder { + fn cost_cents_with(mut configure: impl FnMut(&mut GenerateVideoRequestBuilder)) -> u64 { + let mut b = GenerateVideoRequestBuilder { model: RouterVideoModel::Kling16Pro, provider: RouterProvider::Fal, prompt: Some("test".to_string()), - start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())), - duration_seconds, ..Default::default() }; + configure(&mut b); b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() } - #[test] - fn five_seconds_is_48() { assert_eq!(cost_cents(Some(5)), 48); } + // ── Image-to-video ($0.095/sec) ── #[test] - fn ten_seconds_is_95() { assert_eq!(cost_cents(Some(10)), 95); } + fn i2v_5s_is_48() { + // ceil(95 × 5 / 10) = 48¢ + let cost = cost_cents_with(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.duration_seconds = Some(5); + }); + assert_eq!(cost, 48); + } #[test] - fn default_duration_is_5s_priced_at_48() { assert_eq!(cost_cents(None), 48); } + fn i2v_10s_is_95() { + // ceil(95 × 10 / 10) = 95¢ + let cost = cost_cents_with(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.duration_seconds = Some(10); + }); + assert_eq!(cost, 95); + } + + #[test] + fn i2v_default_duration_is_5s_priced_at_48() { + let cost = cost_cents_with(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + }); + assert_eq!(cost, 48); + } + + // ── Text-to-video ($0.098/sec) ── + + #[test] + fn t2v_5s_is_49() { + // ceil(98 × 5 / 10) = 49¢ + let cost = cost_cents_with(|b| { + b.duration_seconds = Some(5); + }); + assert_eq!(cost, 49); + } + + #[test] + fn t2v_10s_is_98() { + let cost = cost_cents_with(|b| { + b.duration_seconds = Some(10); + }); + assert_eq!(cost, 98); + } + + #[test] + fn t2v_default_duration_is_5s_priced_at_49() { + let cost = cost_cents_with(|_b| {}); + assert_eq!(cost, 49); + } + + // ── Elements-to-video ($0.098/sec, same as t2v) ── + + #[test] + fn elements_5s_is_49() { + let cost = cost_cents_with(|b| { + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()])); + b.duration_seconds = Some(5); + }); + assert_eq!(cost, 49); + } + + #[test] + fn elements_10s_is_98() { + let cost = cost_cents_with(|b| { + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()])); + b.duration_seconds = Some(10); + }); + assert_eq!(cost, 98); + } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/request.rs index 3755001cdf..2e72ea1c28 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/request.rs @@ -1,43 +1,164 @@ use std::fmt::Debug; use std::sync::Arc; -use fal_client::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{ - enqueue_kling_v1p6_pro_image_to_video_webhook, Kling1p6ProArgs, Kling1p6ProRequest, -}; +use fal_client::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::Kling1p6ProElementsToVideoRequest; +use fal_client::requests::api::video::image::kling_1p6_pro_image_to_video::api::Kling1p6ProImageToVideoRequest; +use fal_client::requests::api::video::text::kling_1p6_pro_text_to_video::api::Kling1p6ProTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; use crate::client::router_fal_client::RouterFalClient; use crate::errors::artcraft_router_error::ArtcraftRouterError; -use crate::errors::client_error::ClientError; -use crate::errors::provider_error::ProviderError; use crate::generate::generate_video::generate_video_response::{ FalVideoResponsePayload, GenerateVideoResponse, }; +#[derive(Clone, Debug)] +pub enum FalKling16ProMode { + TextToVideo(Kling1p6ProTextToVideoRequest), + ImageToVideo(Kling1p6ProImageToVideoRequest), + ElementsToVideo(Kling1p6ProElementsToVideoRequest), +} + #[derive(Clone, Debug)] pub struct FalKling16ProRequestState { - pub request: Kling1p6ProRequest, + pub mode: FalKling16ProMode, } impl FalKling16ProRequestState { pub async fn send(&self, client: &RouterFalClient) -> Result { - let webhook_url = client.webhook_url.as_deref() - .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; - let outbound_request: Arc = Arc::new(self.request.clone()); - - let args = Kling1p6ProArgs { - request: self.request.clone(), - webhook_url, - api_key: &client.api_key, + match &self.mode { + FalKling16ProMode::TextToVideo(request) => send_request(request, client).await, + FalKling16ProMode::ImageToVideo(request) => send_request(request, client).await, + FalKling16ProMode::ElementsToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{ + Kling1p6ProElementsToVideoAspectRatio, Kling1p6ProElementsToVideoDuration, + }; + use fal_client::requests::api::video::image::kling_1p6_pro_image_to_video::api::{ + Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration, + }; + use fal_client::requests::api::video::text::kling_1p6_pro_text_to_video::api::{ + Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalKling16ProRequestState { + mode: FalKling16ProMode::TextToVideo(Kling1p6ProTextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + negative_prompt: None, + duration: Some(Kling1p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }), }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } - let webhook_response = enqueue_kling_v1p6_pro_image_to_video_webhook(args) - .await - .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_image_to_video_webhook() { + let state = FalKling16ProRequestState { + mode: FalKling16ProMode::ImageToVideo(Kling1p6ProImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } - Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { - request_id: webhook_response.request_id, - gateway_request_id: webhook_response.gateway_request_id, - maybe_outbound_request: Some(outbound_request), - })) + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_elements_to_video_webhook() { + let state = FalKling16ProRequestState { + mode: FalKling16ProMode::ElementsToVideo(Kling1p6ProElementsToVideoRequest { + prompt: "the elements come to life and dance".to_string(), + input_image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/request.rs index 82b5f5d296..98ab2fb5b3 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/request.rs @@ -37,6 +37,8 @@ impl FalKling21MasterRequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/request.rs index 04e5dd1307..80748ef6d0 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/request.rs @@ -37,6 +37,8 @@ impl FalKling21ProRequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/build.rs index 3bd3270be2..37afc8840a 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/build.rs @@ -1,9 +1,9 @@ -use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p5_turbo_pro_image_to_video_webhook::{ - EnqueueKlingV2p5TurboProImageToVideoDurationSeconds, EnqueueKlingV2p5TurboProImageToVideoRequest, +use fal_client::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::{ + Kling2p5TurboProImageToVideoDuration, Kling2p5TurboProImageToVideoRequest, }; -use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p5_turbo_pro_text_to_video_webhook::{ - EnqueueKlingV2p5TurboProTextToVideoAspectRatio, EnqueueKlingV2p5TurboProTextToVideoDurationSeconds, - EnqueueKlingV2p5TurboProTextToVideoRequest, +use fal_client::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration, + Kling2p5TurboProTextToVideoRequest, }; use crate::api::router_aspect_ratio::RouterAspectRatio; @@ -49,19 +49,21 @@ pub fn build_fal_kling_2_5_turbo_pro( value: "Kling 2.5 Turbo Pro requires a start_frame when end_frame is provided".to_string(), })); } - FalKling2p5TurboProMode::TextToVideo(EnqueueKlingV2p5TurboProTextToVideoRequest { + FalKling2p5TurboProMode::TextToVideo(Kling2p5TurboProTextToVideoRequest { prompt, negative_prompt, duration: duration.map(to_t2v_duration), aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + cfg_scale: None, }) } - Some(image_url) => FalKling2p5TurboProMode::ImageToVideo(EnqueueKlingV2p5TurboProImageToVideoRequest { + Some(image_url) => FalKling2p5TurboProMode::ImageToVideo(Kling2p5TurboProImageToVideoRequest { prompt, image_url, tail_image_url: optional_url(builder.end_frame.clone())?, - negative_prompt, duration: duration.map(to_i2v_duration), + negative_prompt, + cfg_scale: None, }), }; @@ -119,33 +121,33 @@ fn plan_duration( } } -fn to_t2v_duration(d: PlanDuration) -> EnqueueKlingV2p5TurboProTextToVideoDurationSeconds { +fn to_t2v_duration(d: PlanDuration) -> Kling2p5TurboProTextToVideoDuration { match d { - PlanDuration::Five => EnqueueKlingV2p5TurboProTextToVideoDurationSeconds::Five, - PlanDuration::Ten => EnqueueKlingV2p5TurboProTextToVideoDurationSeconds::Ten, + PlanDuration::Five => Kling2p5TurboProTextToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling2p5TurboProTextToVideoDuration::TenSeconds, } } -fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueKlingV2p5TurboProTextToVideoAspectRatio { +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling2p5TurboProTextToVideoAspectRatio { match a { - PlanAspectRatio::Square => EnqueueKlingV2p5TurboProTextToVideoAspectRatio::Square, - PlanAspectRatio::SixteenByNine => EnqueueKlingV2p5TurboProTextToVideoAspectRatio::SixteenByNine, - PlanAspectRatio::NineBySixteen => EnqueueKlingV2p5TurboProTextToVideoAspectRatio::NineBySixteen, + PlanAspectRatio::Square => Kling2p5TurboProTextToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen, } } -fn to_i2v_duration(d: PlanDuration) -> EnqueueKlingV2p5TurboProImageToVideoDurationSeconds { +fn to_i2v_duration(d: PlanDuration) -> Kling2p5TurboProImageToVideoDuration { match d { - PlanDuration::Five => EnqueueKlingV2p5TurboProImageToVideoDurationSeconds::Five, - PlanDuration::Ten => EnqueueKlingV2p5TurboProImageToVideoDurationSeconds::Ten, + PlanDuration::Five => Kling2p5TurboProImageToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling2p5TurboProImageToVideoDuration::TenSeconds, } } #[cfg(test)] mod tests { - use crate::api::router_video_model::RouterVideoModel; use crate::api::image_ref::ImageRef; use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; use super::*; @@ -161,11 +163,10 @@ mod tests { #[test] fn no_start_frame_picks_t2v() { let result = build_fal_kling_2_5_turbo_pro(base_builder()).expect("build"); - if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result { - assert!(matches!(s.mode, FalKling2p5TurboProMode::TextToVideo(_))); - } else { + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result else { panic!("expected FalKling2p5TurboPro"); - } + }; + assert!(matches!(s.mode, FalKling2p5TurboProMode::TextToVideo(_))); } #[test] @@ -173,11 +174,10 @@ mod tests { let mut b = base_builder(); b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); let result = build_fal_kling_2_5_turbo_pro(b).expect("build"); - if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result { - assert!(matches!(s.mode, FalKling2p5TurboProMode::ImageToVideo(_))); - } else { + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result else { panic!("expected FalKling2p5TurboPro"); - } + }; + assert!(matches!(s.mode, FalKling2p5TurboProMode::ImageToVideo(_))); } #[test] @@ -186,4 +186,19 @@ mod tests { b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); assert!(build_fal_kling_2_5_turbo_pro(b).is_err()); } + + #[test] + fn tail_image_url_propagates() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + let result = build_fal_kling_2_5_turbo_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result else { + panic!("expected FalKling2p5TurboPro"); + }; + let FalKling2p5TurboProMode::ImageToVideo(req) = s.mode else { + panic!("expected ImageToVideo"); + }; + assert_eq!(req.tail_image_url.as_deref(), Some("https://example.com/end.png")); + } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/cost.rs index 546f413d62..dac10be4ca 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/cost.rs @@ -1,5 +1,4 @@ -use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p5_turbo_pro_image_to_video_webhook::EnqueueKlingV2p5TurboProImageToVideoDurationSeconds; -use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p5_turbo_pro_text_to_video_webhook::EnqueueKlingV2p5TurboProTextToVideoDurationSeconds; +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; use crate::generate::generate_video::providers::fal::kling_2_5_turbo_pro::request::{ @@ -8,29 +7,24 @@ use crate::generate::generate_video::providers::fal::kling_2_5_turbo_pro::reques #[derive(Clone, Debug)] pub struct FalKling2p5TurboProCostState { - pub is_ten_seconds: bool, + pub cost_in_usd_cents: u64, } impl FalKling2p5TurboProCostState { pub fn from_request(request: &FalKling2p5TurboProRequestState) -> Self { - let is_ten_seconds = match &request.mode { - FalKling2p5TurboProMode::TextToVideo(req) => { - matches!(req.duration, Some(EnqueueKlingV2p5TurboProTextToVideoDurationSeconds::Ten)) - } - FalKling2p5TurboProMode::ImageToVideo(req) => { - matches!(req.duration, Some(EnqueueKlingV2p5TurboProImageToVideoDurationSeconds::Ten)) - } + // Cost math is owned by fal_client's per-endpoint `FalRequestCostCalculator` + // implementations. + let cost_in_usd_cents = match &request.mode { + FalKling2p5TurboProMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalKling2p5TurboProMode::ImageToVideo(req) => req.calculate_cost_in_cents(), }; - Self { is_ten_seconds } + Self { cost_in_usd_cents } } pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { - // Mirrors fal_client kling_v2p5_turbo_pro: 5s = 35¢, 10s = 70¢. None → 5s. - let cost_in_usd_cents: u64 = if self.is_ten_seconds { 70 } else { 35 }; - VideoGenerationCostEstimate { - cost_in_credits: Some(cost_in_usd_cents), - cost_in_usd_cents: Some(cost_in_usd_cents), + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), is_free: false, is_unlimited: false, is_rate_limited: false, @@ -42,9 +36,9 @@ impl FalKling2p5TurboProCostState { #[cfg(test)] mod tests { - use crate::api::router_video_model::RouterVideoModel; use crate::api::image_ref::ImageRef; use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; fn cost_cents(duration_seconds: Option, has_start: bool) -> u64 { @@ -61,6 +55,8 @@ mod tests { b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() } + // Pricing: $0.07/sec flat → 5s = 35¢, 10s = 70¢. Same for t2v and i2v. + #[test] fn t2v_5s_is_35() { assert_eq!(cost_cents(Some(5), false), 35); } @@ -70,6 +66,9 @@ mod tests { #[test] fn i2v_5s_is_35() { assert_eq!(cost_cents(Some(5), true), 35); } + #[test] + fn i2v_10s_is_70() { assert_eq!(cost_cents(Some(10), true), 70); } + #[test] fn default_is_5s_priced_at_35() { assert_eq!(cost_cents(None, false), 35); } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/request.rs index 4e2ceab0af..43e0fe2361 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/request.rs @@ -1,27 +1,20 @@ use std::fmt::Debug; use std::sync::Arc; -use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p5_turbo_pro_image_to_video_webhook::{ - enqueue_kling_v2p5_turbo_pro_image_to_video_webhook, EnqueueKlingV2p5TurboProImageToVideoArgs, - EnqueueKlingV2p5TurboProImageToVideoRequest, -}; -use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p5_turbo_pro_text_to_video_webhook::{ - enqueue_kling_v2p5_turbo_pro_text_to_video_webhook, EnqueueKlingV2p5TurboProTextToVideoArgs, - EnqueueKlingV2p5TurboProTextToVideoRequest, -}; +use fal_client::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::Kling2p5TurboProImageToVideoRequest; +use fal_client::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::Kling2p5TurboProTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; use crate::client::router_fal_client::RouterFalClient; use crate::errors::artcraft_router_error::ArtcraftRouterError; -use crate::errors::client_error::ClientError; -use crate::errors::provider_error::ProviderError; use crate::generate::generate_video::generate_video_response::{ FalVideoResponsePayload, GenerateVideoResponse, }; #[derive(Clone, Debug)] pub enum FalKling2p5TurboProMode { - TextToVideo(EnqueueKlingV2p5TurboProTextToVideoRequest), - ImageToVideo(EnqueueKlingV2p5TurboProImageToVideoRequest), + TextToVideo(Kling2p5TurboProTextToVideoRequest), + ImageToVideo(Kling2p5TurboProImageToVideoRequest), } #[derive(Clone, Debug)] @@ -31,36 +24,115 @@ pub struct FalKling2p5TurboProRequestState { impl FalKling2p5TurboProRequestState { pub async fn send(&self, client: &RouterFalClient) -> Result { - let webhook_url = client.webhook_url.as_deref() - .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; - let (webhook_response, outbound_request): (_, Arc) = match &self.mode { - FalKling2p5TurboProMode::TextToVideo(request) => { - let outbound: Arc = Arc::new(request.clone()); - let args = EnqueueKlingV2p5TurboProTextToVideoArgs { - request: request.clone(), - webhook_url, - api_key: &client.api_key, - }; - (enqueue_kling_v2p5_turbo_pro_text_to_video_webhook(args).await, outbound) - } - FalKling2p5TurboProMode::ImageToVideo(request) => { - let outbound: Arc = Arc::new(request.clone()); - let args = EnqueueKlingV2p5TurboProImageToVideoArgs { - request: request.clone(), - webhook_url, - api_key: &client.api_key, - }; - (enqueue_kling_v2p5_turbo_pro_image_to_video_webhook(args).await, outbound) - } + match &self.mode { + FalKling2p5TurboProMode::TextToVideo(request) => send_request(request, client).await, + FalKling2p5TurboProMode::ImageToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::Kling2p5TurboProImageToVideoDuration; + use fal_client::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalKling2p5TurboProRequestState { + mode: FalKling2p5TurboProMode::TextToVideo(Kling2p5TurboProTextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }), }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } - let webhook_response = webhook_response - .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; - - Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { - request_id: webhook_response.request_id, - gateway_request_id: webhook_response.gateway_request_id, - maybe_outbound_request: Some(outbound_request), - })) + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_image_to_video_webhook() { + let state = FalKling2p5TurboProRequestState { + mode: FalKling2p5TurboProMode::ImageToVideo(Kling2p5TurboProImageToVideoRequest { + prompt: "the lake comes alive".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + tail_image_url: None, + duration: Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + cfg_scale: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/build.rs index 7fa169da71..bd821b1756 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/build.rs @@ -1,9 +1,9 @@ -use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p6_pro_image_to_video_webhook::{ - EnqueueKlingV2p6ProImageToVideoDurationSeconds, EnqueueKlingV2p6ProImageToVideoRequest, +use fal_client::requests::api::video::image::kling_2p6_pro_image_to_video::api::{ + Kling2p6ProImageToVideoDuration, Kling2p6ProImageToVideoRequest, }; -use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p6_pro_text_to_video_webhook::{ - EnqueueKlingV2p6ProTextToVideoAspectRatio, EnqueueKlingV2p6ProTextToVideoDurationSeconds, - EnqueueKlingV2p6ProTextToVideoRequest, +use fal_client::requests::api::video::text::kling_2p6_pro_text_to_video::api::{ + Kling2p6ProTextToVideoAspectRatio, Kling2p6ProTextToVideoDuration, + Kling2p6ProTextToVideoRequest, }; use crate::api::router_aspect_ratio::RouterAspectRatio; @@ -36,33 +36,38 @@ pub fn build_fal_kling_2_6_pro( ) -> Result { let strategy = builder.request_mismatch_mitigation_strategy; - if builder.end_frame.is_some() { - return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { - field: "end_frame", - value: "Kling 2.6 Pro does not support an ending frame".to_string(), - })); - } - let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; let duration = plan_duration(builder.duration_seconds, strategy)?; let prompt = builder.prompt.clone().unwrap_or_default(); let negative_prompt = builder.negative_prompt.clone(); let generate_audio = builder.generate_audio; + let end_image_url = optional_url(builder.end_frame.clone())?; let mode = match optional_url(builder.start_frame.clone())? { - None => FalKling2p6ProMode::TextToVideo(EnqueueKlingV2p6ProTextToVideoRequest { + None => { + if end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Kling 2.6 Pro requires a start_frame when end_frame is provided".to_string(), + })); + } + FalKling2p6ProMode::TextToVideo(Kling2p6ProTextToVideoRequest { + prompt, + generate_audio, + negative_prompt, + duration: duration.map(to_t2v_duration), + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + cfg_scale: None, + }) + } + Some(start_image_url) => FalKling2p6ProMode::ImageToVideo(Kling2p6ProImageToVideoRequest { prompt, - generate_audio, - negative_prompt, - duration: duration.map(to_t2v_duration), - aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), - }), - Some(image_url) => FalKling2p6ProMode::ImageToVideo(EnqueueKlingV2p6ProImageToVideoRequest { - prompt, - image_url, - generate_audio, - negative_prompt, + start_image_url, + end_image_url, duration: duration.map(to_i2v_duration), + negative_prompt, + generate_audio, + voice_ids: None, }), }; @@ -120,33 +125,33 @@ fn plan_duration( } } -fn to_t2v_duration(d: PlanDuration) -> EnqueueKlingV2p6ProTextToVideoDurationSeconds { +fn to_t2v_duration(d: PlanDuration) -> Kling2p6ProTextToVideoDuration { match d { - PlanDuration::Five => EnqueueKlingV2p6ProTextToVideoDurationSeconds::Five, - PlanDuration::Ten => EnqueueKlingV2p6ProTextToVideoDurationSeconds::Ten, + PlanDuration::Five => Kling2p6ProTextToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling2p6ProTextToVideoDuration::TenSeconds, } } -fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueKlingV2p6ProTextToVideoAspectRatio { +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling2p6ProTextToVideoAspectRatio { match a { - PlanAspectRatio::Square => EnqueueKlingV2p6ProTextToVideoAspectRatio::Square, - PlanAspectRatio::SixteenByNine => EnqueueKlingV2p6ProTextToVideoAspectRatio::SixteenByNine, - PlanAspectRatio::NineBySixteen => EnqueueKlingV2p6ProTextToVideoAspectRatio::NineBySixteen, + PlanAspectRatio::Square => Kling2p6ProTextToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling2p6ProTextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling2p6ProTextToVideoAspectRatio::NineBySixteen, } } -fn to_i2v_duration(d: PlanDuration) -> EnqueueKlingV2p6ProImageToVideoDurationSeconds { +fn to_i2v_duration(d: PlanDuration) -> Kling2p6ProImageToVideoDuration { match d { - PlanDuration::Five => EnqueueKlingV2p6ProImageToVideoDurationSeconds::Five, - PlanDuration::Ten => EnqueueKlingV2p6ProImageToVideoDurationSeconds::Ten, + PlanDuration::Five => Kling2p6ProImageToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling2p6ProImageToVideoDuration::TenSeconds, } } #[cfg(test)] mod tests { - use crate::api::router_video_model::RouterVideoModel; use crate::api::image_ref::ImageRef; use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; use super::*; @@ -159,19 +164,13 @@ mod tests { } } - #[test] - fn end_frame_errors() { - let mut b = base_builder(); - b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); - assert!(build_fal_kling_2_6_pro(b).is_err()); - } - #[test] fn no_start_frame_picks_t2v() { let result = build_fal_kling_2_6_pro(base_builder()).expect("build"); - if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result { - assert!(matches!(s.mode, FalKling2p6ProMode::TextToVideo(_))); - } else { panic!("expected FalKling2p6Pro"); } + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else { + panic!("expected FalKling2p6Pro"); + }; + assert!(matches!(s.mode, FalKling2p6ProMode::TextToVideo(_))); } #[test] @@ -179,8 +178,46 @@ mod tests { let mut b = base_builder(); b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); let result = build_fal_kling_2_6_pro(b).expect("build"); - if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result { - assert!(matches!(s.mode, FalKling2p6ProMode::ImageToVideo(_))); - } else { panic!("expected FalKling2p6Pro"); } + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else { + panic!("expected FalKling2p6Pro"); + }; + assert!(matches!(s.mode, FalKling2p6ProMode::ImageToVideo(_))); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_kling_2_6_pro(b).is_err()); + } + + #[test] + fn end_frame_with_start_frame_propagates_through_i2v() { + // Kling 2.6 Pro image-to-video accepts an end frame (`end_image_url`). + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + let result = build_fal_kling_2_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else { + panic!("expected FalKling2p6Pro"); + }; + let FalKling2p6ProMode::ImageToVideo(req) = s.mode else { + panic!("expected ImageToVideo"); + }; + assert_eq!(req.end_image_url.as_deref(), Some("https://example.com/end.png")); + } + + #[test] + fn generate_audio_propagates() { + let mut b = base_builder(); + b.generate_audio = Some(false); + let result = build_fal_kling_2_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else { + panic!("expected FalKling2p6Pro"); + }; + let FalKling2p6ProMode::TextToVideo(req) = s.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.generate_audio, Some(false)); } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/cost.rs index 8b447346ce..f097812ad5 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/cost.rs @@ -1,5 +1,4 @@ -use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p6_pro_image_to_video_webhook::EnqueueKlingV2p6ProImageToVideoDurationSeconds; -use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p6_pro_text_to_video_webhook::EnqueueKlingV2p6ProTextToVideoDurationSeconds; +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; use crate::generate::generate_video::providers::fal::kling_2_6_pro::request::{ @@ -8,39 +7,25 @@ use crate::generate::generate_video::providers::fal::kling_2_6_pro::request::{ #[derive(Clone, Debug)] pub struct FalKling2p6ProCostState { - pub is_ten_seconds: bool, - pub generate_audio: bool, + pub cost_in_usd_cents: u64, } impl FalKling2p6ProCostState { pub fn from_request(request: &FalKling2p6ProRequestState) -> Self { - let (is_ten_seconds, generate_audio) = match &request.mode { - FalKling2p6ProMode::TextToVideo(req) => ( - matches!(req.duration, Some(EnqueueKlingV2p6ProTextToVideoDurationSeconds::Ten)), - req.generate_audio.unwrap_or(true), - ), - FalKling2p6ProMode::ImageToVideo(req) => ( - matches!(req.duration, Some(EnqueueKlingV2p6ProImageToVideoDurationSeconds::Ten)), - req.generate_audio.unwrap_or(true), - ), + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalKling2p6ProMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalKling2p6ProMode::ImageToVideo(req) => req.calculate_cost_in_cents(), }; - Self { is_ten_seconds, generate_audio } + Self { cost_in_usd_cents } } pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { - // Mirrors fal_client kling_v2p6_pro: - // audio off: $0.07/sec → 5s=35¢, 10s=70¢ - // audio on: $0.14/sec → 5s=70¢, 10s=140¢ - let cost_in_usd_cents: u64 = match (self.generate_audio, self.is_ten_seconds) { - (false, false) => 35, - (false, true) => 70, - (true, false) => 70, - (true, true) => 140, - }; - VideoGenerationCostEstimate { - cost_in_credits: Some(cost_in_usd_cents), - cost_in_usd_cents: Some(cost_in_usd_cents), + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), is_free: false, is_unlimited: false, is_rate_limited: false, @@ -52,12 +37,13 @@ impl FalKling2p6ProCostState { #[cfg(test)] mod tests { - use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; - fn cost_cents(duration_seconds: Option, generate_audio: Option) -> u64 { - let b = GenerateVideoRequestBuilder { + fn cost_cents(duration_seconds: Option, generate_audio: Option, has_start: bool) -> u64 { + let mut b = GenerateVideoRequestBuilder { model: RouterVideoModel::Kling2p6Pro, provider: RouterProvider::Fal, prompt: Some("test".to_string()), @@ -65,24 +51,42 @@ mod tests { generate_audio, ..Default::default() }; + if has_start { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + } b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() } - #[test] - fn audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false)), 35); } + // Pricing: + // audio off: $0.07/sec → 5s=35¢, 10s=70¢ + // audio on: $0.14/sec → 5s=70¢, 10s=140¢ + // Image-to-video adds a third tier ($0.168/sec) for voice control, but + // the router build doesn't expose voice_ids yet, so it's not tested here. #[test] - fn audio_off_10s_is_70() { assert_eq!(cost_cents(Some(10), Some(false)), 70); } + fn t2v_audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false), false), 35); } #[test] - fn audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true)), 70); } + fn t2v_audio_off_10s_is_70() { assert_eq!(cost_cents(Some(10), Some(false), false), 70); } #[test] - fn audio_on_10s_is_140() { assert_eq!(cost_cents(Some(10), Some(true)), 140); } + fn t2v_audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true), false), 70); } + + #[test] + fn t2v_audio_on_10s_is_140() { assert_eq!(cost_cents(Some(10), Some(true), false), 140); } + + #[test] + fn i2v_audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false), true), 35); } + + #[test] + fn i2v_audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true), true), 70); } #[test] fn audio_default_is_on() { - // None → audio defaults to true. - assert_eq!(cost_cents(Some(5), None), cost_cents(Some(5), Some(true))); + // generate_audio=None defaults to true on fal's server. + assert_eq!( + cost_cents(Some(5), None, false), + cost_cents(Some(5), Some(true), false), + ); } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/request.rs index bcbb5710ab..6a24cec31d 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/request.rs @@ -1,27 +1,20 @@ use std::fmt::Debug; use std::sync::Arc; -use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p6_pro_image_to_video_webhook::{ - enqueue_kling_v2p6_pro_image_to_video_webhook, EnqueueKlingV2p6ProImageToVideoArgs, - EnqueueKlingV2p6ProImageToVideoRequest, -}; -use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p6_pro_text_to_video_webhook::{ - enqueue_kling_v2p6_pro_text_to_video_webhook, EnqueueKlingV2p6ProTextToVideoArgs, - EnqueueKlingV2p6ProTextToVideoRequest, -}; +use fal_client::requests::api::video::image::kling_2p6_pro_image_to_video::api::Kling2p6ProImageToVideoRequest; +use fal_client::requests::api::video::text::kling_2p6_pro_text_to_video::api::Kling2p6ProTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; use crate::client::router_fal_client::RouterFalClient; use crate::errors::artcraft_router_error::ArtcraftRouterError; -use crate::errors::client_error::ClientError; -use crate::errors::provider_error::ProviderError; use crate::generate::generate_video::generate_video_response::{ FalVideoResponsePayload, GenerateVideoResponse, }; #[derive(Clone, Debug)] pub enum FalKling2p6ProMode { - TextToVideo(EnqueueKlingV2p6ProTextToVideoRequest), - ImageToVideo(EnqueueKlingV2p6ProImageToVideoRequest), + TextToVideo(Kling2p6ProTextToVideoRequest), + ImageToVideo(Kling2p6ProImageToVideoRequest), } #[derive(Clone, Debug)] @@ -31,36 +24,117 @@ pub struct FalKling2p6ProRequestState { impl FalKling2p6ProRequestState { pub async fn send(&self, client: &RouterFalClient) -> Result { - let webhook_url = client.webhook_url.as_deref() - .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; - let (webhook_response, outbound_request): (_, Arc) = match &self.mode { - FalKling2p6ProMode::TextToVideo(request) => { - let outbound: Arc = Arc::new(request.clone()); - let args = EnqueueKlingV2p6ProTextToVideoArgs { - request: request.clone(), - webhook_url, - api_key: &client.api_key, - }; - (enqueue_kling_v2p6_pro_text_to_video_webhook(args).await, outbound) - } - FalKling2p6ProMode::ImageToVideo(request) => { - let outbound: Arc = Arc::new(request.clone()); - let args = EnqueueKlingV2p6ProImageToVideoArgs { - request: request.clone(), - webhook_url, - api_key: &client.api_key, - }; - (enqueue_kling_v2p6_pro_image_to_video_webhook(args).await, outbound) - } + match &self.mode { + FalKling2p6ProMode::TextToVideo(request) => send_request(request, client).await, + FalKling2p6ProMode::ImageToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::image::kling_2p6_pro_image_to_video::api::Kling2p6ProImageToVideoDuration; + use fal_client::requests::api::video::text::kling_2p6_pro_text_to_video::api::{ + Kling2p6ProTextToVideoAspectRatio, Kling2p6ProTextToVideoDuration, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalKling2p6ProRequestState { + mode: FalKling2p6ProMode::TextToVideo(Kling2p6ProTextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }), }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } - let webhook_response = webhook_response - .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; - - Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { - request_id: webhook_response.request_id, - gateway_request_id: webhook_response.gateway_request_id, - maybe_outbound_request: Some(outbound_request), - })) + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_image_to_video_webhook() { + let state = FalKling2p6ProRequestState { + mode: FalKling2p6ProMode::ImageToVideo(Kling2p6ProImageToVideoRequest { + prompt: "the lake comes alive".to_string(), + start_image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + generate_audio: Some(false), + voice_ids: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/request.rs index 969de455d8..2bb8017559 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/request.rs @@ -60,6 +60,8 @@ impl FalKling3p0ProRequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/request.rs index f90138afe6..3f9beefeb6 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/request.rs @@ -60,6 +60,8 @@ impl FalKling3p0StandardRequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/request.rs index c8bee7e086..93bdef61f2 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/request.rs @@ -37,6 +37,8 @@ impl FalSeedance10LiteRequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/request.rs index 05dd0faf03..b2e2895b0c 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/request.rs @@ -60,6 +60,8 @@ impl FalSeedance1p5ProRequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/request.rs index 00754c9f36..3c59ea99cb 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/request.rs @@ -104,6 +104,8 @@ impl FalSora2RequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/request.rs index 63e0bef557..4f1ecacd9b 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/request.rs @@ -101,6 +101,8 @@ impl FalSora2ProRequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/request.rs index c6d4f6f105..129232cf41 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/request.rs @@ -58,6 +58,8 @@ impl FalVeo2RequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/request.rs index 6baf339ade..32731dd7ac 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/request.rs @@ -58,6 +58,8 @@ impl FalVeo3RequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/request.rs index df042f7d22..10c003df99 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/request.rs @@ -37,6 +37,8 @@ impl FalVeo3FastRequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/request.rs index ef656cac08..b9de3a8d08 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/request.rs @@ -72,6 +72,8 @@ impl FalVeo3p1RequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/request.rs index 5080bdc7eb..3af401882d 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/request.rs @@ -75,6 +75,8 @@ impl FalVeo3p1FastRequestState { Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, maybe_outbound_request: Some(outbound_request), })) } diff --git a/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/api.rs new file mode 100644 index 0000000000..c27c95e6cb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/api.rs @@ -0,0 +1,257 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::elements::kling_1p6_pro_elements_to_video::raw_request::{ + Kling1p6ProElementsToVideoInput, Kling1p6ProElementsToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling1p6ProElementsToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Image URLs that drive the generation. fal documents support for up + /// to 4 images; passing more is rejected upstream rather than here. + pub input_image_urls: Vec, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 1.6 Pro Elements supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProElementsToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling1p6ProElementsToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProElementsToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling1p6ProElementsToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v1.6/pro/elements"; + + type RawRequest = Kling1p6ProElementsToVideoInput; + type RawResponse = Kling1p6ProElementsToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling1p6ProElementsToVideoAspectRatio::Square => "1:1", + Kling1p6ProElementsToVideoAspectRatio::SixteenByNine => "16:9", + Kling1p6ProElementsToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + input_image_urls: self.input_image_urls.clone(), + aspect_ratio, + duration, + negative_prompt: self.negative_prompt.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_elements_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProElementsToVideoRequest { + prompt: "the two dogs run side by side across a sunlit meadow".to_string(), + input_image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_elements_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProElementsToVideoRequest { + prompt: "the dogs play together in a snowy field".to_string(), + input_image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling1p6ProElementsToVideoAspectRatio::Square, + Kling1p6ProElementsToVideoAspectRatio::SixteenByNine, + Kling1p6ProElementsToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling1p6ProElementsToVideoRequest { + prompt: "the dogs play together in a snowy field".to_string(), + input_image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling1p6ProElementsToVideoDuration::FiveSeconds, + Kling1p6ProElementsToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling1p6ProElementsToVideoRequest { + prompt: "the dogs leap through tall grass in golden hour light".to_string(), + input_image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling1p6ProElementsToVideoRequest { + prompt: "p".to_string(), + input_image_urls: vec!["https://example.com/a.png".to_string()], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::Square), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("1:1")); + assert_eq!(raw.duration.as_deref(), Some("10")); + } + + #[test] + fn raw_request_passes_through_input_image_urls() { + let urls = vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + "https://example.com/d.png".to_string(), + ]; + let request = Kling1p6ProElementsToVideoRequest { + prompt: "p".to_string(), + input_image_urls: urls.clone(), + negative_prompt: None, + duration: None, + aspect_ratio: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.input_image_urls, urls); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling1p6ProElementsToVideoRequest { + prompt: "p".to_string(), + input_image_urls: vec!["https://example.com/a.png".to_string()], + negative_prompt: None, + duration: None, + aspect_ratio: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert!(raw.aspect_ratio.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling1p6ProElementsToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v1.6/pro/elements", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/cost.rs new file mode 100644 index 0000000000..dbc20023c1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/cost.rs @@ -0,0 +1,261 @@ +use crate::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{ + Kling1p6ProElementsToVideoDuration, Kling1p6ProElementsToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling1p6ProElementsToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 1.6 Pro elements-to-video: $0.098/second + // (see https://fal.ai/models/fal-ai/kling-video/v1.6/pro/elements). + // + // Same per-second rate as the v1.6 Pro text-to-video variant. Rate held + // in tenths-of-cents and rounded up to whole cents at the end so the + // user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling1p6ProElementsToVideoDuration::FiveSeconds) + .to_seconds(); + (98u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{ + Kling1p6ProElementsToVideoAspectRatio, Kling1p6ProElementsToVideoDuration, + }; + + fn make_request( + duration: Option, + ) -> Kling1p6ProElementsToVideoRequest { + Kling1p6ProElementsToVideoRequest { + prompt: "test".to_string(), + input_image_urls: vec!["https://example.com/a.png".to_string()], + negative_prompt: None, + duration, + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + } + } + + // Pricing: $0.098/sec = 98 tenths-of-cents/sec. + // Cents per duration = ceil(98 × secs / 10). + + #[test] + fn five_seconds() { + // (98 * 5 + 9) / 10 = 49 + assert_eq!( + make_request(Some(Kling1p6ProElementsToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 49, + ); + } + + #[test] + fn ten_seconds() { + // (98 * 10 + 9) / 10 = 98 + assert_eq!( + make_request(Some(Kling1p6ProElementsToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 98, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 49¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 49); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling1p6ProElementsToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling1p6ProElementsToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + #[test] + fn input_image_count_does_not_affect_cost() { + // fal bills per second of output, not per input image. Build matched + // pairs differing only in input_image_urls length and confirm cost + // matches exactly. + let base = Kling1p6ProElementsToVideoRequest { + prompt: "p".to_string(), + input_image_urls: vec!["https://example.com/a.png".to_string()], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + }; + let baseline_cost = base.calculate_cost_in_cents(); + for n in 1..=4usize { + let urls: Vec = (0..n).map(|i| format!("https://example.com/{i}.png")).collect(); + let req = Kling1p6ProElementsToVideoRequest { input_image_urls: urls, ..base.clone() }; + assert_eq!( + req.calculate_cost_in_cents(), + baseline_cost, + "image count {n} should not change cost", + ); + } + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration for elements-to-video. There is no direct legacy + /// equivalent (the legacy `enqueue_kling_v1p6_pro_image_to_video_webhook` + /// is image-to-video at a slightly lower rate), so the parity here is + /// against the canonical $0.098/sec formula. A separate sub-test + /// cross-references the legacy image-to-video function to confirm the + /// expected per-duration premium ($0.003/sec). + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use crate::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{ + Kling1p6ProAspectRatio as LegacyAspectRatio, + Kling1p6ProDuration as LegacyDuration, + Kling1p6ProRequest as LegacyRequest, + }; + + fn new_request( + duration: Option, + aspect_ratio: Option, + ) -> Kling1p6ProElementsToVideoRequest { + Kling1p6ProElementsToVideoRequest { + prompt: "test".to_string(), + input_image_urls: vec!["https://example.com/a.png".to_string()], + negative_prompt: None, + duration, + aspect_ratio, + } + } + + // (duration, aspect_ratio, expected_cents) + // + // Math: ceil(98 × secs / 10) where 5s → 49¢ and 10s → 98¢. + // duration=None defaults to 5s. + // Aspect ratio is irrelevant to cost; iterated only to assert it. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default → 49¢ + (None, None, 49), + (None, Some(Kling1p6ProElementsToVideoAspectRatio::Square), 49), + (None, Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), 49), + (None, Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen), 49), + // duration=5s → 49¢ + (Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), None, 49), + (Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::Square), 49), + (Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), 49), + (Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen), 49), + // duration=10s → 98¢ + (Some(Kling1p6ProElementsToVideoDuration::TenSeconds), None, 98), + (Some(Kling1p6ProElementsToVideoDuration::TenSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::Square), 98), + (Some(Kling1p6ProElementsToVideoDuration::TenSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), 98), + (Some(Kling1p6ProElementsToVideoDuration::TenSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen), 98), + ]; + + #[test] + fn new_module_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "new module: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every row in the + /// cost table must produce the same value for any aspect-ratio choice. + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling1p6ProElementsToVideoAspectRatio::Square), + Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + Some(Kling1p6ProElementsToVideoDuration::TenSeconds), + ]; + for duration in durations { + let baseline = new_request(duration, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, ar).calculate_cost_in_cents(); + assert_eq!(cost, baseline, "duration={duration:?} ar={ar:?}"); + } + } + } + + /// Cross-check against the legacy image-to-video function. Elements-to-video + /// is `$0.098/sec` and the legacy image-to-video is `$0.095/sec`, so at + /// each duration the cost difference equals `ceil(0.003 × secs)`: + /// 5s: 49¢ − 48¢ = 1¢ + /// 10s: 98¢ − 95¢ = 3¢ + /// If a future change to the legacy image-to-video rate breaks this + /// invariant, this test calls it out. + #[test] + fn premium_over_legacy_image_to_video_is_as_expected() { + fn legacy_image(secs_variant: LegacyDuration) -> u64 { + LegacyRequest { + image_url: "https://example.com/i.png".to_string(), + end_frame_image_url: None, + prompt: "test".to_string(), + duration: secs_variant, + aspect_ratio: LegacyAspectRatio::WideSixteenNine, + }.calculate_cost_in_cents() + } + + // (elements duration, equivalent legacy image duration, expected premium in cents) + let cases = [ + (Kling1p6ProElementsToVideoDuration::FiveSeconds, LegacyDuration::FiveSeconds, 1u64), + (Kling1p6ProElementsToVideoDuration::TenSeconds, LegacyDuration::TenSeconds, 3u64), + ]; + + for (elements_d, legacy_d, expected_premium) in cases { + let elements_cost = new_request(Some(elements_d), None).calculate_cost_in_cents(); + let image_cost = legacy_image(legacy_d); + let premium = elements_cost.saturating_sub(image_cost); + assert_eq!( + premium, expected_premium, + "{elements_d:?}: elements={elements_cost}¢ legacy_image={image_cost}¢ premium={premium}¢ (want {expected_premium}¢)", + ); + } + } + + /// Elements and text-to-video share the same $0.098/sec rate — cells + /// should match exactly at every duration. Documents the rate-equality + /// expectation, so if either rate drifts in the future this test fires. + #[test] + fn matches_text_to_video_cost_at_each_duration() { + use crate::requests::api::video::text::kling_1p6_pro_text_to_video::api::{ + Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration, + Kling1p6ProTextToVideoRequest, + }; + fn t2v(duration: Kling1p6ProTextToVideoDuration) -> u64 { + Kling1p6ProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration: Some(duration), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }.calculate_cost_in_cents() + } + let pairs = [ + (Kling1p6ProElementsToVideoDuration::FiveSeconds, Kling1p6ProTextToVideoDuration::FiveSeconds), + (Kling1p6ProElementsToVideoDuration::TenSeconds, Kling1p6ProTextToVideoDuration::TenSeconds), + ]; + for (e_d, t_d) in pairs { + let elements_cost = new_request(Some(e_d), None).calculate_cost_in_cents(); + let text_cost = t2v(t_d); + assert_eq!( + elements_cost, text_cost, + "{e_d:?} vs {t_d:?}: elements={elements_cost}¢ text={text_cost}¢ — rates should match at $0.098/sec", + ); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/raw_request.rs new file mode 100644 index 0000000000..4e4d7a2e5d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/raw_request.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v1.6/pro/elements`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling1p6ProElementsToVideoInput { + pub prompt: String, + + /// List of image URLs that drive the generation. fal documents support + /// for up to 4 images. + pub input_image_urls: Vec, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProElementsToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProElementsToVideoOutput { + pub video: Kling1p6ProElementsToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/elements/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/elements/mod.rs new file mode 100644 index 0000000000..1dbfd19473 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/elements/mod.rs @@ -0,0 +1 @@ +pub mod kling_1p6_pro_elements_to_video; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/api.rs new file mode 100644 index 0000000000..ba17fa6baa --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/api.rs @@ -0,0 +1,236 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::kling_1p6_pro_image_to_video::raw_request::{ + Kling1p6ProImageToVideoInput, Kling1p6ProImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling1p6ProImageToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Starting frame image URL. + pub image_url: String, + + /// Optional end-frame image URL. + pub end_image_url: Option, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 1.6 Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProImageToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling1p6ProImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProImageToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling1p6ProImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v1.6/pro/image-to-video"; + + type RawRequest = Kling1p6ProImageToVideoInput; + type RawResponse = Kling1p6ProImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling1p6ProImageToVideoAspectRatio::Square => "1:1", + Kling1p6ProImageToVideoAspectRatio::SixteenByNine => "16:9", + Kling1p6ProImageToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + // fal's schema names this `tail_image_url`; our public API uses + // `end_image_url` for consistency with the other Kling modules. + tail_image_url: self.end_image_url.clone(), + aspect_ratio, + duration, + negative_prompt: self.negative_prompt.clone(), + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProImageToVideoRequest { + prompt: "shiba in glasses runs to the lake and stands by the shore".to_string(), + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()), + negative_prompt: None, + duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProImageToVideoRequest { + prompt: "shiba bounds across the meadow chasing a falling leaf".to_string(), + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling1p6ProImageToVideoAspectRatio::Square, + Kling1p6ProImageToVideoAspectRatio::SixteenByNine, + Kling1p6ProImageToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling1p6ProImageToVideoRequest { + prompt: "shiba bounds across the meadow chasing a falling leaf".to_string(), + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling1p6ProImageToVideoDuration::FiveSeconds, + Kling1p6ProImageToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling1p6ProImageToVideoRequest { + prompt: "shiba bounds across the meadow chasing a falling leaf".to_string(), + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling1p6ProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/i.png".to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9")); + assert_eq!(raw.duration.as_deref(), Some("5")); + } + + #[test] + fn raw_request_translates_end_image_url_to_tail_image_url() { + let request = Kling1p6ProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/i.png".to_string(), + end_image_url: Some("https://example.com/end.png".to_string()), + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.tail_image_url.as_deref(), Some("https://example.com/end.png")); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/cost.rs new file mode 100644 index 0000000000..1af3f5eefc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/cost.rs @@ -0,0 +1,236 @@ +use crate::requests::api::video::image::kling_1p6_pro_image_to_video::api::{ + Kling1p6ProImageToVideoDuration, Kling1p6ProImageToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling1p6ProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 1.6 Pro image-to-video: $0.095/second. + // + // Rate is 95 tenths-of-cents per second; round up to whole cents at the + // end so the user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling1p6ProImageToVideoDuration::FiveSeconds) + .to_seconds(); + (95u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::image::kling_1p6_pro_image_to_video::api::{ + Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration, + }; + + fn make_request( + duration: Option, + ) -> Kling1p6ProImageToVideoRequest { + Kling1p6ProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/image.jpg".to_string(), + end_image_url: None, + negative_prompt: None, + duration, + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + } + } + + // Pricing: $0.095/sec = 95 tenths-of-cents/sec. + // Cents per duration = ceil(95 × secs / 10). + + #[test] + fn five_seconds() { + // (95 * 5 + 9) / 10 = 48 (round up from 47.5¢) + assert_eq!( + make_request(Some(Kling1p6ProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 48, + ); + } + + #[test] + fn ten_seconds() { + // (95 * 10 + 9) / 10 = 95 + assert_eq!( + make_request(Some(Kling1p6ProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 95, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 48¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 48); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling1p6ProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling1p6ProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration, asserted both against the legacy + /// `enqueue_kling_v1p6_pro_image_to_video_webhook` cost function and + /// against the canonical $0.095/sec rate (`(95 * secs + 9) / 10`). + /// + /// Aspect ratio is included in the cross product as a sanity check — + /// neither the new nor legacy implementation reads it for billing, so + /// every cell in a given row must agree. + mod legacy_parity { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use crate::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{ + Kling1p6ProAspectRatio as LegacyAspectRatio, + Kling1p6ProDuration as LegacyDuration, + Kling1p6ProRequest as LegacyRequest, + }; + + // ── Mapping helpers from the new strongly-typed API to the legacy one ── + + fn legacy_duration_for(d: Option) -> LegacyDuration { + match d { + // The legacy `Default` variant means "5 seconds" semantically — same + // as the new module's `None` default. + None => LegacyDuration::Default, + Some(Kling1p6ProImageToVideoDuration::FiveSeconds) => LegacyDuration::FiveSeconds, + Some(Kling1p6ProImageToVideoDuration::TenSeconds) => LegacyDuration::TenSeconds, + } + } + + fn legacy_aspect_ratio_for(ar: Option) -> LegacyAspectRatio { + // The legacy API has no "None" — pick 16:9 as the default when the + // new request leaves it unset, mirroring fal's documented server + // default. + match ar { + None | Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine) => LegacyAspectRatio::WideSixteenNine, + Some(Kling1p6ProImageToVideoAspectRatio::Square) => LegacyAspectRatio::Square, + Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen) => LegacyAspectRatio::TallNineSixteen, + } + } + + fn new_request( + duration: Option, + aspect_ratio: Option, + ) -> Kling1p6ProImageToVideoRequest { + Kling1p6ProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/image.jpg".to_string(), + end_image_url: None, + negative_prompt: None, + duration, + aspect_ratio, + cfg_scale: None, + } + } + + fn legacy_request( + duration: Option, + aspect_ratio: Option, + ) -> LegacyRequest { + LegacyRequest { + image_url: "https://example.com/image.jpg".to_string(), + end_frame_image_url: None, + prompt: "test".to_string(), + duration: legacy_duration_for(duration), + aspect_ratio: legacy_aspect_ratio_for(aspect_ratio), + } + } + + // (new_duration, new_aspect_ratio, expected_cents) + // + // Math: ceil(95 × secs / 10) where 5s → 48¢ and 10s → 95¢. + // duration=None defaults to 5s in both the new and legacy modules. + // Aspect ratio is irrelevant to cost; iterated only to assert it. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default → 48¢ + (None, None, 48), + (None, Some(Kling1p6ProImageToVideoAspectRatio::Square), 48), + (None, Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), 48), + (None, Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen), 48), + // duration=5s → 48¢ + (Some(Kling1p6ProImageToVideoDuration::FiveSeconds), None, 48), + (Some(Kling1p6ProImageToVideoDuration::FiveSeconds), Some(Kling1p6ProImageToVideoAspectRatio::Square), 48), + (Some(Kling1p6ProImageToVideoDuration::FiveSeconds), Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), 48), + (Some(Kling1p6ProImageToVideoDuration::FiveSeconds), Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen), 48), + // duration=10s → 95¢ + (Some(Kling1p6ProImageToVideoDuration::TenSeconds), None, 95), + (Some(Kling1p6ProImageToVideoDuration::TenSeconds), Some(Kling1p6ProImageToVideoAspectRatio::Square), 95), + (Some(Kling1p6ProImageToVideoDuration::TenSeconds), Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), 95), + (Some(Kling1p6ProImageToVideoDuration::TenSeconds), Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen), 95), + ]; + + #[test] + fn new_module_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "new module: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + #[test] + fn legacy_function_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = legacy_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "legacy function: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + /// The headline parity check: at every permutation of the legacy's + /// cost-relevant configuration, the new and legacy + /// `calculate_cost_in_cents` return the same value. If this fails, the + /// new module's pricing has drifted from what shipped previously. + #[test] + fn new_module_matches_legacy_at_every_combo() { + for &(duration, aspect_ratio, _) in COST_TABLE { + let new_cost = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + let legacy_cost = legacy_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + new_cost, legacy_cost, + "parity mismatch at duration={duration:?} aspect_ratio={aspect_ratio:?}: \ + new={new_cost}¢ vs legacy={legacy_cost}¢", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every row in the + /// cost table must produce the same value for any aspect-ratio choice. + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling1p6ProImageToVideoAspectRatio::Square), + Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + Some(Kling1p6ProImageToVideoDuration::TenSeconds), + ]; + for duration in durations { + let baseline = new_request(duration, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, ar).calculate_cost_in_cents(); + assert_eq!(cost, baseline, "duration={duration:?} ar={ar:?}"); + } + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/raw_request.rs new file mode 100644 index 0000000000..9b415b8de0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/raw_request.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v1.6/pro/image-to-video`. +/// xAI's schema: +/// +/// NB: `tail_image_url` is xAI's name for the end-frame keyframe. Our public +/// API surface (`Kling1p6ProImageToVideoRequest::end_image_url`) uses the +/// `end_image_url` name to match the convention in other Kling modules; we +/// translate at the [`crate::requests::traits::fal_endpoint_trait::FalEndpoint::to_raw_request`] +/// boundary. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling1p6ProImageToVideoInput { + pub prompt: String, + + /// Starting frame image URL. + pub image_url: String, + + /// Optional end-frame image URL (the fal schema calls this `tail_image_url`). + #[serde(skip_serializing_if = "Option::is_none")] + pub tail_image_url: Option, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProImageToVideoOutput { + pub video: Kling1p6ProImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/api.rs new file mode 100644 index 0000000000..2b2c39c1a8 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/api.rs @@ -0,0 +1,324 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::raw_request::{ + Kling2p5TurboProImageToVideoInput, Kling2p5TurboProImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p5TurboProImageToVideoRequest { + /// Text prompt describing the desired video content. + pub prompt: String, + + /// URL of the image used as the first frame. + pub image_url: String, + + /// Optional URL of the image used as the last frame. + pub tail_image_url: Option, + + /// Video duration. Kling 2.5 Turbo Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p5TurboProImageToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p5TurboProImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +impl FalEndpoint for Kling2p5TurboProImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.5-turbo/pro/image-to-video"; + + type RawRequest = Kling2p5TurboProImageToVideoInput; + type RawResponse = Kling2p5TurboProImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + duration, + tail_image_url: self.tail_image_url.clone(), + negative_prompt: self.negative_prompt.clone(), + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + tail_image_url: None, + duration: Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + tail_image_url: None, + duration: Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p5TurboProImageToVideoDuration::FiveSeconds, + Kling2p5TurboProImageToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "the dog wags its tail".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + tail_image_url: None, + duration: Some(dur), + negative_prompt: None, + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_uses_canonical_field_names() { + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + tail_image_url: Some("https://example.com/end.png".to_string()), + duration: Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), + negative_prompt: Some("nope".to_string()), + cfg_scale: Some(0.5), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert_eq!(raw.tail_image_url.as_deref(), Some("https://example.com/end.png")); + assert_eq!(raw.duration.as_deref(), Some("10")); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.cfg_scale, Some(0.5)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + tail_image_url: None, + duration: None, + negative_prompt: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert!(raw.tail_image_url.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.cfg_scale.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p5TurboProImageToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.5-turbo/pro/image-to-video", + ); + } + + /// Wire-shape and endpoint parity vs the legacy + /// `requests_old/http/video/image/http_kling_v2p5_turbo_pro_image_to_video.rs`. + /// + /// No legacy *pro* webhook exists for image-to-video (only the legacy + /// `enqueue_kling_v2p5_turbo_standard_image_to_video_webhook` ships, and + /// that targets the `standard` model with a different pricing tier — cost + /// parity for the pro model is asserted in `cost.rs` against the + /// documented pricing instead. + mod legacy_parity { + use super::*; + use crate::requests_old::http::video::image::http_kling_v2p5_turbo_pro_image_to_video::{ + kling_v2p5_turbo_pro_image_to_video, KlingV2p5TurboProImageToVideoInput, + }; + + /// Same `fal-ai/kling-video/v2.5-turbo/pro/image-to-video` path. + #[test] + fn endpoint_path_matches_legacy() { + let legacy = kling_v2p5_turbo_pro_image_to_video(KlingV2p5TurboProImageToVideoInput { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + ..Default::default() + }); + assert_eq!( + Kling2p5TurboProImageToVideoRequest::ENDPOINT, + legacy.endpoint, + ); + } + + /// At a representative fully-populated case, the new module's serialized + /// `RawRequest` must equal the legacy `Input`'s JSON. + #[test] + fn wire_json_matches_legacy_fully_populated() { + let new = Kling2p5TurboProImageToVideoRequest { + prompt: "shared prompt".to_string(), + image_url: "https://example.com/start.png".to_string(), + tail_image_url: Some("https://example.com/end.png".to_string()), + duration: Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), + negative_prompt: Some("blurry".to_string()), + cfg_scale: Some(0.5), + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProImageToVideoInput { + prompt: "shared prompt".to_string(), + image_url: "https://example.com/start.png".to_string(), + duration: Some("10".to_string()), + tail_image_url: Some("https://example.com/end.png".to_string()), + negative_prompt: Some("blurry".to_string()), + cfg_scale: Some(0.5), + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + } + + /// Minimal case (prompt + image_url only): unset optionals must be + /// omitted on the wire just like the legacy struct. + #[test] + fn wire_json_matches_legacy_minimal() { + let new = Kling2p5TurboProImageToVideoRequest { + prompt: "minimal".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: None, + duration: None, + negative_prompt: None, + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProImageToVideoInput { + prompt: "minimal".to_string(), + image_url: "https://example.com/x.png".to_string(), + ..Default::default() + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + assert_eq!( + new_json, + serde_json::json!({ + "prompt": "minimal", + "image_url": "https://example.com/x.png", + }), + ); + } + + /// Cross product over duration × tail_image_url presence: every cell's + /// wire JSON must match the legacy `Input`. + #[test] + fn wire_json_matches_legacy_at_every_combo() { + let durations = [ + (None, None), + (Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), Some("5")), + (Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), Some("10")), + ]; + let tail_urls: [Option<&str>; 2] = [None, Some("https://example.com/tail.png")]; + + for (d_new, d_legacy) in durations { + for tail in tail_urls { + let new = Kling2p5TurboProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: tail.map(String::from), + duration: d_new, + negative_prompt: None, + cfg_scale: None, + }; + let legacy = KlingV2p5TurboProImageToVideoInput { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + duration: d_legacy.map(String::from), + tail_image_url: tail.map(String::from), + negative_prompt: None, + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + let legacy_json = serde_json::to_value(&legacy).unwrap(); + assert_eq!( + new_json, legacy_json, + "duration={d_new:?} tail_image_url={tail:?}", + ); + } + } + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/cost.rs new file mode 100644 index 0000000000..86d7554fa9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/cost.rs @@ -0,0 +1,215 @@ +use crate::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::{ + Kling2p5TurboProImageToVideoDuration, Kling2p5TurboProImageToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p5TurboProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.5 Turbo Pro image-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/image-to-video): + // "For 5s video your request will cost $0.35. + // For every additional second you will be charged $0.07." + // + // That works out to a flat $0.07/second rate, identical to the + // text-to-video pro variant. Rate is held in tenths-of-cents and + // rounded up to whole cents so the user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p5TurboProImageToVideoDuration::FiveSeconds) + .to_seconds(); + (70u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + ) -> Kling2p5TurboProImageToVideoRequest { + Kling2p5TurboProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: None, + duration, + negative_prompt: None, + cfg_scale: None, + } + } + + // Pricing: flat $0.07/sec = 70 tenths-of-cents/sec. + // Cents per duration = ceil(70 × secs / 10). + + #[test] + fn five_seconds() { + // (70 * 5 + 9) / 10 = 35 — matches fal's "$0.35 for 5s" base rate + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn ten_seconds() { + // (70 * 10 + 9) / 10 = 70 — matches "$0.35 + 5×$0.07 = $0.70" + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 35¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 35); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling2p5TurboProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + /// Exhaustive cost-table tests. No legacy *pro* webhook exists for + /// image-to-video — only the legacy `standard` webhook ships — so this is + /// parity against the *documented* fal pricing and against the + /// text-to-video pro rate, which fal sets at the same $0.07/sec. + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + + fn new_request( + duration: Option, + tail_image_url: Option<&str>, + ) -> Kling2p5TurboProImageToVideoRequest { + Kling2p5TurboProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: tail_image_url.map(String::from), + duration, + negative_prompt: None, + cfg_scale: None, + } + } + + // (duration, tail_image_url_present, expected_cents) + // + // Math: ceil(70 × secs / 10). 5s → 35¢; 10s → 70¢. + // duration=None defaults to 5s; tail_image_url is irrelevant to cost. + const COST_TABLE: &[( + Option, + bool, + u64, + )] = &[ + // duration=None → 5s default → 35¢ + (None, false, 35), + (None, true, 35), + // duration=5s → 35¢ + (Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), false, 35), + (Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), true, 35), + // duration=10s → 70¢ + (Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), false, 70), + (Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), true, 70), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, tail_present, expected) in COST_TABLE { + let tail = if tail_present { Some("https://example.com/tail.png") } else { None }; + let got = new_request(duration, tail).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} tail_present={tail_present}", + ); + } + } + + /// tail_image_url is not part of the billing formula — every choice + /// must produce the same value for fixed duration. + #[test] + fn cost_is_independent_of_tail_image_url() { + let durations = [ + None, + Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), + ]; + for duration in durations { + let without = new_request(duration, None).calculate_cost_in_cents(); + let with = new_request(duration, Some("https://example.com/tail.png")) + .calculate_cost_in_cents(); + assert_eq!(with, without, "duration={duration:?}"); + } + } + + /// cfg_scale is not part of the billing formula. + #[test] + fn cost_is_independent_of_cfg_scale() { + for cfg in [None, Some(0.0_f32), Some(0.5), Some(1.0)] { + let req = Kling2p5TurboProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: None, + duration: Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + cfg_scale: cfg, + }; + assert_eq!(req.calculate_cost_in_cents(), 35, "cfg_scale={cfg:?}"); + } + } + + /// Image-to-video and text-to-video pro share the same $0.07/sec rate + /// per fal's docs — costs must match exactly at every duration. If + /// either rate drifts in the future this test fires. + #[test] + fn matches_text_to_video_pro_cost_at_each_duration() { + use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration, + Kling2p5TurboProTextToVideoRequest, + }; + fn t2v(duration: Kling2p5TurboProTextToVideoDuration) -> u64 { + Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration: Some(duration), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }.calculate_cost_in_cents() + } + let pairs = [ + (Kling2p5TurboProImageToVideoDuration::FiveSeconds, Kling2p5TurboProTextToVideoDuration::FiveSeconds), + (Kling2p5TurboProImageToVideoDuration::TenSeconds, Kling2p5TurboProTextToVideoDuration::TenSeconds), + ]; + for (i2v_d, t2v_d) in pairs { + let image_cost = new_request(Some(i2v_d), None).calculate_cost_in_cents(); + let text_cost = t2v(t2v_d); + assert_eq!( + image_cost, text_cost, + "{i2v_d:?} vs {t2v_d:?}: image={image_cost}¢ text={text_cost}¢ — rates should match at $0.07/sec", + ); + } + } + + /// Sanity-check fal's documented examples: + /// "For 5s video your request will cost $0.35" + /// "For every additional second you will be charged $0.07" + /// → 10s costs $0.35 + 5 × $0.07 = $0.70 + #[test] + fn matches_documented_examples() { + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + ); + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + ); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/raw_request.rs new file mode 100644 index 0000000000..40486c3ec2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/raw_request.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.5-turbo/pro/image-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p5TurboProImageToVideoInput { + pub prompt: String, + + /// URL of the image to be used for the video. + pub image_url: String, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" (fal default: "5") + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// URL of the image to be used for the end of the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub tail_image_url: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProImageToVideoOutput { + pub video: Kling2p5TurboProImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/api.rs new file mode 100644 index 0000000000..6233462583 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/api.rs @@ -0,0 +1,311 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::kling_2p6_pro_image_to_video::raw_request::{ + Kling2p6ProImageToVideoInput, Kling2p6ProImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p6ProImageToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// URL of the image used as the first frame. + pub start_image_url: String, + + /// URL of the image used as the last frame (optional). + pub end_image_url: Option, + + /// Video duration. Kling 2.6 Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Voice IDs to use for the generated audio. Maximum of 2 voices per task; + /// reference them in the prompt as `<<>>` and `<<>>`. + /// + /// Note: supplying voice IDs along with `generate_audio = Some(true)` + /// activates fal's "audio + voice control" pricing tier ($0.168/sec). + pub voice_ids: Option>, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p6ProImageToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p6ProImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +impl FalEndpoint for Kling2p6ProImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.6/pro/image-to-video"; + + type RawRequest = Kling2p6ProImageToVideoInput; + type RawResponse = Kling2p6ProImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + start_image_url: self.start_image_url.clone(), + end_image_url: self.end_image_url.clone(), + duration, + negative_prompt: self.negative_prompt.clone(), + generate_audio: self.generate_audio, + voice_ids: self.voice_ids.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + start_image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + generate_audio: Some(false), + voice_ids: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + start_image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + generate_audio: Some(false), + voice_ids: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p6ProImageToVideoDuration::FiveSeconds, + Kling2p6ProImageToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p6ProImageToVideoRequest { + prompt: "the dog wags its tail".to_string(), + start_image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(dur), + negative_prompt: None, + generate_audio: Some(false), + voice_ids: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_uses_canonical_field_names() { + let request = Kling2p6ProImageToVideoRequest { + prompt: "p".to_string(), + start_image_url: "https://example.com/start.png".to_string(), + end_image_url: Some("https://example.com/end.png".to_string()), + duration: Some(Kling2p6ProImageToVideoDuration::TenSeconds), + negative_prompt: Some("nope".to_string()), + generate_audio: Some(true), + voice_ids: Some(vec!["voice_a".to_string(), "voice_b".to_string()]), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.start_image_url, "https://example.com/start.png"); + assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/end.png")); + assert_eq!(raw.duration.as_deref(), Some("10")); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.voice_ids.as_deref(), Some(&["voice_a".to_string(), "voice_b".to_string()][..])); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p6ProImageToVideoRequest { + prompt: "p".to_string(), + start_image_url: "https://example.com/start.png".to_string(), + end_image_url: None, + duration: None, + negative_prompt: None, + generate_audio: None, + voice_ids: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.start_image_url, "https://example.com/start.png"); + assert!(raw.end_image_url.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.generate_audio.is_none()); + assert!(raw.voice_ids.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p6ProImageToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.6/pro/image-to-video", + ); + } + + /// Parity vs the legacy + /// `requests_old/http/video/image/http_kling_v2p6_pro_image_to_video.rs`. + /// + /// The legacy has no cost calculator, so cost parity is N/A. The wire + /// shape *intentionally diverges*: fal renamed the image field from + /// `image_url` (legacy) to `start_image_url` (current docs), and the + /// legacy gained no concept of `voice_ids` or `end_image_url`. These + /// tests document the differences and pin the endpoint URL, so we'll + /// notice if fal ever flips back or further changes the schema. + mod legacy_parity { + use super::*; + use crate::requests_old::http::video::image::http_kling_v2p6_pro_image_to_video::{ + kling_v2p6_pro_image_to_video, KlingV2p6ProImageToVideoInput, + }; + + /// Same `fal-ai/kling-video/v2.6/pro/image-to-video` path. + #[test] + fn endpoint_path_matches_legacy() { + let legacy = kling_v2p6_pro_image_to_video(KlingV2p6ProImageToVideoInput { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + ..Default::default() + }); + assert_eq!( + Kling2p6ProImageToVideoRequest::ENDPOINT, + legacy.endpoint, + ); + } + + /// The image-URL field was renamed by fal: legacy `image_url` → + /// current `start_image_url`. This test pins the new module to the + /// current name and confirms the legacy field name is *not* emitted. + #[test] + fn new_module_emits_start_image_url_not_image_url() { + let new = Kling2p6ProImageToVideoRequest { + prompt: "p".to_string(), + start_image_url: "https://example.com/x.png".to_string(), + end_image_url: None, + duration: None, + negative_prompt: None, + generate_audio: None, + voice_ids: None, + }; + let json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + assert!(json.get("start_image_url").is_some(), "missing start_image_url: {json}"); + assert!(json.get("image_url").is_none(), "unexpected legacy image_url: {json}"); + } + + /// Legacy struct still emits the *old* `image_url` field name — + /// asserting this here flags any future schema drift in the legacy + /// archive (it shouldn't change; if it does we want to know). + #[test] + fn legacy_module_still_emits_image_url() { + let legacy = KlingV2p6ProImageToVideoInput { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + ..Default::default() + }; + let json = serde_json::to_value(&legacy).unwrap(); + assert!(json.get("image_url").is_some(), "missing image_url: {json}"); + assert!(json.get("start_image_url").is_none(), "unexpected start_image_url: {json}"); + } + + /// On fields that *did* survive the rename (prompt, duration, + /// negative_prompt, generate_audio), wire JSON must match between + /// new and legacy when populated with the same values. + #[test] + fn surviving_fields_match_legacy_wire_shape() { + let new = Kling2p6ProImageToVideoRequest { + prompt: "shared prompt".to_string(), + start_image_url: "https://example.com/x.png".to_string(), + end_image_url: None, + duration: Some(Kling2p6ProImageToVideoDuration::TenSeconds), + negative_prompt: Some("blurry".to_string()), + generate_audio: Some(false), + voice_ids: None, + }; + let mut new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p6ProImageToVideoInput { + prompt: "shared prompt".to_string(), + image_url: "https://example.com/x.png".to_string(), + duration: Some("10".to_string()), + negative_prompt: Some("blurry".to_string()), + generate_audio: Some(false), + }; + let mut legacy_json = serde_json::to_value(&legacy).unwrap(); + + // Strip the renamed image-URL field from each side, then compare. + // (`voice_ids` and `end_image_url` are new in 2.6 — neither is set + // in this test, so they don't appear in JSON.) + new_json.as_object_mut().unwrap().remove("start_image_url"); + legacy_json.as_object_mut().unwrap().remove("image_url"); + assert_eq!(new_json, legacy_json); + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/cost.rs new file mode 100644 index 0000000000..3822077e08 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/cost.rs @@ -0,0 +1,314 @@ +use crate::requests::api::video::image::kling_2p6_pro_image_to_video::api::{ + Kling2p6ProImageToVideoDuration, Kling2p6ProImageToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p6ProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.6 Pro image-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.6/pro/image-to-video): + // $0.07/sec — audio off + // $0.14/sec — audio on, no voice control + // $0.168/sec — audio on with voice IDs (voice control) + // + // `generate_audio` defaults to `true` on fal's server, so `None` bills as + // audio-on. Voice control is active when audio is on and `voice_ids` + // contains at least one entry. + // + // Rate is held in tenths-of-cents and rounded up to whole cents so the + // user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p6ProImageToVideoDuration::FiveSeconds) + .to_seconds(); + + let audio_on = self.generate_audio.unwrap_or(true); + let has_voice = self.voice_ids.as_ref().is_some_and(|v| !v.is_empty()); + + let rate_tenth_cents_per_sec: u64 = match (audio_on, has_voice) { + (false, _) => 70, // $0.07/sec — audio off + (true, false) => 140, // $0.14/sec — audio on, no voice control + (true, true) => 168, // $0.168/sec — audio + voice control + }; + + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + generate_audio: Option, + voice_ids: Option>, + ) -> Kling2p6ProImageToVideoRequest { + Kling2p6ProImageToVideoRequest { + prompt: "test".to_string(), + start_image_url: "https://example.com/start.png".to_string(), + end_image_url: None, + duration, + negative_prompt: None, + generate_audio, + voice_ids, + } + } + + // Pricing tiers in tenths-of-cents per second: + // audio off → 70 + // audio on (no voice) → 140 + // audio on + voice control → 168 + // Cents per duration = ceil(rate × secs / 10). + + #[test] + fn five_seconds_audio_off() { + // (70 * 5 + 9) / 10 = 35 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(false), None) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn five_seconds_audio_on_no_voice() { + // (140 * 5 + 9) / 10 = 70 — matches fal's "$0.70 for 5s with audio" example + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), None) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn five_seconds_audio_on_with_voice() { + // (168 * 5 + 9) / 10 = 84 + assert_eq!( + make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ) + .calculate_cost_in_cents(), + 84, + ); + } + + #[test] + fn ten_seconds_audio_off() { + // (70 * 10 + 9) / 10 = 70 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(false), None) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn ten_seconds_audio_on_no_voice() { + // (140 * 10 + 9) / 10 = 140 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(true), None) + .calculate_cost_in_cents(), + 140, + ); + } + + #[test] + fn ten_seconds_audio_on_with_voice() { + // (168 * 10 + 9) / 10 = 168 — matches fal's "$1.68 for 10s with audio + voice" example + assert_eq!( + make_request( + Some(Kling2p6ProImageToVideoDuration::TenSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ) + .calculate_cost_in_cents(), + 168, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None, audio=on (default), no voice → 5s @ $0.14 = 70¢ + assert_eq!(make_request(None, None, None).calculate_cost_in_cents(), 70); + } + + #[test] + fn default_audio_is_on() { + let none_default = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + None, + None, + ).calculate_cost_in_cents(); + let explicit_on = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + None, + ).calculate_cost_in_cents(); + assert_eq!(none_default, explicit_on); + } + + #[test] + fn empty_voice_ids_does_not_trigger_voice_tier() { + // `Some(vec![])` is semantically "no voices supplied" — it should bill at + // the $0.14/sec audio-on rate, not the $0.168/sec voice-control rate. + let with_empty = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec![]), + ).calculate_cost_in_cents(); + let with_none = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + None, + ).calculate_cost_in_cents(); + assert_eq!(with_empty, with_none, "empty voice_ids should match None"); + assert_eq!(with_empty, 70); + } + + #[test] + fn voice_ids_with_audio_off_does_not_change_cost() { + // Audio is off → voice control is meaningless. Pricing must stay at the + // $0.07/sec floor. (fal silently ignores voice_ids if audio is off.) + let with_voice = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(false), + Some(vec!["voice_a".to_string()]), + ).calculate_cost_in_cents(); + let without_voice = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(false), + None, + ).calculate_cost_in_cents(); + assert_eq!(with_voice, without_voice); + assert_eq!(with_voice, 35); + } + + #[test] + fn pricing_tiers_are_strictly_ascending() { + // At each duration: audio_off < audio_on < audio_on+voice + for d in [ + Kling2p6ProImageToVideoDuration::FiveSeconds, + Kling2p6ProImageToVideoDuration::TenSeconds, + ] { + let off = make_request(Some(d), Some(false), None).calculate_cost_in_cents(); + let on = make_request(Some(d), Some(true), None).calculate_cost_in_cents(); + let vc = make_request(Some(d), Some(true), Some(vec!["v".to_string()])) + .calculate_cost_in_cents(); + assert!(off < on, "duration={d:?}: audio_off={off}¢ < audio_on={on}¢"); + assert!(on < vc, "duration={d:?}: audio_on={on}¢ < voice_ctrl={vc}¢"); + } + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration (duration × generate_audio × voice_ids presence). + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + + // (duration, generate_audio, voice_ids_present, expected_cents) + // + // Tenths-of-cents/sec by tier: off=70, on=140, voice=168. + // Cents per duration = ceil(rate × secs / 10). + // duration=None defaults to 5s; generate_audio=None defaults to true. + const COST_TABLE: &[( + Option, + Option, + bool, // voice_ids non-empty + u64, + )] = &[ + // duration=None → 5s default + (None, Some(false), false, 35), + (None, Some(false), true, 35), // voice ignored when audio off + (None, Some(true), false, 70), + (None, Some(true), true, 84), + (None, None, false, 70), // audio default = on + (None, None, true, 84), + // duration=5s + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(false), false, 35), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(false), true, 35), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), false, 70), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), true, 84), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), None, false, 70), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), None, true, 84), + // duration=10s + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(false), false, 70), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(false), true, 70), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(true), false, 140), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(true), true, 168), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), None, false, 140), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), None, true, 168), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, voice_present, expected) in COST_TABLE { + let voice_ids = if voice_present { Some(vec!["voice_a".to_string()]) } else { None }; + let got = make_request(duration, generate_audio, voice_ids).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} audio={generate_audio:?} voice={voice_present}", + ); + } + } + + /// Number of voice IDs (1 vs 2) doesn't affect cost — only the presence + /// of at least one. Tests 1 and 2 voices (fal's max). + #[test] + fn voice_count_does_not_affect_cost() { + let one = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ).calculate_cost_in_cents(); + let two = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec!["voice_a".to_string(), "voice_b".to_string()]), + ).calculate_cost_in_cents(); + assert_eq!(one, two); + assert_eq!(one, 84); + } + + /// end_image_url is not part of the billing formula. + #[test] + fn cost_is_independent_of_end_image_url() { + let with_end = Kling2p6ProImageToVideoRequest { + end_image_url: Some("https://example.com/end.png".to_string()), + ..make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), None) + }; + let without_end = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + None, + ); + assert_eq!( + with_end.calculate_cost_in_cents(), + without_end.calculate_cost_in_cents(), + ); + } + + /// Sanity-check fal's documented examples: + /// "a 5-second video with audio costs $0.70" + /// "a 10-second video with audio and voice control costs $1.68" + #[test] + fn matches_documented_examples() { + // 5s + audio = $0.70 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), None) + .calculate_cost_in_cents(), + 70, + ); + // 10s + audio + voice = $1.68 + assert_eq!( + make_request( + Some(Kling2p6ProImageToVideoDuration::TenSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ).calculate_cost_in_cents(), + 168, + ); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/raw_request.rs new file mode 100644 index 0000000000..125101c748 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/raw_request.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.6/pro/image-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p6ProImageToVideoInput { + pub prompt: String, + + /// URL of the image used as the first frame. + pub start_image_url: String, + + /// URL of the image used as the last frame (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Whether to generate native audio for the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Voice IDs for video generation. Reference them in the prompt with + /// `<<>>` and `<<>>` — maximum of 2 voices per task. + /// Sourced from fal's `create-voice` endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub voice_ids: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProImageToVideoOutput { + pub video: Kling2p6ProImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs index b75ef22ab3..f47692e1be 100644 --- a/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs @@ -1 +1,4 @@ +pub mod kling_1p6_pro_image_to_video; +pub mod kling_2p5_turbo_pro_image_to_video; +pub mod kling_2p6_pro_image_to_video; pub mod kling_3p0_standard_image_to_video; diff --git a/crates/api_clients/fal_client/src/requests/api/video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/mod.rs index 70e7921855..acc16a8985 100644 --- a/crates/api_clients/fal_client/src/requests/api/video/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/video/mod.rs @@ -1,2 +1,3 @@ +pub mod elements; pub mod image; pub mod text; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/api.rs new file mode 100644 index 0000000000..25fd8cef80 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/api.rs @@ -0,0 +1,225 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::kling_1p6_pro_text_to_video::raw_request::{ + Kling1p6ProTextToVideoInput, Kling1p6ProTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling1p6ProTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 1.6 Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProTextToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling1p6ProTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling1p6ProTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v1.6/pro/text-to-video"; + + type RawRequest = Kling1p6ProTextToVideoInput; + type RawResponse = Kling1p6ProTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling1p6ProTextToVideoAspectRatio::Square => "1:1", + Kling1p6ProTextToVideoAspectRatio::SixteenByNine => "16:9", + Kling1p6ProTextToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio, + duration, + negative_prompt: self.negative_prompt.clone(), + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProTextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow, cinematic slow motion".to_string(), + negative_prompt: None, + duration: Some(Kling1p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + negative_prompt: None, + duration: Some(Kling1p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling1p6ProTextToVideoAspectRatio::Square, + Kling1p6ProTextToVideoAspectRatio::SixteenByNine, + Kling1p6ProTextToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling1p6ProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + negative_prompt: None, + duration: Some(Kling1p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling1p6ProTextToVideoDuration::FiveSeconds, + Kling1p6ProTextToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling1p6ProTextToVideoRequest { + prompt: "a candle flame flickers in a dark room".to_string(), + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling1p6ProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: Some(Kling1p6ProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("10")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling1p6ProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert!(raw.aspect_ratio.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.cfg_scale.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling1p6ProTextToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v1.6/pro/text-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/cost.rs new file mode 100644 index 0000000000..6a300c4375 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/cost.rs @@ -0,0 +1,236 @@ +use crate::requests::api::video::text::kling_1p6_pro_text_to_video::api::{ + Kling1p6ProTextToVideoDuration, Kling1p6ProTextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling1p6ProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 1.6 Pro text-to-video: $0.098/second + // (see https://fal.ai/models/fal-ai/kling-video/v1.6/pro/text-to-video). + // + // Slightly pricier than the image-to-video variant of the same model + // (which is $0.095/sec). Rate held in tenths-of-cents and rounded up to + // whole cents at the end so the user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling1p6ProTextToVideoDuration::FiveSeconds) + .to_seconds(); + (98u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::kling_1p6_pro_text_to_video::api::{ + Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration, + }; + use crate::requests::api::video::image::kling_1p6_pro_image_to_video::api::{ + Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration, + Kling1p6ProImageToVideoRequest, + }; + + fn make_request( + duration: Option, + ) -> Kling1p6ProTextToVideoRequest { + Kling1p6ProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration, + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + } + } + + // Pricing: $0.098/sec = 98 tenths-of-cents/sec. + // Cents per duration = ceil(98 × secs / 10). + + #[test] + fn five_seconds() { + // (98 * 5 + 9) / 10 = 49 (round up from 49.0¢) + assert_eq!( + make_request(Some(Kling1p6ProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 49, + ); + } + + #[test] + fn ten_seconds() { + // (98 * 10 + 9) / 10 = 98 + assert_eq!( + make_request(Some(Kling1p6ProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 98, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 49¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 49); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling1p6ProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling1p6ProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + /// Sanity: text-to-video should be slightly more expensive than + /// image-to-video at every supported duration ($0.098 vs $0.095 per + /// second). Protects against accidentally swapping the rates. + #[test] + fn text_to_video_is_pricier_than_image_to_video_at_each_duration() { + fn i2v(duration: Kling1p6ProImageToVideoDuration) -> u64 { + Kling1p6ProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/i.png".to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(duration), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }.calculate_cost_in_cents() + } + let pairs = [ + (Kling1p6ProTextToVideoDuration::FiveSeconds, Kling1p6ProImageToVideoDuration::FiveSeconds), + (Kling1p6ProTextToVideoDuration::TenSeconds, Kling1p6ProImageToVideoDuration::TenSeconds), + ]; + for (t, i) in pairs { + let text = make_request(Some(t)).calculate_cost_in_cents(); + let image = i2v(i); + assert!(text > image, "text-to-video ({text}¢) must be > image-to-video ({image}¢) at {t:?}"); + } + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration for text-to-video. There is no direct legacy + /// equivalent (the legacy `enqueue_kling_v1p6_pro_image_to_video_webhook` + /// is image-to-video at a slightly lower rate), so the parity here is + /// against the canonical $0.098/sec formula. A separate sub-test + /// cross-references the legacy image-to-video function to confirm the + /// expected per-duration premium ($0.003/sec rounded). + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use crate::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{ + Kling1p6ProAspectRatio as LegacyAspectRatio, + Kling1p6ProDuration as LegacyDuration, + Kling1p6ProRequest as LegacyRequest, + }; + + fn new_request( + duration: Option, + aspect_ratio: Option, + ) -> Kling1p6ProTextToVideoRequest { + Kling1p6ProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration, + aspect_ratio, + cfg_scale: None, + } + } + + // (duration, aspect_ratio, expected_cents) + // + // Math: ceil(98 × secs / 10) where 5s → 49¢ and 10s → 98¢. + // duration=None defaults to 5s. + // Aspect ratio is irrelevant to cost; iterated only to assert it. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default → 49¢ + (None, None, 49), + (None, Some(Kling1p6ProTextToVideoAspectRatio::Square), 49), + (None, Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), 49), + (None, Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), 49), + // duration=5s → 49¢ + (Some(Kling1p6ProTextToVideoDuration::FiveSeconds), None, 49), + (Some(Kling1p6ProTextToVideoDuration::FiveSeconds), Some(Kling1p6ProTextToVideoAspectRatio::Square), 49), + (Some(Kling1p6ProTextToVideoDuration::FiveSeconds), Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), 49), + (Some(Kling1p6ProTextToVideoDuration::FiveSeconds), Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), 49), + // duration=10s → 98¢ + (Some(Kling1p6ProTextToVideoDuration::TenSeconds), None, 98), + (Some(Kling1p6ProTextToVideoDuration::TenSeconds), Some(Kling1p6ProTextToVideoAspectRatio::Square), 98), + (Some(Kling1p6ProTextToVideoDuration::TenSeconds), Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), 98), + (Some(Kling1p6ProTextToVideoDuration::TenSeconds), Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), 98), + ]; + + #[test] + fn new_module_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "new module: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every row in the + /// cost table must produce the same value for any aspect-ratio choice. + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling1p6ProTextToVideoAspectRatio::Square), + Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling1p6ProTextToVideoDuration::FiveSeconds), + Some(Kling1p6ProTextToVideoDuration::TenSeconds), + ]; + for duration in durations { + let baseline = new_request(duration, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, ar).calculate_cost_in_cents(); + assert_eq!(cost, baseline, "duration={duration:?} ar={ar:?}"); + } + } + } + + /// Cross-check against the legacy image-to-video function. Text-to-video + /// is `$0.098/sec` and image-to-video is `$0.095/sec`, so at each + /// duration the cost difference equals `ceil(0.003 × secs)`: + /// 5s: 49¢ − 48¢ = 1¢ (≈ $0.015 difference before rounding) + /// 10s: 98¢ − 95¢ = 3¢ + /// If a future change to the legacy image-to-video rate breaks this + /// invariant, this test calls it out. + #[test] + fn premium_over_legacy_image_to_video_is_as_expected() { + fn legacy_image(secs_variant: LegacyDuration) -> u64 { + LegacyRequest { + image_url: "https://example.com/i.png".to_string(), + end_frame_image_url: None, + prompt: "test".to_string(), + duration: secs_variant, + aspect_ratio: LegacyAspectRatio::WideSixteenNine, + }.calculate_cost_in_cents() + } + + // (text duration, equivalent legacy image duration, expected premium in cents) + let cases = [ + (Kling1p6ProTextToVideoDuration::FiveSeconds, LegacyDuration::FiveSeconds, 1u64), + (Kling1p6ProTextToVideoDuration::TenSeconds, LegacyDuration::TenSeconds, 3u64), + ]; + + for (text_d, legacy_d, expected_premium) in cases { + let text_cost = new_request(Some(text_d), None).calculate_cost_in_cents(); + let image_cost = legacy_image(legacy_d); + let premium = text_cost.saturating_sub(image_cost); + assert_eq!( + premium, expected_premium, + "{text_d:?}: text={text_cost}¢ legacy_image={image_cost}¢ premium={premium}¢ (want {expected_premium}¢)", + ); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/raw_request.rs new file mode 100644 index 0000000000..f3d4ec425c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/raw_request.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v1.6/pro/text-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling1p6ProTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProTextToVideoOutput { + pub video: Kling1p6ProTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/api.rs new file mode 100644 index 0000000000..8a0e41dea3 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/api.rs @@ -0,0 +1,335 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::raw_request::{ + Kling2p5TurboProTextToVideoInput, Kling2p5TurboProTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p5TurboProTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 2.5 Turbo Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. fal's default is `16:9` when this is `None`. + pub aspect_ratio: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p5TurboProTextToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p5TurboProTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p5TurboProTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling2p5TurboProTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.5-turbo/pro/text-to-video"; + + type RawRequest = Kling2p5TurboProTextToVideoInput; + type RawResponse = Kling2p5TurboProTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling2p5TurboProTextToVideoAspectRatio::Square => "1:1", + Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine => "16:9", + Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio, + negative_prompt: self.negative_prompt.clone(), + duration, + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "an owl flies past an abandoned castle at dusk, fireflies dance in the trees".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "a candle flickers in a dark stone hall".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling2p5TurboProTextToVideoAspectRatio::Square, + Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine, + Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p5TurboProTextToVideoDuration::FiveSeconds, + Kling2p5TurboProTextToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "a candle flame flickers in a dark room".to_string(), + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("10")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert!(raw.aspect_ratio.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.cfg_scale.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p5TurboProTextToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.5-turbo/pro/text-to-video", + ); + } + + /// Wire-shape, endpoint, and cost parity vs the legacy modules: + /// `requests_old/http/video/text/http_kling_v2p5_turbo_pro_text_to_video.rs` + /// `requests_old/webhook/video/text/enqueue_kling_v2p5_turbo_pro_text_to_video_webhook.rs` + mod legacy_parity { + use super::*; + use crate::requests_old::http::video::text::http_kling_v2p5_turbo_pro_text_to_video::{ + kling_v2p5_turbo_pro_text_to_video, KlingV2p5TurboProTextToVideoInput, + }; + + /// Same `fal-ai/kling-video/v2.5-turbo/pro/text-to-video` path. + #[test] + fn endpoint_path_matches_legacy() { + let legacy = kling_v2p5_turbo_pro_text_to_video(KlingV2p5TurboProTextToVideoInput::default()); + assert_eq!( + Kling2p5TurboProTextToVideoRequest::ENDPOINT, + legacy.endpoint, + ); + } + + /// At a representative fully-populated case, the new module's serialized + /// `RawRequest` must equal the legacy `Input`'s JSON. + #[test] + fn wire_json_matches_legacy_fully_populated() { + let new = Kling2p5TurboProTextToVideoRequest { + prompt: "a wave at dawn".to_string(), + negative_prompt: Some("blurry".to_string()), + duration: Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: Some(0.5), + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProTextToVideoInput { + prompt: "a wave at dawn".to_string(), + aspect_ratio: Some("9:16".to_string()), + negative_prompt: Some("blurry".to_string()), + duration: Some("10".to_string()), + cfg_scale: Some(0.5), + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + } + + /// Minimal case (prompt only): unset optionals must be omitted on the + /// wire just like the legacy struct. + #[test] + fn wire_json_matches_legacy_minimal() { + let new = Kling2p5TurboProTextToVideoRequest { + prompt: "minimal".to_string(), + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProTextToVideoInput { + prompt: "minimal".to_string(), + ..Default::default() + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + assert_eq!(new_json, serde_json::json!({ "prompt": "minimal" })); + } + + /// Cross product over duration × aspect_ratio: every cell's wire JSON + /// must match the equivalent legacy `Input`. + #[test] + fn wire_json_matches_legacy_at_every_combo() { + let durations = [ + (None, None), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some("5")), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some("10")), + ]; + let aspect_ratios = [ + (None, None), + (Some(Kling2p5TurboProTextToVideoAspectRatio::Square), Some("1:1")), + (Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), Some("16:9")), + (Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), Some("9:16")), + ]; + + for (d_new, d_legacy) in durations { + for (ar_new, ar_legacy) in aspect_ratios { + let new = Kling2p5TurboProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: d_new, + aspect_ratio: ar_new, + cfg_scale: None, + }; + let legacy = KlingV2p5TurboProTextToVideoInput { + prompt: "p".to_string(), + aspect_ratio: ar_legacy.map(String::from), + negative_prompt: None, + duration: d_legacy.map(String::from), + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + let legacy_json = serde_json::to_value(&legacy).unwrap(); + assert_eq!( + new_json, legacy_json, + "duration={d_new:?} aspect_ratio={ar_new:?}", + ); + } + } + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/cost.rs new file mode 100644 index 0000000000..0a95ef30df --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/cost.rs @@ -0,0 +1,261 @@ +use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoDuration, Kling2p5TurboProTextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p5TurboProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.5 Turbo Pro text-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/text-to-video): + // "For 5s video your request will cost $0.35. + // For every additional second you will be charged $0.07." + // + // That works out to a flat $0.07/second rate. Rate is held in + // tenths-of-cents and rounded up to whole cents so the user is never + // undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p5TurboProTextToVideoDuration::FiveSeconds) + .to_seconds(); + (70u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration, + }; + + fn make_request( + duration: Option, + ) -> Kling2p5TurboProTextToVideoRequest { + Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration, + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + } + } + + // Pricing: flat $0.07/sec = 70 tenths-of-cents/sec. + // Cents per duration = ceil(70 × secs / 10). + + #[test] + fn five_seconds() { + // (70 * 5 + 9) / 10 = 35 — matches fal's "$0.35 for 5s" base rate + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn ten_seconds() { + // (70 * 10 + 9) / 10 = 70 — matches "$0.35 + 5×$0.07 = $0.70" + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 35¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 35); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling2p5TurboProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + /// Exhaustive cost-table tests and cross-checks against the legacy + /// `enqueue_kling_v2p5_turbo_pro_text_to_video_webhook` calculator. + mod legacy_parity { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use crate::requests_old::webhook::video::text::enqueue_kling_v2p5_turbo_pro_text_to_video_webhook::{ + EnqueueKlingV2p5TurboProTextToVideoAspectRatio as LegacyAspectRatio, + EnqueueKlingV2p5TurboProTextToVideoDurationSeconds as LegacyDuration, + EnqueueKlingV2p5TurboProTextToVideoRequest as LegacyRequest, + }; + + // ── Mapping helpers from the new strongly-typed API to the legacy one ── + + fn legacy_duration_for(d: Option) -> Option { + match d { + None => None, + Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds) => Some(LegacyDuration::Five), + Some(Kling2p5TurboProTextToVideoDuration::TenSeconds) => Some(LegacyDuration::Ten), + } + } + + fn legacy_aspect_ratio_for( + ar: Option, + ) -> Option { + match ar { + None => None, + Some(Kling2p5TurboProTextToVideoAspectRatio::Square) => Some(LegacyAspectRatio::Square), + Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine) => Some(LegacyAspectRatio::SixteenByNine), + Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen) => Some(LegacyAspectRatio::NineBySixteen), + } + } + + fn new_request( + duration: Option, + aspect_ratio: Option, + ) -> Kling2p5TurboProTextToVideoRequest { + Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration, + aspect_ratio, + cfg_scale: None, + } + } + + fn legacy_request( + duration: Option, + aspect_ratio: Option, + ) -> LegacyRequest { + LegacyRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration: legacy_duration_for(duration), + aspect_ratio: legacy_aspect_ratio_for(aspect_ratio), + } + } + + // (new_duration, new_aspect_ratio, expected_cents) + // + // Math: ceil(70 × secs / 10). 5s → 35¢; 10s → 70¢. + // duration=None defaults to 5s in both new and legacy modules. + // Aspect ratio is irrelevant to cost; iterated only to assert it. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default → 35¢ + (None, None, 35), + (None, Some(Kling2p5TurboProTextToVideoAspectRatio::Square), 35), + (None, Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), 35), + (None, Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), 35), + // duration=5s → 35¢ + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), None, 35), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::Square), 35), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), 35), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), 35), + // duration=10s → 70¢ + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), None, 70), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::Square), 70), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), 70), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), 70), + ]; + + #[test] + fn new_module_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "new module: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + #[test] + fn legacy_function_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = legacy_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "legacy function: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + /// Headline parity check: new vs legacy give identical cents at every + /// permutation. If this fails, pricing has drifted. + #[test] + fn new_module_matches_legacy_at_every_combo() { + for &(duration, aspect_ratio, _) in COST_TABLE { + let new_cost = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + let legacy_cost = legacy_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + new_cost, legacy_cost, + "parity mismatch at duration={duration:?} aspect_ratio={aspect_ratio:?}: \ + new={new_cost}¢ vs legacy={legacy_cost}¢", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every aspect-ratio + /// choice (including `None`) must produce the same value for fixed + /// duration. + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling2p5TurboProTextToVideoAspectRatio::Square), + Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), + ]; + for duration in durations { + let baseline = new_request(duration, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, ar).calculate_cost_in_cents(); + assert_eq!(cost, baseline, "duration={duration:?} ar={ar:?}"); + } + } + } + + /// cfg_scale is not part of the billing formula. + #[test] + fn cost_is_independent_of_cfg_scale() { + for cfg in [None, Some(0.0_f32), Some(0.5), Some(1.0)] { + let req = Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: cfg, + }; + assert_eq!(req.calculate_cost_in_cents(), 35, "cfg_scale={cfg:?}"); + } + } + + /// Sanity-check fal's documented examples: + /// "For 5s video your request will cost $0.35" + /// "For every additional second you will be charged $0.07" + /// → 10s costs $0.35 + 5 × $0.07 = $0.70 + #[test] + fn matches_documented_examples() { + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + "5s should be $0.35 per fal's docs", + ); + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + "10s should be $0.70 per fal's docs", + ); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/raw_request.rs new file mode 100644 index 0000000000..c06edfdeb6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/raw_request.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.5-turbo/pro/text-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p5TurboProTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" (fal default: "16:9") + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" (fal default: "5") + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProTextToVideoOutput { + pub video: Kling2p5TurboProTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/api.rs new file mode 100644 index 0000000000..e42cacba13 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/api.rs @@ -0,0 +1,358 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::kling_2p6_pro_text_to_video::raw_request::{ + Kling2p6ProTextToVideoInput, Kling2p6ProTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p6ProTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 2.6 Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p6ProTextToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p6ProTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p6ProTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling2p6ProTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.6/pro/text-to-video"; + + type RawRequest = Kling2p6ProTextToVideoInput; + type RawResponse = Kling2p6ProTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling2p6ProTextToVideoAspectRatio::Square => "1:1", + Kling2p6ProTextToVideoAspectRatio::SixteenByNine => "16:9", + Kling2p6ProTextToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio, + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + duration, + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProTextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling2p6ProTextToVideoAspectRatio::Square, + Kling2p6ProTextToVideoAspectRatio::SixteenByNine, + Kling2p6ProTextToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling2p6ProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p6ProTextToVideoDuration::FiveSeconds, + Kling2p6ProTextToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p6ProTextToVideoRequest { + prompt: "a candle flame flickers in a dark room".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling2p6ProTextToVideoRequest { + prompt: "p".to_string(), + generate_audio: None, + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("10")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p6ProTextToVideoRequest { + prompt: "p".to_string(), + generate_audio: None, + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert!(raw.aspect_ratio.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.generate_audio.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.cfg_scale.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p6ProTextToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.6/pro/text-to-video", + ); + } + + /// Wire-shape and endpoint parity vs the legacy + /// `requests_old/http/video/text/http_kling_v2p6_pro_text_to_video.rs`. + /// The legacy module has no cost calculator, so cost parity is N/A; + /// these tests verify the *shape sent on the wire* hasn't drifted. + mod legacy_parity { + use super::*; + use crate::requests_old::http::video::text::http_kling_v2p6_pro_text_to_video::{ + kling_v2p6_pro_text_to_video, KlingV2p6ProTextToVideoInput, + }; + + /// Same `fal-ai/kling-video/v2.6/pro/text-to-video` path. + #[test] + fn endpoint_path_matches_legacy() { + let legacy = kling_v2p6_pro_text_to_video(KlingV2p6ProTextToVideoInput::default()); + assert_eq!( + Kling2p6ProTextToVideoRequest::ENDPOINT, + legacy.endpoint, + ); + } + + /// At a representative populated case, the new module's serialized + /// `RawRequest` must equal the legacy `Input`'s JSON byte-for-byte + /// (modulo serde field ordering, which is stable within each struct). + #[test] + fn wire_json_matches_legacy_fully_populated() { + let new = Kling2p6ProTextToVideoRequest { + prompt: "a wave at dawn".to_string(), + generate_audio: Some(true), + negative_prompt: Some("blurry".to_string()), + duration: Some(Kling2p6ProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: Some(0.5), + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p6ProTextToVideoInput { + prompt: "a wave at dawn".to_string(), + aspect_ratio: Some("9:16".to_string()), + generate_audio: Some(true), + negative_prompt: Some("blurry".to_string()), + duration: Some("10".to_string()), + cfg_scale: Some(0.5), + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + } + + /// At minimum (prompt only), unset optionals must be omitted on the + /// wire just like the legacy struct. + #[test] + fn wire_json_matches_legacy_minimal() { + let new = Kling2p6ProTextToVideoRequest { + prompt: "minimal".to_string(), + generate_audio: None, + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p6ProTextToVideoInput { + prompt: "minimal".to_string(), + ..Default::default() + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + // And the minimal payload should literally be `{"prompt": "minimal"}`. + assert_eq!(new_json, serde_json::json!({ "prompt": "minimal" })); + } + + /// Cross product over duration × aspect_ratio × audio: every cell's wire + /// JSON must match the equivalent legacy `Input`. + #[test] + fn wire_json_matches_legacy_at_every_combo() { + let durations = [ + (None, None), + (Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some("5")), + (Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some("10")), + ]; + let aspect_ratios = [ + (None, None), + (Some(Kling2p6ProTextToVideoAspectRatio::Square), Some("1:1")), + (Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), Some("16:9")), + (Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen), Some("9:16")), + ]; + let audio_options = [None, Some(false), Some(true)]; + + for (d_new, d_legacy) in durations { + for (ar_new, ar_legacy) in aspect_ratios { + for audio in audio_options { + let new = Kling2p6ProTextToVideoRequest { + prompt: "p".to_string(), + generate_audio: audio, + negative_prompt: None, + duration: d_new, + aspect_ratio: ar_new, + cfg_scale: None, + }; + let legacy = KlingV2p6ProTextToVideoInput { + prompt: "p".to_string(), + aspect_ratio: ar_legacy.map(String::from), + generate_audio: audio, + negative_prompt: None, + duration: d_legacy.map(String::from), + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + let legacy_json = serde_json::to_value(&legacy).unwrap(); + assert_eq!( + new_json, legacy_json, + "duration={d_new:?} aspect_ratio={ar_new:?} audio={audio:?}", + ); + } + } + } + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/cost.rs new file mode 100644 index 0000000000..192e08a0ef --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/cost.rs @@ -0,0 +1,247 @@ +use crate::requests::api::video::text::kling_2p6_pro_text_to_video::api::{ + Kling2p6ProTextToVideoDuration, Kling2p6ProTextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p6ProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.6 Pro text-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.6/pro/text-to-video): + // $0.07/sec when audio is off + // $0.14/sec when audio is on + // + // generate_audio defaults to `true` on fal's server, so `None` is billed + // as audio-on. Rate stored in tenths-of-cents and rounded up to whole + // cents so the user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p6ProTextToVideoDuration::FiveSeconds) + .to_seconds(); + + let audio_on = self.generate_audio.unwrap_or(true); + let rate_tenth_cents_per_sec: u64 = if audio_on { 140 } else { 70 }; + + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::kling_2p6_pro_text_to_video::api::{ + Kling2p6ProTextToVideoAspectRatio, Kling2p6ProTextToVideoDuration, + }; + + fn make_request( + duration: Option, + generate_audio: Option, + ) -> Kling2p6ProTextToVideoRequest { + Kling2p6ProTextToVideoRequest { + prompt: "test".to_string(), + generate_audio, + negative_prompt: None, + duration, + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + } + } + + // Pricing: $0.07/sec audio off, $0.14/sec audio on. + // Cents per duration = ceil(rate × secs / 10) where rate is in tenths-of-cents. + + #[test] + fn five_seconds_audio_off() { + // (70 * 5 + 9) / 10 = 35 + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(false)) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn five_seconds_audio_on() { + // (140 * 5 + 9) / 10 = 70 — matches fal's $0.70 example + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn ten_seconds_audio_off() { + // (70 * 10 + 9) / 10 = 70 + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(false)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn ten_seconds_audio_on() { + // (140 * 10 + 9) / 10 = 140 + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(true)) + .calculate_cost_in_cents(), + 140, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None, audio=on (default) → 5s @ $0.14 = 70¢ + assert_eq!(make_request(None, None).calculate_cost_in_cents(), 70); + } + + #[test] + fn default_audio_is_on() { + // duration=5s, audio=None → audio_on=true → 70¢ + let none_default = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), None) + .calculate_cost_in_cents(); + let explicit_on = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true)) + .calculate_cost_in_cents(); + assert_eq!(none_default, explicit_on); + } + + #[test] + fn audio_on_costs_twice_as_much_as_off() { + // $0.14 / $0.07 = 2x at every duration + let durations = [ + Kling2p6ProTextToVideoDuration::FiveSeconds, + Kling2p6ProTextToVideoDuration::TenSeconds, + ]; + for d in durations { + let off = make_request(Some(d), Some(false)).calculate_cost_in_cents(); + let on = make_request(Some(d), Some(true)).calculate_cost_in_cents(); + assert_eq!(on, off * 2, "duration={d:?}: audio_on={on}¢ should be 2× audio_off={off}¢"); + } + } + + #[test] + fn ten_seconds_costs_more_than_five() { + // At a given audio setting, 10s > 5s. + for audio in [Some(false), Some(true), None] { + let five = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), audio) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling2p6ProTextToVideoDuration::TenSeconds), audio) + .calculate_cost_in_cents(); + assert!(five < ten, "audio={audio:?}: five={five}¢ < ten={ten}¢"); + } + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration (duration × generate_audio). Aspect ratio is in the cross + /// product as a sanity check — it must not affect the bill. + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + + fn new_request( + duration: Option, + generate_audio: Option, + aspect_ratio: Option, + ) -> Kling2p6ProTextToVideoRequest { + Kling2p6ProTextToVideoRequest { + prompt: "test".to_string(), + generate_audio, + negative_prompt: None, + duration, + aspect_ratio, + cfg_scale: None, + } + } + + // (duration, generate_audio, expected_cents) + // + // Math: ceil(rate × secs / 10) where rate is 70 (audio off) or 140 (audio on). + // duration=None defaults to 5s; generate_audio=None defaults to true. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default + (None, Some(false), 35), + (None, Some(true), 70), + (None, None, 70), // audio default = on + // duration=5s + (Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(false), 35), + (Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true), 70), + (Some(Kling2p6ProTextToVideoDuration::FiveSeconds), None, 70), + // duration=10s + (Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(false), 70), + (Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(true), 140), + (Some(Kling2p6ProTextToVideoDuration::TenSeconds), None, 140), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, expected) in COST_TABLE { + let got = new_request(duration, generate_audio, None).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} generate_audio={generate_audio:?}", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every aspect-ratio + /// choice (including `None`) must produce the same value for fixed + /// (duration, generate_audio). + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling2p6ProTextToVideoAspectRatio::Square), + Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + Some(Kling2p6ProTextToVideoDuration::TenSeconds), + ]; + let audio_options = [None, Some(false), Some(true)]; + + for duration in durations { + for audio in audio_options { + let baseline = new_request(duration, audio, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, audio, ar).calculate_cost_in_cents(); + assert_eq!( + cost, baseline, + "duration={duration:?} audio={audio:?} ar={ar:?}: cost={cost}¢ != baseline={baseline}¢", + ); + } + } + } + } + + /// cfg_scale is not part of the billing formula — every choice must produce + /// the same value for fixed (duration, generate_audio). + #[test] + fn cost_is_independent_of_cfg_scale() { + for cfg in [None, Some(0.0_f32), Some(0.5), Some(1.0)] { + let req = Kling2p6ProTextToVideoRequest { + prompt: "test".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: cfg, + }; + assert_eq!(req.calculate_cost_in_cents(), 70, "cfg_scale={cfg:?}"); + } + } + + /// Sanity-check the documented examples from fal: + /// "a 5s video with audio on will cost $0.70" + #[test] + fn matches_documented_examples() { + let cost = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true)) + .calculate_cost_in_cents(); + assert_eq!(cost, 70, "5s audio-on should be $0.70 per fal's docs"); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/raw_request.rs new file mode 100644 index 0000000000..91347fbb22 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/raw_request.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.6/pro/text-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p6ProTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate native audio for the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProTextToVideoOutput { + pub video: Kling2p6ProTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs index fab6db1317..76c76f832b 100644 --- a/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs @@ -1 +1,4 @@ +pub mod kling_1p6_pro_text_to_video; +pub mod kling_2p5_turbo_pro_text_to_video; +pub mod kling_2p6_pro_text_to_video; pub mod kling_3p0_standard_text_to_video; diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs index f7a4476726..cb3dad22f8 100644 --- a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs @@ -124,6 +124,64 @@ fn build_omni_gen_video_models() -> Vec { ..Default::default() }); + models.push(OmniGenVideoModelDetails { + model: CommonVideoModel::Kling16Pro, + model_creator: Some(ModelCreator::Kling), + full_name: Some("Kling 1.6 Pro".to_string()), + text_prompt_supported: Some(true), + starting_keyframe_supported: Some(true), + ending_keyframe_supported: Some(true), + image_references_supported: Some(true), // NB: 1.6 Elements! + image_references_max: Some(4), // NB: 1.6 Elements! + aspect_ratio_options: Some(vec![ + CommonAspectRatio::WideSixteenByNine, + CommonAspectRatio::Square, + CommonAspectRatio::TallNineBySixteen, + ]), + aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine), + duration_seconds_options: Some(vec![5, 10]), + duration_seconds_default: Some(5), + ..Default::default() + }); + + // TODO: Kling 2.5 doesn't let you control aspect ratio for image-to-video + models.push(OmniGenVideoModelDetails { + model: CommonVideoModel::Kling2p5TurboPro, + model_creator: Some(ModelCreator::Kling), + full_name: Some("Kling 2.5 Turbo Pro".to_string()), + text_prompt_supported: Some(true), + starting_keyframe_supported: Some(true), + ending_keyframe_supported: Some(true), + aspect_ratio_options: Some(vec![ + CommonAspectRatio::WideSixteenByNine, + CommonAspectRatio::Square, + CommonAspectRatio::TallNineBySixteen, + ]), + aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine), + duration_seconds_options: Some(vec![5, 10]), + duration_seconds_default: Some(5), + ..Default::default() + }); + + // TODO: Kling 2.6 doesn't let you control aspect ratio for image-to-video + models.push(OmniGenVideoModelDetails { + model: CommonVideoModel::Kling2p6Pro, + model_creator: Some(ModelCreator::Kling), + full_name: Some("Kling 2.6 Pro".to_string()), + text_prompt_supported: Some(true), + starting_keyframe_supported: Some(true), + ending_keyframe_supported: Some(true), + aspect_ratio_options: Some(vec![ + CommonAspectRatio::WideSixteenByNine, + CommonAspectRatio::Square, + CommonAspectRatio::TallNineBySixteen, + ]), + aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine), + duration_seconds_options: Some(vec![5, 10]), + duration_seconds_default: Some(5), + ..Default::default() + }); + models.push(OmniGenVideoModelDetails { model: CommonVideoModel::Seedance1p5Pro, model_creator: Some(ModelCreator::Bytedance),