From 8a2c4dc0e8bdf4c914968f801a4121ea6b50eb59 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 11 May 2026 18:29:25 -0400 Subject: [PATCH] kling 3.0 i2v --- .../kling_3p0_standard_image_to_video/api.rs | 294 ++++++++++++++++++ .../kling_3p0_standard_image_to_video/cost.rs | 110 +++++++ .../kling_3p0_standard_image_to_video/mod.rs | 3 + .../raw_request.rs | 51 +++ .../src/requests/api/video/image/mod.rs | 1 + 5 files changed, 459 insertions(+) create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/api.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/cost.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/raw_request.rs diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/api.rs new file mode 100644 index 0000000000..967a78d7b1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/api.rs @@ -0,0 +1,294 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::kling_3p0_standard_image_to_video::raw_request::{ + Kling3p0StandardImageToVideoInput, Kling3p0StandardImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling3p0StandardImageToVideoRequest { + /// 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, + + /// Whether to generate audio. Defaults to true if not specified. + pub generate_audio: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Video duration. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, + + /// Shot type for multi-shot generation. + pub shot_type: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling3p0StandardImageToVideoDuration { + ThreeSeconds, + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, + ThirteenSeconds, + FourteenSeconds, + FifteenSeconds, +} + +impl Kling3p0StandardImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::ThreeSeconds => 3, + Self::FourSeconds => 4, + Self::FiveSeconds => 5, + Self::SixSeconds => 6, + Self::SevenSeconds => 7, + Self::EightSeconds => 8, + Self::NineSeconds => 9, + Self::TenSeconds => 10, + Self::ElevenSeconds => 11, + Self::TwelveSeconds => 12, + Self::ThirteenSeconds => 13, + Self::FourteenSeconds => 14, + Self::FifteenSeconds => 15, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::ThreeSeconds => "3", + Self::FourSeconds => "4", + Self::FiveSeconds => "5", + Self::SixSeconds => "6", + Self::SevenSeconds => "7", + Self::EightSeconds => "8", + Self::NineSeconds => "9", + Self::TenSeconds => "10", + Self::ElevenSeconds => "11", + Self::TwelveSeconds => "12", + Self::ThirteenSeconds => "13", + Self::FourteenSeconds => "14", + Self::FifteenSeconds => "15", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling3p0StandardImageToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling3p0StandardImageToVideoShotType { + Customize, + Intelligent, +} + +impl FalEndpoint for Kling3p0StandardImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v3/standard/image-to-video"; + + type RawRequest = Kling3p0StandardImageToVideoInput; + type RawResponse = Kling3p0StandardImageToVideoOutput; + + 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 { + Kling3p0StandardImageToVideoAspectRatio::Square => "1:1", + Kling3p0StandardImageToVideoAspectRatio::SixteenByNine => "16:9", + Kling3p0StandardImageToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + let shot_type = self.shot_type.map(|st| match st { + Kling3p0StandardImageToVideoShotType::Customize => "customize", + Kling3p0StandardImageToVideoShotType::Intelligent => "intelligent", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + end_image_url: self.end_image_url.clone(), + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + duration, + aspect_ratio, + shot_type, + cfg_scale: None, + }) + } +} + +#[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::TREX_SKELETON_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 = Kling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking toward the camera".to_string(), + duration: Some(Kling3p0StandardImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(true), + negative_prompt: None, + end_image_url: None, + shot_type: 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 = Kling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton comes alive and roars at the camera".to_string(), + duration: Some(Kling3p0StandardImageToVideoDuration::ThreeSeconds), + aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(false), + negative_prompt: None, + end_image_url: None, + shot_type: 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 = [ + Kling3p0StandardImageToVideoAspectRatio::Square, + Kling3p0StandardImageToVideoAspectRatio::SixteenByNine, + Kling3p0StandardImageToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton comes alive and roars at the camera".to_string(), + duration: Some(Kling3p0StandardImageToVideoDuration::ThreeSeconds), + aspect_ratio: Some(ar), + generate_audio: Some(false), + negative_prompt: None, + end_image_url: None, + shot_type: 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 = [ + Kling3p0StandardImageToVideoDuration::ThreeSeconds, + Kling3p0StandardImageToVideoDuration::FourSeconds, + Kling3p0StandardImageToVideoDuration::FiveSeconds, + Kling3p0StandardImageToVideoDuration::SixSeconds, + Kling3p0StandardImageToVideoDuration::SevenSeconds, + Kling3p0StandardImageToVideoDuration::EightSeconds, + Kling3p0StandardImageToVideoDuration::NineSeconds, + Kling3p0StandardImageToVideoDuration::TenSeconds, + Kling3p0StandardImageToVideoDuration::ElevenSeconds, + Kling3p0StandardImageToVideoDuration::TwelveSeconds, + Kling3p0StandardImageToVideoDuration::ThirteenSeconds, + Kling3p0StandardImageToVideoDuration::FourteenSeconds, + Kling3p0StandardImageToVideoDuration::FifteenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton slowly turns its head and opens its jaw".to_string(), + duration: Some(dur), + aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(false), + negative_prompt: None, + end_image_url: None, + shot_type: 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_shot_types() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let shot_types = [ + Kling3p0StandardImageToVideoShotType::Customize, + Kling3p0StandardImageToVideoShotType::Intelligent, + ]; + + for st in shot_types { + println!("--- shot type: {:?} ---", st); + let request = Kling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton lurches forward dramatically".to_string(), + duration: Some(Kling3p0StandardImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(true), + negative_prompt: None, + end_image_url: None, + shot_type: Some(st), + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/cost.rs new file mode 100644 index 0000000000..9adb31b871 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/cost.rs @@ -0,0 +1,110 @@ +use crate::requests::api::video::image::kling_3p0_standard_image_to_video::api::{ + Kling3p0StandardImageToVideoDuration, Kling3p0StandardImageToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling3p0StandardImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Same pricing as text-to-video for Kling 3.0 Standard: + // Audio off: $0.168/second + // Audio on: $0.252/second + let generate_audio = self.generate_audio.unwrap_or(true); + let duration_secs = self.duration + .unwrap_or(Kling3p0StandardImageToVideoDuration::FiveSeconds) + .to_seconds(); + + // Rate in tenths-of-cents per second, rounded up + let rate = if generate_audio { 252u64 } else { 168u64 }; + (rate * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::image::kling_3p0_standard_image_to_video::api::{ + Kling3p0StandardImageToVideoAspectRatio, Kling3p0StandardImageToVideoDuration, + }; + + fn make_request( + generate_audio: Option, + duration: Option, + ) -> Kling3p0StandardImageToVideoRequest { + Kling3p0StandardImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/image.jpg".to_string(), + end_image_url: None, + generate_audio, + negative_prompt: None, + duration, + aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine), + shot_type: None, + } + } + + mod audio_off { + use super::*; + + // Audio off: $0.168/sec → rate = 168 tenths-of-cents/sec + + #[test] + fn five_seconds() { + // (168 * 5 + 9) / 10 = 84 + assert_eq!(make_request(Some(false), Some(Kling3p0StandardImageToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 84); + } + + #[test] + fn ten_seconds() { + // (168 * 10 + 9) / 10 = 168 + assert_eq!(make_request(Some(false), Some(Kling3p0StandardImageToVideoDuration::TenSeconds)).calculate_cost_in_cents(), 168); + } + + #[test] + fn fifteen_seconds() { + // (168 * 15 + 9) / 10 = 252 + assert_eq!(make_request(Some(false), Some(Kling3p0StandardImageToVideoDuration::FifteenSeconds)).calculate_cost_in_cents(), 252); + } + } + + mod audio_on { + use super::*; + + // Audio on: $0.252/sec → rate = 252 tenths-of-cents/sec + + #[test] + fn five_seconds() { + // (252 * 5 + 9) / 10 = 126 + assert_eq!(make_request(Some(true), Some(Kling3p0StandardImageToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 126); + } + + #[test] + fn ten_seconds() { + // (252 * 10 + 9) / 10 = 252 + assert_eq!(make_request(Some(true), Some(Kling3p0StandardImageToVideoDuration::TenSeconds)).calculate_cost_in_cents(), 252); + } + } + + mod defaults { + use super::*; + + #[test] + fn default_audio_is_on() { + // generate_audio=None defaults to true (audio on) + // duration=5s: (252 * 5 + 9) / 10 = 126 + assert_eq!(make_request(None, Some(Kling3p0StandardImageToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 126); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None defaults to 5s + // audio off: (168 * 5 + 9) / 10 = 84 + assert_eq!(make_request(Some(false), None).calculate_cost_in_cents(), 84); + } + + #[test] + fn both_defaults() { + // audio on + 5s: (252 * 5 + 9) / 10 = 126 + assert_eq!(make_request(None, None).calculate_cost_in_cents(), 126); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_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_3p0_standard_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_3p0_standard_image_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/raw_request.rs new file mode 100644 index 0000000000..cab12ca32f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/raw_request.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling3p0StandardImageToVideoInput { + pub prompt: String, + + /// Starting frame image URL. + pub image_url: String, + + /// Optional end frame image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate audio alongside the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds (as a string). + /// Options: "3" through "15" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Shot type for multi-shot video generation. + /// Possible values: "customize", "intelligent" + #[serde(skip_serializing_if = "Option::is_none")] + pub shot_type: 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 Kling3p0StandardImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0StandardImageToVideoOutput { + pub video: Kling3p0StandardImageToVideoVideoFile, +} 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 e69de29bb2..b75ef22ab3 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 @@ -0,0 +1 @@ +pub mod kling_3p0_standard_image_to_video;