From 3df1ab7bb875da817824464f613552849f75cc35 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 1 Jun 2026 18:19:23 -0400 Subject: [PATCH] kling 2.5 turbo --- .../kling_2p5_turbo_pro_image_to_video/api.rs | 324 +++++++++++++++++ .../cost.rs | 215 +++++++++++ .../kling_2p5_turbo_pro_image_to_video/mod.rs | 3 + .../raw_request.rs | 40 +++ .../src/requests/api/video/image/mod.rs | 1 + .../kling_2p5_turbo_pro_text_to_video/api.rs | 335 ++++++++++++++++++ .../kling_2p5_turbo_pro_text_to_video/cost.rs | 261 ++++++++++++++ .../kling_2p5_turbo_pro_text_to_video/mod.rs | 3 + .../raw_request.rs | 38 ++ .../src/requests/api/video/text/mod.rs | 1 + 10 files changed, 1221 insertions(+) create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/raw_request.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/raw_request.rs diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/api.rs new file mode 100644 index 0000000000..2b2c39c1a8 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/api.rs @@ -0,0 +1,324 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::raw_request::{ + Kling2p5TurboProImageToVideoInput, Kling2p5TurboProImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p5TurboProImageToVideoRequest { + /// Text prompt describing the desired video content. + pub prompt: String, + + /// URL of the image used as the first frame. + pub image_url: String, + + /// Optional URL of the image used as the last frame. + pub tail_image_url: Option, + + /// Video duration. Kling 2.5 Turbo Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p5TurboProImageToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p5TurboProImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +impl FalEndpoint for Kling2p5TurboProImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.5-turbo/pro/image-to-video"; + + type RawRequest = Kling2p5TurboProImageToVideoInput; + type RawResponse = Kling2p5TurboProImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + duration, + tail_image_url: self.tail_image_url.clone(), + negative_prompt: self.negative_prompt.clone(), + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + tail_image_url: None, + duration: Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + tail_image_url: None, + duration: Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p5TurboProImageToVideoDuration::FiveSeconds, + Kling2p5TurboProImageToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "the dog wags its tail".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + tail_image_url: None, + duration: Some(dur), + negative_prompt: None, + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_uses_canonical_field_names() { + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + tail_image_url: Some("https://example.com/end.png".to_string()), + duration: Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), + negative_prompt: Some("nope".to_string()), + cfg_scale: Some(0.5), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert_eq!(raw.tail_image_url.as_deref(), Some("https://example.com/end.png")); + assert_eq!(raw.duration.as_deref(), Some("10")); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.cfg_scale, Some(0.5)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + tail_image_url: None, + duration: None, + negative_prompt: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert!(raw.tail_image_url.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.cfg_scale.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p5TurboProImageToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.5-turbo/pro/image-to-video", + ); + } + + /// Wire-shape and endpoint parity vs the legacy + /// `requests_old/http/video/image/http_kling_v2p5_turbo_pro_image_to_video.rs`. + /// + /// No legacy *pro* webhook exists for image-to-video (only the legacy + /// `enqueue_kling_v2p5_turbo_standard_image_to_video_webhook` ships, and + /// that targets the `standard` model with a different pricing tier — cost + /// parity for the pro model is asserted in `cost.rs` against the + /// documented pricing instead. + mod legacy_parity { + use super::*; + use crate::requests_old::http::video::image::http_kling_v2p5_turbo_pro_image_to_video::{ + kling_v2p5_turbo_pro_image_to_video, KlingV2p5TurboProImageToVideoInput, + }; + + /// Same `fal-ai/kling-video/v2.5-turbo/pro/image-to-video` path. + #[test] + fn endpoint_path_matches_legacy() { + let legacy = kling_v2p5_turbo_pro_image_to_video(KlingV2p5TurboProImageToVideoInput { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + ..Default::default() + }); + assert_eq!( + Kling2p5TurboProImageToVideoRequest::ENDPOINT, + legacy.endpoint, + ); + } + + /// At a representative fully-populated case, the new module's serialized + /// `RawRequest` must equal the legacy `Input`'s JSON. + #[test] + fn wire_json_matches_legacy_fully_populated() { + let new = Kling2p5TurboProImageToVideoRequest { + prompt: "shared prompt".to_string(), + image_url: "https://example.com/start.png".to_string(), + tail_image_url: Some("https://example.com/end.png".to_string()), + duration: Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), + negative_prompt: Some("blurry".to_string()), + cfg_scale: Some(0.5), + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProImageToVideoInput { + prompt: "shared prompt".to_string(), + image_url: "https://example.com/start.png".to_string(), + duration: Some("10".to_string()), + tail_image_url: Some("https://example.com/end.png".to_string()), + negative_prompt: Some("blurry".to_string()), + cfg_scale: Some(0.5), + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + } + + /// Minimal case (prompt + image_url only): unset optionals must be + /// omitted on the wire just like the legacy struct. + #[test] + fn wire_json_matches_legacy_minimal() { + let new = Kling2p5TurboProImageToVideoRequest { + prompt: "minimal".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: None, + duration: None, + negative_prompt: None, + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProImageToVideoInput { + prompt: "minimal".to_string(), + image_url: "https://example.com/x.png".to_string(), + ..Default::default() + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + assert_eq!( + new_json, + serde_json::json!({ + "prompt": "minimal", + "image_url": "https://example.com/x.png", + }), + ); + } + + /// Cross product over duration × tail_image_url presence: every cell's + /// wire JSON must match the legacy `Input`. + #[test] + fn wire_json_matches_legacy_at_every_combo() { + let durations = [ + (None, None), + (Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), Some("5")), + (Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), Some("10")), + ]; + let tail_urls: [Option<&str>; 2] = [None, Some("https://example.com/tail.png")]; + + for (d_new, d_legacy) in durations { + for tail in tail_urls { + let new = Kling2p5TurboProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: tail.map(String::from), + duration: d_new, + negative_prompt: None, + cfg_scale: None, + }; + let legacy = KlingV2p5TurboProImageToVideoInput { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + duration: d_legacy.map(String::from), + tail_image_url: tail.map(String::from), + negative_prompt: None, + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + let legacy_json = serde_json::to_value(&legacy).unwrap(); + assert_eq!( + new_json, legacy_json, + "duration={d_new:?} tail_image_url={tail:?}", + ); + } + } + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/cost.rs new file mode 100644 index 0000000000..86d7554fa9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/cost.rs @@ -0,0 +1,215 @@ +use crate::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::{ + Kling2p5TurboProImageToVideoDuration, Kling2p5TurboProImageToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p5TurboProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.5 Turbo Pro image-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/image-to-video): + // "For 5s video your request will cost $0.35. + // For every additional second you will be charged $0.07." + // + // That works out to a flat $0.07/second rate, identical to the + // text-to-video pro variant. Rate is held in tenths-of-cents and + // rounded up to whole cents so the user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p5TurboProImageToVideoDuration::FiveSeconds) + .to_seconds(); + (70u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + ) -> Kling2p5TurboProImageToVideoRequest { + Kling2p5TurboProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: None, + duration, + negative_prompt: None, + cfg_scale: None, + } + } + + // Pricing: flat $0.07/sec = 70 tenths-of-cents/sec. + // Cents per duration = ceil(70 × secs / 10). + + #[test] + fn five_seconds() { + // (70 * 5 + 9) / 10 = 35 — matches fal's "$0.35 for 5s" base rate + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn ten_seconds() { + // (70 * 10 + 9) / 10 = 70 — matches "$0.35 + 5×$0.07 = $0.70" + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 35¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 35); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling2p5TurboProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + /// Exhaustive cost-table tests. No legacy *pro* webhook exists for + /// image-to-video — only the legacy `standard` webhook ships — so this is + /// parity against the *documented* fal pricing and against the + /// text-to-video pro rate, which fal sets at the same $0.07/sec. + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + + fn new_request( + duration: Option, + tail_image_url: Option<&str>, + ) -> Kling2p5TurboProImageToVideoRequest { + Kling2p5TurboProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: tail_image_url.map(String::from), + duration, + negative_prompt: None, + cfg_scale: None, + } + } + + // (duration, tail_image_url_present, expected_cents) + // + // Math: ceil(70 × secs / 10). 5s → 35¢; 10s → 70¢. + // duration=None defaults to 5s; tail_image_url is irrelevant to cost. + const COST_TABLE: &[( + Option, + bool, + u64, + )] = &[ + // duration=None → 5s default → 35¢ + (None, false, 35), + (None, true, 35), + // duration=5s → 35¢ + (Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), false, 35), + (Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), true, 35), + // duration=10s → 70¢ + (Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), false, 70), + (Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), true, 70), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, tail_present, expected) in COST_TABLE { + let tail = if tail_present { Some("https://example.com/tail.png") } else { None }; + let got = new_request(duration, tail).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} tail_present={tail_present}", + ); + } + } + + /// tail_image_url is not part of the billing formula — every choice + /// must produce the same value for fixed duration. + #[test] + fn cost_is_independent_of_tail_image_url() { + let durations = [ + None, + Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), + ]; + for duration in durations { + let without = new_request(duration, None).calculate_cost_in_cents(); + let with = new_request(duration, Some("https://example.com/tail.png")) + .calculate_cost_in_cents(); + assert_eq!(with, without, "duration={duration:?}"); + } + } + + /// cfg_scale is not part of the billing formula. + #[test] + fn cost_is_independent_of_cfg_scale() { + for cfg in [None, Some(0.0_f32), Some(0.5), Some(1.0)] { + let req = Kling2p5TurboProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: None, + duration: Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + cfg_scale: cfg, + }; + assert_eq!(req.calculate_cost_in_cents(), 35, "cfg_scale={cfg:?}"); + } + } + + /// Image-to-video and text-to-video pro share the same $0.07/sec rate + /// per fal's docs — costs must match exactly at every duration. If + /// either rate drifts in the future this test fires. + #[test] + fn matches_text_to_video_pro_cost_at_each_duration() { + use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration, + Kling2p5TurboProTextToVideoRequest, + }; + fn t2v(duration: Kling2p5TurboProTextToVideoDuration) -> u64 { + Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration: Some(duration), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }.calculate_cost_in_cents() + } + let pairs = [ + (Kling2p5TurboProImageToVideoDuration::FiveSeconds, Kling2p5TurboProTextToVideoDuration::FiveSeconds), + (Kling2p5TurboProImageToVideoDuration::TenSeconds, Kling2p5TurboProTextToVideoDuration::TenSeconds), + ]; + for (i2v_d, t2v_d) in pairs { + let image_cost = new_request(Some(i2v_d), None).calculate_cost_in_cents(); + let text_cost = t2v(t2v_d); + assert_eq!( + image_cost, text_cost, + "{i2v_d:?} vs {t2v_d:?}: image={image_cost}¢ text={text_cost}¢ — rates should match at $0.07/sec", + ); + } + } + + /// Sanity-check fal's documented examples: + /// "For 5s video your request will cost $0.35" + /// "For every additional second you will be charged $0.07" + /// → 10s costs $0.35 + 5 × $0.07 = $0.70 + #[test] + fn matches_documented_examples() { + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + ); + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + ); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/raw_request.rs new file mode 100644 index 0000000000..40486c3ec2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/raw_request.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.5-turbo/pro/image-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p5TurboProImageToVideoInput { + pub prompt: String, + + /// URL of the image to be used for the video. + pub image_url: String, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" (fal default: "5") + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// URL of the image to be used for the end of the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub tail_image_url: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProImageToVideoOutput { + pub video: Kling2p5TurboProImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs index 18ea8c3c58..f47692e1be 100644 --- a/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs @@ -1,3 +1,4 @@ pub mod kling_1p6_pro_image_to_video; +pub mod kling_2p5_turbo_pro_image_to_video; pub mod kling_2p6_pro_image_to_video; pub mod kling_3p0_standard_image_to_video; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/api.rs new file mode 100644 index 0000000000..8a0e41dea3 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/api.rs @@ -0,0 +1,335 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::raw_request::{ + Kling2p5TurboProTextToVideoInput, Kling2p5TurboProTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p5TurboProTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 2.5 Turbo Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. fal's default is `16:9` when this is `None`. + pub aspect_ratio: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p5TurboProTextToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p5TurboProTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p5TurboProTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling2p5TurboProTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.5-turbo/pro/text-to-video"; + + type RawRequest = Kling2p5TurboProTextToVideoInput; + type RawResponse = Kling2p5TurboProTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling2p5TurboProTextToVideoAspectRatio::Square => "1:1", + Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine => "16:9", + Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio, + negative_prompt: self.negative_prompt.clone(), + duration, + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "an owl flies past an abandoned castle at dusk, fireflies dance in the trees".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "a candle flickers in a dark stone hall".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling2p5TurboProTextToVideoAspectRatio::Square, + Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine, + Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p5TurboProTextToVideoDuration::FiveSeconds, + Kling2p5TurboProTextToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "a candle flame flickers in a dark room".to_string(), + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("10")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert!(raw.aspect_ratio.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.cfg_scale.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p5TurboProTextToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.5-turbo/pro/text-to-video", + ); + } + + /// Wire-shape, endpoint, and cost parity vs the legacy modules: + /// `requests_old/http/video/text/http_kling_v2p5_turbo_pro_text_to_video.rs` + /// `requests_old/webhook/video/text/enqueue_kling_v2p5_turbo_pro_text_to_video_webhook.rs` + mod legacy_parity { + use super::*; + use crate::requests_old::http::video::text::http_kling_v2p5_turbo_pro_text_to_video::{ + kling_v2p5_turbo_pro_text_to_video, KlingV2p5TurboProTextToVideoInput, + }; + + /// Same `fal-ai/kling-video/v2.5-turbo/pro/text-to-video` path. + #[test] + fn endpoint_path_matches_legacy() { + let legacy = kling_v2p5_turbo_pro_text_to_video(KlingV2p5TurboProTextToVideoInput::default()); + assert_eq!( + Kling2p5TurboProTextToVideoRequest::ENDPOINT, + legacy.endpoint, + ); + } + + /// At a representative fully-populated case, the new module's serialized + /// `RawRequest` must equal the legacy `Input`'s JSON. + #[test] + fn wire_json_matches_legacy_fully_populated() { + let new = Kling2p5TurboProTextToVideoRequest { + prompt: "a wave at dawn".to_string(), + negative_prompt: Some("blurry".to_string()), + duration: Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: Some(0.5), + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProTextToVideoInput { + prompt: "a wave at dawn".to_string(), + aspect_ratio: Some("9:16".to_string()), + negative_prompt: Some("blurry".to_string()), + duration: Some("10".to_string()), + cfg_scale: Some(0.5), + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + } + + /// Minimal case (prompt only): unset optionals must be omitted on the + /// wire just like the legacy struct. + #[test] + fn wire_json_matches_legacy_minimal() { + let new = Kling2p5TurboProTextToVideoRequest { + prompt: "minimal".to_string(), + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProTextToVideoInput { + prompt: "minimal".to_string(), + ..Default::default() + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + assert_eq!(new_json, serde_json::json!({ "prompt": "minimal" })); + } + + /// Cross product over duration × aspect_ratio: every cell's wire JSON + /// must match the equivalent legacy `Input`. + #[test] + fn wire_json_matches_legacy_at_every_combo() { + let durations = [ + (None, None), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some("5")), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some("10")), + ]; + let aspect_ratios = [ + (None, None), + (Some(Kling2p5TurboProTextToVideoAspectRatio::Square), Some("1:1")), + (Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), Some("16:9")), + (Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), Some("9:16")), + ]; + + for (d_new, d_legacy) in durations { + for (ar_new, ar_legacy) in aspect_ratios { + let new = Kling2p5TurboProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: d_new, + aspect_ratio: ar_new, + cfg_scale: None, + }; + let legacy = KlingV2p5TurboProTextToVideoInput { + prompt: "p".to_string(), + aspect_ratio: ar_legacy.map(String::from), + negative_prompt: None, + duration: d_legacy.map(String::from), + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + let legacy_json = serde_json::to_value(&legacy).unwrap(); + assert_eq!( + new_json, legacy_json, + "duration={d_new:?} aspect_ratio={ar_new:?}", + ); + } + } + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/cost.rs new file mode 100644 index 0000000000..0a95ef30df --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/cost.rs @@ -0,0 +1,261 @@ +use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoDuration, Kling2p5TurboProTextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p5TurboProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.5 Turbo Pro text-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/text-to-video): + // "For 5s video your request will cost $0.35. + // For every additional second you will be charged $0.07." + // + // That works out to a flat $0.07/second rate. Rate is held in + // tenths-of-cents and rounded up to whole cents so the user is never + // undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p5TurboProTextToVideoDuration::FiveSeconds) + .to_seconds(); + (70u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration, + }; + + fn make_request( + duration: Option, + ) -> Kling2p5TurboProTextToVideoRequest { + Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration, + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + } + } + + // Pricing: flat $0.07/sec = 70 tenths-of-cents/sec. + // Cents per duration = ceil(70 × secs / 10). + + #[test] + fn five_seconds() { + // (70 * 5 + 9) / 10 = 35 — matches fal's "$0.35 for 5s" base rate + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn ten_seconds() { + // (70 * 10 + 9) / 10 = 70 — matches "$0.35 + 5×$0.07 = $0.70" + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 35¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 35); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling2p5TurboProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + /// Exhaustive cost-table tests and cross-checks against the legacy + /// `enqueue_kling_v2p5_turbo_pro_text_to_video_webhook` calculator. + mod legacy_parity { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use crate::requests_old::webhook::video::text::enqueue_kling_v2p5_turbo_pro_text_to_video_webhook::{ + EnqueueKlingV2p5TurboProTextToVideoAspectRatio as LegacyAspectRatio, + EnqueueKlingV2p5TurboProTextToVideoDurationSeconds as LegacyDuration, + EnqueueKlingV2p5TurboProTextToVideoRequest as LegacyRequest, + }; + + // ── Mapping helpers from the new strongly-typed API to the legacy one ── + + fn legacy_duration_for(d: Option) -> Option { + match d { + None => None, + Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds) => Some(LegacyDuration::Five), + Some(Kling2p5TurboProTextToVideoDuration::TenSeconds) => Some(LegacyDuration::Ten), + } + } + + fn legacy_aspect_ratio_for( + ar: Option, + ) -> Option { + match ar { + None => None, + Some(Kling2p5TurboProTextToVideoAspectRatio::Square) => Some(LegacyAspectRatio::Square), + Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine) => Some(LegacyAspectRatio::SixteenByNine), + Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen) => Some(LegacyAspectRatio::NineBySixteen), + } + } + + fn new_request( + duration: Option, + aspect_ratio: Option, + ) -> Kling2p5TurboProTextToVideoRequest { + Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration, + aspect_ratio, + cfg_scale: None, + } + } + + fn legacy_request( + duration: Option, + aspect_ratio: Option, + ) -> LegacyRequest { + LegacyRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration: legacy_duration_for(duration), + aspect_ratio: legacy_aspect_ratio_for(aspect_ratio), + } + } + + // (new_duration, new_aspect_ratio, expected_cents) + // + // Math: ceil(70 × secs / 10). 5s → 35¢; 10s → 70¢. + // duration=None defaults to 5s in both new and legacy modules. + // Aspect ratio is irrelevant to cost; iterated only to assert it. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default → 35¢ + (None, None, 35), + (None, Some(Kling2p5TurboProTextToVideoAspectRatio::Square), 35), + (None, Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), 35), + (None, Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), 35), + // duration=5s → 35¢ + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), None, 35), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::Square), 35), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), 35), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), 35), + // duration=10s → 70¢ + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), None, 70), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::Square), 70), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), 70), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), 70), + ]; + + #[test] + fn new_module_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "new module: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + #[test] + fn legacy_function_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = legacy_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "legacy function: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + /// Headline parity check: new vs legacy give identical cents at every + /// permutation. If this fails, pricing has drifted. + #[test] + fn new_module_matches_legacy_at_every_combo() { + for &(duration, aspect_ratio, _) in COST_TABLE { + let new_cost = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + let legacy_cost = legacy_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + new_cost, legacy_cost, + "parity mismatch at duration={duration:?} aspect_ratio={aspect_ratio:?}: \ + new={new_cost}¢ vs legacy={legacy_cost}¢", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every aspect-ratio + /// choice (including `None`) must produce the same value for fixed + /// duration. + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling2p5TurboProTextToVideoAspectRatio::Square), + Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), + ]; + for duration in durations { + let baseline = new_request(duration, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, ar).calculate_cost_in_cents(); + assert_eq!(cost, baseline, "duration={duration:?} ar={ar:?}"); + } + } + } + + /// cfg_scale is not part of the billing formula. + #[test] + fn cost_is_independent_of_cfg_scale() { + for cfg in [None, Some(0.0_f32), Some(0.5), Some(1.0)] { + let req = Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: cfg, + }; + assert_eq!(req.calculate_cost_in_cents(), 35, "cfg_scale={cfg:?}"); + } + } + + /// Sanity-check fal's documented examples: + /// "For 5s video your request will cost $0.35" + /// "For every additional second you will be charged $0.07" + /// → 10s costs $0.35 + 5 × $0.07 = $0.70 + #[test] + fn matches_documented_examples() { + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + "5s should be $0.35 per fal's docs", + ); + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + "10s should be $0.70 per fal's docs", + ); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/raw_request.rs new file mode 100644 index 0000000000..c06edfdeb6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/raw_request.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.5-turbo/pro/text-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p5TurboProTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" (fal default: "16:9") + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" (fal default: "5") + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProTextToVideoOutput { + pub video: Kling2p5TurboProTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs index fdcd129412..76c76f832b 100644 --- a/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs @@ -1,3 +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;