Compare commits

...
Author SHA1 Message Date
Brandon Thomas 4048b12684 add kling models to model list 2026-06-01 19:06:47 -04:00
Brandon Thomas 92acdcf027 artcraft router to use new methods 2026-06-01 18:47:40 -04:00
Brandon Thomas 3df1ab7bb8 kling 2.5 turbo 2026-06-01 18:19:23 -04:00
Brandon Thomas afd831ee2c tests 2026-06-01 18:06:31 -04:00
Brandon Thomas ef41b77ce8 kling 2.6 pro 2026-06-01 18:02:34 -04:00
Brandon Thomas dcd44573e3 elements 2026-06-01 17:34:01 -04:00
Brandon Thomas 41e48a6ca1 tests 2026-06-01 17:21:17 -04:00
Brandon Thomas 7efc0855a4 add kling 1.6 2026-06-01 16:50:31 -04:00
56 changed files with 5205 additions and 304 deletions
@@ -22,6 +22,14 @@ pub struct FalVideoResponsePayload {
pub request_id: Option<String>,
pub gateway_request_id: Option<String>,
/// 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<String>,
/// Queue-mode response URL (fal's queue/polling flow). `None` for webhook
/// dispatch.
pub maybe_response_url: Option<String>,
/// 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.
@@ -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<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
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<ImageRef>,
field: &'static str,
@@ -62,21 +133,35 @@ pub(crate) fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>
}
}
fn resolve_reference_image_urls(
reference_images: Option<ImageListRef>,
) -> Result<Vec<String>, 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<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Kling1p6ProAspectRatio, ArtcraftRouterError> {
use Kling1p6ProAspectRatio as Ar;
) -> Result<Option<PlanAspectRatio>, 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<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Kling1p6ProDuration, ArtcraftRouterError> {
) -> Result<Option<PlanDuration>, 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);
}
}
@@ -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<u16>) -> 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);
}
}
@@ -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<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let outbound_request: Arc<dyn Debug + Send + Sync> = 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<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = 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<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
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());
}
}
@@ -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),
}))
}
@@ -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),
}))
}
@@ -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"));
}
}
@@ -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<u16>, 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); }
}
@@ -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<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
FalKling2p5TurboProMode::TextToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = 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<dyn Debug + Send + Sync> = 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<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = 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<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
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());
}
}
@@ -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<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
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));
}
}
@@ -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<u16>, generate_audio: Option<bool>) -> u64 {
let b = GenerateVideoRequestBuilder {
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>, 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),
);
}
}
@@ -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<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
FalKling2p6ProMode::TextToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = 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<dyn Debug + Send + Sync> = 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<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = 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<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
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());
}
}
@@ -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),
}))
}
@@ -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),
}))
}
@@ -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),
}))
}
@@ -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),
}))
}
@@ -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),
}))
}
@@ -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),
}))
}
@@ -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),
}))
}
@@ -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),
}))
}
@@ -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),
}))
}
@@ -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),
}))
}
@@ -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),
}))
}
@@ -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<String>,
/// Optional negative prompt. fal's default is
/// `"blur, distort, and low quality"` when this is `None`.
pub negative_prompt: Option<String>,
/// Video duration. Kling 1.6 Pro Elements supports 5 or 10 seconds only.
pub duration: Option<Kling1p6ProElementsToVideoDuration>,
/// Aspect ratio.
pub aspect_ratio: Option<Kling1p6ProElementsToVideoAspectRatio>,
}
#[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<Self::RawRequest, FalErrorPlus> {
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
}
@@ -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<Kling1p6ProElementsToVideoDuration>,
) -> 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<String> = (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<Kling1p6ProElementsToVideoDuration>,
aspect_ratio: Option<Kling1p6ProElementsToVideoAspectRatio>,
) -> 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<Kling1p6ProElementsToVideoDuration>,
Option<Kling1p6ProElementsToVideoAspectRatio>,
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",
);
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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: <https://fal.ai/models/fal-ai/kling-video/v1.6/pro/elements/api>
#[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<String>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16", "1:1"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "5", "10"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Optional negative prompt.
/// fal's default: "blur, distort, and low quality"
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling1p6ProElementsToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling1p6ProElementsToVideoOutput {
pub video: Kling1p6ProElementsToVideoVideoFile,
}
@@ -0,0 +1 @@
pub mod kling_1p6_pro_elements_to_video;
@@ -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<String>,
/// Optional negative prompt. fal's default is
/// `"blur, distort, and low quality"` when this is `None`.
pub negative_prompt: Option<String>,
/// Video duration. Kling 1.6 Pro supports 5 or 10 seconds only.
pub duration: Option<Kling1p6ProImageToVideoDuration>,
/// Aspect ratio.
pub aspect_ratio: Option<Kling1p6ProImageToVideoAspectRatio>,
/// CFG scale. Defaults to fal's `0.5` when `None`.
pub cfg_scale: Option<f32>,
}
#[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<Self::RawRequest, FalErrorPlus> {
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
}
@@ -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<Kling1p6ProImageToVideoDuration>,
) -> 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<Kling1p6ProImageToVideoDuration>) -> 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<Kling1p6ProImageToVideoAspectRatio>) -> 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<Kling1p6ProImageToVideoDuration>,
aspect_ratio: Option<Kling1p6ProImageToVideoAspectRatio>,
) -> 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<Kling1p6ProImageToVideoDuration>,
aspect_ratio: Option<Kling1p6ProImageToVideoAspectRatio>,
) -> 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<Kling1p6ProImageToVideoDuration>,
Option<Kling1p6ProImageToVideoAspectRatio>,
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:?}");
}
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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: <https://fal.ai/models/fal-ai/kling-video/v1.6/pro/image-to-video/api>
///
/// 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<String>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16", "1:1"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "5", "10"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Optional negative prompt.
/// fal's default: "blur, distort, and low quality"
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// The CFG (Classifier Free Guidance) scale.
/// Default value: 0.5
#[serde(skip_serializing_if = "Option::is_none")]
pub cfg_scale: Option<f32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling1p6ProImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling1p6ProImageToVideoOutput {
pub video: Kling1p6ProImageToVideoVideoFile,
}
@@ -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<String>,
/// Video duration. Kling 2.5 Turbo Pro supports 5 or 10 seconds only.
pub duration: Option<Kling2p5TurboProImageToVideoDuration>,
/// Optional negative prompt. fal's default is
/// `"blur, distort, and low quality"` when this is `None`.
pub negative_prompt: Option<String>,
/// CFG scale. Defaults to fal's `0.5` when `None`.
pub cfg_scale: Option<f32>,
}
#[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<Self::RawRequest, FalErrorPlus> {
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
}
@@ -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<Kling2p5TurboProImageToVideoDuration>,
) -> 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<Kling2p5TurboProImageToVideoDuration>,
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<Kling2p5TurboProImageToVideoDuration>,
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,
);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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: <https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/image-to-video/api>
#[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<String>,
/// 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<String>,
/// Optional negative prompt.
/// fal's default: "blur, distort, and low quality"
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// The CFG (Classifier Free Guidance) scale.
/// Default value: 0.5
#[serde(skip_serializing_if = "Option::is_none")]
pub cfg_scale: Option<f32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling2p5TurboProImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling2p5TurboProImageToVideoOutput {
pub video: Kling2p5TurboProImageToVideoVideoFile,
}
@@ -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<String>,
/// Video duration. Kling 2.6 Pro supports 5 or 10 seconds only.
pub duration: Option<Kling2p6ProImageToVideoDuration>,
/// Optional negative prompt. fal's default is
/// `"blur, distort, and low quality"` when this is `None`.
pub negative_prompt: Option<String>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Voice IDs to use for the generated audio. Maximum of 2 voices per task;
/// reference them in the prompt as `<<<voice_1>>>` and `<<<voice_2>>>`.
///
/// 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<Vec<String>>,
}
#[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<Self::RawRequest, FalErrorPlus> {
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
}
@@ -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<Kling2p6ProImageToVideoDuration>,
generate_audio: Option<bool>,
voice_ids: Option<Vec<String>>,
) -> 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<Kling2p6ProImageToVideoDuration>,
Option<bool>,
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,
);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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: <https://fal.ai/models/fal-ai/kling-video/v2.6/pro/image-to-video>
#[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<String>,
/// Duration in seconds (as a string).
/// Possible values: "5", "10"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Optional negative prompt.
/// fal's default: "blur, distort, and low quality"
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Whether to generate native audio for the video.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Voice IDs for video generation. Reference them in the prompt with
/// `<<<voice_1>>>` and `<<<voice_2>>>` — maximum of 2 voices per task.
/// Sourced from fal's `create-voice` endpoint.
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_ids: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling2p6ProImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling2p6ProImageToVideoOutput {
pub video: Kling2p6ProImageToVideoVideoFile,
}
@@ -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;
@@ -1,2 +1,3 @@
pub mod elements;
pub mod image;
pub mod text;
@@ -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<String>,
/// Video duration. Kling 1.6 Pro supports 5 or 10 seconds only.
pub duration: Option<Kling1p6ProTextToVideoDuration>,
/// Aspect ratio.
pub aspect_ratio: Option<Kling1p6ProTextToVideoAspectRatio>,
/// CFG scale. Defaults to fal's `0.5` when `None`.
pub cfg_scale: Option<f32>,
}
#[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<Self::RawRequest, FalErrorPlus> {
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
}
@@ -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<Kling1p6ProTextToVideoDuration>,
) -> 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<Kling1p6ProTextToVideoDuration>,
aspect_ratio: Option<Kling1p6ProTextToVideoAspectRatio>,
) -> 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<Kling1p6ProTextToVideoDuration>,
Option<Kling1p6ProTextToVideoAspectRatio>,
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}¢)",
);
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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: <https://fal.ai/models/fal-ai/kling-video/v1.6/pro/text-to-video/api>
#[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<String>,
/// Duration in seconds (as a string).
/// Possible values: "5", "10"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Optional negative prompt.
/// fal's default: "blur, distort, and low quality"
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// The CFG (Classifier Free Guidance) scale.
/// Default value: 0.5
#[serde(skip_serializing_if = "Option::is_none")]
pub cfg_scale: Option<f32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling1p6ProTextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling1p6ProTextToVideoOutput {
pub video: Kling1p6ProTextToVideoVideoFile,
}
@@ -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<String>,
/// Video duration. Kling 2.5 Turbo Pro supports 5 or 10 seconds only.
pub duration: Option<Kling2p5TurboProTextToVideoDuration>,
/// Aspect ratio. fal's default is `16:9` when this is `None`.
pub aspect_ratio: Option<Kling2p5TurboProTextToVideoAspectRatio>,
/// CFG scale. Defaults to fal's `0.5` when `None`.
pub cfg_scale: Option<f32>,
}
#[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<Self::RawRequest, FalErrorPlus> {
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
}
@@ -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<Kling2p5TurboProTextToVideoDuration>,
) -> 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<Kling2p5TurboProTextToVideoDuration>) -> Option<LegacyDuration> {
match d {
None => None,
Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds) => Some(LegacyDuration::Five),
Some(Kling2p5TurboProTextToVideoDuration::TenSeconds) => Some(LegacyDuration::Ten),
}
}
fn legacy_aspect_ratio_for(
ar: Option<Kling2p5TurboProTextToVideoAspectRatio>,
) -> Option<LegacyAspectRatio> {
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<Kling2p5TurboProTextToVideoDuration>,
aspect_ratio: Option<Kling2p5TurboProTextToVideoAspectRatio>,
) -> Kling2p5TurboProTextToVideoRequest {
Kling2p5TurboProTextToVideoRequest {
prompt: "test".to_string(),
negative_prompt: None,
duration,
aspect_ratio,
cfg_scale: None,
}
}
fn legacy_request(
duration: Option<Kling2p5TurboProTextToVideoDuration>,
aspect_ratio: Option<Kling2p5TurboProTextToVideoAspectRatio>,
) -> 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<Kling2p5TurboProTextToVideoDuration>,
Option<Kling2p5TurboProTextToVideoAspectRatio>,
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",
);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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: <https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/text-to-video/api>
#[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<String>,
/// Optional negative prompt.
/// fal's default: "blur, distort, and low quality"
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "5", "10" (fal default: "5")
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// The CFG (Classifier Free Guidance) scale.
/// Default value: 0.5
#[serde(skip_serializing_if = "Option::is_none")]
pub cfg_scale: Option<f32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling2p5TurboProTextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling2p5TurboProTextToVideoOutput {
pub video: Kling2p5TurboProTextToVideoVideoFile,
}
@@ -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<bool>,
/// Optional negative prompt. fal's default is
/// `"blur, distort, and low quality"` when this is `None`.
pub negative_prompt: Option<String>,
/// Video duration. Kling 2.6 Pro supports 5 or 10 seconds only.
pub duration: Option<Kling2p6ProTextToVideoDuration>,
/// Aspect ratio.
pub aspect_ratio: Option<Kling2p6ProTextToVideoAspectRatio>,
/// CFG scale. Defaults to fal's `0.5` when `None`.
pub cfg_scale: Option<f32>,
}
#[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<Self::RawRequest, FalErrorPlus> {
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
}
@@ -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<Kling2p6ProTextToVideoDuration>,
generate_audio: Option<bool>,
) -> 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<Kling2p6ProTextToVideoDuration>,
generate_audio: Option<bool>,
aspect_ratio: Option<Kling2p6ProTextToVideoAspectRatio>,
) -> 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<Kling2p6ProTextToVideoDuration>,
Option<bool>,
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");
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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: <https://fal.ai/models/fal-ai/kling-video/v2.6/pro/text-to-video/api>
#[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<String>,
/// Whether to generate native audio for the video.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
/// fal's default: "blur, distort, and low quality"
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "5", "10"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// The CFG (Classifier Free Guidance) scale.
/// Default value: 0.5
#[serde(skip_serializing_if = "Option::is_none")]
pub cfg_scale: Option<f32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling2p6ProTextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling2p6ProTextToVideoOutput {
pub video: Kling2p6ProTextToVideoVideoFile,
}
@@ -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;
@@ -124,6 +124,64 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
..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),