mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-28 20:42:50 -04:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4048b12684 | ||
|
|
92acdcf027 | ||
|
|
3df1ab7bb8 | ||
|
|
afd831ee2c | ||
|
|
ef41b77ce8 | ||
|
|
dcd44573e3 | ||
|
|
41e48a6ca1 | ||
|
|
7efc0855a4 |
+8
@@ -22,6 +22,14 @@ pub struct FalVideoResponsePayload {
|
||||
pub request_id: Option<String>,
|
||||
pub gateway_request_id: Option<String>,
|
||||
|
||||
/// Queue-mode status URL (fal's queue/polling flow). `None` for webhook
|
||||
/// dispatch — the webhook callback drives status updates instead.
|
||||
pub maybe_status_url: Option<String>,
|
||||
|
||||
/// Queue-mode response URL (fal's queue/polling flow). `None` for webhook
|
||||
/// dispatch.
|
||||
pub maybe_response_url: Option<String>,
|
||||
|
||||
/// The outbound request that was sent to Fal.
|
||||
/// Stored as a trait object so any Request type can be captured.
|
||||
/// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print.
|
||||
|
||||
+333
-53
@@ -1,40 +1,111 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{
|
||||
Kling1p6ProAspectRatio, Kling1p6ProDuration, Kling1p6ProRequest,
|
||||
use fal_client::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{
|
||||
Kling1p6ProElementsToVideoAspectRatio, Kling1p6ProElementsToVideoDuration,
|
||||
Kling1p6ProElementsToVideoRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::image::kling_1p6_pro_image_to_video::api::{
|
||||
Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration,
|
||||
Kling1p6ProImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::text::kling_1p6_pro_text_to_video::api::{
|
||||
Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration,
|
||||
Kling1p6ProTextToVideoRequest,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::FalKling16ProRequestState;
|
||||
use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::{
|
||||
FalKling16ProMode, FalKling16ProRequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanAspectRatio {
|
||||
Square,
|
||||
SixteenByNine,
|
||||
NineBySixteen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanDuration {
|
||||
Five,
|
||||
Ten,
|
||||
}
|
||||
|
||||
pub fn build_fal_kling_1_6_pro(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let image_url = require_url(builder.start_frame.clone(), "start_frame", "Kling 1.6 Pro requires a starting frame")?;
|
||||
let end_frame_image_url = optional_url(builder.end_frame.clone())?;
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
let prompt = builder.prompt.clone().unwrap_or_default();
|
||||
let negative_prompt = builder.negative_prompt.clone();
|
||||
|
||||
let request = Kling1p6ProRequest {
|
||||
image_url,
|
||||
end_frame_image_url,
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
duration,
|
||||
aspect_ratio,
|
||||
let reference_image_urls = resolve_reference_image_urls(builder.reference_images.clone())?;
|
||||
let start_image_url = optional_url(builder.start_frame.clone())?;
|
||||
let end_image_url = optional_url(builder.end_frame.clone())?;
|
||||
|
||||
// Dispatch: reference_images → elements; start_frame → image; else → text.
|
||||
// Elements-to-video has no concept of start/end frame, so passing both with
|
||||
// reference_images is rejected unless the strategy is lenient.
|
||||
let mode = if !reference_image_urls.is_empty() {
|
||||
if (start_image_url.is_some() || end_image_url.is_some())
|
||||
&& matches!(strategy, RequestMismatchMitigationStrategy::ErrorOut)
|
||||
{
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "Kling 1.6 Pro elements-to-video cannot also accept start_frame or end_frame".to_string(),
|
||||
}));
|
||||
}
|
||||
FalKling16ProMode::ElementsToVideo(Kling1p6ProElementsToVideoRequest {
|
||||
prompt,
|
||||
input_image_urls: reference_image_urls,
|
||||
negative_prompt,
|
||||
duration: duration.map(to_elements_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_elements_aspect_ratio),
|
||||
})
|
||||
} else if let Some(image_url) = start_image_url {
|
||||
FalKling16ProMode::ImageToVideo(Kling1p6ProImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
end_image_url,
|
||||
negative_prompt,
|
||||
duration: duration.map(to_i2v_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
|
||||
cfg_scale: None,
|
||||
})
|
||||
} else {
|
||||
if end_image_url.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Kling 1.6 Pro requires a start_frame when end_frame is provided".to_string(),
|
||||
}));
|
||||
}
|
||||
FalKling16ProMode::TextToVideo(Kling1p6ProTextToVideoRequest {
|
||||
prompt,
|
||||
negative_prompt,
|
||||
duration: duration.map(to_t2v_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
|
||||
cfg_scale: None,
|
||||
})
|
||||
};
|
||||
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(
|
||||
FalKling16ProRequestState { request },
|
||||
FalKling16ProRequestState { mode },
|
||||
)))
|
||||
}
|
||||
|
||||
// ── Shared URL helpers ──
|
||||
//
|
||||
// `optional_url` is also imported by other Kling router modules (e.g.
|
||||
// kling_3p0_pro) to avoid copy/paste. Preserve the signature.
|
||||
|
||||
pub(crate) fn require_url(
|
||||
image_ref: Option<ImageRef>,
|
||||
field: &'static str,
|
||||
@@ -62,21 +133,35 @@ pub(crate) fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_reference_image_urls(
|
||||
reference_images: Option<ImageListRef>,
|
||||
) -> Result<Vec<String>, ArtcraftRouterError> {
|
||||
match reference_images {
|
||||
None => Ok(vec![]),
|
||||
Some(ImageListRef::Urls(urls)) => Ok(urls),
|
||||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Kling1p6ProAspectRatio, ArtcraftRouterError> {
|
||||
use Kling1p6ProAspectRatio as Ar;
|
||||
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
|
||||
use PlanAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None => Ok(Ar::WideSixteenNine),
|
||||
None => Ok(None),
|
||||
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
|
||||
|
||||
Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::WideSixteenNine),
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::SixteenByNine)),
|
||||
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
@@ -85,7 +170,7 @@ fn plan_aspect_ratio(
|
||||
value: format!("{:?}", other),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Ar::WideSixteenNine),
|
||||
_ => Ok(Some(Ar::SixteenByNine)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -93,11 +178,11 @@ fn plan_aspect_ratio(
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Kling1p6ProDuration, ArtcraftRouterError> {
|
||||
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
|
||||
match duration_seconds {
|
||||
None => Ok(Kling1p6ProDuration::Default),
|
||||
Some(5) => Ok(Kling1p6ProDuration::FiveSeconds),
|
||||
Some(10) => Ok(Kling1p6ProDuration::TenSeconds),
|
||||
None => Ok(None),
|
||||
Some(5) => Ok(Some(PlanDuration::Five)),
|
||||
Some(10) => Ok(Some(PlanDuration::Ten)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -105,12 +190,57 @@ fn plan_duration(
|
||||
value: format!("{}", other),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Kling1p6ProDuration::TenSeconds),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Kling1p6ProDuration::FiveSeconds),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Ten)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Five)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling1p6ProTextToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Square => Kling1p6ProTextToVideoAspectRatio::Square,
|
||||
PlanAspectRatio::SixteenByNine => Kling1p6ProTextToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Kling1p6ProTextToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_duration(d: PlanDuration) -> Kling1p6ProTextToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Five => Kling1p6ProTextToVideoDuration::FiveSeconds,
|
||||
PlanDuration::Ten => Kling1p6ProTextToVideoDuration::TenSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Kling1p6ProImageToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Square => Kling1p6ProImageToVideoAspectRatio::Square,
|
||||
PlanAspectRatio::SixteenByNine => Kling1p6ProImageToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Kling1p6ProImageToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> Kling1p6ProImageToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Five => Kling1p6ProImageToVideoDuration::FiveSeconds,
|
||||
PlanDuration::Ten => Kling1p6ProImageToVideoDuration::TenSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_elements_aspect_ratio(a: PlanAspectRatio) -> Kling1p6ProElementsToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Square => Kling1p6ProElementsToVideoAspectRatio::Square,
|
||||
PlanAspectRatio::SixteenByNine => Kling1p6ProElementsToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Kling1p6ProElementsToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_elements_duration(d: PlanDuration) -> Kling1p6ProElementsToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Five => Kling1p6ProElementsToVideoDuration::FiveSeconds,
|
||||
PlanDuration::Ten => Kling1p6ProElementsToVideoDuration::TenSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
@@ -123,44 +253,194 @@ mod tests {
|
||||
model: RouterVideoModel::Kling16Pro,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_start_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = None;
|
||||
assert!(build_fal_kling_1_6_pro(b).is_err());
|
||||
// ── Mode dispatch ──
|
||||
|
||||
mod mode_dispatch {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_frames_picks_t2v() {
|
||||
let result = build_fal_kling_1_6_pro(base_builder()).expect("build");
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else {
|
||||
panic!("expected FalKling16Pro");
|
||||
};
|
||||
assert!(matches!(s.mode, FalKling16ProMode::TextToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_picks_i2v() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
let result = build_fal_kling_1_6_pro(b).expect("build");
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else {
|
||||
panic!("expected FalKling16Pro");
|
||||
};
|
||||
assert!(matches!(s.mode, FalKling16ProMode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_images_picks_elements() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()]));
|
||||
let result = build_fal_kling_1_6_pro(b).expect("build");
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else {
|
||||
panic!("expected FalKling16Pro");
|
||||
};
|
||||
assert!(matches!(s.mode, FalKling16ProMode::ElementsToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elements_with_start_frame_errors_in_strict_mode() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()]));
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_kling_1_6_pro(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elements_with_start_frame_drops_frame_in_lenient_mode() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()]));
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
let result = build_fal_kling_1_6_pro(b).expect("build");
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else {
|
||||
panic!("expected FalKling16Pro");
|
||||
};
|
||||
assert!(matches!(s.mode, FalKling16ProMode::ElementsToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
assert!(build_fal_kling_1_6_pro(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_is_optional() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
assert!(build_fal_kling_1_6_pro(b).is_ok());
|
||||
// ── Field passthrough ──
|
||||
|
||||
mod field_passthrough {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn t2v_prompt_and_aspect_propagate() {
|
||||
let mut b = base_builder();
|
||||
b.prompt = Some("custom prompt".to_string());
|
||||
b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen);
|
||||
b.duration_seconds = Some(10);
|
||||
let result = build_fal_kling_1_6_pro(b).expect("build");
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else {
|
||||
panic!("expected FalKling16Pro");
|
||||
};
|
||||
let FalKling16ProMode::TextToVideo(req) = s.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.prompt, "custom prompt");
|
||||
assert!(matches!(req.aspect_ratio, Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen)));
|
||||
assert!(matches!(req.duration, Some(Kling1p6ProTextToVideoDuration::TenSeconds)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_start_and_end_frames_propagate() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
let result = build_fal_kling_1_6_pro(b).expect("build");
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else {
|
||||
panic!("expected FalKling16Pro");
|
||||
};
|
||||
let FalKling16ProMode::ImageToVideo(req) = s.mode else {
|
||||
panic!("expected ImageToVideo");
|
||||
};
|
||||
assert_eq!(req.image_url, "https://example.com/start.png");
|
||||
assert_eq!(req.end_image_url.as_deref(), Some("https://example.com/end.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elements_input_image_urls_propagate() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![
|
||||
"https://example.com/a.png".to_string(),
|
||||
"https://example.com/b.png".to_string(),
|
||||
]));
|
||||
let result = build_fal_kling_1_6_pro(b).expect("build");
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else {
|
||||
panic!("expected FalKling16Pro");
|
||||
};
|
||||
let FalKling16ProMode::ElementsToVideo(req) = s.mode else {
|
||||
panic!("expected ElementsToVideo");
|
||||
};
|
||||
assert_eq!(req.input_image_urls.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_combinatorial_pass() {
|
||||
let aspect_ratios = [None, Some(RouterAspectRatio::Square), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen), Some(RouterAspectRatio::Auto)];
|
||||
let durations = [None, Some(5u16), Some(10)];
|
||||
let mut combos = 0;
|
||||
for &aspect_ratio in &aspect_ratios {
|
||||
for &duration in &durations {
|
||||
for include_end in [false, true] {
|
||||
let mut b = base_builder();
|
||||
b.aspect_ratio = aspect_ratio;
|
||||
b.duration_seconds = duration;
|
||||
if include_end {
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
// ── Validation ──
|
||||
|
||||
mod validation {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn media_file_token_rejected_for_start_frame() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(
|
||||
tokens::tokens::media_files::MediaFileToken::new_from_str("mf_xxx"),
|
||||
));
|
||||
assert!(matches!(
|
||||
build_fal_kling_1_6_pro(b),
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_out_of_range_errors_in_strict_mode() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(7);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_kling_1_6_pro(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_out_of_range_upgrades() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(7);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
|
||||
let result = build_fal_kling_1_6_pro(b).expect("build");
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else {
|
||||
panic!("expected FalKling16Pro");
|
||||
};
|
||||
let FalKling16ProMode::TextToVideo(req) = s.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert!(matches!(req.duration, Some(Kling1p6ProTextToVideoDuration::TenSeconds)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_combinatorial_pass() {
|
||||
let aspect_ratios = [None, Some(RouterAspectRatio::Square), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen), Some(RouterAspectRatio::Auto)];
|
||||
let durations = [None, Some(5u16), Some(10)];
|
||||
let mut combos = 0;
|
||||
for &aspect_ratio in &aspect_ratios {
|
||||
for &duration in &durations {
|
||||
for include_end in [false, true] {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.aspect_ratio = aspect_ratio;
|
||||
b.duration_seconds = duration;
|
||||
if include_end {
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
}
|
||||
assert!(build_fal_kling_1_6_pro(b).is_ok());
|
||||
combos += 1;
|
||||
}
|
||||
assert!(build_fal_kling_1_6_pro(b).is_ok());
|
||||
combos += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(combos, 5 * 3 * 2);
|
||||
}
|
||||
assert_eq!(combos, 5 * 3 * 2);
|
||||
}
|
||||
}
|
||||
|
||||
+91
-20
@@ -1,27 +1,32 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::Kling1p6ProDuration;
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::FalKling16ProRequestState;
|
||||
use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::{
|
||||
FalKling16ProMode, FalKling16ProRequestState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalKling16ProCostState {
|
||||
pub is_ten_seconds: bool,
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalKling16ProCostState {
|
||||
pub fn from_request(request: &FalKling16ProRequestState) -> Self {
|
||||
Self {
|
||||
is_ten_seconds: matches!(request.request.duration, Kling1p6ProDuration::TenSeconds),
|
||||
}
|
||||
// Cost math is owned by fal_client's per-endpoint `FalRequestCostCalculator`
|
||||
// implementations. The router state mirrors whichever calculator matches
|
||||
// the dispatched mode so router cost ≡ fal_client cost by construction.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalKling16ProMode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalKling16ProMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalKling16ProMode::ElementsToVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client kling_v1p6_pro: 5s = 48¢, 10s = 95¢ (Default → 5s).
|
||||
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 95 } else { 48 };
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
@@ -33,29 +38,95 @@ impl FalKling16ProCostState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
fn cost_cents_with(mut configure: impl FnMut(&mut GenerateVideoRequestBuilder)) -> u64 {
|
||||
let mut b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Kling16Pro,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
|
||||
duration_seconds,
|
||||
..Default::default()
|
||||
};
|
||||
configure(&mut b);
|
||||
b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_is_48() { assert_eq!(cost_cents(Some(5)), 48); }
|
||||
// ── Image-to-video ($0.095/sec) ──
|
||||
|
||||
#[test]
|
||||
fn ten_seconds_is_95() { assert_eq!(cost_cents(Some(10)), 95); }
|
||||
fn i2v_5s_is_48() {
|
||||
// ceil(95 × 5 / 10) = 48¢
|
||||
let cost = cost_cents_with(|b| {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.duration_seconds = Some(5);
|
||||
});
|
||||
assert_eq!(cost, 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_5s_priced_at_48() { assert_eq!(cost_cents(None), 48); }
|
||||
fn i2v_10s_is_95() {
|
||||
// ceil(95 × 10 / 10) = 95¢
|
||||
let cost = cost_cents_with(|b| {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.duration_seconds = Some(10);
|
||||
});
|
||||
assert_eq!(cost, 95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_default_duration_is_5s_priced_at_48() {
|
||||
let cost = cost_cents_with(|b| {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
});
|
||||
assert_eq!(cost, 48);
|
||||
}
|
||||
|
||||
// ── Text-to-video ($0.098/sec) ──
|
||||
|
||||
#[test]
|
||||
fn t2v_5s_is_49() {
|
||||
// ceil(98 × 5 / 10) = 49¢
|
||||
let cost = cost_cents_with(|b| {
|
||||
b.duration_seconds = Some(5);
|
||||
});
|
||||
assert_eq!(cost, 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_10s_is_98() {
|
||||
let cost = cost_cents_with(|b| {
|
||||
b.duration_seconds = Some(10);
|
||||
});
|
||||
assert_eq!(cost, 98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_default_duration_is_5s_priced_at_49() {
|
||||
let cost = cost_cents_with(|_b| {});
|
||||
assert_eq!(cost, 49);
|
||||
}
|
||||
|
||||
// ── Elements-to-video ($0.098/sec, same as t2v) ──
|
||||
|
||||
#[test]
|
||||
fn elements_5s_is_49() {
|
||||
let cost = cost_cents_with(|b| {
|
||||
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()]));
|
||||
b.duration_seconds = Some(5);
|
||||
});
|
||||
assert_eq!(cost, 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elements_10s_is_98() {
|
||||
let cost = cost_cents_with(|b| {
|
||||
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()]));
|
||||
b.duration_seconds = Some(10);
|
||||
});
|
||||
assert_eq!(cost, 98);
|
||||
}
|
||||
}
|
||||
|
||||
+143
-22
@@ -1,43 +1,164 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{
|
||||
enqueue_kling_v1p6_pro_image_to_video_webhook, Kling1p6ProArgs, Kling1p6ProRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::Kling1p6ProElementsToVideoRequest;
|
||||
use fal_client::requests::api::video::image::kling_1p6_pro_image_to_video::api::Kling1p6ProImageToVideoRequest;
|
||||
use fal_client::requests::api::video::text::kling_1p6_pro_text_to_video::api::Kling1p6ProTextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalKling16ProMode {
|
||||
TextToVideo(Kling1p6ProTextToVideoRequest),
|
||||
ImageToVideo(Kling1p6ProImageToVideoRequest),
|
||||
ElementsToVideo(Kling1p6ProElementsToVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalKling16ProRequestState {
|
||||
pub request: Kling1p6ProRequest,
|
||||
pub mode: FalKling16ProMode,
|
||||
}
|
||||
|
||||
impl FalKling16ProRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let webhook_url = client.webhook_url.as_deref()
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
|
||||
let outbound_request: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
|
||||
|
||||
let args = Kling1p6ProArgs {
|
||||
request: self.request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
match &self.mode {
|
||||
FalKling16ProMode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalKling16ProMode::ImageToVideo(request) => send_request(request, client).await,
|
||||
FalKling16ProMode::ElementsToVideo(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use fal_client::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{
|
||||
Kling1p6ProElementsToVideoAspectRatio, Kling1p6ProElementsToVideoDuration,
|
||||
};
|
||||
use fal_client::requests::api::video::image::kling_1p6_pro_image_to_video::api::{
|
||||
Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration,
|
||||
};
|
||||
use fal_client::requests::api::video::text::kling_1p6_pro_text_to_video::api::{
|
||||
Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration,
|
||||
};
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
fn read_fal_api_key() -> FalApiKey {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
|
||||
.expect("Failed to read fal_api_key.txt");
|
||||
FalApiKey::from_str(secret.trim())
|
||||
}
|
||||
|
||||
fn client_with_webhook() -> RouterFalClient {
|
||||
RouterFalClient::new_with_webhook(
|
||||
read_fal_api_key(),
|
||||
"https://example.com/fal-webhook-test".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_text_to_video_webhook() {
|
||||
let state = FalKling16ProRequestState {
|
||||
mode: FalKling16ProMode::TextToVideo(Kling1p6ProTextToVideoRequest {
|
||||
prompt: "a calm lake at sunrise".to_string(),
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProTextToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine),
|
||||
cfg_scale: None,
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
|
||||
let webhook_response = enqueue_kling_v1p6_pro_image_to_video_webhook(args)
|
||||
.await
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_image_to_video_webhook() {
|
||||
let state = FalKling16ProRequestState {
|
||||
mode: FalKling16ProMode::ImageToVideo(Kling1p6ProImageToVideoRequest {
|
||||
prompt: "the lake comes alive with gentle ripples".to_string(),
|
||||
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
end_image_url: None,
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine),
|
||||
cfg_scale: None,
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_elements_to_video_webhook() {
|
||||
let state = FalKling16ProRequestState {
|
||||
mode: FalKling16ProMode::ElementsToVideo(Kling1p6ProElementsToVideoRequest {
|
||||
prompt: "the elements come to life and dance".to_string(),
|
||||
input_image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine),
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -37,6 +37,8 @@ impl FalKling21MasterRequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+2
@@ -37,6 +37,8 @@ impl FalKling21ProRequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+42
-27
@@ -1,9 +1,9 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p5_turbo_pro_image_to_video_webhook::{
|
||||
EnqueueKlingV2p5TurboProImageToVideoDurationSeconds, EnqueueKlingV2p5TurboProImageToVideoRequest,
|
||||
use fal_client::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::{
|
||||
Kling2p5TurboProImageToVideoDuration, Kling2p5TurboProImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p5_turbo_pro_text_to_video_webhook::{
|
||||
EnqueueKlingV2p5TurboProTextToVideoAspectRatio, EnqueueKlingV2p5TurboProTextToVideoDurationSeconds,
|
||||
EnqueueKlingV2p5TurboProTextToVideoRequest,
|
||||
use fal_client::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{
|
||||
Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration,
|
||||
Kling2p5TurboProTextToVideoRequest,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
@@ -49,19 +49,21 @@ pub fn build_fal_kling_2_5_turbo_pro(
|
||||
value: "Kling 2.5 Turbo Pro requires a start_frame when end_frame is provided".to_string(),
|
||||
}));
|
||||
}
|
||||
FalKling2p5TurboProMode::TextToVideo(EnqueueKlingV2p5TurboProTextToVideoRequest {
|
||||
FalKling2p5TurboProMode::TextToVideo(Kling2p5TurboProTextToVideoRequest {
|
||||
prompt,
|
||||
negative_prompt,
|
||||
duration: duration.map(to_t2v_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
|
||||
cfg_scale: None,
|
||||
})
|
||||
}
|
||||
Some(image_url) => FalKling2p5TurboProMode::ImageToVideo(EnqueueKlingV2p5TurboProImageToVideoRequest {
|
||||
Some(image_url) => FalKling2p5TurboProMode::ImageToVideo(Kling2p5TurboProImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
tail_image_url: optional_url(builder.end_frame.clone())?,
|
||||
negative_prompt,
|
||||
duration: duration.map(to_i2v_duration),
|
||||
negative_prompt,
|
||||
cfg_scale: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -119,33 +121,33 @@ fn plan_duration(
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_duration(d: PlanDuration) -> EnqueueKlingV2p5TurboProTextToVideoDurationSeconds {
|
||||
fn to_t2v_duration(d: PlanDuration) -> Kling2p5TurboProTextToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Five => EnqueueKlingV2p5TurboProTextToVideoDurationSeconds::Five,
|
||||
PlanDuration::Ten => EnqueueKlingV2p5TurboProTextToVideoDurationSeconds::Ten,
|
||||
PlanDuration::Five => Kling2p5TurboProTextToVideoDuration::FiveSeconds,
|
||||
PlanDuration::Ten => Kling2p5TurboProTextToVideoDuration::TenSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueKlingV2p5TurboProTextToVideoAspectRatio {
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling2p5TurboProTextToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Square => EnqueueKlingV2p5TurboProTextToVideoAspectRatio::Square,
|
||||
PlanAspectRatio::SixteenByNine => EnqueueKlingV2p5TurboProTextToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => EnqueueKlingV2p5TurboProTextToVideoAspectRatio::NineBySixteen,
|
||||
PlanAspectRatio::Square => Kling2p5TurboProTextToVideoAspectRatio::Square,
|
||||
PlanAspectRatio::SixteenByNine => Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> EnqueueKlingV2p5TurboProImageToVideoDurationSeconds {
|
||||
fn to_i2v_duration(d: PlanDuration) -> Kling2p5TurboProImageToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Five => EnqueueKlingV2p5TurboProImageToVideoDurationSeconds::Five,
|
||||
PlanDuration::Ten => EnqueueKlingV2p5TurboProImageToVideoDurationSeconds::Ten,
|
||||
PlanDuration::Five => Kling2p5TurboProImageToVideoDuration::FiveSeconds,
|
||||
PlanDuration::Ten => Kling2p5TurboProImageToVideoDuration::TenSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -161,11 +163,10 @@ mod tests {
|
||||
#[test]
|
||||
fn no_start_frame_picks_t2v() {
|
||||
let result = build_fal_kling_2_5_turbo_pro(base_builder()).expect("build");
|
||||
if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result {
|
||||
assert!(matches!(s.mode, FalKling2p5TurboProMode::TextToVideo(_)));
|
||||
} else {
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result else {
|
||||
panic!("expected FalKling2p5TurboPro");
|
||||
}
|
||||
};
|
||||
assert!(matches!(s.mode, FalKling2p5TurboProMode::TextToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -173,11 +174,10 @@ mod tests {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
let result = build_fal_kling_2_5_turbo_pro(b).expect("build");
|
||||
if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result {
|
||||
assert!(matches!(s.mode, FalKling2p5TurboProMode::ImageToVideo(_)));
|
||||
} else {
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result else {
|
||||
panic!("expected FalKling2p5TurboPro");
|
||||
}
|
||||
};
|
||||
assert!(matches!(s.mode, FalKling2p5TurboProMode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -186,4 +186,19 @@ mod tests {
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
assert!(build_fal_kling_2_5_turbo_pro(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_image_url_propagates() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
let result = build_fal_kling_2_5_turbo_pro(b).expect("build");
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result else {
|
||||
panic!("expected FalKling2p5TurboPro");
|
||||
};
|
||||
let FalKling2p5TurboProMode::ImageToVideo(req) = s.mode else {
|
||||
panic!("expected ImageToVideo");
|
||||
};
|
||||
assert_eq!(req.tail_image_url.as_deref(), Some("https://example.com/end.png"));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-17
@@ -1,5 +1,4 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p5_turbo_pro_image_to_video_webhook::EnqueueKlingV2p5TurboProImageToVideoDurationSeconds;
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p5_turbo_pro_text_to_video_webhook::EnqueueKlingV2p5TurboProTextToVideoDurationSeconds;
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::fal::kling_2_5_turbo_pro::request::{
|
||||
@@ -8,29 +7,24 @@ use crate::generate::generate_video::providers::fal::kling_2_5_turbo_pro::reques
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalKling2p5TurboProCostState {
|
||||
pub is_ten_seconds: bool,
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalKling2p5TurboProCostState {
|
||||
pub fn from_request(request: &FalKling2p5TurboProRequestState) -> Self {
|
||||
let is_ten_seconds = match &request.mode {
|
||||
FalKling2p5TurboProMode::TextToVideo(req) => {
|
||||
matches!(req.duration, Some(EnqueueKlingV2p5TurboProTextToVideoDurationSeconds::Ten))
|
||||
}
|
||||
FalKling2p5TurboProMode::ImageToVideo(req) => {
|
||||
matches!(req.duration, Some(EnqueueKlingV2p5TurboProImageToVideoDurationSeconds::Ten))
|
||||
}
|
||||
// Cost math is owned by fal_client's per-endpoint `FalRequestCostCalculator`
|
||||
// implementations.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalKling2p5TurboProMode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalKling2p5TurboProMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { is_ten_seconds }
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client kling_v2p5_turbo_pro: 5s = 35¢, 10s = 70¢. None → 5s.
|
||||
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 70 } else { 35 };
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
@@ -42,9 +36,9 @@ impl FalKling2p5TurboProCostState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, has_start: bool) -> u64 {
|
||||
@@ -61,6 +55,8 @@ mod tests {
|
||||
b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
// Pricing: $0.07/sec flat → 5s = 35¢, 10s = 70¢. Same for t2v and i2v.
|
||||
|
||||
#[test]
|
||||
fn t2v_5s_is_35() { assert_eq!(cost_cents(Some(5), false), 35); }
|
||||
|
||||
@@ -70,6 +66,9 @@ mod tests {
|
||||
#[test]
|
||||
fn i2v_5s_is_35() { assert_eq!(cost_cents(Some(5), true), 35); }
|
||||
|
||||
#[test]
|
||||
fn i2v_10s_is_70() { assert_eq!(cost_cents(Some(10), true), 70); }
|
||||
|
||||
#[test]
|
||||
fn default_is_5s_priced_at_35() { assert_eq!(cost_cents(None, false), 35); }
|
||||
}
|
||||
|
||||
+113
-41
@@ -1,27 +1,20 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p5_turbo_pro_image_to_video_webhook::{
|
||||
enqueue_kling_v2p5_turbo_pro_image_to_video_webhook, EnqueueKlingV2p5TurboProImageToVideoArgs,
|
||||
EnqueueKlingV2p5TurboProImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p5_turbo_pro_text_to_video_webhook::{
|
||||
enqueue_kling_v2p5_turbo_pro_text_to_video_webhook, EnqueueKlingV2p5TurboProTextToVideoArgs,
|
||||
EnqueueKlingV2p5TurboProTextToVideoRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::Kling2p5TurboProImageToVideoRequest;
|
||||
use fal_client::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::Kling2p5TurboProTextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalKling2p5TurboProMode {
|
||||
TextToVideo(EnqueueKlingV2p5TurboProTextToVideoRequest),
|
||||
ImageToVideo(EnqueueKlingV2p5TurboProImageToVideoRequest),
|
||||
TextToVideo(Kling2p5TurboProTextToVideoRequest),
|
||||
ImageToVideo(Kling2p5TurboProImageToVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -31,36 +24,115 @@ pub struct FalKling2p5TurboProRequestState {
|
||||
|
||||
impl FalKling2p5TurboProRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let webhook_url = client.webhook_url.as_deref()
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
|
||||
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
|
||||
FalKling2p5TurboProMode::TextToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueKlingV2p5TurboProTextToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_kling_v2p5_turbo_pro_text_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
FalKling2p5TurboProMode::ImageToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueKlingV2p5TurboProImageToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_kling_v2p5_turbo_pro_image_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
match &self.mode {
|
||||
FalKling2p5TurboProMode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalKling2p5TurboProMode::ImageToVideo(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use fal_client::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::Kling2p5TurboProImageToVideoDuration;
|
||||
use fal_client::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{
|
||||
Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration,
|
||||
};
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
fn read_fal_api_key() -> FalApiKey {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
|
||||
.expect("Failed to read fal_api_key.txt");
|
||||
FalApiKey::from_str(secret.trim())
|
||||
}
|
||||
|
||||
fn client_with_webhook() -> RouterFalClient {
|
||||
RouterFalClient::new_with_webhook(
|
||||
read_fal_api_key(),
|
||||
"https://example.com/fal-webhook-test".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_text_to_video_webhook() {
|
||||
let state = FalKling2p5TurboProRequestState {
|
||||
mode: FalKling2p5TurboProMode::TextToVideo(Kling2p5TurboProTextToVideoRequest {
|
||||
prompt: "a calm lake at sunrise".to_string(),
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine),
|
||||
cfg_scale: None,
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
|
||||
let webhook_response = webhook_response
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_image_to_video_webhook() {
|
||||
let state = FalKling2p5TurboProRequestState {
|
||||
mode: FalKling2p5TurboProMode::ImageToVideo(Kling2p5TurboProImageToVideoRequest {
|
||||
prompt: "the lake comes alive".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 response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
+84
-47
@@ -1,9 +1,9 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p6_pro_image_to_video_webhook::{
|
||||
EnqueueKlingV2p6ProImageToVideoDurationSeconds, EnqueueKlingV2p6ProImageToVideoRequest,
|
||||
use fal_client::requests::api::video::image::kling_2p6_pro_image_to_video::api::{
|
||||
Kling2p6ProImageToVideoDuration, Kling2p6ProImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p6_pro_text_to_video_webhook::{
|
||||
EnqueueKlingV2p6ProTextToVideoAspectRatio, EnqueueKlingV2p6ProTextToVideoDurationSeconds,
|
||||
EnqueueKlingV2p6ProTextToVideoRequest,
|
||||
use fal_client::requests::api::video::text::kling_2p6_pro_text_to_video::api::{
|
||||
Kling2p6ProTextToVideoAspectRatio, Kling2p6ProTextToVideoDuration,
|
||||
Kling2p6ProTextToVideoRequest,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
@@ -36,33 +36,38 @@ pub fn build_fal_kling_2_6_pro(
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
if builder.end_frame.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Kling 2.6 Pro does not support an ending frame".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
let prompt = builder.prompt.clone().unwrap_or_default();
|
||||
let negative_prompt = builder.negative_prompt.clone();
|
||||
let generate_audio = builder.generate_audio;
|
||||
let end_image_url = optional_url(builder.end_frame.clone())?;
|
||||
|
||||
let mode = match optional_url(builder.start_frame.clone())? {
|
||||
None => FalKling2p6ProMode::TextToVideo(EnqueueKlingV2p6ProTextToVideoRequest {
|
||||
None => {
|
||||
if end_image_url.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Kling 2.6 Pro requires a start_frame when end_frame is provided".to_string(),
|
||||
}));
|
||||
}
|
||||
FalKling2p6ProMode::TextToVideo(Kling2p6ProTextToVideoRequest {
|
||||
prompt,
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
duration: duration.map(to_t2v_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
|
||||
cfg_scale: None,
|
||||
})
|
||||
}
|
||||
Some(start_image_url) => FalKling2p6ProMode::ImageToVideo(Kling2p6ProImageToVideoRequest {
|
||||
prompt,
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
duration: duration.map(to_t2v_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
|
||||
}),
|
||||
Some(image_url) => FalKling2p6ProMode::ImageToVideo(EnqueueKlingV2p6ProImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
start_image_url,
|
||||
end_image_url,
|
||||
duration: duration.map(to_i2v_duration),
|
||||
negative_prompt,
|
||||
generate_audio,
|
||||
voice_ids: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -120,33 +125,33 @@ fn plan_duration(
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_duration(d: PlanDuration) -> EnqueueKlingV2p6ProTextToVideoDurationSeconds {
|
||||
fn to_t2v_duration(d: PlanDuration) -> Kling2p6ProTextToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Five => EnqueueKlingV2p6ProTextToVideoDurationSeconds::Five,
|
||||
PlanDuration::Ten => EnqueueKlingV2p6ProTextToVideoDurationSeconds::Ten,
|
||||
PlanDuration::Five => Kling2p6ProTextToVideoDuration::FiveSeconds,
|
||||
PlanDuration::Ten => Kling2p6ProTextToVideoDuration::TenSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueKlingV2p6ProTextToVideoAspectRatio {
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling2p6ProTextToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Square => EnqueueKlingV2p6ProTextToVideoAspectRatio::Square,
|
||||
PlanAspectRatio::SixteenByNine => EnqueueKlingV2p6ProTextToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => EnqueueKlingV2p6ProTextToVideoAspectRatio::NineBySixteen,
|
||||
PlanAspectRatio::Square => Kling2p6ProTextToVideoAspectRatio::Square,
|
||||
PlanAspectRatio::SixteenByNine => Kling2p6ProTextToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Kling2p6ProTextToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> EnqueueKlingV2p6ProImageToVideoDurationSeconds {
|
||||
fn to_i2v_duration(d: PlanDuration) -> Kling2p6ProImageToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Five => EnqueueKlingV2p6ProImageToVideoDurationSeconds::Five,
|
||||
PlanDuration::Ten => EnqueueKlingV2p6ProImageToVideoDurationSeconds::Ten,
|
||||
PlanDuration::Five => Kling2p6ProImageToVideoDuration::FiveSeconds,
|
||||
PlanDuration::Ten => Kling2p6ProImageToVideoDuration::TenSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -159,19 +164,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
assert!(build_fal_kling_2_6_pro(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_start_frame_picks_t2v() {
|
||||
let result = build_fal_kling_2_6_pro(base_builder()).expect("build");
|
||||
if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result {
|
||||
assert!(matches!(s.mode, FalKling2p6ProMode::TextToVideo(_)));
|
||||
} else { panic!("expected FalKling2p6Pro"); }
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else {
|
||||
panic!("expected FalKling2p6Pro");
|
||||
};
|
||||
assert!(matches!(s.mode, FalKling2p6ProMode::TextToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -179,8 +178,46 @@ mod tests {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
let result = build_fal_kling_2_6_pro(b).expect("build");
|
||||
if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result {
|
||||
assert!(matches!(s.mode, FalKling2p6ProMode::ImageToVideo(_)));
|
||||
} else { panic!("expected FalKling2p6Pro"); }
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else {
|
||||
panic!("expected FalKling2p6Pro");
|
||||
};
|
||||
assert!(matches!(s.mode, FalKling2p6ProMode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
assert!(build_fal_kling_2_6_pro(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_with_start_frame_propagates_through_i2v() {
|
||||
// Kling 2.6 Pro image-to-video accepts an end frame (`end_image_url`).
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
let result = build_fal_kling_2_6_pro(b).expect("build");
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else {
|
||||
panic!("expected FalKling2p6Pro");
|
||||
};
|
||||
let FalKling2p6ProMode::ImageToVideo(req) = s.mode else {
|
||||
panic!("expected ImageToVideo");
|
||||
};
|
||||
assert_eq!(req.end_image_url.as_deref(), Some("https://example.com/end.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_audio_propagates() {
|
||||
let mut b = base_builder();
|
||||
b.generate_audio = Some(false);
|
||||
let result = build_fal_kling_2_6_pro(b).expect("build");
|
||||
let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else {
|
||||
panic!("expected FalKling2p6Pro");
|
||||
};
|
||||
let FalKling2p6ProMode::TextToVideo(req) = s.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.generate_audio, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
+40
-36
@@ -1,5 +1,4 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p6_pro_image_to_video_webhook::EnqueueKlingV2p6ProImageToVideoDurationSeconds;
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p6_pro_text_to_video_webhook::EnqueueKlingV2p6ProTextToVideoDurationSeconds;
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::fal::kling_2_6_pro::request::{
|
||||
@@ -8,39 +7,25 @@ use crate::generate::generate_video::providers::fal::kling_2_6_pro::request::{
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalKling2p6ProCostState {
|
||||
pub is_ten_seconds: bool,
|
||||
pub generate_audio: bool,
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalKling2p6ProCostState {
|
||||
pub fn from_request(request: &FalKling2p6ProRequestState) -> Self {
|
||||
let (is_ten_seconds, generate_audio) = match &request.mode {
|
||||
FalKling2p6ProMode::TextToVideo(req) => (
|
||||
matches!(req.duration, Some(EnqueueKlingV2p6ProTextToVideoDurationSeconds::Ten)),
|
||||
req.generate_audio.unwrap_or(true),
|
||||
),
|
||||
FalKling2p6ProMode::ImageToVideo(req) => (
|
||||
matches!(req.duration, Some(EnqueueKlingV2p6ProImageToVideoDurationSeconds::Ten)),
|
||||
req.generate_audio.unwrap_or(true),
|
||||
),
|
||||
// Cost math is owned by fal_client's per-endpoint
|
||||
// `FalRequestCostCalculator` implementations. The router state just
|
||||
// forwards the result so router cost ≡ fal_client cost by construction.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalKling2p6ProMode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalKling2p6ProMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { is_ten_seconds, generate_audio }
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client kling_v2p6_pro:
|
||||
// audio off: $0.07/sec → 5s=35¢, 10s=70¢
|
||||
// audio on: $0.14/sec → 5s=70¢, 10s=140¢
|
||||
let cost_in_usd_cents: u64 = match (self.generate_audio, self.is_ten_seconds) {
|
||||
(false, false) => 35,
|
||||
(false, true) => 70,
|
||||
(true, false) => 70,
|
||||
(true, true) => 140,
|
||||
};
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
@@ -52,12 +37,13 @@ impl FalKling2p6ProCostState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>, has_start: bool) -> u64 {
|
||||
let mut b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Kling2p6Pro,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
@@ -65,24 +51,42 @@ mod tests {
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
if has_start {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
}
|
||||
b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false)), 35); }
|
||||
// Pricing:
|
||||
// audio off: $0.07/sec → 5s=35¢, 10s=70¢
|
||||
// audio on: $0.14/sec → 5s=70¢, 10s=140¢
|
||||
// Image-to-video adds a third tier ($0.168/sec) for voice control, but
|
||||
// the router build doesn't expose voice_ids yet, so it's not tested here.
|
||||
|
||||
#[test]
|
||||
fn audio_off_10s_is_70() { assert_eq!(cost_cents(Some(10), Some(false)), 70); }
|
||||
fn t2v_audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false), false), 35); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true)), 70); }
|
||||
fn t2v_audio_off_10s_is_70() { assert_eq!(cost_cents(Some(10), Some(false), false), 70); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_10s_is_140() { assert_eq!(cost_cents(Some(10), Some(true)), 140); }
|
||||
fn t2v_audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true), false), 70); }
|
||||
|
||||
#[test]
|
||||
fn t2v_audio_on_10s_is_140() { assert_eq!(cost_cents(Some(10), Some(true), false), 140); }
|
||||
|
||||
#[test]
|
||||
fn i2v_audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false), true), 35); }
|
||||
|
||||
#[test]
|
||||
fn i2v_audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true), true), 70); }
|
||||
|
||||
#[test]
|
||||
fn audio_default_is_on() {
|
||||
// None → audio defaults to true.
|
||||
assert_eq!(cost_cents(Some(5), None), cost_cents(Some(5), Some(true)));
|
||||
// generate_audio=None defaults to true on fal's server.
|
||||
assert_eq!(
|
||||
cost_cents(Some(5), None, false),
|
||||
cost_cents(Some(5), Some(true), false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+115
-41
@@ -1,27 +1,20 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p6_pro_image_to_video_webhook::{
|
||||
enqueue_kling_v2p6_pro_image_to_video_webhook, EnqueueKlingV2p6ProImageToVideoArgs,
|
||||
EnqueueKlingV2p6ProImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_kling_v2p6_pro_text_to_video_webhook::{
|
||||
enqueue_kling_v2p6_pro_text_to_video_webhook, EnqueueKlingV2p6ProTextToVideoArgs,
|
||||
EnqueueKlingV2p6ProTextToVideoRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::image::kling_2p6_pro_image_to_video::api::Kling2p6ProImageToVideoRequest;
|
||||
use fal_client::requests::api::video::text::kling_2p6_pro_text_to_video::api::Kling2p6ProTextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalKling2p6ProMode {
|
||||
TextToVideo(EnqueueKlingV2p6ProTextToVideoRequest),
|
||||
ImageToVideo(EnqueueKlingV2p6ProImageToVideoRequest),
|
||||
TextToVideo(Kling2p6ProTextToVideoRequest),
|
||||
ImageToVideo(Kling2p6ProImageToVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -31,36 +24,117 @@ pub struct FalKling2p6ProRequestState {
|
||||
|
||||
impl FalKling2p6ProRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let webhook_url = client.webhook_url.as_deref()
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
|
||||
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
|
||||
FalKling2p6ProMode::TextToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueKlingV2p6ProTextToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_kling_v2p6_pro_text_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
FalKling2p6ProMode::ImageToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueKlingV2p6ProImageToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_kling_v2p6_pro_image_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
match &self.mode {
|
||||
FalKling2p6ProMode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalKling2p6ProMode::ImageToVideo(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use fal_client::requests::api::video::image::kling_2p6_pro_image_to_video::api::Kling2p6ProImageToVideoDuration;
|
||||
use fal_client::requests::api::video::text::kling_2p6_pro_text_to_video::api::{
|
||||
Kling2p6ProTextToVideoAspectRatio, Kling2p6ProTextToVideoDuration,
|
||||
};
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
fn read_fal_api_key() -> FalApiKey {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
|
||||
.expect("Failed to read fal_api_key.txt");
|
||||
FalApiKey::from_str(secret.trim())
|
||||
}
|
||||
|
||||
fn client_with_webhook() -> RouterFalClient {
|
||||
RouterFalClient::new_with_webhook(
|
||||
read_fal_api_key(),
|
||||
"https://example.com/fal-webhook-test".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_text_to_video_webhook() {
|
||||
let state = FalKling2p6ProRequestState {
|
||||
mode: FalKling2p6ProMode::TextToVideo(Kling2p6ProTextToVideoRequest {
|
||||
prompt: "a calm lake at sunrise".to_string(),
|
||||
generate_audio: Some(false),
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine),
|
||||
cfg_scale: None,
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
|
||||
let webhook_response = webhook_response
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_image_to_video_webhook() {
|
||||
let state = FalKling2p6ProRequestState {
|
||||
mode: FalKling2p6ProMode::ImageToVideo(Kling2p6ProImageToVideoRequest {
|
||||
prompt: "the lake comes alive".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 response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -60,6 +60,8 @@ impl FalKling3p0ProRequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+2
@@ -60,6 +60,8 @@ impl FalKling3p0StandardRequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+2
@@ -37,6 +37,8 @@ impl FalSeedance10LiteRequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+2
@@ -60,6 +60,8 @@ impl FalSeedance1p5ProRequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+2
@@ -104,6 +104,8 @@ impl FalSora2RequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+2
@@ -101,6 +101,8 @@ impl FalSora2ProRequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+2
@@ -58,6 +58,8 @@ impl FalVeo2RequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+2
@@ -58,6 +58,8 @@ impl FalVeo3RequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+2
@@ -37,6 +37,8 @@ impl FalVeo3FastRequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+2
@@ -72,6 +72,8 @@ impl FalVeo3p1RequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+2
@@ -75,6 +75,8 @@ impl FalVeo3p1FastRequestState {
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
}
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::elements::kling_1p6_pro_elements_to_video::raw_request::{
|
||||
Kling1p6ProElementsToVideoInput, Kling1p6ProElementsToVideoOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Kling1p6ProElementsToVideoRequest {
|
||||
/// Text prompt describing the video to generate.
|
||||
pub prompt: String,
|
||||
|
||||
/// Image URLs that drive the generation. fal documents support for up
|
||||
/// to 4 images; passing more is rejected upstream rather than here.
|
||||
pub input_image_urls: Vec<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 1.6 Pro Elements supports 5 or 10 seconds only.
|
||||
pub duration: Option<Kling1p6ProElementsToVideoDuration>,
|
||||
|
||||
/// Aspect ratio.
|
||||
pub aspect_ratio: Option<Kling1p6ProElementsToVideoAspectRatio>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Kling1p6ProElementsToVideoDuration {
|
||||
FiveSeconds,
|
||||
TenSeconds,
|
||||
}
|
||||
|
||||
impl Kling1p6ProElementsToVideoDuration {
|
||||
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 Kling1p6ProElementsToVideoAspectRatio {
|
||||
Square,
|
||||
SixteenByNine,
|
||||
NineBySixteen,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Kling1p6ProElementsToVideoRequest {
|
||||
const ENDPOINT: &str = "fal-ai/kling-video/v1.6/pro/elements";
|
||||
|
||||
type RawRequest = Kling1p6ProElementsToVideoInput;
|
||||
type RawResponse = Kling1p6ProElementsToVideoOutput;
|
||||
|
||||
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 {
|
||||
Kling1p6ProElementsToVideoAspectRatio::Square => "1:1",
|
||||
Kling1p6ProElementsToVideoAspectRatio::SixteenByNine => "16:9",
|
||||
Kling1p6ProElementsToVideoAspectRatio::NineBySixteen => "9:16",
|
||||
}.to_string());
|
||||
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
input_image_urls: self.input_image_urls.clone(),
|
||||
aspect_ratio,
|
||||
duration,
|
||||
negative_prompt: self.negative_prompt.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, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_elements_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 = Kling1p6ProElementsToVideoRequest {
|
||||
prompt: "the two dogs run side by side across a sunlit meadow".to_string(),
|
||||
input_image_urls: vec![
|
||||
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
|
||||
JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
],
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine),
|
||||
};
|
||||
|
||||
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_elements_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 = Kling1p6ProElementsToVideoRequest {
|
||||
prompt: "the dogs play together in a snowy field".to_string(),
|
||||
input_image_urls: vec![
|
||||
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
|
||||
JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
],
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine),
|
||||
};
|
||||
|
||||
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 = [
|
||||
Kling1p6ProElementsToVideoAspectRatio::Square,
|
||||
Kling1p6ProElementsToVideoAspectRatio::SixteenByNine,
|
||||
Kling1p6ProElementsToVideoAspectRatio::NineBySixteen,
|
||||
];
|
||||
|
||||
for ar in aspect_ratios {
|
||||
println!("--- aspect ratio: {:?} ---", ar);
|
||||
let request = Kling1p6ProElementsToVideoRequest {
|
||||
prompt: "the dogs play together in a snowy field".to_string(),
|
||||
input_image_urls: vec![
|
||||
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
|
||||
JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
],
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(ar),
|
||||
};
|
||||
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 = [
|
||||
Kling1p6ProElementsToVideoDuration::FiveSeconds,
|
||||
Kling1p6ProElementsToVideoDuration::TenSeconds,
|
||||
];
|
||||
|
||||
for dur in durations {
|
||||
println!("--- duration: {:?} ---", dur);
|
||||
let request = Kling1p6ProElementsToVideoRequest {
|
||||
prompt: "the dogs leap through tall grass in golden hour light".to_string(),
|
||||
input_image_urls: vec![
|
||||
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
|
||||
JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
],
|
||||
negative_prompt: None,
|
||||
duration: Some(dur),
|
||||
aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine),
|
||||
};
|
||||
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 = Kling1p6ProElementsToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
input_image_urls: vec!["https://example.com/a.png".to_string()],
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProElementsToVideoDuration::TenSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::Square),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("1:1"));
|
||||
assert_eq!(raw.duration.as_deref(), Some("10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_passes_through_input_image_urls() {
|
||||
let urls = vec![
|
||||
"https://example.com/a.png".to_string(),
|
||||
"https://example.com/b.png".to_string(),
|
||||
"https://example.com/c.png".to_string(),
|
||||
"https://example.com/d.png".to_string(),
|
||||
];
|
||||
let request = Kling1p6ProElementsToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
input_image_urls: urls.clone(),
|
||||
negative_prompt: None,
|
||||
duration: None,
|
||||
aspect_ratio: None,
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.input_image_urls, urls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Kling1p6ProElementsToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
input_image_urls: vec!["https://example.com/a.png".to_string()],
|
||||
negative_prompt: None,
|
||||
duration: None,
|
||||
aspect_ratio: 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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(
|
||||
Kling1p6ProElementsToVideoRequest::ENDPOINT,
|
||||
"fal-ai/kling-video/v1.6/pro/elements",
|
||||
);
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
use crate::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{
|
||||
Kling1p6ProElementsToVideoDuration, Kling1p6ProElementsToVideoRequest,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for Kling1p6ProElementsToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// Kling 1.6 Pro elements-to-video: $0.098/second
|
||||
// (see https://fal.ai/models/fal-ai/kling-video/v1.6/pro/elements).
|
||||
//
|
||||
// Same per-second rate as the v1.6 Pro text-to-video variant. Rate held
|
||||
// in tenths-of-cents and rounded up to whole cents at the end so the
|
||||
// user is never undercharged.
|
||||
let duration_secs = self.duration
|
||||
.unwrap_or(Kling1p6ProElementsToVideoDuration::FiveSeconds)
|
||||
.to_seconds();
|
||||
(98u64 * duration_secs + 9) / 10
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{
|
||||
Kling1p6ProElementsToVideoAspectRatio, Kling1p6ProElementsToVideoDuration,
|
||||
};
|
||||
|
||||
fn make_request(
|
||||
duration: Option<Kling1p6ProElementsToVideoDuration>,
|
||||
) -> Kling1p6ProElementsToVideoRequest {
|
||||
Kling1p6ProElementsToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
input_image_urls: vec!["https://example.com/a.png".to_string()],
|
||||
negative_prompt: None,
|
||||
duration,
|
||||
aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine),
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing: $0.098/sec = 98 tenths-of-cents/sec.
|
||||
// Cents per duration = ceil(98 × secs / 10).
|
||||
|
||||
#[test]
|
||||
fn five_seconds() {
|
||||
// (98 * 5 + 9) / 10 = 49
|
||||
assert_eq!(
|
||||
make_request(Some(Kling1p6ProElementsToVideoDuration::FiveSeconds))
|
||||
.calculate_cost_in_cents(),
|
||||
49,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_seconds() {
|
||||
// (98 * 10 + 9) / 10 = 98
|
||||
assert_eq!(
|
||||
make_request(Some(Kling1p6ProElementsToVideoDuration::TenSeconds))
|
||||
.calculate_cost_in_cents(),
|
||||
98,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_five_seconds() {
|
||||
// duration=None → 5s → 49¢
|
||||
assert_eq!(make_request(None).calculate_cost_in_cents(), 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_seconds_costs_more_than_five() {
|
||||
let five = make_request(Some(Kling1p6ProElementsToVideoDuration::FiveSeconds))
|
||||
.calculate_cost_in_cents();
|
||||
let ten = make_request(Some(Kling1p6ProElementsToVideoDuration::TenSeconds))
|
||||
.calculate_cost_in_cents();
|
||||
assert!(five < ten, "five={five}¢ < ten={ten}¢");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_image_count_does_not_affect_cost() {
|
||||
// fal bills per second of output, not per input image. Build matched
|
||||
// pairs differing only in input_image_urls length and confirm cost
|
||||
// matches exactly.
|
||||
let base = Kling1p6ProElementsToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
input_image_urls: vec!["https://example.com/a.png".to_string()],
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine),
|
||||
};
|
||||
let baseline_cost = base.calculate_cost_in_cents();
|
||||
for n in 1..=4usize {
|
||||
let urls: Vec<String> = (0..n).map(|i| format!("https://example.com/{i}.png")).collect();
|
||||
let req = Kling1p6ProElementsToVideoRequest { input_image_urls: urls, ..base.clone() };
|
||||
assert_eq!(
|
||||
req.calculate_cost_in_cents(),
|
||||
baseline_cost,
|
||||
"image count {n} should not change cost",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exhaustive cost-table tests: every permutation of cost-relevant
|
||||
/// configuration for elements-to-video. There is no direct legacy
|
||||
/// equivalent (the legacy `enqueue_kling_v1p6_pro_image_to_video_webhook`
|
||||
/// is image-to-video at a slightly lower rate), so the parity here is
|
||||
/// against the canonical $0.098/sec formula. A separate sub-test
|
||||
/// cross-references the legacy image-to-video function to confirm the
|
||||
/// expected per-duration premium ($0.003/sec).
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
use crate::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{
|
||||
Kling1p6ProAspectRatio as LegacyAspectRatio,
|
||||
Kling1p6ProDuration as LegacyDuration,
|
||||
Kling1p6ProRequest as LegacyRequest,
|
||||
};
|
||||
|
||||
fn new_request(
|
||||
duration: Option<Kling1p6ProElementsToVideoDuration>,
|
||||
aspect_ratio: Option<Kling1p6ProElementsToVideoAspectRatio>,
|
||||
) -> Kling1p6ProElementsToVideoRequest {
|
||||
Kling1p6ProElementsToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
input_image_urls: vec!["https://example.com/a.png".to_string()],
|
||||
negative_prompt: None,
|
||||
duration,
|
||||
aspect_ratio,
|
||||
}
|
||||
}
|
||||
|
||||
// (duration, aspect_ratio, expected_cents)
|
||||
//
|
||||
// Math: ceil(98 × secs / 10) where 5s → 49¢ and 10s → 98¢.
|
||||
// duration=None defaults to 5s.
|
||||
// Aspect ratio is irrelevant to cost; iterated only to assert it.
|
||||
const COST_TABLE: &[(
|
||||
Option<Kling1p6ProElementsToVideoDuration>,
|
||||
Option<Kling1p6ProElementsToVideoAspectRatio>,
|
||||
u64,
|
||||
)] = &[
|
||||
// duration=None → 5s default → 49¢
|
||||
(None, None, 49),
|
||||
(None, Some(Kling1p6ProElementsToVideoAspectRatio::Square), 49),
|
||||
(None, Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), 49),
|
||||
(None, Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen), 49),
|
||||
// duration=5s → 49¢
|
||||
(Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), None, 49),
|
||||
(Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::Square), 49),
|
||||
(Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), 49),
|
||||
(Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen), 49),
|
||||
// duration=10s → 98¢
|
||||
(Some(Kling1p6ProElementsToVideoDuration::TenSeconds), None, 98),
|
||||
(Some(Kling1p6ProElementsToVideoDuration::TenSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::Square), 98),
|
||||
(Some(Kling1p6ProElementsToVideoDuration::TenSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), 98),
|
||||
(Some(Kling1p6ProElementsToVideoDuration::TenSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen), 98),
|
||||
];
|
||||
|
||||
#[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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Aspect ratio is not part of the billing formula — every row in the
|
||||
/// cost table must produce the same value for any aspect-ratio choice.
|
||||
#[test]
|
||||
fn cost_is_independent_of_aspect_ratio() {
|
||||
let aspect_ratios = [
|
||||
None,
|
||||
Some(Kling1p6ProElementsToVideoAspectRatio::Square),
|
||||
Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine),
|
||||
Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen),
|
||||
];
|
||||
let durations = [
|
||||
None,
|
||||
Some(Kling1p6ProElementsToVideoDuration::FiveSeconds),
|
||||
Some(Kling1p6ProElementsToVideoDuration::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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-check against the legacy image-to-video function. Elements-to-video
|
||||
/// is `$0.098/sec` and the legacy image-to-video is `$0.095/sec`, so at
|
||||
/// each duration the cost difference equals `ceil(0.003 × secs)`:
|
||||
/// 5s: 49¢ − 48¢ = 1¢
|
||||
/// 10s: 98¢ − 95¢ = 3¢
|
||||
/// If a future change to the legacy image-to-video rate breaks this
|
||||
/// invariant, this test calls it out.
|
||||
#[test]
|
||||
fn premium_over_legacy_image_to_video_is_as_expected() {
|
||||
fn legacy_image(secs_variant: LegacyDuration) -> u64 {
|
||||
LegacyRequest {
|
||||
image_url: "https://example.com/i.png".to_string(),
|
||||
end_frame_image_url: None,
|
||||
prompt: "test".to_string(),
|
||||
duration: secs_variant,
|
||||
aspect_ratio: LegacyAspectRatio::WideSixteenNine,
|
||||
}.calculate_cost_in_cents()
|
||||
}
|
||||
|
||||
// (elements duration, equivalent legacy image duration, expected premium in cents)
|
||||
let cases = [
|
||||
(Kling1p6ProElementsToVideoDuration::FiveSeconds, LegacyDuration::FiveSeconds, 1u64),
|
||||
(Kling1p6ProElementsToVideoDuration::TenSeconds, LegacyDuration::TenSeconds, 3u64),
|
||||
];
|
||||
|
||||
for (elements_d, legacy_d, expected_premium) in cases {
|
||||
let elements_cost = new_request(Some(elements_d), None).calculate_cost_in_cents();
|
||||
let image_cost = legacy_image(legacy_d);
|
||||
let premium = elements_cost.saturating_sub(image_cost);
|
||||
assert_eq!(
|
||||
premium, expected_premium,
|
||||
"{elements_d:?}: elements={elements_cost}¢ legacy_image={image_cost}¢ premium={premium}¢ (want {expected_premium}¢)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Elements and text-to-video share the same $0.098/sec rate — cells
|
||||
/// should match exactly at every duration. Documents the rate-equality
|
||||
/// expectation, so if either rate drifts in the future this test fires.
|
||||
#[test]
|
||||
fn matches_text_to_video_cost_at_each_duration() {
|
||||
use crate::requests::api::video::text::kling_1p6_pro_text_to_video::api::{
|
||||
Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration,
|
||||
Kling1p6ProTextToVideoRequest,
|
||||
};
|
||||
fn t2v(duration: Kling1p6ProTextToVideoDuration) -> u64 {
|
||||
Kling1p6ProTextToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
negative_prompt: None,
|
||||
duration: Some(duration),
|
||||
aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine),
|
||||
cfg_scale: None,
|
||||
}.calculate_cost_in_cents()
|
||||
}
|
||||
let pairs = [
|
||||
(Kling1p6ProElementsToVideoDuration::FiveSeconds, Kling1p6ProTextToVideoDuration::FiveSeconds),
|
||||
(Kling1p6ProElementsToVideoDuration::TenSeconds, Kling1p6ProTextToVideoDuration::TenSeconds),
|
||||
];
|
||||
for (e_d, t_d) in pairs {
|
||||
let elements_cost = new_request(Some(e_d), None).calculate_cost_in_cents();
|
||||
let text_cost = t2v(t_d);
|
||||
assert_eq!(
|
||||
elements_cost, text_cost,
|
||||
"{e_d:?} vs {t_d:?}: elements={elements_cost}¢ text={text_cost}¢ — rates should match at $0.098/sec",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `fal-ai/kling-video/v1.6/pro/elements`.
|
||||
/// fal's schema: <https://fal.ai/models/fal-ai/kling-video/v1.6/pro/elements/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Kling1p6ProElementsToVideoInput {
|
||||
pub prompt: String,
|
||||
|
||||
/// List of image URLs that drive the generation. fal documents support
|
||||
/// for up to 4 images.
|
||||
pub input_image_urls: Vec<String>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "16:9", "9:16", "1:1"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: 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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Kling1p6ProElementsToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Kling1p6ProElementsToVideoOutput {
|
||||
pub video: Kling1p6ProElementsToVideoVideoFile,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod kling_1p6_pro_elements_to_video;
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::image::kling_1p6_pro_image_to_video::raw_request::{
|
||||
Kling1p6ProImageToVideoInput, Kling1p6ProImageToVideoOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Kling1p6ProImageToVideoRequest {
|
||||
/// 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<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 1.6 Pro supports 5 or 10 seconds only.
|
||||
pub duration: Option<Kling1p6ProImageToVideoDuration>,
|
||||
|
||||
/// Aspect ratio.
|
||||
pub aspect_ratio: Option<Kling1p6ProImageToVideoAspectRatio>,
|
||||
|
||||
/// CFG scale. Defaults to fal's `0.5` when `None`.
|
||||
pub cfg_scale: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Kling1p6ProImageToVideoDuration {
|
||||
FiveSeconds,
|
||||
TenSeconds,
|
||||
}
|
||||
|
||||
impl Kling1p6ProImageToVideoDuration {
|
||||
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 Kling1p6ProImageToVideoAspectRatio {
|
||||
Square,
|
||||
SixteenByNine,
|
||||
NineBySixteen,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Kling1p6ProImageToVideoRequest {
|
||||
const ENDPOINT: &str = "fal-ai/kling-video/v1.6/pro/image-to-video";
|
||||
|
||||
type RawRequest = Kling1p6ProImageToVideoInput;
|
||||
type RawResponse = Kling1p6ProImageToVideoOutput;
|
||||
|
||||
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 {
|
||||
Kling1p6ProImageToVideoAspectRatio::Square => "1:1",
|
||||
Kling1p6ProImageToVideoAspectRatio::SixteenByNine => "16:9",
|
||||
Kling1p6ProImageToVideoAspectRatio::NineBySixteen => "9:16",
|
||||
}.to_string());
|
||||
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
image_url: self.image_url.clone(),
|
||||
// fal's schema names this `tail_image_url`; our public API uses
|
||||
// `end_image_url` for consistency with the other Kling modules.
|
||||
tail_image_url: self.end_image_url.clone(),
|
||||
aspect_ratio,
|
||||
duration,
|
||||
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, TALL_MOCHI_WITH_GLASSES_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 = Kling1p6ProImageToVideoRequest {
|
||||
prompt: "shiba in glasses runs to the lake and stands by the shore".to_string(),
|
||||
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
|
||||
end_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()),
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::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_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 = Kling1p6ProImageToVideoRequest {
|
||||
prompt: "shiba bounds across the meadow chasing a falling leaf".to_string(),
|
||||
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
|
||||
end_image_url: None,
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::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 = [
|
||||
Kling1p6ProImageToVideoAspectRatio::Square,
|
||||
Kling1p6ProImageToVideoAspectRatio::SixteenByNine,
|
||||
Kling1p6ProImageToVideoAspectRatio::NineBySixteen,
|
||||
];
|
||||
|
||||
for ar in aspect_ratios {
|
||||
println!("--- aspect ratio: {:?} ---", ar);
|
||||
let request = Kling1p6ProImageToVideoRequest {
|
||||
prompt: "shiba bounds across the meadow chasing a falling leaf".to_string(),
|
||||
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
|
||||
end_image_url: None,
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProImageToVideoDuration::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 = [
|
||||
Kling1p6ProImageToVideoDuration::FiveSeconds,
|
||||
Kling1p6ProImageToVideoDuration::TenSeconds,
|
||||
];
|
||||
|
||||
for dur in durations {
|
||||
println!("--- duration: {:?} ---", dur);
|
||||
let request = Kling1p6ProImageToVideoRequest {
|
||||
prompt: "shiba bounds across the meadow chasing a falling leaf".to_string(),
|
||||
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
|
||||
end_image_url: None,
|
||||
negative_prompt: None,
|
||||
duration: Some(dur),
|
||||
aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::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 = Kling1p6ProImageToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
image_url: "https://example.com/i.png".to_string(),
|
||||
end_image_url: None,
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine),
|
||||
cfg_scale: None,
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
|
||||
assert_eq!(raw.duration.as_deref(), Some("5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_translates_end_image_url_to_tail_image_url() {
|
||||
let request = Kling1p6ProImageToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
image_url: "https://example.com/i.png".to_string(),
|
||||
end_image_url: Some("https://example.com/end.png".to_string()),
|
||||
negative_prompt: None,
|
||||
duration: None,
|
||||
aspect_ratio: None,
|
||||
cfg_scale: None,
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.tail_image_url.as_deref(), Some("https://example.com/end.png"));
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
use crate::requests::api::video::image::kling_1p6_pro_image_to_video::api::{
|
||||
Kling1p6ProImageToVideoDuration, Kling1p6ProImageToVideoRequest,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for Kling1p6ProImageToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// Kling 1.6 Pro image-to-video: $0.095/second.
|
||||
//
|
||||
// Rate is 95 tenths-of-cents per second; round up to whole cents at the
|
||||
// end so the user is never undercharged.
|
||||
let duration_secs = self.duration
|
||||
.unwrap_or(Kling1p6ProImageToVideoDuration::FiveSeconds)
|
||||
.to_seconds();
|
||||
(95u64 * duration_secs + 9) / 10
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::image::kling_1p6_pro_image_to_video::api::{
|
||||
Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration,
|
||||
};
|
||||
|
||||
fn make_request(
|
||||
duration: Option<Kling1p6ProImageToVideoDuration>,
|
||||
) -> Kling1p6ProImageToVideoRequest {
|
||||
Kling1p6ProImageToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
image_url: "https://example.com/image.jpg".to_string(),
|
||||
end_image_url: None,
|
||||
negative_prompt: None,
|
||||
duration,
|
||||
aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine),
|
||||
cfg_scale: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing: $0.095/sec = 95 tenths-of-cents/sec.
|
||||
// Cents per duration = ceil(95 × secs / 10).
|
||||
|
||||
#[test]
|
||||
fn five_seconds() {
|
||||
// (95 * 5 + 9) / 10 = 48 (round up from 47.5¢)
|
||||
assert_eq!(
|
||||
make_request(Some(Kling1p6ProImageToVideoDuration::FiveSeconds))
|
||||
.calculate_cost_in_cents(),
|
||||
48,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_seconds() {
|
||||
// (95 * 10 + 9) / 10 = 95
|
||||
assert_eq!(
|
||||
make_request(Some(Kling1p6ProImageToVideoDuration::TenSeconds))
|
||||
.calculate_cost_in_cents(),
|
||||
95,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_five_seconds() {
|
||||
// duration=None → 5s → 48¢
|
||||
assert_eq!(make_request(None).calculate_cost_in_cents(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_seconds_costs_more_than_five() {
|
||||
let five = make_request(Some(Kling1p6ProImageToVideoDuration::FiveSeconds))
|
||||
.calculate_cost_in_cents();
|
||||
let ten = make_request(Some(Kling1p6ProImageToVideoDuration::TenSeconds))
|
||||
.calculate_cost_in_cents();
|
||||
assert!(five < ten, "five={five}¢ < ten={ten}¢");
|
||||
}
|
||||
|
||||
/// Exhaustive cost-table tests: every permutation of cost-relevant
|
||||
/// configuration, asserted both against the legacy
|
||||
/// `enqueue_kling_v1p6_pro_image_to_video_webhook` cost function and
|
||||
/// against the canonical $0.095/sec rate (`(95 * secs + 9) / 10`).
|
||||
///
|
||||
/// Aspect ratio is included in the cross product as a sanity check —
|
||||
/// neither the new nor legacy implementation reads it for billing, so
|
||||
/// every cell in a given row must agree.
|
||||
mod legacy_parity {
|
||||
use super::*;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
use crate::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{
|
||||
Kling1p6ProAspectRatio as LegacyAspectRatio,
|
||||
Kling1p6ProDuration as LegacyDuration,
|
||||
Kling1p6ProRequest as LegacyRequest,
|
||||
};
|
||||
|
||||
// ── Mapping helpers from the new strongly-typed API to the legacy one ──
|
||||
|
||||
fn legacy_duration_for(d: Option<Kling1p6ProImageToVideoDuration>) -> LegacyDuration {
|
||||
match d {
|
||||
// The legacy `Default` variant means "5 seconds" semantically — same
|
||||
// as the new module's `None` default.
|
||||
None => LegacyDuration::Default,
|
||||
Some(Kling1p6ProImageToVideoDuration::FiveSeconds) => LegacyDuration::FiveSeconds,
|
||||
Some(Kling1p6ProImageToVideoDuration::TenSeconds) => LegacyDuration::TenSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_aspect_ratio_for(ar: Option<Kling1p6ProImageToVideoAspectRatio>) -> LegacyAspectRatio {
|
||||
// The legacy API has no "None" — pick 16:9 as the default when the
|
||||
// new request leaves it unset, mirroring fal's documented server
|
||||
// default.
|
||||
match ar {
|
||||
None | Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine) => LegacyAspectRatio::WideSixteenNine,
|
||||
Some(Kling1p6ProImageToVideoAspectRatio::Square) => LegacyAspectRatio::Square,
|
||||
Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen) => LegacyAspectRatio::TallNineSixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_request(
|
||||
duration: Option<Kling1p6ProImageToVideoDuration>,
|
||||
aspect_ratio: Option<Kling1p6ProImageToVideoAspectRatio>,
|
||||
) -> Kling1p6ProImageToVideoRequest {
|
||||
Kling1p6ProImageToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
image_url: "https://example.com/image.jpg".to_string(),
|
||||
end_image_url: None,
|
||||
negative_prompt: None,
|
||||
duration,
|
||||
aspect_ratio,
|
||||
cfg_scale: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_request(
|
||||
duration: Option<Kling1p6ProImageToVideoDuration>,
|
||||
aspect_ratio: Option<Kling1p6ProImageToVideoAspectRatio>,
|
||||
) -> LegacyRequest {
|
||||
LegacyRequest {
|
||||
image_url: "https://example.com/image.jpg".to_string(),
|
||||
end_frame_image_url: None,
|
||||
prompt: "test".to_string(),
|
||||
duration: legacy_duration_for(duration),
|
||||
aspect_ratio: legacy_aspect_ratio_for(aspect_ratio),
|
||||
}
|
||||
}
|
||||
|
||||
// (new_duration, new_aspect_ratio, expected_cents)
|
||||
//
|
||||
// Math: ceil(95 × secs / 10) where 5s → 48¢ and 10s → 95¢.
|
||||
// duration=None defaults to 5s in both the new and legacy modules.
|
||||
// Aspect ratio is irrelevant to cost; iterated only to assert it.
|
||||
const COST_TABLE: &[(
|
||||
Option<Kling1p6ProImageToVideoDuration>,
|
||||
Option<Kling1p6ProImageToVideoAspectRatio>,
|
||||
u64,
|
||||
)] = &[
|
||||
// duration=None → 5s default → 48¢
|
||||
(None, None, 48),
|
||||
(None, Some(Kling1p6ProImageToVideoAspectRatio::Square), 48),
|
||||
(None, Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), 48),
|
||||
(None, Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen), 48),
|
||||
// duration=5s → 48¢
|
||||
(Some(Kling1p6ProImageToVideoDuration::FiveSeconds), None, 48),
|
||||
(Some(Kling1p6ProImageToVideoDuration::FiveSeconds), Some(Kling1p6ProImageToVideoAspectRatio::Square), 48),
|
||||
(Some(Kling1p6ProImageToVideoDuration::FiveSeconds), Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), 48),
|
||||
(Some(Kling1p6ProImageToVideoDuration::FiveSeconds), Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen), 48),
|
||||
// duration=10s → 95¢
|
||||
(Some(Kling1p6ProImageToVideoDuration::TenSeconds), None, 95),
|
||||
(Some(Kling1p6ProImageToVideoDuration::TenSeconds), Some(Kling1p6ProImageToVideoAspectRatio::Square), 95),
|
||||
(Some(Kling1p6ProImageToVideoDuration::TenSeconds), Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), 95),
|
||||
(Some(Kling1p6ProImageToVideoDuration::TenSeconds), Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen), 95),
|
||||
];
|
||||
|
||||
#[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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The headline parity check: at every permutation of the legacy's
|
||||
/// cost-relevant configuration, the new and legacy
|
||||
/// `calculate_cost_in_cents` return the same value. If this fails, the
|
||||
/// new module's pricing has drifted from what shipped previously.
|
||||
#[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 row in the
|
||||
/// cost table must produce the same value for any aspect-ratio choice.
|
||||
#[test]
|
||||
fn cost_is_independent_of_aspect_ratio() {
|
||||
let aspect_ratios = [
|
||||
None,
|
||||
Some(Kling1p6ProImageToVideoAspectRatio::Square),
|
||||
Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine),
|
||||
Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen),
|
||||
];
|
||||
let durations = [
|
||||
None,
|
||||
Some(Kling1p6ProImageToVideoDuration::FiveSeconds),
|
||||
Some(Kling1p6ProImageToVideoDuration::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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `fal-ai/kling-video/v1.6/pro/image-to-video`.
|
||||
/// xAI's schema: <https://fal.ai/models/fal-ai/kling-video/v1.6/pro/image-to-video/api>
|
||||
///
|
||||
/// NB: `tail_image_url` is xAI's name for the end-frame keyframe. Our public
|
||||
/// API surface (`Kling1p6ProImageToVideoRequest::end_image_url`) uses the
|
||||
/// `end_image_url` name to match the convention in other Kling modules; we
|
||||
/// translate at the [`crate::requests::traits::fal_endpoint_trait::FalEndpoint::to_raw_request`]
|
||||
/// boundary.
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Kling1p6ProImageToVideoInput {
|
||||
pub prompt: String,
|
||||
|
||||
/// Starting frame image URL.
|
||||
pub image_url: String,
|
||||
|
||||
/// Optional end-frame image URL (the fal schema calls this `tail_image_url`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tail_image_url: Option<String>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "16:9", "9:16", "1:1"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: 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>,
|
||||
|
||||
/// 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 Kling1p6ProImageToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Kling1p6ProImageToVideoOutput {
|
||||
pub video: Kling1p6ProImageToVideoVideoFile,
|
||||
}
|
||||
+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,
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
/// Parity vs the legacy
|
||||
/// `requests_old/http/video/image/http_kling_v2p6_pro_image_to_video.rs`.
|
||||
///
|
||||
/// The legacy has no cost calculator, so cost parity is N/A. The wire
|
||||
/// shape *intentionally diverges*: fal renamed the image field from
|
||||
/// `image_url` (legacy) to `start_image_url` (current docs), and the
|
||||
/// legacy gained no concept of `voice_ids` or `end_image_url`. These
|
||||
/// tests document the differences and pin the endpoint URL, so we'll
|
||||
/// notice if fal ever flips back or further changes the schema.
|
||||
mod legacy_parity {
|
||||
use super::*;
|
||||
use crate::requests_old::http::video::image::http_kling_v2p6_pro_image_to_video::{
|
||||
kling_v2p6_pro_image_to_video, KlingV2p6ProImageToVideoInput,
|
||||
};
|
||||
|
||||
/// Same `fal-ai/kling-video/v2.6/pro/image-to-video` path.
|
||||
#[test]
|
||||
fn endpoint_path_matches_legacy() {
|
||||
let legacy = kling_v2p6_pro_image_to_video(KlingV2p6ProImageToVideoInput {
|
||||
prompt: "p".to_string(),
|
||||
image_url: "https://example.com/x.png".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
Kling2p6ProImageToVideoRequest::ENDPOINT,
|
||||
legacy.endpoint,
|
||||
);
|
||||
}
|
||||
|
||||
/// The image-URL field was renamed by fal: legacy `image_url` →
|
||||
/// current `start_image_url`. This test pins the new module to the
|
||||
/// current name and confirms the legacy field name is *not* emitted.
|
||||
#[test]
|
||||
fn new_module_emits_start_image_url_not_image_url() {
|
||||
let new = Kling2p6ProImageToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
start_image_url: "https://example.com/x.png".to_string(),
|
||||
end_image_url: None,
|
||||
duration: None,
|
||||
negative_prompt: None,
|
||||
generate_audio: None,
|
||||
voice_ids: None,
|
||||
};
|
||||
let json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap();
|
||||
assert!(json.get("start_image_url").is_some(), "missing start_image_url: {json}");
|
||||
assert!(json.get("image_url").is_none(), "unexpected legacy image_url: {json}");
|
||||
}
|
||||
|
||||
/// Legacy struct still emits the *old* `image_url` field name —
|
||||
/// asserting this here flags any future schema drift in the legacy
|
||||
/// archive (it shouldn't change; if it does we want to know).
|
||||
#[test]
|
||||
fn legacy_module_still_emits_image_url() {
|
||||
let legacy = KlingV2p6ProImageToVideoInput {
|
||||
prompt: "p".to_string(),
|
||||
image_url: "https://example.com/x.png".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_value(&legacy).unwrap();
|
||||
assert!(json.get("image_url").is_some(), "missing image_url: {json}");
|
||||
assert!(json.get("start_image_url").is_none(), "unexpected start_image_url: {json}");
|
||||
}
|
||||
|
||||
/// On fields that *did* survive the rename (prompt, duration,
|
||||
/// negative_prompt, generate_audio), wire JSON must match between
|
||||
/// new and legacy when populated with the same values.
|
||||
#[test]
|
||||
fn surviving_fields_match_legacy_wire_shape() {
|
||||
let new = Kling2p6ProImageToVideoRequest {
|
||||
prompt: "shared prompt".to_string(),
|
||||
start_image_url: "https://example.com/x.png".to_string(),
|
||||
end_image_url: None,
|
||||
duration: Some(Kling2p6ProImageToVideoDuration::TenSeconds),
|
||||
negative_prompt: Some("blurry".to_string()),
|
||||
generate_audio: Some(false),
|
||||
voice_ids: None,
|
||||
};
|
||||
let mut new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap();
|
||||
|
||||
let legacy = KlingV2p6ProImageToVideoInput {
|
||||
prompt: "shared prompt".to_string(),
|
||||
image_url: "https://example.com/x.png".to_string(),
|
||||
duration: Some("10".to_string()),
|
||||
negative_prompt: Some("blurry".to_string()),
|
||||
generate_audio: Some(false),
|
||||
};
|
||||
let mut legacy_json = serde_json::to_value(&legacy).unwrap();
|
||||
|
||||
// Strip the renamed image-URL field from each side, then compare.
|
||||
// (`voice_ids` and `end_image_url` are new in 2.6 — neither is set
|
||||
// in this test, so they don't appear in JSON.)
|
||||
new_json.as_object_mut().unwrap().remove("start_image_url");
|
||||
legacy_json.as_object_mut().unwrap().remove("image_url");
|
||||
assert_eq!(new_json, legacy_json);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +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;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod elements;
|
||||
pub mod image;
|
||||
pub mod text;
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::text::kling_1p6_pro_text_to_video::raw_request::{
|
||||
Kling1p6ProTextToVideoInput, Kling1p6ProTextToVideoOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Kling1p6ProTextToVideoRequest {
|
||||
/// 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 1.6 Pro supports 5 or 10 seconds only.
|
||||
pub duration: Option<Kling1p6ProTextToVideoDuration>,
|
||||
|
||||
/// Aspect ratio.
|
||||
pub aspect_ratio: Option<Kling1p6ProTextToVideoAspectRatio>,
|
||||
|
||||
/// CFG scale. Defaults to fal's `0.5` when `None`.
|
||||
pub cfg_scale: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Kling1p6ProTextToVideoDuration {
|
||||
FiveSeconds,
|
||||
TenSeconds,
|
||||
}
|
||||
|
||||
impl Kling1p6ProTextToVideoDuration {
|
||||
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 Kling1p6ProTextToVideoAspectRatio {
|
||||
Square,
|
||||
SixteenByNine,
|
||||
NineBySixteen,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Kling1p6ProTextToVideoRequest {
|
||||
const ENDPOINT: &str = "fal-ai/kling-video/v1.6/pro/text-to-video";
|
||||
|
||||
type RawRequest = Kling1p6ProTextToVideoInput;
|
||||
type RawResponse = Kling1p6ProTextToVideoOutput;
|
||||
|
||||
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 {
|
||||
Kling1p6ProTextToVideoAspectRatio::Square => "1:1",
|
||||
Kling1p6ProTextToVideoAspectRatio::SixteenByNine => "16:9",
|
||||
Kling1p6ProTextToVideoAspectRatio::NineBySixteen => "9:16",
|
||||
}.to_string());
|
||||
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
aspect_ratio,
|
||||
duration,
|
||||
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;
|
||||
|
||||
#[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 = Kling1p6ProTextToVideoRequest {
|
||||
prompt: "a golden retriever puppy chases butterflies through a sunlit meadow, cinematic slow motion".to_string(),
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProTextToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::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 = Kling1p6ProTextToVideoRequest {
|
||||
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProTextToVideoDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::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 = [
|
||||
Kling1p6ProTextToVideoAspectRatio::Square,
|
||||
Kling1p6ProTextToVideoAspectRatio::SixteenByNine,
|
||||
Kling1p6ProTextToVideoAspectRatio::NineBySixteen,
|
||||
];
|
||||
|
||||
for ar in aspect_ratios {
|
||||
println!("--- aspect ratio: {:?} ---", ar);
|
||||
let request = Kling1p6ProTextToVideoRequest {
|
||||
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProTextToVideoDuration::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 = [
|
||||
Kling1p6ProTextToVideoDuration::FiveSeconds,
|
||||
Kling1p6ProTextToVideoDuration::TenSeconds,
|
||||
];
|
||||
|
||||
for dur in durations {
|
||||
println!("--- duration: {:?} ---", dur);
|
||||
let request = Kling1p6ProTextToVideoRequest {
|
||||
prompt: "a candle flame flickers in a dark room".to_string(),
|
||||
negative_prompt: None,
|
||||
duration: Some(dur),
|
||||
aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::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 = Kling1p6ProTextToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
negative_prompt: None,
|
||||
duration: Some(Kling1p6ProTextToVideoDuration::TenSeconds),
|
||||
aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::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 = Kling1p6ProTextToVideoRequest {
|
||||
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!(
|
||||
Kling1p6ProTextToVideoRequest::ENDPOINT,
|
||||
"fal-ai/kling-video/v1.6/pro/text-to-video",
|
||||
);
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
use crate::requests::api::video::text::kling_1p6_pro_text_to_video::api::{
|
||||
Kling1p6ProTextToVideoDuration, Kling1p6ProTextToVideoRequest,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for Kling1p6ProTextToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// Kling 1.6 Pro text-to-video: $0.098/second
|
||||
// (see https://fal.ai/models/fal-ai/kling-video/v1.6/pro/text-to-video).
|
||||
//
|
||||
// Slightly pricier than the image-to-video variant of the same model
|
||||
// (which is $0.095/sec). Rate held in tenths-of-cents and rounded up to
|
||||
// whole cents at the end so the user is never undercharged.
|
||||
let duration_secs = self.duration
|
||||
.unwrap_or(Kling1p6ProTextToVideoDuration::FiveSeconds)
|
||||
.to_seconds();
|
||||
(98u64 * duration_secs + 9) / 10
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::text::kling_1p6_pro_text_to_video::api::{
|
||||
Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration,
|
||||
};
|
||||
use crate::requests::api::video::image::kling_1p6_pro_image_to_video::api::{
|
||||
Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration,
|
||||
Kling1p6ProImageToVideoRequest,
|
||||
};
|
||||
|
||||
fn make_request(
|
||||
duration: Option<Kling1p6ProTextToVideoDuration>,
|
||||
) -> Kling1p6ProTextToVideoRequest {
|
||||
Kling1p6ProTextToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
negative_prompt: None,
|
||||
duration,
|
||||
aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine),
|
||||
cfg_scale: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing: $0.098/sec = 98 tenths-of-cents/sec.
|
||||
// Cents per duration = ceil(98 × secs / 10).
|
||||
|
||||
#[test]
|
||||
fn five_seconds() {
|
||||
// (98 * 5 + 9) / 10 = 49 (round up from 49.0¢)
|
||||
assert_eq!(
|
||||
make_request(Some(Kling1p6ProTextToVideoDuration::FiveSeconds))
|
||||
.calculate_cost_in_cents(),
|
||||
49,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_seconds() {
|
||||
// (98 * 10 + 9) / 10 = 98
|
||||
assert_eq!(
|
||||
make_request(Some(Kling1p6ProTextToVideoDuration::TenSeconds))
|
||||
.calculate_cost_in_cents(),
|
||||
98,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_five_seconds() {
|
||||
// duration=None → 5s → 49¢
|
||||
assert_eq!(make_request(None).calculate_cost_in_cents(), 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_seconds_costs_more_than_five() {
|
||||
let five = make_request(Some(Kling1p6ProTextToVideoDuration::FiveSeconds))
|
||||
.calculate_cost_in_cents();
|
||||
let ten = make_request(Some(Kling1p6ProTextToVideoDuration::TenSeconds))
|
||||
.calculate_cost_in_cents();
|
||||
assert!(five < ten, "five={five}¢ < ten={ten}¢");
|
||||
}
|
||||
|
||||
/// Sanity: text-to-video should be slightly more expensive than
|
||||
/// image-to-video at every supported duration ($0.098 vs $0.095 per
|
||||
/// second). Protects against accidentally swapping the rates.
|
||||
#[test]
|
||||
fn text_to_video_is_pricier_than_image_to_video_at_each_duration() {
|
||||
fn i2v(duration: Kling1p6ProImageToVideoDuration) -> u64 {
|
||||
Kling1p6ProImageToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
image_url: "https://example.com/i.png".to_string(),
|
||||
end_image_url: None,
|
||||
negative_prompt: None,
|
||||
duration: Some(duration),
|
||||
aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine),
|
||||
cfg_scale: None,
|
||||
}.calculate_cost_in_cents()
|
||||
}
|
||||
let pairs = [
|
||||
(Kling1p6ProTextToVideoDuration::FiveSeconds, Kling1p6ProImageToVideoDuration::FiveSeconds),
|
||||
(Kling1p6ProTextToVideoDuration::TenSeconds, Kling1p6ProImageToVideoDuration::TenSeconds),
|
||||
];
|
||||
for (t, i) in pairs {
|
||||
let text = make_request(Some(t)).calculate_cost_in_cents();
|
||||
let image = i2v(i);
|
||||
assert!(text > image, "text-to-video ({text}¢) must be > image-to-video ({image}¢) at {t:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Exhaustive cost-table tests: every permutation of cost-relevant
|
||||
/// configuration for text-to-video. There is no direct legacy
|
||||
/// equivalent (the legacy `enqueue_kling_v1p6_pro_image_to_video_webhook`
|
||||
/// is image-to-video at a slightly lower rate), so the parity here is
|
||||
/// against the canonical $0.098/sec formula. A separate sub-test
|
||||
/// cross-references the legacy image-to-video function to confirm the
|
||||
/// expected per-duration premium ($0.003/sec rounded).
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
use crate::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{
|
||||
Kling1p6ProAspectRatio as LegacyAspectRatio,
|
||||
Kling1p6ProDuration as LegacyDuration,
|
||||
Kling1p6ProRequest as LegacyRequest,
|
||||
};
|
||||
|
||||
fn new_request(
|
||||
duration: Option<Kling1p6ProTextToVideoDuration>,
|
||||
aspect_ratio: Option<Kling1p6ProTextToVideoAspectRatio>,
|
||||
) -> Kling1p6ProTextToVideoRequest {
|
||||
Kling1p6ProTextToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
negative_prompt: None,
|
||||
duration,
|
||||
aspect_ratio,
|
||||
cfg_scale: None,
|
||||
}
|
||||
}
|
||||
|
||||
// (duration, aspect_ratio, expected_cents)
|
||||
//
|
||||
// Math: ceil(98 × secs / 10) where 5s → 49¢ and 10s → 98¢.
|
||||
// duration=None defaults to 5s.
|
||||
// Aspect ratio is irrelevant to cost; iterated only to assert it.
|
||||
const COST_TABLE: &[(
|
||||
Option<Kling1p6ProTextToVideoDuration>,
|
||||
Option<Kling1p6ProTextToVideoAspectRatio>,
|
||||
u64,
|
||||
)] = &[
|
||||
// duration=None → 5s default → 49¢
|
||||
(None, None, 49),
|
||||
(None, Some(Kling1p6ProTextToVideoAspectRatio::Square), 49),
|
||||
(None, Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), 49),
|
||||
(None, Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), 49),
|
||||
// duration=5s → 49¢
|
||||
(Some(Kling1p6ProTextToVideoDuration::FiveSeconds), None, 49),
|
||||
(Some(Kling1p6ProTextToVideoDuration::FiveSeconds), Some(Kling1p6ProTextToVideoAspectRatio::Square), 49),
|
||||
(Some(Kling1p6ProTextToVideoDuration::FiveSeconds), Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), 49),
|
||||
(Some(Kling1p6ProTextToVideoDuration::FiveSeconds), Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), 49),
|
||||
// duration=10s → 98¢
|
||||
(Some(Kling1p6ProTextToVideoDuration::TenSeconds), None, 98),
|
||||
(Some(Kling1p6ProTextToVideoDuration::TenSeconds), Some(Kling1p6ProTextToVideoAspectRatio::Square), 98),
|
||||
(Some(Kling1p6ProTextToVideoDuration::TenSeconds), Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), 98),
|
||||
(Some(Kling1p6ProTextToVideoDuration::TenSeconds), Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), 98),
|
||||
];
|
||||
|
||||
#[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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Aspect ratio is not part of the billing formula — every row in the
|
||||
/// cost table must produce the same value for any aspect-ratio choice.
|
||||
#[test]
|
||||
fn cost_is_independent_of_aspect_ratio() {
|
||||
let aspect_ratios = [
|
||||
None,
|
||||
Some(Kling1p6ProTextToVideoAspectRatio::Square),
|
||||
Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine),
|
||||
Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen),
|
||||
];
|
||||
let durations = [
|
||||
None,
|
||||
Some(Kling1p6ProTextToVideoDuration::FiveSeconds),
|
||||
Some(Kling1p6ProTextToVideoDuration::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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-check against the legacy image-to-video function. Text-to-video
|
||||
/// is `$0.098/sec` and image-to-video is `$0.095/sec`, so at each
|
||||
/// duration the cost difference equals `ceil(0.003 × secs)`:
|
||||
/// 5s: 49¢ − 48¢ = 1¢ (≈ $0.015 difference before rounding)
|
||||
/// 10s: 98¢ − 95¢ = 3¢
|
||||
/// If a future change to the legacy image-to-video rate breaks this
|
||||
/// invariant, this test calls it out.
|
||||
#[test]
|
||||
fn premium_over_legacy_image_to_video_is_as_expected() {
|
||||
fn legacy_image(secs_variant: LegacyDuration) -> u64 {
|
||||
LegacyRequest {
|
||||
image_url: "https://example.com/i.png".to_string(),
|
||||
end_frame_image_url: None,
|
||||
prompt: "test".to_string(),
|
||||
duration: secs_variant,
|
||||
aspect_ratio: LegacyAspectRatio::WideSixteenNine,
|
||||
}.calculate_cost_in_cents()
|
||||
}
|
||||
|
||||
// (text duration, equivalent legacy image duration, expected premium in cents)
|
||||
let cases = [
|
||||
(Kling1p6ProTextToVideoDuration::FiveSeconds, LegacyDuration::FiveSeconds, 1u64),
|
||||
(Kling1p6ProTextToVideoDuration::TenSeconds, LegacyDuration::TenSeconds, 3u64),
|
||||
];
|
||||
|
||||
for (text_d, legacy_d, expected_premium) in cases {
|
||||
let text_cost = new_request(Some(text_d), None).calculate_cost_in_cents();
|
||||
let image_cost = legacy_image(legacy_d);
|
||||
let premium = text_cost.saturating_sub(image_cost);
|
||||
assert_eq!(
|
||||
premium, expected_premium,
|
||||
"{text_d:?}: text={text_cost}¢ legacy_image={image_cost}¢ premium={premium}¢ (want {expected_premium}¢)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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/v1.6/pro/text-to-video`.
|
||||
/// fal's schema: <https://fal.ai/models/fal-ai/kling-video/v1.6/pro/text-to-video/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Kling1p6ProTextToVideoInput {
|
||||
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>,
|
||||
|
||||
/// 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>,
|
||||
|
||||
/// 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 Kling1p6ProTextToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Kling1p6ProTextToVideoOutput {
|
||||
pub video: Kling1p6ProTextToVideoVideoFile,
|
||||
}
|
||||
+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,
|
||||
}
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
/// Wire-shape and endpoint parity vs the legacy
|
||||
/// `requests_old/http/video/text/http_kling_v2p6_pro_text_to_video.rs`.
|
||||
/// The legacy module has no cost calculator, so cost parity is N/A;
|
||||
/// these tests verify the *shape sent on the wire* hasn't drifted.
|
||||
mod legacy_parity {
|
||||
use super::*;
|
||||
use crate::requests_old::http::video::text::http_kling_v2p6_pro_text_to_video::{
|
||||
kling_v2p6_pro_text_to_video, KlingV2p6ProTextToVideoInput,
|
||||
};
|
||||
|
||||
/// Same `fal-ai/kling-video/v2.6/pro/text-to-video` path.
|
||||
#[test]
|
||||
fn endpoint_path_matches_legacy() {
|
||||
let legacy = kling_v2p6_pro_text_to_video(KlingV2p6ProTextToVideoInput::default());
|
||||
assert_eq!(
|
||||
Kling2p6ProTextToVideoRequest::ENDPOINT,
|
||||
legacy.endpoint,
|
||||
);
|
||||
}
|
||||
|
||||
/// At a representative populated case, the new module's serialized
|
||||
/// `RawRequest` must equal the legacy `Input`'s JSON byte-for-byte
|
||||
/// (modulo serde field ordering, which is stable within each struct).
|
||||
#[test]
|
||||
fn wire_json_matches_legacy_fully_populated() {
|
||||
let new = Kling2p6ProTextToVideoRequest {
|
||||
prompt: "a wave at dawn".to_string(),
|
||||
generate_audio: Some(true),
|
||||
negative_prompt: Some("blurry".to_string()),
|
||||
duration: Some(Kling2p6ProTextToVideoDuration::TenSeconds),
|
||||
aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen),
|
||||
cfg_scale: Some(0.5),
|
||||
};
|
||||
let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap();
|
||||
|
||||
let legacy = KlingV2p6ProTextToVideoInput {
|
||||
prompt: "a wave at dawn".to_string(),
|
||||
aspect_ratio: Some("9:16".to_string()),
|
||||
generate_audio: Some(true),
|
||||
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);
|
||||
}
|
||||
|
||||
/// At minimum (prompt only), unset optionals must be omitted on the
|
||||
/// wire just like the legacy struct.
|
||||
#[test]
|
||||
fn wire_json_matches_legacy_minimal() {
|
||||
let new = Kling2p6ProTextToVideoRequest {
|
||||
prompt: "minimal".to_string(),
|
||||
generate_audio: None,
|
||||
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 = KlingV2p6ProTextToVideoInput {
|
||||
prompt: "minimal".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let legacy_json = serde_json::to_value(&legacy).unwrap();
|
||||
|
||||
assert_eq!(new_json, legacy_json);
|
||||
// And the minimal payload should literally be `{"prompt": "minimal"}`.
|
||||
assert_eq!(new_json, serde_json::json!({ "prompt": "minimal" }));
|
||||
}
|
||||
|
||||
/// Cross product over duration × aspect_ratio × audio: 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(Kling2p6ProTextToVideoDuration::FiveSeconds), Some("5")),
|
||||
(Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some("10")),
|
||||
];
|
||||
let aspect_ratios = [
|
||||
(None, None),
|
||||
(Some(Kling2p6ProTextToVideoAspectRatio::Square), Some("1:1")),
|
||||
(Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), Some("16:9")),
|
||||
(Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen), Some("9:16")),
|
||||
];
|
||||
let audio_options = [None, Some(false), Some(true)];
|
||||
|
||||
for (d_new, d_legacy) in durations {
|
||||
for (ar_new, ar_legacy) in aspect_ratios {
|
||||
for audio in audio_options {
|
||||
let new = Kling2p6ProTextToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
generate_audio: audio,
|
||||
negative_prompt: None,
|
||||
duration: d_new,
|
||||
aspect_ratio: ar_new,
|
||||
cfg_scale: None,
|
||||
};
|
||||
let legacy = KlingV2p6ProTextToVideoInput {
|
||||
prompt: "p".to_string(),
|
||||
aspect_ratio: ar_legacy.map(String::from),
|
||||
generate_audio: audio,
|
||||
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:?} audio={audio:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +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;
|
||||
|
||||
@@ -124,6 +124,64 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Kling16Pro,
|
||||
model_creator: Some(ModelCreator::Kling),
|
||||
full_name: Some("Kling 1.6 Pro".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
image_references_supported: Some(true), // NB: 1.6 Elements!
|
||||
image_references_max: Some(4), // NB: 1.6 Elements!
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
duration_seconds_options: Some(vec![5, 10]),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// TODO: Kling 2.5 doesn't let you control aspect ratio for image-to-video
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Kling2p5TurboPro,
|
||||
model_creator: Some(ModelCreator::Kling),
|
||||
full_name: Some("Kling 2.5 Turbo Pro".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
duration_seconds_options: Some(vec![5, 10]),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// TODO: Kling 2.6 doesn't let you control aspect ratio for image-to-video
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Kling2p6Pro,
|
||||
model_creator: Some(ModelCreator::Kling),
|
||||
full_name: Some("Kling 2.6 Pro".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
duration_seconds_options: Some(vec![5, 10]),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Seedance1p5Pro,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
|
||||
Reference in New Issue
Block a user