From ef41b77ce8e404daff9aefbaaf9378e482ead51b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 1 Jun 2026 18:02:34 -0400 Subject: [PATCH] kling 2.6 pro --- .../image/kling_2p6_pro_image_to_video/api.rs | 214 ++++++++++++ .../kling_2p6_pro_image_to_video/cost.rs | 314 ++++++++++++++++++ .../image/kling_2p6_pro_image_to_video/mod.rs | 3 + .../raw_request.rs | 45 +++ .../src/requests/api/video/image/mod.rs | 1 + .../text/kling_2p6_pro_text_to_video/api.rs | 237 +++++++++++++ .../text/kling_2p6_pro_text_to_video/cost.rs | 247 ++++++++++++++ .../text/kling_2p6_pro_text_to_video/mod.rs | 3 + .../raw_request.rs | 42 +++ .../src/requests/api/video/text/mod.rs | 1 + 10 files changed, 1107 insertions(+) create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/raw_request.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/raw_request.rs diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/api.rs new file mode 100644 index 0000000000..f768e78a26 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/api.rs @@ -0,0 +1,214 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::kling_2p6_pro_image_to_video::raw_request::{ + Kling2p6ProImageToVideoInput, Kling2p6ProImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p6ProImageToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// URL of the image used as the first frame. + pub start_image_url: String, + + /// URL of the image used as the last frame (optional). + pub end_image_url: Option, + + /// Video duration. Kling 2.6 Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Voice IDs to use for the generated audio. Maximum of 2 voices per task; + /// reference them in the prompt as `<<>>` and `<<>>`. + /// + /// Note: supplying voice IDs along with `generate_audio = Some(true)` + /// activates fal's "audio + voice control" pricing tier ($0.168/sec). + pub voice_ids: Option>, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p6ProImageToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p6ProImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +impl FalEndpoint for Kling2p6ProImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.6/pro/image-to-video"; + + type RawRequest = Kling2p6ProImageToVideoInput; + type RawResponse = Kling2p6ProImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + start_image_url: self.start_image_url.clone(), + end_image_url: self.end_image_url.clone(), + duration, + negative_prompt: self.negative_prompt.clone(), + generate_audio: self.generate_audio, + voice_ids: self.voice_ids.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + start_image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + generate_audio: Some(false), + voice_ids: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + start_image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + generate_audio: Some(false), + voice_ids: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p6ProImageToVideoDuration::FiveSeconds, + Kling2p6ProImageToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p6ProImageToVideoRequest { + prompt: "the dog wags its tail".to_string(), + start_image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(dur), + negative_prompt: None, + generate_audio: Some(false), + voice_ids: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_uses_canonical_field_names() { + let request = Kling2p6ProImageToVideoRequest { + prompt: "p".to_string(), + start_image_url: "https://example.com/start.png".to_string(), + end_image_url: Some("https://example.com/end.png".to_string()), + duration: Some(Kling2p6ProImageToVideoDuration::TenSeconds), + negative_prompt: Some("nope".to_string()), + generate_audio: Some(true), + voice_ids: Some(vec!["voice_a".to_string(), "voice_b".to_string()]), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.start_image_url, "https://example.com/start.png"); + assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/end.png")); + assert_eq!(raw.duration.as_deref(), Some("10")); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.voice_ids.as_deref(), Some(&["voice_a".to_string(), "voice_b".to_string()][..])); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p6ProImageToVideoRequest { + prompt: "p".to_string(), + start_image_url: "https://example.com/start.png".to_string(), + end_image_url: None, + duration: None, + negative_prompt: None, + generate_audio: None, + voice_ids: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.start_image_url, "https://example.com/start.png"); + assert!(raw.end_image_url.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.generate_audio.is_none()); + assert!(raw.voice_ids.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p6ProImageToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.6/pro/image-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/cost.rs new file mode 100644 index 0000000000..3822077e08 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/cost.rs @@ -0,0 +1,314 @@ +use crate::requests::api::video::image::kling_2p6_pro_image_to_video::api::{ + Kling2p6ProImageToVideoDuration, Kling2p6ProImageToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p6ProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.6 Pro image-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.6/pro/image-to-video): + // $0.07/sec — audio off + // $0.14/sec — audio on, no voice control + // $0.168/sec — audio on with voice IDs (voice control) + // + // `generate_audio` defaults to `true` on fal's server, so `None` bills as + // audio-on. Voice control is active when audio is on and `voice_ids` + // contains at least one entry. + // + // Rate is held in tenths-of-cents and rounded up to whole cents so the + // user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p6ProImageToVideoDuration::FiveSeconds) + .to_seconds(); + + let audio_on = self.generate_audio.unwrap_or(true); + let has_voice = self.voice_ids.as_ref().is_some_and(|v| !v.is_empty()); + + let rate_tenth_cents_per_sec: u64 = match (audio_on, has_voice) { + (false, _) => 70, // $0.07/sec — audio off + (true, false) => 140, // $0.14/sec — audio on, no voice control + (true, true) => 168, // $0.168/sec — audio + voice control + }; + + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + generate_audio: Option, + voice_ids: Option>, + ) -> Kling2p6ProImageToVideoRequest { + Kling2p6ProImageToVideoRequest { + prompt: "test".to_string(), + start_image_url: "https://example.com/start.png".to_string(), + end_image_url: None, + duration, + negative_prompt: None, + generate_audio, + voice_ids, + } + } + + // Pricing tiers in tenths-of-cents per second: + // audio off → 70 + // audio on (no voice) → 140 + // audio on + voice control → 168 + // Cents per duration = ceil(rate × secs / 10). + + #[test] + fn five_seconds_audio_off() { + // (70 * 5 + 9) / 10 = 35 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(false), None) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn five_seconds_audio_on_no_voice() { + // (140 * 5 + 9) / 10 = 70 — matches fal's "$0.70 for 5s with audio" example + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), None) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn five_seconds_audio_on_with_voice() { + // (168 * 5 + 9) / 10 = 84 + assert_eq!( + make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ) + .calculate_cost_in_cents(), + 84, + ); + } + + #[test] + fn ten_seconds_audio_off() { + // (70 * 10 + 9) / 10 = 70 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(false), None) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn ten_seconds_audio_on_no_voice() { + // (140 * 10 + 9) / 10 = 140 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(true), None) + .calculate_cost_in_cents(), + 140, + ); + } + + #[test] + fn ten_seconds_audio_on_with_voice() { + // (168 * 10 + 9) / 10 = 168 — matches fal's "$1.68 for 10s with audio + voice" example + assert_eq!( + make_request( + Some(Kling2p6ProImageToVideoDuration::TenSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ) + .calculate_cost_in_cents(), + 168, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None, audio=on (default), no voice → 5s @ $0.14 = 70¢ + assert_eq!(make_request(None, None, None).calculate_cost_in_cents(), 70); + } + + #[test] + fn default_audio_is_on() { + let none_default = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + None, + None, + ).calculate_cost_in_cents(); + let explicit_on = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + None, + ).calculate_cost_in_cents(); + assert_eq!(none_default, explicit_on); + } + + #[test] + fn empty_voice_ids_does_not_trigger_voice_tier() { + // `Some(vec![])` is semantically "no voices supplied" — it should bill at + // the $0.14/sec audio-on rate, not the $0.168/sec voice-control rate. + let with_empty = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec![]), + ).calculate_cost_in_cents(); + let with_none = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + None, + ).calculate_cost_in_cents(); + assert_eq!(with_empty, with_none, "empty voice_ids should match None"); + assert_eq!(with_empty, 70); + } + + #[test] + fn voice_ids_with_audio_off_does_not_change_cost() { + // Audio is off → voice control is meaningless. Pricing must stay at the + // $0.07/sec floor. (fal silently ignores voice_ids if audio is off.) + let with_voice = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(false), + Some(vec!["voice_a".to_string()]), + ).calculate_cost_in_cents(); + let without_voice = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(false), + None, + ).calculate_cost_in_cents(); + assert_eq!(with_voice, without_voice); + assert_eq!(with_voice, 35); + } + + #[test] + fn pricing_tiers_are_strictly_ascending() { + // At each duration: audio_off < audio_on < audio_on+voice + for d in [ + Kling2p6ProImageToVideoDuration::FiveSeconds, + Kling2p6ProImageToVideoDuration::TenSeconds, + ] { + let off = make_request(Some(d), Some(false), None).calculate_cost_in_cents(); + let on = make_request(Some(d), Some(true), None).calculate_cost_in_cents(); + let vc = make_request(Some(d), Some(true), Some(vec!["v".to_string()])) + .calculate_cost_in_cents(); + assert!(off < on, "duration={d:?}: audio_off={off}¢ < audio_on={on}¢"); + assert!(on < vc, "duration={d:?}: audio_on={on}¢ < voice_ctrl={vc}¢"); + } + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration (duration × generate_audio × voice_ids presence). + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + + // (duration, generate_audio, voice_ids_present, expected_cents) + // + // Tenths-of-cents/sec by tier: off=70, on=140, voice=168. + // Cents per duration = ceil(rate × secs / 10). + // duration=None defaults to 5s; generate_audio=None defaults to true. + const COST_TABLE: &[( + Option, + Option, + bool, // voice_ids non-empty + u64, + )] = &[ + // duration=None → 5s default + (None, Some(false), false, 35), + (None, Some(false), true, 35), // voice ignored when audio off + (None, Some(true), false, 70), + (None, Some(true), true, 84), + (None, None, false, 70), // audio default = on + (None, None, true, 84), + // duration=5s + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(false), false, 35), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(false), true, 35), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), false, 70), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), true, 84), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), None, false, 70), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), None, true, 84), + // duration=10s + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(false), false, 70), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(false), true, 70), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(true), false, 140), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(true), true, 168), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), None, false, 140), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), None, true, 168), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, voice_present, expected) in COST_TABLE { + let voice_ids = if voice_present { Some(vec!["voice_a".to_string()]) } else { None }; + let got = make_request(duration, generate_audio, voice_ids).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} audio={generate_audio:?} voice={voice_present}", + ); + } + } + + /// Number of voice IDs (1 vs 2) doesn't affect cost — only the presence + /// of at least one. Tests 1 and 2 voices (fal's max). + #[test] + fn voice_count_does_not_affect_cost() { + let one = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ).calculate_cost_in_cents(); + let two = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec!["voice_a".to_string(), "voice_b".to_string()]), + ).calculate_cost_in_cents(); + assert_eq!(one, two); + assert_eq!(one, 84); + } + + /// end_image_url is not part of the billing formula. + #[test] + fn cost_is_independent_of_end_image_url() { + let with_end = Kling2p6ProImageToVideoRequest { + end_image_url: Some("https://example.com/end.png".to_string()), + ..make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), None) + }; + let without_end = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + None, + ); + assert_eq!( + with_end.calculate_cost_in_cents(), + without_end.calculate_cost_in_cents(), + ); + } + + /// Sanity-check fal's documented examples: + /// "a 5-second video with audio costs $0.70" + /// "a 10-second video with audio and voice control costs $1.68" + #[test] + fn matches_documented_examples() { + // 5s + audio = $0.70 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), None) + .calculate_cost_in_cents(), + 70, + ); + // 10s + audio + voice = $1.68 + assert_eq!( + make_request( + Some(Kling2p6ProImageToVideoDuration::TenSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ).calculate_cost_in_cents(), + 168, + ); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/raw_request.rs new file mode 100644 index 0000000000..125101c748 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/raw_request.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.6/pro/image-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p6ProImageToVideoInput { + pub prompt: String, + + /// URL of the image used as the first frame. + pub start_image_url: String, + + /// URL of the image used as the last frame (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Whether to generate native audio for the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Voice IDs for video generation. Reference them in the prompt with + /// `<<>>` and `<<>>` — maximum of 2 voices per task. + /// Sourced from fal's `create-voice` endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub voice_ids: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProImageToVideoOutput { + pub video: Kling2p6ProImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs index 31d69f86a9..18ea8c3c58 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,2 +1,3 @@ pub mod kling_1p6_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_2p6_pro_text_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/api.rs new file mode 100644 index 0000000000..9944be59ee --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/api.rs @@ -0,0 +1,237 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::kling_2p6_pro_text_to_video::raw_request::{ + Kling2p6ProTextToVideoInput, Kling2p6ProTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p6ProTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 2.6 Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p6ProTextToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p6ProTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p6ProTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling2p6ProTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.6/pro/text-to-video"; + + type RawRequest = Kling2p6ProTextToVideoInput; + type RawResponse = Kling2p6ProTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling2p6ProTextToVideoAspectRatio::Square => "1:1", + Kling2p6ProTextToVideoAspectRatio::SixteenByNine => "16:9", + Kling2p6ProTextToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio, + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + duration, + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProTextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling2p6ProTextToVideoAspectRatio::Square, + Kling2p6ProTextToVideoAspectRatio::SixteenByNine, + Kling2p6ProTextToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling2p6ProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p6ProTextToVideoDuration::FiveSeconds, + Kling2p6ProTextToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p6ProTextToVideoRequest { + prompt: "a candle flame flickers in a dark room".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling2p6ProTextToVideoRequest { + prompt: "p".to_string(), + generate_audio: None, + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("10")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p6ProTextToVideoRequest { + prompt: "p".to_string(), + generate_audio: None, + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert!(raw.aspect_ratio.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.generate_audio.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.cfg_scale.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p6ProTextToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.6/pro/text-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/cost.rs new file mode 100644 index 0000000000..192e08a0ef --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/cost.rs @@ -0,0 +1,247 @@ +use crate::requests::api::video::text::kling_2p6_pro_text_to_video::api::{ + Kling2p6ProTextToVideoDuration, Kling2p6ProTextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p6ProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.6 Pro text-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.6/pro/text-to-video): + // $0.07/sec when audio is off + // $0.14/sec when audio is on + // + // generate_audio defaults to `true` on fal's server, so `None` is billed + // as audio-on. Rate stored in tenths-of-cents and rounded up to whole + // cents so the user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p6ProTextToVideoDuration::FiveSeconds) + .to_seconds(); + + let audio_on = self.generate_audio.unwrap_or(true); + let rate_tenth_cents_per_sec: u64 = if audio_on { 140 } else { 70 }; + + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::kling_2p6_pro_text_to_video::api::{ + Kling2p6ProTextToVideoAspectRatio, Kling2p6ProTextToVideoDuration, + }; + + fn make_request( + duration: Option, + generate_audio: Option, + ) -> Kling2p6ProTextToVideoRequest { + Kling2p6ProTextToVideoRequest { + prompt: "test".to_string(), + generate_audio, + negative_prompt: None, + duration, + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + } + } + + // Pricing: $0.07/sec audio off, $0.14/sec audio on. + // Cents per duration = ceil(rate × secs / 10) where rate is in tenths-of-cents. + + #[test] + fn five_seconds_audio_off() { + // (70 * 5 + 9) / 10 = 35 + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(false)) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn five_seconds_audio_on() { + // (140 * 5 + 9) / 10 = 70 — matches fal's $0.70 example + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn ten_seconds_audio_off() { + // (70 * 10 + 9) / 10 = 70 + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(false)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn ten_seconds_audio_on() { + // (140 * 10 + 9) / 10 = 140 + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(true)) + .calculate_cost_in_cents(), + 140, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None, audio=on (default) → 5s @ $0.14 = 70¢ + assert_eq!(make_request(None, None).calculate_cost_in_cents(), 70); + } + + #[test] + fn default_audio_is_on() { + // duration=5s, audio=None → audio_on=true → 70¢ + let none_default = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), None) + .calculate_cost_in_cents(); + let explicit_on = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true)) + .calculate_cost_in_cents(); + assert_eq!(none_default, explicit_on); + } + + #[test] + fn audio_on_costs_twice_as_much_as_off() { + // $0.14 / $0.07 = 2x at every duration + let durations = [ + Kling2p6ProTextToVideoDuration::FiveSeconds, + Kling2p6ProTextToVideoDuration::TenSeconds, + ]; + for d in durations { + let off = make_request(Some(d), Some(false)).calculate_cost_in_cents(); + let on = make_request(Some(d), Some(true)).calculate_cost_in_cents(); + assert_eq!(on, off * 2, "duration={d:?}: audio_on={on}¢ should be 2× audio_off={off}¢"); + } + } + + #[test] + fn ten_seconds_costs_more_than_five() { + // At a given audio setting, 10s > 5s. + for audio in [Some(false), Some(true), None] { + let five = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), audio) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling2p6ProTextToVideoDuration::TenSeconds), audio) + .calculate_cost_in_cents(); + assert!(five < ten, "audio={audio:?}: five={five}¢ < ten={ten}¢"); + } + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration (duration × generate_audio). Aspect ratio is in the cross + /// product as a sanity check — it must not affect the bill. + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + + fn new_request( + duration: Option, + generate_audio: Option, + aspect_ratio: Option, + ) -> Kling2p6ProTextToVideoRequest { + Kling2p6ProTextToVideoRequest { + prompt: "test".to_string(), + generate_audio, + negative_prompt: None, + duration, + aspect_ratio, + cfg_scale: None, + } + } + + // (duration, generate_audio, expected_cents) + // + // Math: ceil(rate × secs / 10) where rate is 70 (audio off) or 140 (audio on). + // duration=None defaults to 5s; generate_audio=None defaults to true. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default + (None, Some(false), 35), + (None, Some(true), 70), + (None, None, 70), // audio default = on + // duration=5s + (Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(false), 35), + (Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true), 70), + (Some(Kling2p6ProTextToVideoDuration::FiveSeconds), None, 70), + // duration=10s + (Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(false), 70), + (Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(true), 140), + (Some(Kling2p6ProTextToVideoDuration::TenSeconds), None, 140), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, expected) in COST_TABLE { + let got = new_request(duration, generate_audio, None).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} generate_audio={generate_audio:?}", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every aspect-ratio + /// choice (including `None`) must produce the same value for fixed + /// (duration, generate_audio). + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling2p6ProTextToVideoAspectRatio::Square), + Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + Some(Kling2p6ProTextToVideoDuration::TenSeconds), + ]; + let audio_options = [None, Some(false), Some(true)]; + + for duration in durations { + for audio in audio_options { + let baseline = new_request(duration, audio, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, audio, ar).calculate_cost_in_cents(); + assert_eq!( + cost, baseline, + "duration={duration:?} audio={audio:?} ar={ar:?}: cost={cost}¢ != baseline={baseline}¢", + ); + } + } + } + } + + /// cfg_scale is not part of the billing formula — every choice must produce + /// the same value for fixed (duration, generate_audio). + #[test] + fn cost_is_independent_of_cfg_scale() { + for cfg in [None, Some(0.0_f32), Some(0.5), Some(1.0)] { + let req = Kling2p6ProTextToVideoRequest { + prompt: "test".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: cfg, + }; + assert_eq!(req.calculate_cost_in_cents(), 70, "cfg_scale={cfg:?}"); + } + } + + /// Sanity-check the documented examples from fal: + /// "a 5s video with audio on will cost $0.70" + #[test] + fn matches_documented_examples() { + let cost = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true)) + .calculate_cost_in_cents(); + assert_eq!(cost, 70, "5s audio-on should be $0.70 per fal's docs"); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/raw_request.rs new file mode 100644 index 0000000000..91347fbb22 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/raw_request.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.6/pro/text-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p6ProTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate native audio for the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProTextToVideoOutput { + pub video: Kling2p6ProTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs index 361d27d235..fdcd129412 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,2 +1,3 @@ pub mod kling_1p6_pro_text_to_video; +pub mod kling_2p6_pro_text_to_video; pub mod kling_3p0_standard_text_to_video;