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