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