mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-26 19:58:46 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b71981317a | ||
|
|
8f3909123f | ||
|
|
b841aebb2f |
@@ -14,6 +14,12 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "grok_imagine_video_1p5")]
|
||||
GrokImagineVideo1p5,
|
||||
|
||||
#[serde(rename = "flux_3")]
|
||||
Flux3,
|
||||
|
||||
#[serde(rename = "flux_3_draft")]
|
||||
Flux3Draft,
|
||||
|
||||
#[serde(rename = "kling_1p6_pro")]
|
||||
Kling16Pro,
|
||||
|
||||
@@ -153,6 +159,14 @@ mod tests {
|
||||
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flux_3_variants_serialize() {
|
||||
// NB: These strings must match `CommonVideoModel` — the two enums convert
|
||||
// via serde string round-trip in the omni handlers.
|
||||
assert_serde_round_trip(RouterVideoModel::Flux3, "flux_3");
|
||||
assert_serde_round_trip(RouterVideoModel::Flux3Draft, "flux_3_draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_h3_serializes() {
|
||||
// NB: This string must match `CommonVideoModel` — the two enums convert
|
||||
|
||||
+8
@@ -43,6 +43,8 @@ use crate::generate::generate_video::providers::artcraft::veo_3_fast::build::bui
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1::build::build_artcraft_veo_3p1;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::build::build_artcraft_veo_3p1_fast;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite;
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3::build::build_artcraft_flux_3;
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3_draft::build::build_artcraft_flux_3_draft;
|
||||
use crate::generate::generate_video::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo;
|
||||
@@ -69,6 +71,8 @@ use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fa
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite;
|
||||
use crate::generate::generate_video::providers::fal::flux_3::build::build_fal_flux_3;
|
||||
use crate::generate::generate_video::providers::fal::flux_3_draft::build::build_fal_flux_3_draft;
|
||||
use crate::generate::generate_video::providers::fal::minimax_h3::build::build_fal_minimax_h3;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo;
|
||||
@@ -174,6 +178,8 @@ impl GenerateVideoRequestBuilder {
|
||||
// Artcraft
|
||||
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo) => build_artcraft_grok_imagine_video(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Flux3) => build_artcraft_flux_3(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Flux3Draft) => build_artcraft_flux_3_draft(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self),
|
||||
@@ -210,6 +216,8 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3) => build_artcraft_vidu_q3(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3Turbo) => build_artcraft_vidu_q3_turbo(self),
|
||||
// Fal
|
||||
(RouterProvider::Fal, RouterVideoModel::Flux3) => build_fal_flux_3(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::Flux3Draft) => build_fal_flux_3_draft(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::Kling16Pro) => build_fal_kling_1_6_pro(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::Kling21Master) => build_fal_kling_2_1_master(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::Kling21Pro) => build_fal_kling_2_1_pro(self),
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
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::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_flux_3(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_artcraft_flux_3_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftFlux3(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_artcraft_flux_3_state(mut builder: GenerateVideoRequestBuilder) -> Result<ArtcraftFlux3RequestState, ArtcraftRouterError> {
|
||||
// Flux 3 accepts 5–20 seconds — wider than the generic helper's window —
|
||||
// so pre-plan the duration and reapply it after (the helper would clamp
|
||||
// 16–20s down). Also preserve generate_audio (the helper hardcodes None on
|
||||
// its output).
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let final_duration = plan_flux_3_duration(builder.duration_seconds, strategy)?;
|
||||
builder.duration_seconds = final_duration;
|
||||
|
||||
let generate_audio = builder.generate_audio;
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Flux3,
|
||||
SupportedResolutions::Full,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.generate_audio = generate_audio;
|
||||
request.duration_seconds = final_duration;
|
||||
|
||||
Ok(ArtcraftFlux3RequestState { request })
|
||||
}
|
||||
|
||||
/// Flux 3 durations: 5–20 seconds (unset lets the model pick).
|
||||
pub(crate) fn plan_flux_3_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<u16>, ArtcraftRouterError> {
|
||||
const MIN: u16 = 5;
|
||||
const MAX: u16 = 20;
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(d) if (MIN..=MAX).contains(&d) => Ok(Some(d)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", other),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(other.clamp(MIN, MAX)))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
/// Per-second rates in hundredths of a US cent.
|
||||
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_955;
|
||||
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 3_335;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftFlux3CostState {
|
||||
pub duration_seconds: u64,
|
||||
pub is_1080p: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftFlux3CostState {
|
||||
pub fn from_request(request: &ArtcraftFlux3RequestState) -> Self {
|
||||
Self {
|
||||
// Flux 3 defaults None → 5s (the shortest selectable duration).
|
||||
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
|
||||
is_1080p: is_1080p(request.request.resolution),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let rate = if self.is_1080p {
|
||||
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
|
||||
} else {
|
||||
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
|
||||
};
|
||||
// Round up to the next whole cent.
|
||||
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flux 3 renders 720p or 1080p; 1080p and above land on the higher tier,
|
||||
/// while 720p and below render at 720p. The default (unset) resolution is
|
||||
/// 720p.
|
||||
fn is_1080p(resolution: Option<CommonResolutionEnum>) -> bool {
|
||||
match resolution {
|
||||
None => false, // defaults to 720p
|
||||
Some(CommonResolutionEnum::TenEightyP)
|
||||
| Some(CommonResolutionEnum::OneK)
|
||||
| Some(CommonResolutionEnum::TwoK)
|
||||
| Some(CommonResolutionEnum::ThreeK)
|
||||
| Some(CommonResolutionEnum::FourK) => true,
|
||||
Some(CommonResolutionEnum::HalfK)
|
||||
| Some(CommonResolutionEnum::FourEightyP)
|
||||
| Some(CommonResolutionEnum::SevenTwentyP) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3::build::build_artcraft_flux_3_state;
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3::cost::ArtcraftFlux3CostState;
|
||||
|
||||
#[test]
|
||||
fn default_settings_5s_is_98() { assert_eq!(cost_cents(Some(5), None), 98); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 98); }
|
||||
|
||||
#[test]
|
||||
fn low_res_20s_is_391() { assert_eq!(cost_cents(Some(20), Some(RouterResolution::SevenTwentyP)), 391); }
|
||||
|
||||
#[test]
|
||||
fn four_eighty_p_lands_on_low_tier() {
|
||||
assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_res_5s_is_167() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::TenEightyP)), 167); }
|
||||
|
||||
#[test]
|
||||
fn high_res_10s_is_334() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 334); }
|
||||
|
||||
#[test]
|
||||
fn odd_duration_rounds_up_to_whole_cents() {
|
||||
// 7s low res: 1955 × 7 = 13685 hundredth-cents → 137 cents.
|
||||
assert_eq!(cost_cents(Some(7), None), 137);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_classifier_defaults_720p() {
|
||||
assert!(!super::is_1080p(None));
|
||||
assert!(super::is_1080p(Some(CommonResolutionEnum::TenEightyP)));
|
||||
assert!(!super::is_1080p(Some(CommonResolutionEnum::SevenTwentyP)));
|
||||
assert!(!super::is_1080p(Some(CommonResolutionEnum::FourEightyP)));
|
||||
}
|
||||
|
||||
fn cost_cents(
|
||||
duration_seconds: Option<u16>,
|
||||
resolution: Option<RouterResolution>,
|
||||
) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Flux3,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
resolution,
|
||||
..Default::default()
|
||||
};
|
||||
let state = build_artcraft_flux_3_state(b).unwrap();
|
||||
ArtcraftFlux3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftFlux3RequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftFlux3RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
|
||||
async fn keyframe_start_frame() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog stands up and walks toward the lake.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn artcraft_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Flux3,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Artcraft(p) => {
|
||||
println!("inference_job_token={:?}", p.inference_job_token);
|
||||
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3::build::plan_flux_3_duration;
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_flux_3_draft(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_artcraft_flux_3_draft_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftFlux3Draft(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_artcraft_flux_3_draft_state(mut builder: GenerateVideoRequestBuilder) -> Result<ArtcraftFlux3DraftRequestState, ArtcraftRouterError> {
|
||||
// Same duration handling as full-quality Flux 3: 5–20 seconds is wider than
|
||||
// the generic helper's window, so pre-plan and reapply after. Also preserve
|
||||
// generate_audio (the helper hardcodes None on its output).
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let final_duration = plan_flux_3_duration(builder.duration_seconds, strategy)?;
|
||||
builder.duration_seconds = final_duration;
|
||||
|
||||
let generate_audio = builder.generate_audio;
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Flux3Draft,
|
||||
SupportedResolutions::Full,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.generate_audio = generate_audio;
|
||||
request.duration_seconds = final_duration;
|
||||
|
||||
Ok(ArtcraftFlux3DraftRequestState { request })
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
/// Per-second rate in hundredths of a US cent. Drafts always render 720p, so
|
||||
/// resolution does not affect the bill.
|
||||
const GENERATION_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 690;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftFlux3DraftCostState {
|
||||
pub duration_seconds: u64,
|
||||
}
|
||||
|
||||
impl ArtcraftFlux3DraftCostState {
|
||||
pub fn from_request(request: &ArtcraftFlux3DraftRequestState) -> Self {
|
||||
Self {
|
||||
// Flux 3 Draft defaults None → 5s (the shortest selectable duration).
|
||||
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Round up to the next whole cent.
|
||||
let cost_in_usd_cents =
|
||||
(GENERATION_RATE_HUNDREDTH_CENTS_PER_SEC * self.duration_seconds).div_ceil(100);
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3_draft::build::build_artcraft_flux_3_draft_state;
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3_draft::cost::ArtcraftFlux3DraftCostState;
|
||||
|
||||
#[test]
|
||||
fn default_settings_5s_is_35() { assert_eq!(cost_cents(Some(5), None), 35); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 35); }
|
||||
|
||||
#[test]
|
||||
fn twenty_seconds_is_138() { assert_eq!(cost_cents(Some(20), None), 138); }
|
||||
|
||||
/// Drafts always render 720p; a requested resolution does not change the
|
||||
/// bill.
|
||||
#[test]
|
||||
fn cost_ignores_resolution() {
|
||||
for resolution in [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)] {
|
||||
assert_eq!(cost_cents(Some(5), resolution), 35, "resolution={resolution:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_duration_rounds_up_to_whole_cents() {
|
||||
// 7s generation: 690 × 7 = 4830 hundredth-cents → 49 cents.
|
||||
assert_eq!(cost_cents(Some(7), None), 49);
|
||||
}
|
||||
|
||||
fn cost_cents(
|
||||
duration_seconds: Option<u16>,
|
||||
resolution: Option<RouterResolution>,
|
||||
) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Flux3Draft,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
resolution,
|
||||
..Default::default()
|
||||
};
|
||||
let state = build_artcraft_flux_3_draft_state(b).unwrap();
|
||||
ArtcraftFlux3DraftCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftFlux3DraftRequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftFlux3DraftRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn artcraft_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Flux3Draft,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Artcraft(p) => {
|
||||
println!("inference_job_token={:?}", p.inference_job_token);
|
||||
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
+2
@@ -4,6 +4,8 @@ pub(crate) mod seedance_common;
|
||||
mod parity_tests;
|
||||
pub mod grok_imagine_video;
|
||||
pub mod grok_imagine_video_1p5;
|
||||
pub mod flux_3;
|
||||
pub mod flux_3_draft;
|
||||
pub mod happy_horse_1p0;
|
||||
pub mod kling_1_6_pro;
|
||||
pub mod kling_2_1_master;
|
||||
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
use fal_client::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest;
|
||||
use fal_client::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest;
|
||||
use fal_client::requests::api::video::text::flux_3::api::{
|
||||
Flux3AspectRatio, Flux3Duration, Flux3Resolution, Flux3TextToVideoRequest,
|
||||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
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::flux_3::request::{
|
||||
FalFlux3Mode, FalFlux3RequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub(crate) const MIN_DURATION_SECONDS: u16 = 5;
|
||||
pub(crate) const MAX_DURATION_SECONDS: u16 = 20;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanAspectRatio {
|
||||
Auto,
|
||||
TwentyOneByNine,
|
||||
SixteenByNine,
|
||||
FourByThree,
|
||||
Square,
|
||||
ThreeByFour,
|
||||
NineBySixteen,
|
||||
}
|
||||
|
||||
pub fn build_fal_flux_3(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_flux_3_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalFlux3(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_flux_3_state(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<FalFlux3RequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// Flux 3 exposes no reference modality here — only text, starting keyframe,
|
||||
// and start/end keyframes.
|
||||
reject_references(&builder)?;
|
||||
|
||||
let start = optional_url(builder.start_frame.clone())?;
|
||||
let end = optional_url(builder.end_frame.clone())?;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
let resolution = plan_resolution(builder.resolution, strategy)?;
|
||||
let flexible_duration = plan_flexible_duration(builder.duration_seconds, strategy)?;
|
||||
let fixed_duration = plan_fixed_duration(builder.duration_seconds, strategy)?;
|
||||
let prompt = builder.prompt.clone().unwrap_or_default();
|
||||
let generate_audio = builder.generate_audio;
|
||||
|
||||
// Modality dispatch:
|
||||
// start + end frames → first-last-frame-to-video
|
||||
// start frame only → image-to-video
|
||||
// no media → text-to-video
|
||||
let mode = match (start, end) {
|
||||
(None, None) => FalFlux3Mode::TextToVideo(Flux3TextToVideoRequest {
|
||||
prompt,
|
||||
duration: flexible_duration,
|
||||
resolution,
|
||||
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
|
||||
generate_audio,
|
||||
safety_tolerance: Some(4), // NB: 4 is the most permissive.
|
||||
}),
|
||||
(Some(image_url), None) => FalFlux3Mode::ImageToVideo(Flux3ImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
duration: flexible_duration,
|
||||
resolution,
|
||||
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
|
||||
generate_audio,
|
||||
safety_tolerance: Some(4), // NB: 4 is the most permissive.
|
||||
}),
|
||||
(Some(start_image_url), Some(end_image_url)) => FalFlux3Mode::FirstLastFrameToVideo(
|
||||
Flux3FirstLastFrameToVideoRequest {
|
||||
prompt,
|
||||
start_image_url,
|
||||
end_image_url,
|
||||
duration: fixed_duration,
|
||||
resolution,
|
||||
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
|
||||
generate_audio,
|
||||
safety_tolerance: Some(4), // NB: 4 is the most permissive.
|
||||
},
|
||||
),
|
||||
(None, Some(_)) => {
|
||||
return Err(unsupported(
|
||||
"end_frame",
|
||||
"Flux 3 requires a start_frame when end_frame is provided",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(FalFlux3RequestState { mode })
|
||||
}
|
||||
|
||||
// ── Input helpers (shared with the draft variant) ──
|
||||
|
||||
pub(crate) fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
|
||||
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field,
|
||||
value: value.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Flux 3 supports no reference inputs — no image, video, or audio references.
|
||||
pub(crate) fn reject_references(builder: &GenerateVideoRequestBuilder) -> Result<(), ArtcraftRouterError> {
|
||||
let has_reference_images = match &builder.reference_images {
|
||||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
};
|
||||
if has_reference_images {
|
||||
return Err(unsupported("reference_images", "Flux 3 does not support image references"));
|
||||
}
|
||||
|
||||
let has_reference_videos = match &builder.reference_videos {
|
||||
None => false,
|
||||
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
};
|
||||
if has_reference_videos {
|
||||
return Err(unsupported("reference_videos", "Flux 3 does not support video references"));
|
||||
}
|
||||
|
||||
let has_reference_audio = match &builder.reference_audio {
|
||||
None => false,
|
||||
Some(AudioListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(AudioListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
};
|
||||
if has_reference_audio {
|
||||
return Err(unsupported("reference_audio", "Flux 3 does not support audio references"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(url)),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plan helpers (shared with the draft variant) ──
|
||||
|
||||
pub(crate) fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
|
||||
use PlanAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None => Ok(None),
|
||||
|
||||
Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto3k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
|
||||
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
|
||||
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)),
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
|
||||
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
|
||||
}
|
||||
_ => Ok(Some(Ar::Auto)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Flux 3 renders 720p or 1080p; 480p rounds up to 720p (same billing tier).
|
||||
pub(crate) fn plan_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Flux3Resolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
Some(RouterResolution::FourEightyP)
|
||||
| Some(RouterResolution::SevenTwentyP) => Ok(Some(Flux3Resolution::SevenTwentyP)),
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(Flux3Resolution::TenEightyP)),
|
||||
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("resolution", &format!("{:?}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(Flux3Resolution::TenEightyP)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(Flux3Resolution::SevenTwentyP)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Duration for the modalities that accept `auto` (text, image). `None` stays
|
||||
/// unset so fal's `auto` default applies.
|
||||
pub(crate) fn plan_flexible_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Flux3Duration>, ArtcraftRouterError> {
|
||||
Ok(plan_fixed_duration(duration_seconds, strategy)?.map(Flux3Duration::Seconds))
|
||||
}
|
||||
|
||||
/// Duration for the fixed-duration first-last-frame modality. `None` stays
|
||||
/// unset so fal's 5-second default applies.
|
||||
pub(crate) fn plan_fixed_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<u8>, ArtcraftRouterError> {
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => {
|
||||
Ok(Some(seconds as u8))
|
||||
}
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("duration_seconds", &format!("{}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Leaf converters ──
|
||||
|
||||
pub(crate) fn to_flux_3_aspect_ratio(a: PlanAspectRatio) -> Flux3AspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => Flux3AspectRatio::Auto,
|
||||
PlanAspectRatio::TwentyOneByNine => Flux3AspectRatio::TwentyOneByNine,
|
||||
PlanAspectRatio::SixteenByNine => Flux3AspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::FourByThree => Flux3AspectRatio::FourByThree,
|
||||
PlanAspectRatio::Square => Flux3AspectRatio::Square,
|
||||
PlanAspectRatio::ThreeByFour => Flux3AspectRatio::ThreeByFour,
|
||||
PlanAspectRatio::NineBySixteen => Flux3AspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
|
||||
use super::*;
|
||||
|
||||
const START_URL: &str = "https://example.com/start.png";
|
||||
const END_URL: &str = "https://example.com/end.png";
|
||||
|
||||
mod dispatch_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_inputs_picks_t2v() {
|
||||
let state = build_fal_flux_3_state(base_builder()).expect("build");
|
||||
assert!(matches!(state.mode, FalFlux3Mode::TextToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_picks_i2v() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
|
||||
let state = build_fal_flux_3_state(b).expect("build");
|
||||
assert!(matches!(state.mode, FalFlux3Mode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_frames_pick_first_last_frame() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
|
||||
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
|
||||
let state = build_fal_flux_3_state(b).expect("build");
|
||||
let FalFlux3Mode::FirstLastFrameToVideo(req) = state.mode else {
|
||||
panic!("expected FirstLastFrameToVideo");
|
||||
};
|
||||
assert_eq!(req.start_image_url, START_URL);
|
||||
assert_eq!(req.end_image_url, END_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
|
||||
assert!(build_fal_flux_3_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_images_error() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
|
||||
assert!(build_fal_flux_3_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_videos_error() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
|
||||
assert!(build_fal_flux_3_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_audio_errors() {
|
||||
let mut b = base_builder();
|
||||
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()]));
|
||||
assert!(build_fal_flux_3_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_reference_lists_are_ignored() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![]));
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![]));
|
||||
b.reference_audio = Some(AudioListRef::Urls(vec![]));
|
||||
let state = build_fal_flux_3_state(b).expect("build");
|
||||
assert!(matches!(state.mode, FalFlux3Mode::TextToVideo(_)));
|
||||
}
|
||||
}
|
||||
|
||||
mod plan_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duration_25_errors_under_error_out() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(25);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_flux_3_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_25_clamps_to_20_under_pay_less() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(25);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
let state = build_fal_flux_3_state(b).expect("build");
|
||||
let FalFlux3Mode::TextToVideo(req) = state.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.duration, Some(Flux3Duration::Seconds(20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_480p_and_720p_map_to_720p() {
|
||||
for res in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
|
||||
let mut b = base_builder();
|
||||
b.resolution = Some(res);
|
||||
let state = build_fal_flux_3_state(b).expect("build");
|
||||
let FalFlux3Mode::TextToVideo(req) = state.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.resolution, Some(Flux3Resolution::SevenTwentyP), "res={res:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_aspect_ratio_maps_to_flux_auto() {
|
||||
let mut b = base_builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Auto);
|
||||
let state = build_fal_flux_3_state(b).expect("build");
|
||||
let FalFlux3Mode::TextToVideo(req) = state.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.aspect_ratio, Some(Flux3AspectRatio::Auto));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twenty_one_by_nine_is_supported() {
|
||||
let mut b = base_builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
let state = build_fal_flux_3_state(b).expect("build");
|
||||
let FalFlux3Mode::TextToVideo(req) = state.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.aspect_ratio, Some(Flux3AspectRatio::TwentyOneByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_audio_propagates() {
|
||||
let mut b = base_builder();
|
||||
b.generate_audio = Some(false);
|
||||
let state = build_fal_flux_3_state(b).expect("build");
|
||||
let FalFlux3Mode::TextToVideo(req) = state.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.generate_audio, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Flux3,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::providers::fal::flux_3::request::{
|
||||
FalFlux3Mode, FalFlux3RequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalFlux3CostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalFlux3CostState {
|
||||
pub fn from_request(request: &FalFlux3RequestState) -> Self {
|
||||
// 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 {
|
||||
FalFlux3Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalFlux3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalFlux3Mode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
VideoGenerationCostEstimate {
|
||||
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,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::flux_3::build::build_fal_flux_3_state;
|
||||
|
||||
use super::*;
|
||||
|
||||
// Pricing (from fal_client's flux_3 cost modules):
|
||||
// 720p: $0.17/s, 1080p: $0.29/s (all modalities)
|
||||
// fal defaults when unset: duration = 5s (auto estimates at the 5s floor),
|
||||
// resolution = 720p.
|
||||
|
||||
#[test]
|
||||
fn t2v_default_settings_is_85() {
|
||||
assert_eq!(cost_cents(base_builder(None, None)), 85);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_10s_1080p_is_290() {
|
||||
assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 290);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_5s_720p_is_85() {
|
||||
let mut b = base_builder(Some(5), Some(RouterResolution::SevenTwentyP));
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
|
||||
assert_eq!(cost_cents(b), 85);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_last_frame_20s_1080p_is_580() {
|
||||
let mut b = base_builder(Some(20), Some(RouterResolution::TenEightyP));
|
||||
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()));
|
||||
assert_eq!(cost_cents(b), 580);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_cost_forwards_cents() {
|
||||
let estimate = FalFlux3CostState { cost_in_usd_cents: 85 }.estimate_cost();
|
||||
assert_eq!(estimate.cost_in_usd_cents, Some(85));
|
||||
assert_eq!(estimate.cost_in_credits, Some(85));
|
||||
assert!(!estimate.is_free);
|
||||
}
|
||||
|
||||
fn base_builder(
|
||||
duration_seconds: Option<u16>,
|
||||
resolution: Option<RouterResolution>,
|
||||
) -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Flux3,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
resolution,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
|
||||
let state = build_fal_flux_3_state(builder).expect("build");
|
||||
FalFlux3CostState::from_request(&state).cost_in_usd_cents
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest;
|
||||
use fal_client::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest;
|
||||
use fal_client::requests::api::video::text::flux_3::api::Flux3TextToVideoRequest;
|
||||
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::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalFlux3Mode {
|
||||
TextToVideo(Flux3TextToVideoRequest),
|
||||
ImageToVideo(Flux3ImageToVideoRequest),
|
||||
FirstLastFrameToVideo(Flux3FirstLastFrameToVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalFlux3RequestState {
|
||||
pub mode: FalFlux3Mode,
|
||||
}
|
||||
|
||||
impl FalFlux3RequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
match &self.mode {
|
||||
FalFlux3Mode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalFlux3Mode::ImageToVideo(request) => send_request(request, client).await,
|
||||
FalFlux3Mode::FirstLastFrameToVideo(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::text::flux_3::api::{
|
||||
Flux3AspectRatio, Flux3Duration, Flux3Resolution,
|
||||
};
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_text_to_video_webhook() {
|
||||
let state = FalFlux3RequestState {
|
||||
mode: FalFlux3Mode::TextToVideo(Flux3TextToVideoRequest {
|
||||
prompt: "a calm lake at sunrise".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
resolution: Some(Flux3Resolution::SevenTwentyP),
|
||||
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: Some(4), // NB: 4 is the most permissive.
|
||||
}),
|
||||
};
|
||||
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());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_image_to_video_webhook() {
|
||||
let state = FalFlux3RequestState {
|
||||
mode: FalFlux3Mode::ImageToVideo(Flux3ImageToVideoRequest {
|
||||
prompt: "the lake comes alive".to_string(),
|
||||
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
resolution: Some(Flux3Resolution::SevenTwentyP),
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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());
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
use fal_client::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest;
|
||||
use fal_client::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest;
|
||||
use fal_client::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::flux_3::build::{
|
||||
optional_url, plan_aspect_ratio, plan_fixed_duration, plan_flexible_duration,
|
||||
reject_references, to_flux_3_aspect_ratio, unsupported,
|
||||
};
|
||||
use crate::generate::generate_video::providers::fal::flux_3_draft::request::{
|
||||
FalFlux3DraftMode, FalFlux3DraftRequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_fal_flux_3_draft(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_flux_3_draft_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalFlux3Draft(state)))
|
||||
}
|
||||
|
||||
/// Same modality dispatch as full-quality Flux 3 (see that build module) —
|
||||
/// text, starting keyframe, and start/end keyframes; no references — but
|
||||
/// against the draft endpoints, which always render 720p and take no
|
||||
/// resolution input, so any requested resolution is silently dropped.
|
||||
pub(crate) fn build_fal_flux_3_draft_state(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<FalFlux3DraftRequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
reject_references(&builder)?;
|
||||
|
||||
let start = optional_url(builder.start_frame.clone())?;
|
||||
let end = optional_url(builder.end_frame.clone())?;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
let flexible_duration = plan_flexible_duration(builder.duration_seconds, strategy)?;
|
||||
let fixed_duration = plan_fixed_duration(builder.duration_seconds, strategy)?;
|
||||
let prompt = builder.prompt.clone().unwrap_or_default();
|
||||
let generate_audio = builder.generate_audio;
|
||||
|
||||
let mode = match (start, end) {
|
||||
(None, None) => FalFlux3DraftMode::TextToVideo(Flux3DraftTextToVideoRequest {
|
||||
prompt,
|
||||
duration: flexible_duration,
|
||||
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
|
||||
generate_audio,
|
||||
safety_tolerance: Some(4), // NB: 4 is the most permissive.
|
||||
}),
|
||||
(Some(image_url), None) => FalFlux3DraftMode::ImageToVideo(Flux3DraftImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
duration: flexible_duration,
|
||||
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
|
||||
generate_audio,
|
||||
safety_tolerance: Some(4), // NB: 4 is the most permissive.
|
||||
}),
|
||||
(Some(start_image_url), Some(end_image_url)) => FalFlux3DraftMode::FirstLastFrameToVideo(
|
||||
Flux3DraftFirstLastFrameToVideoRequest {
|
||||
prompt,
|
||||
start_image_url,
|
||||
end_image_url,
|
||||
duration: fixed_duration,
|
||||
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
|
||||
generate_audio,
|
||||
safety_tolerance: Some(4), // NB: 4 is the most permissive.
|
||||
},
|
||||
),
|
||||
(None, Some(_)) => {
|
||||
return Err(unsupported(
|
||||
"end_frame",
|
||||
"Flux 3 Draft requires a start_frame when end_frame is provided",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(FalFlux3DraftRequestState { mode })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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::api::video_list_ref::VideoListRef;
|
||||
|
||||
use super::*;
|
||||
|
||||
const START_URL: &str = "https://example.com/start.png";
|
||||
const END_URL: &str = "https://example.com/end.png";
|
||||
|
||||
mod dispatch_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_inputs_picks_t2v() {
|
||||
let state = build_fal_flux_3_draft_state(base_builder()).expect("build");
|
||||
assert!(matches!(state.mode, FalFlux3DraftMode::TextToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_picks_i2v() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
|
||||
let state = build_fal_flux_3_draft_state(b).expect("build");
|
||||
assert!(matches!(state.mode, FalFlux3DraftMode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_frames_pick_first_last_frame() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
|
||||
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
|
||||
let state = build_fal_flux_3_draft_state(b).expect("build");
|
||||
assert!(matches!(state.mode, FalFlux3DraftMode::FirstLastFrameToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
|
||||
assert!(build_fal_flux_3_draft_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_images_error() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
|
||||
assert!(build_fal_flux_3_draft_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_videos_error() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
|
||||
assert!(build_fal_flux_3_draft_state(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Flux3Draft,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::providers::fal::flux_3_draft::request::{
|
||||
FalFlux3DraftMode, FalFlux3DraftRequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalFlux3DraftCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalFlux3DraftCostState {
|
||||
pub fn from_request(request: &FalFlux3DraftRequestState) -> Self {
|
||||
// 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 {
|
||||
FalFlux3DraftMode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalFlux3DraftMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalFlux3DraftMode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
VideoGenerationCostEstimate {
|
||||
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,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::flux_3_draft::build::build_fal_flux_3_draft_state;
|
||||
|
||||
use super::*;
|
||||
|
||||
// Pricing (from fal_client's flux_3_draft cost modules):
|
||||
// $0.06/s (always 720p, all modalities)
|
||||
// fal defaults when unset: duration = 5s (auto estimates at the 5s floor).
|
||||
|
||||
#[test]
|
||||
fn t2v_default_settings_is_30() {
|
||||
assert_eq!(cost_cents(base_builder(None)), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_20s_is_120() {
|
||||
assert_eq!(cost_cents(base_builder(Some(20))), 120);
|
||||
}
|
||||
|
||||
fn base_builder(duration_seconds: Option<u16>) -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Flux3Draft,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
|
||||
let state = build_fal_flux_3_draft_state(builder).expect("build");
|
||||
FalFlux3DraftCostState::from_request(&state).cost_in_usd_cents
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest;
|
||||
use fal_client::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest;
|
||||
use fal_client::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest;
|
||||
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::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalFlux3DraftMode {
|
||||
TextToVideo(Flux3DraftTextToVideoRequest),
|
||||
ImageToVideo(Flux3DraftImageToVideoRequest),
|
||||
FirstLastFrameToVideo(Flux3DraftFirstLastFrameToVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalFlux3DraftRequestState {
|
||||
pub mode: FalFlux3DraftMode,
|
||||
}
|
||||
|
||||
impl FalFlux3DraftRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
match &self.mode {
|
||||
FalFlux3DraftMode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalFlux3DraftMode::ImageToVideo(request) => send_request(request, client).await,
|
||||
FalFlux3DraftMode::FirstLastFrameToVideo(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::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_text_to_video_webhook() {
|
||||
let state = FalFlux3DraftRequestState {
|
||||
mode: FalFlux3DraftMode::TextToVideo(Flux3DraftTextToVideoRequest {
|
||||
prompt: "a calm lake at sunrise".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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());
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
pub mod flux_3;
|
||||
pub mod flux_3_draft;
|
||||
pub mod kling_1_6_pro;
|
||||
pub mod kling_2_1_master;
|
||||
pub mod kling_2_1_pro;
|
||||
|
||||
+36
@@ -21,6 +21,10 @@ use crate::generate::generate_video::providers::artcraft::kling_2_6_pro::cost::A
|
||||
use crate::generate::generate_video::providers::artcraft::kling_2_6_pro::request::ArtcraftKling2p6ProRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::cost::ArtcraftKling3p0ProCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::request::ArtcraftKling3p0ProRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3::cost::ArtcraftFlux3CostState;
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3_draft::cost::ArtcraftFlux3DraftCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::cost::ArtcraftKling3p0StandardCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::request::ArtcraftKling3p0StandardRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::minimax_h3::cost::ArtcraftMinimaxH3CostState;
|
||||
@@ -89,6 +93,10 @@ use crate::generate::generate_video::providers::fal::kling_2_6_pro::cost::FalKli
|
||||
use crate::generate::generate_video::providers::fal::kling_2_6_pro::request::FalKling2p6ProRequestState;
|
||||
use crate::generate::generate_video::providers::fal::kling_3p0_pro::cost::FalKling3p0ProCostState;
|
||||
use crate::generate::generate_video::providers::fal::kling_3p0_pro::request::FalKling3p0ProRequestState;
|
||||
use crate::generate::generate_video::providers::fal::flux_3::cost::FalFlux3CostState;
|
||||
use crate::generate::generate_video::providers::fal::flux_3::request::FalFlux3RequestState;
|
||||
use crate::generate::generate_video::providers::fal::flux_3_draft::cost::FalFlux3DraftCostState;
|
||||
use crate::generate::generate_video::providers::fal::flux_3_draft::request::FalFlux3DraftRequestState;
|
||||
use crate::generate::generate_video::providers::fal::kling_3p0_standard::cost::FalKling3p0StandardCostState;
|
||||
use crate::generate::generate_video::providers::fal::kling_3p0_standard::request::FalKling3p0StandardRequestState;
|
||||
use crate::generate::generate_video::providers::fal::minimax_h3::cost::FalMinimaxH3CostState;
|
||||
@@ -140,6 +148,8 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::re
|
||||
pub enum VideoGenerationRequest {
|
||||
ArtcraftGrokImagineVideo(ArtcraftGrokImagineVideoRequestState),
|
||||
ArtcraftGrokImagineVideo1p5(ArtcraftGrokImagineVideo1p5RequestState),
|
||||
ArtcraftFlux3(ArtcraftFlux3RequestState),
|
||||
ArtcraftFlux3Draft(ArtcraftFlux3DraftRequestState),
|
||||
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
|
||||
ArtcraftKling16Pro(ArtcraftKling16ProRequestState),
|
||||
ArtcraftKling21Master(ArtcraftKling21MasterRequestState),
|
||||
@@ -175,6 +185,8 @@ pub enum VideoGenerationRequest {
|
||||
ArtcraftVeo3p1Lite(ArtcraftVeo3p1LiteRequestState),
|
||||
ArtcraftViduQ3(ArtcraftViduQ3RequestState),
|
||||
ArtcraftViduQ3Turbo(ArtcraftViduQ3TurboRequestState),
|
||||
FalFlux3(FalFlux3RequestState),
|
||||
FalFlux3Draft(FalFlux3DraftRequestState),
|
||||
FalKling16Pro(FalKling16ProRequestState),
|
||||
FalKling21Master(FalKling21MasterRequestState),
|
||||
FalKling21Pro(FalKling21ProRequestState),
|
||||
@@ -219,6 +231,8 @@ impl VideoGenerationRequest {
|
||||
Self::ArtcraftKling2p5TurboPro(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftKling2p6Pro(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftKling3p0Pro(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftFlux3(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftFlux3Draft(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftKling3p0Standard(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftMinimaxH3(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftPreviewModel(_) => RouterProvider::Artcraft,
|
||||
@@ -253,6 +267,8 @@ impl VideoGenerationRequest {
|
||||
Self::FalKling2p5TurboPro(_) => RouterProvider::Fal,
|
||||
Self::FalKling2p6Pro(_) => RouterProvider::Fal,
|
||||
Self::FalKling3p0Pro(_) => RouterProvider::Fal,
|
||||
Self::FalFlux3(_) => RouterProvider::Fal,
|
||||
Self::FalFlux3Draft(_) => RouterProvider::Fal,
|
||||
Self::FalKling3p0Standard(_) => RouterProvider::Fal,
|
||||
Self::FalMinimaxH3(_) => RouterProvider::Fal,
|
||||
Self::FalSeedance10Lite(_) => RouterProvider::Fal,
|
||||
@@ -291,6 +307,8 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::ArtcraftKling2p5TurboPro(request) => Ok(ArtcraftKling2p5TurboProCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftKling2p6Pro(request) => Ok(ArtcraftKling2p6ProCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftKling3p0Pro(request) => Ok(ArtcraftKling3p0ProCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftFlux3(request) => Ok(ArtcraftFlux3CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftFlux3Draft(request) => Ok(ArtcraftFlux3DraftCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftKling3p0Standard(request) => Ok(ArtcraftKling3p0StandardCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftMinimaxH3(request) => Ok(ArtcraftMinimaxH3CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftPreviewModel(request) => Ok(ArtcraftPreviewModelCostState::from_request(request).estimate_cost()),
|
||||
@@ -325,6 +343,8 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::FalKling2p5TurboPro(request) => Ok(FalKling2p5TurboProCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalKling2p6Pro(request) => Ok(FalKling2p6ProCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalKling3p0Pro(request) => Ok(FalKling3p0ProCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalFlux3(request) => Ok(FalFlux3CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalFlux3Draft(request) => Ok(FalFlux3DraftCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalKling3p0Standard(request) => Ok(FalKling3p0StandardCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalMinimaxH3(request) => Ok(FalMinimaxH3CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalSeedance10Lite(request) => Ok(FalSeedance10LiteCostState::from_request(request).estimate_cost()),
|
||||
@@ -399,6 +419,14 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftFlux3(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftFlux3Draft(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftPreviewModel(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
@@ -535,6 +563,14 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_fal_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::FalFlux3(request) => {
|
||||
let client_ref = client.get_fal_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::FalFlux3Draft(request) => {
|
||||
let client_ref = client.get_fal_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::FalSeedance10Lite(request) => {
|
||||
let client_ref = client.get_fal_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::enhance::flux_3_draft_enhance::raw_request::{
|
||||
Flux3DraftEnhanceInput, Flux3DraftEnhanceOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Re-render a Flux 3 Draft generation at full quality (1080p, with
|
||||
/// synchronized audio) from its `draft_cache` bundle.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flux3DraftEnhanceRequest {
|
||||
/// URL of the `draft_cache` bundle returned by a Flux 3 Draft generation.
|
||||
pub draft_cache_url: String,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
|
||||
/// `2` when `None`.
|
||||
pub safety_tolerance: Option<u8>,
|
||||
|
||||
/// Duration of the draft being enhanced, in seconds. NOT sent to fal — the
|
||||
/// cache bundle already fixes the duration — but used by the cost
|
||||
/// calculator, which otherwise cannot know the billable length. Estimated
|
||||
/// at 5 seconds (the shortest draft) when `None`.
|
||||
pub expected_duration_seconds: Option<u8>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Flux3DraftEnhanceRequest {
|
||||
const ENDPOINT: &str = "blackforestlabs/flux-3/draft-enhance";
|
||||
|
||||
type RawRequest = Flux3DraftEnhanceInput;
|
||||
type RawResponse = Flux3DraftEnhanceOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
draft_cache_url: self.draft_cache_url.clone(),
|
||||
safety_tolerance: self.safety_tolerance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — needs a fresh draft_cache URL from a prior draft run, and incurs cost
|
||||
async fn test_draft_enhance_queue() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
// Paste a `draft_cache` URL from a recent Flux 3 Draft generation here.
|
||||
let draft_cache_url = read_to_string("/tmp/flux3_draft_cache_url.txt")?;
|
||||
|
||||
let request = Flux3DraftEnhanceRequest {
|
||||
draft_cache_url: draft_cache_url.trim().to_string(),
|
||||
safety_tolerance: None,
|
||||
expected_duration_seconds: Some(5),
|
||||
};
|
||||
|
||||
let result = request.send_queue_request(&api_key).await?;
|
||||
println!("Queue result — request_id: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = Flux3DraftEnhanceRequest {
|
||||
draft_cache_url: "https://example.com/cache.bin".to_string(),
|
||||
safety_tolerance: Some(3),
|
||||
expected_duration_seconds: Some(10),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.draft_cache_url, "https://example.com/cache.bin");
|
||||
assert_eq!(raw.safety_tolerance, Some(3));
|
||||
}
|
||||
|
||||
/// `expected_duration_seconds` is cost-calculator metadata and must never
|
||||
/// reach the wire.
|
||||
#[test]
|
||||
fn expected_duration_is_not_serialized() {
|
||||
let request = Flux3DraftEnhanceRequest {
|
||||
draft_cache_url: "https://example.com/cache.bin".to_string(),
|
||||
safety_tolerance: None,
|
||||
expected_duration_seconds: Some(20),
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"draft_cache_url": "https://example.com/cache.bin",
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(Flux3DraftEnhanceRequest::ENDPOINT, "blackforestlabs/flux-3/draft-enhance");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
use crate::requests::api::video::enhance::flux_3_draft_enhance::api::Flux3DraftEnhanceRequest;
|
||||
use crate::requests::api::video::text::flux_3::cost::flux_3_video_cost_cents;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// Flux 3 draft-enhance pricing (see
|
||||
// https://fal.ai/models/blackforestlabs/flux-3/draft-enhance):
|
||||
// $0.29 per second of the enhanced video (full-quality 1080p render);
|
||||
// synchronized audio is included at no extra cost.
|
||||
//
|
||||
// The request carries only a `draft_cache_url`, so the billable duration is
|
||||
// taken from the request's `expected_duration_seconds` hint (5s — the
|
||||
// shortest draft — when unset).
|
||||
const ENHANCE_RATE_CENTS_PER_SEC: u64 = 29; // $0.29/sec
|
||||
|
||||
impl FalRequestCostCalculator for Flux3DraftEnhanceRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
let duration_secs = u64::from(self.expected_duration_seconds.unwrap_or(5));
|
||||
flux_3_video_cost_cents(ENHANCE_RATE_CENTS_PER_SEC, duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_request(expected_duration_seconds: Option<u8>) -> Flux3DraftEnhanceRequest {
|
||||
Flux3DraftEnhanceRequest {
|
||||
draft_cache_url: "https://example.com/cache.bin".to_string(),
|
||||
safety_tolerance: None,
|
||||
expected_duration_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (expected_duration, expected_cents) — 29¢ × secs; None estimates at 5s.
|
||||
const COST_TABLE: &[(Option<u8>, u64)] = &[
|
||||
(Some(5), 145),
|
||||
(Some(10), 290),
|
||||
(Some(20), 580),
|
||||
(None, 145),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, expected) in COST_TABLE {
|
||||
let got = make_request(duration).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhance matches the full-quality 1080p generation rate — drafting then
|
||||
/// enhancing ($0.06 + $0.29) intentionally costs more than generating at
|
||||
/// full quality directly ($0.29 at 1080p).
|
||||
#[test]
|
||||
fn enhance_rate_matches_full_quality_1080p_rate() {
|
||||
use crate::requests::api::video::text::flux_3::cost::flux_3_standard_rate_cents_per_sec;
|
||||
assert_eq!(ENHANCE_RATE_CENTS_PER_SEC, flux_3_standard_rate_cents_per_sec(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `blackforestlabs/flux-3/draft-enhance`.
|
||||
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/draft-enhance/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Flux3DraftEnhanceInput {
|
||||
/// URL of the `draft_cache` bundle returned by a Flux 3 Draft generation.
|
||||
pub draft_cache_url: String,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftEnhanceVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftEnhanceOutput {
|
||||
pub video: Flux3DraftEnhanceVideoFile,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod flux_3_draft_enhance;
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::extend::flux_3::raw_request::{
|
||||
Flux3ExtendVideoInput, Flux3ExtendVideoOutput,
|
||||
};
|
||||
use crate::requests::api::video::text::flux_3::api::{
|
||||
Flux3AspectRatio, Flux3Duration, Flux3Resolution,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flux3ExtendVideoRequest {
|
||||
/// Text prompt describing how the video should continue.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the video to extend.
|
||||
pub video_url: String,
|
||||
|
||||
/// Duration of the extension in seconds. fal's default is `auto` when
|
||||
/// `None`.
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Output resolution. fal's default is `720p` when `None`.
|
||||
pub resolution: Option<Flux3Resolution>,
|
||||
|
||||
/// Aspect ratio. fal's default is `auto` when `None`.
|
||||
pub aspect_ratio: Option<Flux3AspectRatio>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal's default is `true` when
|
||||
/// `None`. Audio does not affect the bill.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
|
||||
/// `2` when `None`.
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Flux3ExtendVideoRequest {
|
||||
const ENDPOINT: &str = "blackforestlabs/flux-3/extend-video";
|
||||
|
||||
type RawRequest = Flux3ExtendVideoInput;
|
||||
type RawResponse = Flux3ExtendVideoOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
video_url: self.video_url.clone(),
|
||||
duration: self.duration,
|
||||
resolution: self.resolution.map(|r| r.to_str().to_string()),
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
generate_audio: self.generate_audio,
|
||||
safety_tolerance: self.safety_tolerance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_extend_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 = Flux3ExtendVideoRequest {
|
||||
prompt: "the camera keeps drifting forward as the scene continues".to_string(),
|
||||
video_url: "https://storage.googleapis.com/falserverless/example_inputs/hunyuan_video_input.mp4".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
resolution: Some(Flux3Resolution::SevenTwentyP),
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = Flux3ExtendVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
video_url: "https://example.com/source.mp4".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(10)),
|
||||
resolution: Some(Flux3Resolution::TenEightyP),
|
||||
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
|
||||
generate_audio: Some(true),
|
||||
safety_tolerance: Some(2),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.video_url, "https://example.com/source.mp4");
|
||||
assert_eq!(raw.duration, Some(Flux3Duration::Seconds(10)));
|
||||
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Flux3ExtendVideoRequest {
|
||||
prompt: "minimal".to_string(),
|
||||
video_url: "https://example.com/source.mp4".to_string(),
|
||||
duration: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "minimal",
|
||||
"video_url": "https://example.com/source.mp4",
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(Flux3ExtendVideoRequest::ENDPOINT, "blackforestlabs/flux-3/extend-video");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use crate::requests::api::video::extend::flux_3::api::Flux3ExtendVideoRequest;
|
||||
use crate::requests::api::video::text::flux_3::cost::{
|
||||
flux_3_estimated_duration_secs, flux_3_is_1080p, flux_3_video_cost_cents,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// Flux 3 extend-video pricing (see https://fal.ai/models/blackforestlabs/flux-3/extend-video):
|
||||
// 720p: $0.41 per second of generated video
|
||||
// 1080p: $0.53 per second of generated video
|
||||
//
|
||||
// Extend bills higher than the other Flux 3 modalities (which are $0.17 and
|
||||
// $0.29; see the text module). The source video is free; pricing depends on
|
||||
// resolution and the generated extension's duration only.
|
||||
const EXTEND_RATE_720P_CENTS_PER_SEC: u64 = 41; // $0.41/sec
|
||||
const EXTEND_RATE_1080P_CENTS_PER_SEC: u64 = 53; // $0.53/sec
|
||||
|
||||
impl FalRequestCostCalculator for Flux3ExtendVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
let duration_secs = flux_3_estimated_duration_secs(self.duration);
|
||||
let rate = if flux_3_is_1080p(self.resolution) {
|
||||
EXTEND_RATE_1080P_CENTS_PER_SEC
|
||||
} else {
|
||||
EXTEND_RATE_720P_CENTS_PER_SEC
|
||||
};
|
||||
flux_3_video_cost_cents(rate, duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::text::flux_3::api::{Flux3Duration, Flux3Resolution};
|
||||
|
||||
fn make_request(
|
||||
duration: Option<Flux3Duration>,
|
||||
resolution: Option<Flux3Resolution>,
|
||||
) -> Flux3ExtendVideoRequest {
|
||||
Flux3ExtendVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
video_url: "https://example.com/source.mp4".to_string(),
|
||||
duration,
|
||||
resolution,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, resolution, expected_cents)
|
||||
// Math: rate × secs where rate (cents) = 41 (720p) or 53 (1080p);
|
||||
// None/auto durations are estimated at the 5s floor.
|
||||
const COST_TABLE: &[(Option<Flux3Duration>, Option<Flux3Resolution>, u64)] = &[
|
||||
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::SevenTwentyP), 205),
|
||||
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::SevenTwentyP), 820),
|
||||
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::TenEightyP), 265),
|
||||
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::TenEightyP), 1060),
|
||||
// Defaults: duration=None/auto→5s estimate, resolution=None→720p
|
||||
(None, None, 205),
|
||||
(Some(Flux3Duration::Auto), None, 205),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, resolution, expected) in COST_TABLE {
|
||||
let got = make_request(duration, resolution).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_is_more_expensive_than_standard_generation() {
|
||||
use crate::requests::api::video::text::flux_3::cost::flux_3_standard_rate_cents_per_sec;
|
||||
assert!(EXTEND_RATE_720P_CENTS_PER_SEC > flux_3_standard_rate_cents_per_sec(false));
|
||||
assert!(EXTEND_RATE_1080P_CENTS_PER_SEC > flux_3_standard_rate_cents_per_sec(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
|
||||
|
||||
/// Over-the-wire input shape for `blackforestlabs/flux-3/extend-video`.
|
||||
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/extend-video/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Flux3ExtendVideoInput {
|
||||
/// Text prompt describing how the video should continue.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the video to extend.
|
||||
pub video_url: String,
|
||||
|
||||
/// Duration of the extension in seconds ("auto" or 5–20). fal default:
|
||||
/// "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Output resolution.
|
||||
/// Possible values: "720p", "1080p". fal default: "720p".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
|
||||
/// "9:16". fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3ExtendVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3ExtendVideoOutput {
|
||||
pub video: Flux3ExtendVideoVideoFile,
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::extend::flux_3_draft::raw_request::{
|
||||
Flux3DraftExtendVideoInput, Flux3DraftExtendVideoOutput,
|
||||
};
|
||||
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Flux 3 Draft extend-video: lower cost, lower fidelity (always 720p). The
|
||||
/// response includes a `draft_cache` that `draft-enhance` can upgrade to a
|
||||
/// full-quality 1080p render.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flux3DraftExtendVideoRequest {
|
||||
/// Text prompt describing how the video should continue.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the video to extend.
|
||||
pub video_url: String,
|
||||
|
||||
/// Duration of the extension in seconds. fal's default is `auto` when
|
||||
/// `None`.
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Aspect ratio. fal's default is `auto` when `None`.
|
||||
pub aspect_ratio: Option<Flux3AspectRatio>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal's default is `true` when
|
||||
/// `None`. Audio does not affect the bill.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
|
||||
/// `2` when `None`.
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Flux3DraftExtendVideoRequest {
|
||||
const ENDPOINT: &str = "blackforestlabs/flux-3/extend-video/draft";
|
||||
|
||||
type RawRequest = Flux3DraftExtendVideoInput;
|
||||
type RawResponse = Flux3DraftExtendVideoOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
video_url: self.video_url.clone(),
|
||||
duration: self.duration,
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
generate_audio: self.generate_audio,
|
||||
safety_tolerance: self.safety_tolerance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_extend_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 = Flux3DraftExtendVideoRequest {
|
||||
prompt: "the camera keeps drifting forward as the scene continues".to_string(),
|
||||
video_url: "https://storage.googleapis.com/falserverless/example_inputs/hunyuan_video_input.mp4".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = Flux3DraftExtendVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
video_url: "https://example.com/source.mp4".to_string(),
|
||||
duration: Some(Flux3Duration::Auto),
|
||||
aspect_ratio: Some(Flux3AspectRatio::Auto),
|
||||
generate_audio: Some(true),
|
||||
safety_tolerance: Some(4),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.video_url, "https://example.com/source.mp4");
|
||||
assert_eq!(raw.duration, Some(Flux3Duration::Auto));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
|
||||
assert_eq!(raw.generate_audio, Some(true));
|
||||
assert_eq!(raw.safety_tolerance, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Flux3DraftExtendVideoRequest {
|
||||
prompt: "minimal".to_string(),
|
||||
video_url: "https://example.com/source.mp4".to_string(),
|
||||
duration: None,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "minimal",
|
||||
"video_url": "https://example.com/source.mp4",
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(Flux3DraftExtendVideoRequest::ENDPOINT, "blackforestlabs/flux-3/extend-video/draft");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use crate::requests::api::video::extend::flux_3_draft::api::Flux3DraftExtendVideoRequest;
|
||||
use crate::requests::api::video::text::flux_3::cost::{
|
||||
flux_3_estimated_duration_secs, flux_3_video_cost_cents,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// Flux 3 Draft extend-video pricing (see
|
||||
// https://fal.ai/models/blackforestlabs/flux-3/extend-video/draft):
|
||||
// $0.12 per second of generated draft video (always 720p)
|
||||
//
|
||||
// Draft extend bills higher than the other draft modalities ($0.06/sec; see
|
||||
// the draft text module). The source video is free; pricing depends on the
|
||||
// generated extension's duration only.
|
||||
const DRAFT_EXTEND_RATE_CENTS_PER_SEC: u64 = 12; // $0.12/sec
|
||||
|
||||
impl FalRequestCostCalculator for Flux3DraftExtendVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
let duration_secs = flux_3_estimated_duration_secs(self.duration);
|
||||
flux_3_video_cost_cents(DRAFT_EXTEND_RATE_CENTS_PER_SEC, duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
|
||||
|
||||
fn make_request(duration: Option<Flux3Duration>) -> Flux3DraftExtendVideoRequest {
|
||||
Flux3DraftExtendVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
video_url: "https://example.com/source.mp4".to_string(),
|
||||
duration,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, expected_cents) — 12¢ × secs; None/auto estimate at 5s.
|
||||
const COST_TABLE: &[(Option<Flux3Duration>, u64)] = &[
|
||||
(Some(Flux3Duration::Seconds(5)), 60),
|
||||
(Some(Flux3Duration::Seconds(20)), 240),
|
||||
(None, 60),
|
||||
(Some(Flux3Duration::Auto), 60),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, expected) in COST_TABLE {
|
||||
let got = make_request(duration).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_extend_is_double_the_draft_generation_rate() {
|
||||
use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec;
|
||||
assert_eq!(DRAFT_EXTEND_RATE_CENTS_PER_SEC, flux_3_draft_generation_rate_cents_per_sec() * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
|
||||
|
||||
/// Over-the-wire input shape for `blackforestlabs/flux-3/extend-video/draft`.
|
||||
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/extend-video/draft/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Flux3DraftExtendVideoInput {
|
||||
/// Text prompt describing how the video should continue.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the video to extend.
|
||||
pub video_url: String,
|
||||
|
||||
/// Duration of the extension in seconds ("auto" or 5–20). fal default:
|
||||
/// "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
|
||||
/// "9:16". fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftExtendVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
|
||||
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
|
||||
/// 1080p render.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftExtendVideoOutput {
|
||||
pub video: Flux3DraftExtendVideoVideoFile,
|
||||
pub draft_cache: Flux3DraftExtendVideoVideoFile,
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod flux_3;
|
||||
pub mod flux_3_draft;
|
||||
pub mod veo_3p1;
|
||||
pub mod veo_3p1_fast;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::image::flux_3::raw_request::{
|
||||
Flux3ImageToVideoInput, Flux3ImageToVideoOutput,
|
||||
};
|
||||
use crate::requests::api::video::text::flux_3::api::{
|
||||
Flux3AspectRatio, Flux3Duration, Flux3Resolution,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flux3ImageToVideoRequest {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the image to animate.
|
||||
pub image_url: String,
|
||||
|
||||
/// Duration in seconds. fal's default is `auto` when `None`.
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Output resolution. fal's default is `720p` when `None`.
|
||||
pub resolution: Option<Flux3Resolution>,
|
||||
|
||||
/// Aspect ratio. fal's default is `auto` when `None`.
|
||||
pub aspect_ratio: Option<Flux3AspectRatio>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal's default is `true` when
|
||||
/// `None`. Audio does not affect the bill.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
|
||||
/// `2` when `None`.
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Flux3ImageToVideoRequest {
|
||||
const ENDPOINT: &str = "blackforestlabs/flux-3/image-to-video";
|
||||
|
||||
type RawRequest = Flux3ImageToVideoInput;
|
||||
type RawResponse = Flux3ImageToVideoOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
image_url: self.image_url.clone(),
|
||||
duration: self.duration,
|
||||
resolution: self.resolution.map(|r| r.to_str().to_string()),
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
generate_audio: self.generate_audio,
|
||||
safety_tolerance: self.safety_tolerance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
const EXAMPLE_IMAGE_URL: &str =
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[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 = Flux3ImageToVideoRequest {
|
||||
prompt: "the camera slowly pulls back, revealing the full landscape".to_string(),
|
||||
image_url: EXAMPLE_IMAGE_URL.to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
resolution: Some(Flux3Resolution::SevenTwentyP),
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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 = Flux3ImageToVideoRequest {
|
||||
prompt: "gentle wind moves through the scene as clouds drift overhead".to_string(),
|
||||
image_url: EXAMPLE_IMAGE_URL.to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
resolution: Some(Flux3Resolution::SevenTwentyP),
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = Flux3ImageToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
image_url: "https://example.com/frame.png".to_string(),
|
||||
duration: Some(Flux3Duration::Auto),
|
||||
resolution: Some(Flux3Resolution::TenEightyP),
|
||||
aspect_ratio: Some(Flux3AspectRatio::Auto),
|
||||
generate_audio: Some(true),
|
||||
safety_tolerance: Some(0),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.image_url, "https://example.com/frame.png");
|
||||
assert_eq!(raw.duration, Some(Flux3Duration::Auto));
|
||||
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
|
||||
assert_eq!(raw.generate_audio, Some(true));
|
||||
assert_eq!(raw.safety_tolerance, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Flux3ImageToVideoRequest {
|
||||
prompt: "minimal".to_string(),
|
||||
image_url: "https://example.com/frame.png".to_string(),
|
||||
duration: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "minimal",
|
||||
"image_url": "https://example.com/frame.png",
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(Flux3ImageToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/image-to-video");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use crate::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest;
|
||||
use crate::requests::api::video::text::flux_3::cost::{
|
||||
flux_3_estimated_duration_secs, flux_3_is_1080p, flux_3_standard_rate_cents_per_sec,
|
||||
flux_3_video_cost_cents,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// Image-to-video shares the standard Flux 3 per-second pricing (see the text
|
||||
// module for the canonical rate table):
|
||||
// 720p: $0.17/sec
|
||||
// 1080p: $0.29/sec
|
||||
// The input image is free; pricing depends on resolution and duration only.
|
||||
|
||||
impl FalRequestCostCalculator for Flux3ImageToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
let duration_secs = flux_3_estimated_duration_secs(self.duration);
|
||||
let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution));
|
||||
flux_3_video_cost_cents(rate, duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::text::flux_3::api::{Flux3Duration, Flux3Resolution};
|
||||
|
||||
fn make_request(
|
||||
duration: Option<Flux3Duration>,
|
||||
resolution: Option<Flux3Resolution>,
|
||||
) -> Flux3ImageToVideoRequest {
|
||||
Flux3ImageToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
image_url: "https://example.com/frame.png".to_string(),
|
||||
duration,
|
||||
resolution,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, resolution, expected_cents)
|
||||
const COST_TABLE: &[(Option<Flux3Duration>, Option<Flux3Resolution>, u64)] = &[
|
||||
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::SevenTwentyP), 85),
|
||||
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::SevenTwentyP), 340),
|
||||
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::TenEightyP), 145),
|
||||
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::TenEightyP), 580),
|
||||
// Defaults: duration=None/auto→5s estimate, resolution=None→720p
|
||||
(None, None, 85),
|
||||
(Some(Flux3Duration::Auto), Some(Flux3Resolution::TenEightyP), 145),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, resolution, expected) in COST_TABLE {
|
||||
let got = make_request(duration, resolution).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
|
||||
|
||||
/// Over-the-wire input shape for `blackforestlabs/flux-3/image-to-video`.
|
||||
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/image-to-video/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Flux3ImageToVideoInput {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the image to animate.
|
||||
pub image_url: String,
|
||||
|
||||
/// Duration in seconds ("auto" or 5–20). fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Output resolution.
|
||||
/// Possible values: "720p", "1080p". fal default: "720p".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
|
||||
/// "9:16". fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3ImageToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3ImageToVideoOutput {
|
||||
pub video: Flux3ImageToVideoVideoFile,
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::image::flux_3_draft::raw_request::{
|
||||
Flux3DraftImageToVideoInput, Flux3DraftImageToVideoOutput,
|
||||
};
|
||||
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Flux 3 Draft image-to-video: lower cost, lower fidelity (always 720p). The
|
||||
/// response includes a `draft_cache` that `draft-enhance` can upgrade to a
|
||||
/// full-quality 1080p render.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flux3DraftImageToVideoRequest {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the image to animate.
|
||||
pub image_url: String,
|
||||
|
||||
/// Duration in seconds. fal's default is `auto` when `None`.
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Aspect ratio. fal's default is `auto` when `None`.
|
||||
pub aspect_ratio: Option<Flux3AspectRatio>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal's default is `true` when
|
||||
/// `None`. Audio does not affect the bill.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
|
||||
/// `2` when `None`.
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Flux3DraftImageToVideoRequest {
|
||||
const ENDPOINT: &str = "blackforestlabs/flux-3/image-to-video/draft";
|
||||
|
||||
type RawRequest = Flux3DraftImageToVideoInput;
|
||||
type RawResponse = Flux3DraftImageToVideoOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
image_url: self.image_url.clone(),
|
||||
duration: self.duration,
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
generate_audio: self.generate_audio,
|
||||
safety_tolerance: self.safety_tolerance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
const EXAMPLE_IMAGE_URL: &str =
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[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 = Flux3DraftImageToVideoRequest {
|
||||
prompt: "the camera slowly pulls back, revealing the full landscape".to_string(),
|
||||
image_url: EXAMPLE_IMAGE_URL.to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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 = Flux3DraftImageToVideoRequest {
|
||||
prompt: "gentle wind moves through the scene as clouds drift overhead".to_string(),
|
||||
image_url: EXAMPLE_IMAGE_URL.to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = Flux3DraftImageToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
image_url: "https://example.com/frame.png".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(8)),
|
||||
aspect_ratio: Some(Flux3AspectRatio::NineBySixteen),
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: Some(1),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.image_url, "https://example.com/frame.png");
|
||||
assert_eq!(raw.duration, Some(Flux3Duration::Seconds(8)));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16"));
|
||||
assert_eq!(raw.generate_audio, Some(false));
|
||||
assert_eq!(raw.safety_tolerance, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Flux3DraftImageToVideoRequest {
|
||||
prompt: "minimal".to_string(),
|
||||
image_url: "https://example.com/frame.png".to_string(),
|
||||
duration: None,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "minimal",
|
||||
"image_url": "https://example.com/frame.png",
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(Flux3DraftImageToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/image-to-video/draft");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest;
|
||||
use crate::requests::api::video::text::flux_3::cost::{
|
||||
flux_3_estimated_duration_secs, flux_3_video_cost_cents,
|
||||
};
|
||||
use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// Draft image-to-video shares the Flux 3 Draft generation pricing (see the
|
||||
// draft text module for the canonical rate):
|
||||
// $0.06 per second of generated draft video (always 720p)
|
||||
|
||||
impl FalRequestCostCalculator for Flux3DraftImageToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
let duration_secs = flux_3_estimated_duration_secs(self.duration);
|
||||
flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
|
||||
|
||||
fn make_request(duration: Option<Flux3Duration>) -> Flux3DraftImageToVideoRequest {
|
||||
Flux3DraftImageToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
image_url: "https://example.com/frame.png".to_string(),
|
||||
duration,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, expected_cents) — 6¢ × secs; None/auto estimate at 5s.
|
||||
const COST_TABLE: &[(Option<Flux3Duration>, u64)] = &[
|
||||
(Some(Flux3Duration::Seconds(5)), 30),
|
||||
(Some(Flux3Duration::Seconds(20)), 120),
|
||||
(None, 30),
|
||||
(Some(Flux3Duration::Auto), 30),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, expected) in COST_TABLE {
|
||||
let got = make_request(duration).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
|
||||
|
||||
/// Over-the-wire input shape for `blackforestlabs/flux-3/image-to-video/draft`.
|
||||
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/image-to-video/draft/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Flux3DraftImageToVideoInput {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the image to animate.
|
||||
pub image_url: String,
|
||||
|
||||
/// Duration in seconds ("auto" or 5–20). fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
|
||||
/// "9:16". fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftImageToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
|
||||
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
|
||||
/// 1080p render.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftImageToVideoOutput {
|
||||
pub video: Flux3DraftImageToVideoVideoFile,
|
||||
pub draft_cache: Flux3DraftImageToVideoVideoFile,
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod flux_3;
|
||||
pub mod flux_3_draft;
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::images::flux_3::raw_request::{
|
||||
Flux3FirstLastFrameToVideoInput, Flux3FirstLastFrameToVideoOutput,
|
||||
};
|
||||
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Resolution};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flux3FirstLastFrameToVideoRequest {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the first frame.
|
||||
pub start_image_url: String,
|
||||
|
||||
/// URL of the last frame.
|
||||
pub end_image_url: String,
|
||||
|
||||
/// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`.
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Output resolution. fal's default is `720p` when `None`.
|
||||
pub resolution: Option<Flux3Resolution>,
|
||||
|
||||
/// Aspect ratio. fal's default is `auto` when `None`.
|
||||
pub aspect_ratio: Option<Flux3AspectRatio>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal's default is `true` when
|
||||
/// `None`. Audio does not affect the bill.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
|
||||
/// `2` when `None`.
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Flux3FirstLastFrameToVideoRequest {
|
||||
const ENDPOINT: &str = "blackforestlabs/flux-3/first-last-frame-to-video";
|
||||
|
||||
type RawRequest = Flux3FirstLastFrameToVideoInput;
|
||||
type RawResponse = Flux3FirstLastFrameToVideoOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
start_image_url: self.start_image_url.clone(),
|
||||
end_image_url: self.end_image_url.clone(),
|
||||
duration: self.duration,
|
||||
resolution: self.resolution.map(|r| r.to_str().to_string()),
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
generate_audio: self.generate_audio,
|
||||
safety_tolerance: self.safety_tolerance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
const START_IMAGE_URL: &str =
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
|
||||
const END_IMAGE_URL: &str =
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg";
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_first_last_frame_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 = Flux3FirstLastFrameToVideoRequest {
|
||||
prompt: "the scene smoothly transitions from the first frame to the last".to_string(),
|
||||
start_image_url: START_IMAGE_URL.to_string(),
|
||||
end_image_url: END_IMAGE_URL.to_string(),
|
||||
duration: Some(5),
|
||||
resolution: Some(Flux3Resolution::SevenTwentyP),
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = Flux3FirstLastFrameToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
start_image_url: "https://example.com/first.png".to_string(),
|
||||
end_image_url: "https://example.com/last.png".to_string(),
|
||||
duration: Some(12),
|
||||
resolution: Some(Flux3Resolution::TenEightyP),
|
||||
aspect_ratio: Some(Flux3AspectRatio::FourByThree),
|
||||
generate_audio: Some(true),
|
||||
safety_tolerance: Some(3),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.start_image_url, "https://example.com/first.png");
|
||||
assert_eq!(raw.end_image_url, "https://example.com/last.png");
|
||||
assert_eq!(raw.duration, Some(12));
|
||||
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3"));
|
||||
assert_eq!(raw.generate_audio, Some(true));
|
||||
assert_eq!(raw.safety_tolerance, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Flux3FirstLastFrameToVideoRequest {
|
||||
prompt: "minimal".to_string(),
|
||||
start_image_url: "https://example.com/first.png".to_string(),
|
||||
end_image_url: "https://example.com/last.png".to_string(),
|
||||
duration: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "minimal",
|
||||
"start_image_url": "https://example.com/first.png",
|
||||
"end_image_url": "https://example.com/last.png",
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(
|
||||
Flux3FirstLastFrameToVideoRequest::ENDPOINT,
|
||||
"blackforestlabs/flux-3/first-last-frame-to-video");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use crate::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest;
|
||||
use crate::requests::api::video::text::flux_3::cost::{
|
||||
flux_3_is_1080p, flux_3_standard_rate_cents_per_sec, flux_3_video_cost_cents,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// First-last-frame-to-video shares the standard Flux 3 per-second pricing
|
||||
// (see the text module for the canonical rate table):
|
||||
// 720p: $0.17/sec
|
||||
// 1080p: $0.29/sec
|
||||
// The input frames are free; pricing depends on resolution and duration only.
|
||||
|
||||
impl FalRequestCostCalculator for Flux3FirstLastFrameToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// fal default when unset: duration = 5s.
|
||||
let duration_secs = u64::from(self.duration.unwrap_or(5));
|
||||
let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution));
|
||||
flux_3_video_cost_cents(rate, duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3Resolution;
|
||||
|
||||
fn make_request(
|
||||
duration: Option<u8>,
|
||||
resolution: Option<Flux3Resolution>,
|
||||
) -> Flux3FirstLastFrameToVideoRequest {
|
||||
Flux3FirstLastFrameToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
start_image_url: "https://example.com/first.png".to_string(),
|
||||
end_image_url: "https://example.com/last.png".to_string(),
|
||||
duration,
|
||||
resolution,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, resolution, expected_cents)
|
||||
const COST_TABLE: &[(Option<u8>, Option<Flux3Resolution>, u64)] = &[
|
||||
(Some(5), Some(Flux3Resolution::SevenTwentyP), 85),
|
||||
(Some(20), Some(Flux3Resolution::SevenTwentyP), 340),
|
||||
(Some(5), Some(Flux3Resolution::TenEightyP), 145),
|
||||
(Some(20), Some(Flux3Resolution::TenEightyP), 580),
|
||||
// Defaults: duration=None→5s, resolution=None→720p
|
||||
(None, None, 85),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, resolution, expected) in COST_TABLE {
|
||||
let got = make_request(duration, resolution).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `blackforestlabs/flux-3/first-last-frame-to-video`.
|
||||
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/first-last-frame-to-video/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Flux3FirstLastFrameToVideoInput {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the first frame.
|
||||
pub start_image_url: String,
|
||||
|
||||
/// URL of the last frame.
|
||||
pub end_image_url: String,
|
||||
|
||||
/// Duration in seconds (5–20). fal default: 5.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Output resolution.
|
||||
/// Possible values: "720p", "1080p". fal default: "720p".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
|
||||
/// "9:16". fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3FirstLastFrameToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3FirstLastFrameToVideoOutput {
|
||||
pub video: Flux3FirstLastFrameToVideoVideoFile,
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::images::flux_3_draft::raw_request::{
|
||||
Flux3DraftFirstLastFrameToVideoInput, Flux3DraftFirstLastFrameToVideoOutput,
|
||||
};
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3AspectRatio;
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Flux 3 Draft first-last-frame-to-video: lower cost, lower fidelity (always
|
||||
/// 720p). The response includes a `draft_cache` that `draft-enhance` can
|
||||
/// upgrade to a full-quality 1080p render.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flux3DraftFirstLastFrameToVideoRequest {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the first frame.
|
||||
pub start_image_url: String,
|
||||
|
||||
/// URL of the last frame.
|
||||
pub end_image_url: String,
|
||||
|
||||
/// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`.
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Aspect ratio. fal's default is `auto` when `None`.
|
||||
pub aspect_ratio: Option<Flux3AspectRatio>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal's default is `true` when
|
||||
/// `None`. Audio does not affect the bill.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
|
||||
/// `2` when `None`.
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Flux3DraftFirstLastFrameToVideoRequest {
|
||||
const ENDPOINT: &str = "blackforestlabs/flux-3/first-last-frame-to-video/draft";
|
||||
|
||||
type RawRequest = Flux3DraftFirstLastFrameToVideoInput;
|
||||
type RawResponse = Flux3DraftFirstLastFrameToVideoOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
start_image_url: self.start_image_url.clone(),
|
||||
end_image_url: self.end_image_url.clone(),
|
||||
duration: self.duration,
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
generate_audio: self.generate_audio,
|
||||
safety_tolerance: self.safety_tolerance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
const START_IMAGE_URL: &str =
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
|
||||
const END_IMAGE_URL: &str =
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg";
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_first_last_frame_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 = Flux3DraftFirstLastFrameToVideoRequest {
|
||||
prompt: "the scene smoothly transitions from the first frame to the last".to_string(),
|
||||
start_image_url: START_IMAGE_URL.to_string(),
|
||||
end_image_url: END_IMAGE_URL.to_string(),
|
||||
duration: Some(5),
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = Flux3DraftFirstLastFrameToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
start_image_url: "https://example.com/first.png".to_string(),
|
||||
end_image_url: "https://example.com/last.png".to_string(),
|
||||
duration: Some(7),
|
||||
aspect_ratio: Some(Flux3AspectRatio::Square),
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: Some(2),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.start_image_url, "https://example.com/first.png");
|
||||
assert_eq!(raw.end_image_url, "https://example.com/last.png");
|
||||
assert_eq!(raw.duration, Some(7));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("1:1"));
|
||||
assert_eq!(raw.generate_audio, Some(false));
|
||||
assert_eq!(raw.safety_tolerance, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Flux3DraftFirstLastFrameToVideoRequest {
|
||||
prompt: "minimal".to_string(),
|
||||
start_image_url: "https://example.com/first.png".to_string(),
|
||||
end_image_url: "https://example.com/last.png".to_string(),
|
||||
duration: None,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "minimal",
|
||||
"start_image_url": "https://example.com/first.png",
|
||||
"end_image_url": "https://example.com/last.png",
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(
|
||||
Flux3DraftFirstLastFrameToVideoRequest::ENDPOINT,
|
||||
"blackforestlabs/flux-3/first-last-frame-to-video/draft");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest;
|
||||
use crate::requests::api::video::text::flux_3::cost::flux_3_video_cost_cents;
|
||||
use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// Draft first-last-frame-to-video shares the Flux 3 Draft generation pricing
|
||||
// (see the draft text module for the canonical rate):
|
||||
// $0.06 per second of generated draft video (always 720p)
|
||||
|
||||
impl FalRequestCostCalculator for Flux3DraftFirstLastFrameToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// fal default when unset: duration = 5s.
|
||||
let duration_secs = u64::from(self.duration.unwrap_or(5));
|
||||
flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_request(duration: Option<u8>) -> Flux3DraftFirstLastFrameToVideoRequest {
|
||||
Flux3DraftFirstLastFrameToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
start_image_url: "https://example.com/first.png".to_string(),
|
||||
end_image_url: "https://example.com/last.png".to_string(),
|
||||
duration,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, expected_cents) — 6¢ × secs; None defaults to 5s.
|
||||
const COST_TABLE: &[(Option<u8>, u64)] = &[
|
||||
(Some(5), 30),
|
||||
(Some(20), 120),
|
||||
(None, 30),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, expected) in COST_TABLE {
|
||||
let got = make_request(duration).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `blackforestlabs/flux-3/first-last-frame-to-video/draft`.
|
||||
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/first-last-frame-to-video/draft/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Flux3DraftFirstLastFrameToVideoInput {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the first frame.
|
||||
pub start_image_url: String,
|
||||
|
||||
/// URL of the last frame.
|
||||
pub end_image_url: String,
|
||||
|
||||
/// Duration in seconds (5–20). fal default: 5.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
|
||||
/// "9:16". fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftFirstLastFrameToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
|
||||
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
|
||||
/// 1080p render.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftFirstLastFrameToVideoOutput {
|
||||
pub video: Flux3DraftFirstLastFrameToVideoVideoFile,
|
||||
pub draft_cache: Flux3DraftFirstLastFrameToVideoVideoFile,
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod flux_3;
|
||||
pub mod flux_3_draft;
|
||||
pub mod veo_3p1;
|
||||
pub mod veo_3p1_fast;
|
||||
pub mod veo_3p1_lite;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::keyframes::flux_3::raw_request::{
|
||||
Flux3Keyframe, Flux3KeyframesToVideoInput, Flux3KeyframesToVideoOutput,
|
||||
};
|
||||
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Resolution};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flux3KeyframesToVideoRequest {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// Keyframe images pinned to frame positions in the generated 24 fps video
|
||||
/// (1 to 10 entries with unique `frame_index` values, each at most
|
||||
/// `duration * 24`).
|
||||
pub keyframes: Vec<Flux3Keyframe>,
|
||||
|
||||
/// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`.
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Output resolution. fal's default is `720p` when `None`.
|
||||
pub resolution: Option<Flux3Resolution>,
|
||||
|
||||
/// Aspect ratio. fal's default is `auto` when `None`.
|
||||
pub aspect_ratio: Option<Flux3AspectRatio>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal's default is `true` when
|
||||
/// `None`. Audio does not affect the bill.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
|
||||
/// `2` when `None`.
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Flux3KeyframesToVideoRequest {
|
||||
const ENDPOINT: &str = "blackforestlabs/flux-3/keyframes-to-video";
|
||||
|
||||
type RawRequest = Flux3KeyframesToVideoInput;
|
||||
type RawResponse = Flux3KeyframesToVideoOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
keyframes: self.keyframes.clone(),
|
||||
duration: self.duration,
|
||||
resolution: self.resolution.map(|r| r.to_str().to_string()),
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
generate_audio: self.generate_audio,
|
||||
safety_tolerance: self.safety_tolerance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
const KEYFRAME_ONE_URL: &str =
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
|
||||
const KEYFRAME_TWO_URL: &str =
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg";
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_keyframes_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 = Flux3KeyframesToVideoRequest {
|
||||
prompt: "the scene flows naturally between the pinned keyframes".to_string(),
|
||||
keyframes: vec![
|
||||
Flux3Keyframe { image_url: KEYFRAME_ONE_URL.to_string(), frame_index: 0 },
|
||||
Flux3Keyframe { image_url: KEYFRAME_TWO_URL.to_string(), frame_index: 96 },
|
||||
],
|
||||
duration: Some(5),
|
||||
resolution: Some(Flux3Resolution::SevenTwentyP),
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = Flux3KeyframesToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
keyframes: vec![
|
||||
Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 },
|
||||
Flux3Keyframe { image_url: "https://example.com/b.png".to_string(), frame_index: 120 },
|
||||
],
|
||||
duration: Some(10),
|
||||
resolution: Some(Flux3Resolution::TenEightyP),
|
||||
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
|
||||
generate_audio: Some(true),
|
||||
safety_tolerance: Some(2),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.keyframes.len(), 2);
|
||||
assert_eq!(raw.keyframes[0].image_url, "https://example.com/a.png");
|
||||
assert_eq!(raw.keyframes[0].frame_index, 0);
|
||||
assert_eq!(raw.keyframes[1].frame_index, 120);
|
||||
assert_eq!(raw.duration, Some(10));
|
||||
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Flux3KeyframesToVideoRequest {
|
||||
prompt: "minimal".to_string(),
|
||||
keyframes: vec![
|
||||
Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 },
|
||||
],
|
||||
duration: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "minimal",
|
||||
"keyframes": [
|
||||
{ "image_url": "https://example.com/a.png", "frame_index": 0 },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(Flux3KeyframesToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/keyframes-to-video");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use crate::requests::api::video::keyframes::flux_3::api::Flux3KeyframesToVideoRequest;
|
||||
use crate::requests::api::video::text::flux_3::cost::{
|
||||
flux_3_is_1080p, flux_3_standard_rate_cents_per_sec, flux_3_video_cost_cents,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// Keyframes-to-video shares the standard Flux 3 per-second pricing (see the
|
||||
// text module for the canonical rate table):
|
||||
// 720p: $0.17/sec
|
||||
// 1080p: $0.29/sec
|
||||
// Keyframe images are free; pricing depends on resolution and duration only.
|
||||
|
||||
impl FalRequestCostCalculator for Flux3KeyframesToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// fal default when unset: duration = 5s.
|
||||
let duration_secs = u64::from(self.duration.unwrap_or(5));
|
||||
let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution));
|
||||
flux_3_video_cost_cents(rate, duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe;
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3Resolution;
|
||||
|
||||
fn make_request(
|
||||
duration: Option<u8>,
|
||||
resolution: Option<Flux3Resolution>,
|
||||
keyframe_count: u32,
|
||||
) -> Flux3KeyframesToVideoRequest {
|
||||
Flux3KeyframesToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
keyframes: (0..keyframe_count)
|
||||
.map(|i| Flux3Keyframe {
|
||||
image_url: format!("https://example.com/kf-{i}.png"),
|
||||
frame_index: i * 24,
|
||||
})
|
||||
.collect(),
|
||||
duration,
|
||||
resolution,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, resolution, expected_cents)
|
||||
const COST_TABLE: &[(Option<u8>, Option<Flux3Resolution>, u64)] = &[
|
||||
(Some(5), Some(Flux3Resolution::SevenTwentyP), 85),
|
||||
(Some(20), Some(Flux3Resolution::SevenTwentyP), 340),
|
||||
(Some(5), Some(Flux3Resolution::TenEightyP), 145),
|
||||
(Some(20), Some(Flux3Resolution::TenEightyP), 580),
|
||||
// Defaults: duration=None→5s, resolution=None→720p
|
||||
(None, None, 85),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, resolution, expected) in COST_TABLE {
|
||||
let got = make_request(duration, resolution, 2).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of keyframes does not affect the bill.
|
||||
#[test]
|
||||
fn cost_is_independent_of_keyframe_count() {
|
||||
let one = make_request(Some(10), None, 1).calculate_cost_in_cents();
|
||||
let ten = make_request(Some(10), None, 10).calculate_cost_in_cents();
|
||||
assert_eq!(one, ten);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -0,0 +1,58 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `blackforestlabs/flux-3/keyframes-to-video`.
|
||||
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/keyframes-to-video/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Flux3KeyframesToVideoInput {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// Keyframe images pinned to frame positions (1 to 10 entries with unique
|
||||
/// `frame_index` values).
|
||||
pub keyframes: Vec<Flux3Keyframe>,
|
||||
|
||||
/// Duration in seconds (5–20). fal default: 5.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Output resolution.
|
||||
/// Possible values: "720p", "1080p". fal default: "720p".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
|
||||
/// "9:16". fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
/// A keyframe image pinned to a frame position in the generated 24 fps video.
|
||||
/// Shared by the full-quality and draft keyframes endpoints.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default, Eq, PartialEq)]
|
||||
pub struct Flux3Keyframe {
|
||||
/// URL of the keyframe image (PNG, JPEG, or WebP).
|
||||
pub image_url: String,
|
||||
|
||||
/// Frame position of this keyframe in the generated 24 fps video. Must be
|
||||
/// unique and at most `duration * 24`.
|
||||
pub frame_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3KeyframesToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3KeyframesToVideoOutput {
|
||||
pub video: Flux3KeyframesToVideoVideoFile,
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe;
|
||||
use crate::requests::api::video::keyframes::flux_3_draft::raw_request::{
|
||||
Flux3DraftKeyframesToVideoInput, Flux3DraftKeyframesToVideoOutput,
|
||||
};
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3AspectRatio;
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Flux 3 Draft keyframes-to-video: lower cost, lower fidelity (always 720p).
|
||||
/// The response includes a `draft_cache` that `draft-enhance` can upgrade to
|
||||
/// a full-quality 1080p render.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flux3DraftKeyframesToVideoRequest {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// Keyframe images pinned to frame positions in the generated 24 fps video
|
||||
/// (1 to 10 entries with unique `frame_index` values, each at most
|
||||
/// `duration * 24`).
|
||||
pub keyframes: Vec<Flux3Keyframe>,
|
||||
|
||||
/// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`.
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Aspect ratio. fal's default is `auto` when `None`.
|
||||
pub aspect_ratio: Option<Flux3AspectRatio>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal's default is `true` when
|
||||
/// `None`. Audio does not affect the bill.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
|
||||
/// `2` when `None`.
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Flux3DraftKeyframesToVideoRequest {
|
||||
const ENDPOINT: &str = "blackforestlabs/flux-3/keyframes-to-video/draft";
|
||||
|
||||
type RawRequest = Flux3DraftKeyframesToVideoInput;
|
||||
type RawResponse = Flux3DraftKeyframesToVideoOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
keyframes: self.keyframes.clone(),
|
||||
duration: self.duration,
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
generate_audio: self.generate_audio,
|
||||
safety_tolerance: self.safety_tolerance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
const KEYFRAME_ONE_URL: &str =
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
|
||||
const KEYFRAME_TWO_URL: &str =
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg";
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_keyframes_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 = Flux3DraftKeyframesToVideoRequest {
|
||||
prompt: "the scene flows naturally between the pinned keyframes".to_string(),
|
||||
keyframes: vec![
|
||||
Flux3Keyframe { image_url: KEYFRAME_ONE_URL.to_string(), frame_index: 0 },
|
||||
Flux3Keyframe { image_url: KEYFRAME_TWO_URL.to_string(), frame_index: 96 },
|
||||
],
|
||||
duration: Some(5),
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = Flux3DraftKeyframesToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
keyframes: vec![
|
||||
Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 48 },
|
||||
],
|
||||
duration: Some(6),
|
||||
aspect_ratio: Some(Flux3AspectRatio::TwentyOneByNine),
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: Some(0),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.keyframes.len(), 1);
|
||||
assert_eq!(raw.keyframes[0].frame_index, 48);
|
||||
assert_eq!(raw.duration, Some(6));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("21:9"));
|
||||
assert_eq!(raw.generate_audio, Some(false));
|
||||
assert_eq!(raw.safety_tolerance, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Flux3DraftKeyframesToVideoRequest {
|
||||
prompt: "minimal".to_string(),
|
||||
keyframes: vec![
|
||||
Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 },
|
||||
],
|
||||
duration: None,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "minimal",
|
||||
"keyframes": [
|
||||
{ "image_url": "https://example.com/a.png", "frame_index": 0 },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(
|
||||
Flux3DraftKeyframesToVideoRequest::ENDPOINT,
|
||||
"blackforestlabs/flux-3/keyframes-to-video/draft");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::requests::api::video::keyframes::flux_3_draft::api::Flux3DraftKeyframesToVideoRequest;
|
||||
use crate::requests::api::video::text::flux_3::cost::flux_3_video_cost_cents;
|
||||
use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// Draft keyframes-to-video shares the Flux 3 Draft generation pricing (see
|
||||
// the draft text module for the canonical rate):
|
||||
// $0.06 per second of generated draft video (always 720p)
|
||||
|
||||
impl FalRequestCostCalculator for Flux3DraftKeyframesToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// fal default when unset: duration = 5s.
|
||||
let duration_secs = u64::from(self.duration.unwrap_or(5));
|
||||
flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe;
|
||||
|
||||
fn make_request(duration: Option<u8>) -> Flux3DraftKeyframesToVideoRequest {
|
||||
Flux3DraftKeyframesToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
keyframes: vec![
|
||||
Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 },
|
||||
],
|
||||
duration,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, expected_cents) — 6¢ × secs; None defaults to 5s.
|
||||
const COST_TABLE: &[(Option<u8>, u64)] = &[
|
||||
(Some(5), 30),
|
||||
(Some(20), 120),
|
||||
(None, 30),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, expected) in COST_TABLE {
|
||||
let got = make_request(duration).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe;
|
||||
|
||||
/// Over-the-wire input shape for `blackforestlabs/flux-3/keyframes-to-video/draft`.
|
||||
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/keyframes-to-video/draft/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Flux3DraftKeyframesToVideoInput {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// Keyframe images pinned to frame positions (1 to 10 entries with unique
|
||||
/// `frame_index` values).
|
||||
pub keyframes: Vec<Flux3Keyframe>,
|
||||
|
||||
/// Duration in seconds (5–20). fal default: 5.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
|
||||
/// "9:16". fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftKeyframesToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
|
||||
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
|
||||
/// 1080p render.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftKeyframesToVideoOutput {
|
||||
pub video: Flux3DraftKeyframesToVideoVideoFile,
|
||||
pub draft_cache: Flux3DraftKeyframesToVideoVideoFile,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod flux_3;
|
||||
pub mod flux_3_draft;
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod elements;
|
||||
pub mod enhance;
|
||||
pub mod extend;
|
||||
pub mod image;
|
||||
pub mod images;
|
||||
pub mod keyframes;
|
||||
pub mod reference;
|
||||
pub mod text;
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
use std::fmt;
|
||||
|
||||
use serde::de::{self, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::text::flux_3::raw_request::{
|
||||
Flux3TextToVideoInput, Flux3TextToVideoOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flux3TextToVideoRequest {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// Duration in seconds. fal's default is `auto` when `None` (the model
|
||||
/// picks a duration that fits the prompt).
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Output resolution. fal's default is `720p` when `None`.
|
||||
pub resolution: Option<Flux3Resolution>,
|
||||
|
||||
/// Aspect ratio. fal's default is `auto` when `None`.
|
||||
pub aspect_ratio: Option<Flux3AspectRatio>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal's default is `true` when
|
||||
/// `None`. Audio does not affect the bill.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
|
||||
/// `2` when `None`.
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
/// Flux 3 durations: either `auto` (the model decides) or a whole number of
|
||||
/// seconds in 5–20. Serializes as the string `"auto"` or a bare integer.
|
||||
/// Shared by every Flux 3 / Flux 3 Draft video modality.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Flux3Duration {
|
||||
Auto,
|
||||
Seconds(u8),
|
||||
}
|
||||
|
||||
/// Flux 3 resolutions. 1080p bills at a higher per-second rate than 720p (see
|
||||
/// the cost modules). Draft endpoints always render 720p and take no
|
||||
/// resolution input. Shared by every full-quality Flux 3 video modality.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Flux3Resolution {
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
/// Flux 3 aspect ratios. `auto` derives the ratio from the inputs (or the
|
||||
/// prompt for text-to-video). Shared by every Flux 3 / Flux 3 Draft video
|
||||
/// modality.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Flux3AspectRatio {
|
||||
Auto,
|
||||
TwentyOneByNine,
|
||||
TwoByOne,
|
||||
SixteenByNine,
|
||||
FourByThree,
|
||||
Square,
|
||||
ThreeByFour,
|
||||
NineBySixteen,
|
||||
}
|
||||
|
||||
impl Serialize for Flux3Duration {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
Self::Auto => serializer.serialize_str("auto"),
|
||||
Self::Seconds(seconds) => serializer.serialize_u8(*seconds),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Flux3Duration {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct Flux3DurationVisitor;
|
||||
|
||||
impl Visitor<'_> for Flux3DurationVisitor {
|
||||
type Value = Flux3Duration;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("the string \"auto\" or an integer number of seconds")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, value: &str) -> Result<Flux3Duration, E> {
|
||||
if value == "auto" {
|
||||
Ok(Flux3Duration::Auto)
|
||||
} else {
|
||||
Err(E::invalid_value(de::Unexpected::Str(value), &self))
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_u64<E: de::Error>(self, value: u64) -> Result<Flux3Duration, E> {
|
||||
u8::try_from(value)
|
||||
.map(Flux3Duration::Seconds)
|
||||
.map_err(|_| E::invalid_value(de::Unexpected::Unsigned(value), &self))
|
||||
}
|
||||
|
||||
fn visit_i64<E: de::Error>(self, value: i64) -> Result<Flux3Duration, E> {
|
||||
u8::try_from(value)
|
||||
.map(Flux3Duration::Seconds)
|
||||
.map_err(|_| E::invalid_value(de::Unexpected::Signed(value), &self))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(Flux3DurationVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl Flux3Resolution {
|
||||
/// 1080p is billed at the higher per-second rate.
|
||||
pub fn is_1080p(&self) -> bool {
|
||||
matches!(self, Self::TenEightyP)
|
||||
}
|
||||
|
||||
pub(crate) fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SevenTwentyP => "720p",
|
||||
Self::TenEightyP => "1080p",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Flux3AspectRatio {
|
||||
pub(crate) fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::TwentyOneByNine => "21:9",
|
||||
Self::TwoByOne => "2:1",
|
||||
Self::SixteenByNine => "16:9",
|
||||
Self::FourByThree => "4:3",
|
||||
Self::Square => "1:1",
|
||||
Self::ThreeByFour => "3:4",
|
||||
Self::NineBySixteen => "9:16",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FalEndpoint for Flux3TextToVideoRequest {
|
||||
const ENDPOINT: &str = "blackforestlabs/flux-3/text-to-video";
|
||||
|
||||
type RawRequest = Flux3TextToVideoInput;
|
||||
type RawResponse = Flux3TextToVideoOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
duration: self.duration,
|
||||
resolution: self.resolution.map(|r| r.to_str().to_string()),
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
generate_audio: self.generate_audio,
|
||||
safety_tolerance: self.safety_tolerance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[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 = Flux3TextToVideoRequest {
|
||||
prompt: "a white kitten chases a butterfly across a sunlit garden".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
resolution: Some(Flux3Resolution::SevenTwentyP),
|
||||
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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 = Flux3TextToVideoRequest {
|
||||
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
resolution: Some(Flux3Resolution::SevenTwentyP),
|
||||
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = Flux3TextToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(12)),
|
||||
resolution: Some(Flux3Resolution::TenEightyP),
|
||||
aspect_ratio: Some(Flux3AspectRatio::TwoByOne),
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: Some(4),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.duration, Some(Flux3Duration::Seconds(12)));
|
||||
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("2:1"));
|
||||
assert_eq!(raw.generate_audio, Some(false));
|
||||
assert_eq!(raw.safety_tolerance, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Flux3TextToVideoRequest {
|
||||
prompt: "minimal".to_string(),
|
||||
duration: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({ "prompt": "minimal" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_serializes_as_auto_string_or_bare_integer() {
|
||||
assert_eq!(serde_json::to_value(Flux3Duration::Auto).unwrap(), serde_json::json!("auto"));
|
||||
assert_eq!(serde_json::to_value(Flux3Duration::Seconds(12)).unwrap(), serde_json::json!(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_deserializes_from_auto_string_or_integer() {
|
||||
assert_eq!(serde_json::from_value::<Flux3Duration>(serde_json::json!("auto")).unwrap(), Flux3Duration::Auto);
|
||||
assert_eq!(serde_json::from_value::<Flux3Duration>(serde_json::json!(7)).unwrap(), Flux3Duration::Seconds(7));
|
||||
assert!(serde_json::from_value::<Flux3Duration>(serde_json::json!("fast")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_resolution_maps_to_wire_string() {
|
||||
for (variant, expected, is_1080p) in [
|
||||
(Flux3Resolution::SevenTwentyP, "720p", false),
|
||||
(Flux3Resolution::TenEightyP, "1080p", true),
|
||||
] {
|
||||
assert_eq!(variant.to_str(), expected);
|
||||
assert_eq!(variant.is_1080p(), is_1080p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_aspect_ratio_maps_to_wire_string() {
|
||||
for (variant, expected) in [
|
||||
(Flux3AspectRatio::Auto, "auto"),
|
||||
(Flux3AspectRatio::TwentyOneByNine, "21:9"),
|
||||
(Flux3AspectRatio::TwoByOne, "2:1"),
|
||||
(Flux3AspectRatio::SixteenByNine, "16:9"),
|
||||
(Flux3AspectRatio::FourByThree, "4:3"),
|
||||
(Flux3AspectRatio::Square, "1:1"),
|
||||
(Flux3AspectRatio::ThreeByFour, "3:4"),
|
||||
(Flux3AspectRatio::NineBySixteen, "9:16"),
|
||||
] {
|
||||
assert_eq!(variant.to_str(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(Flux3TextToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/text-to-video");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use crate::requests::api::video::text::flux_3::api::{
|
||||
Flux3Duration, Flux3Resolution, Flux3TextToVideoRequest,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// Flux 3 pricing (see https://fal.ai/models/blackforestlabs/flux-3/text-to-video):
|
||||
// 720p: $0.17 per second of generated video
|
||||
// 1080p: $0.29 per second of generated video
|
||||
//
|
||||
// The same rates apply to the image-to-video, first-last-frame-to-video, and
|
||||
// keyframes-to-video modalities (extend-video bills higher; see its cost
|
||||
// module). Audio, aspect ratio, and safety tolerance are free. Both rates are
|
||||
// exact whole cents.
|
||||
const RATE_720P_CENTS_PER_SEC: u64 = 17; // $0.17/sec
|
||||
const RATE_1080P_CENTS_PER_SEC: u64 = 29; // $0.29/sec
|
||||
|
||||
/// Duration assumed by cost estimates when the request leaves duration unset
|
||||
/// or `auto` — fal bills the actual generated seconds, and 5s (the shortest
|
||||
/// selectable duration) is the quotable floor.
|
||||
const ASSUMED_AUTO_DURATION_SECS: u64 = 5;
|
||||
|
||||
/// Per-second rate in whole cents for the standard (non-extend) Flux 3
|
||||
/// modalities. `is_1080p` selects the 1080p tier.
|
||||
pub(crate) fn flux_3_standard_rate_cents_per_sec(is_1080p: bool) -> u64 {
|
||||
if is_1080p { RATE_1080P_CENTS_PER_SEC } else { RATE_720P_CENTS_PER_SEC }
|
||||
}
|
||||
|
||||
/// Per-second rate × duration → whole cents (all Flux 3 rates are exact
|
||||
/// cents, so no rounding is needed).
|
||||
pub(crate) fn flux_3_video_cost_cents(rate_cents_per_sec: u64, duration_secs: u64) -> UsdCents {
|
||||
rate_cents_per_sec * duration_secs
|
||||
}
|
||||
|
||||
/// Billable seconds for a flexible (`auto`-capable) duration. `None` and
|
||||
/// `Auto` estimate at the 5-second floor.
|
||||
pub(crate) fn flux_3_estimated_duration_secs(duration: Option<Flux3Duration>) -> u64 {
|
||||
match duration {
|
||||
None | Some(Flux3Duration::Auto) => ASSUMED_AUTO_DURATION_SECS,
|
||||
Some(Flux3Duration::Seconds(seconds)) => u64::from(seconds),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an optional resolution lands on the 1080p tier (fal defaults to
|
||||
/// 720p when unset).
|
||||
pub(crate) fn flux_3_is_1080p(resolution: Option<Flux3Resolution>) -> bool {
|
||||
resolution.map_or(false, |r| r.is_1080p())
|
||||
}
|
||||
|
||||
impl FalRequestCostCalculator for Flux3TextToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
let duration_secs = flux_3_estimated_duration_secs(self.duration);
|
||||
let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution));
|
||||
flux_3_video_cost_cents(rate, duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3AspectRatio;
|
||||
|
||||
fn make_request(
|
||||
duration: Option<Flux3Duration>,
|
||||
resolution: Option<Flux3Resolution>,
|
||||
) -> Flux3TextToVideoRequest {
|
||||
Flux3TextToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
duration,
|
||||
resolution,
|
||||
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
|
||||
generate_audio: Some(true),
|
||||
safety_tolerance: None,
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, resolution, expected_cents)
|
||||
// Math: rate × secs where rate (cents) = 17 (720p) or 29 (1080p);
|
||||
// None/auto durations are estimated at the 5s floor.
|
||||
const COST_TABLE: &[(Option<Flux3Duration>, Option<Flux3Resolution>, u64)] = &[
|
||||
// 720p → $0.17/s
|
||||
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::SevenTwentyP), 85),
|
||||
(Some(Flux3Duration::Seconds(10)), Some(Flux3Resolution::SevenTwentyP), 170),
|
||||
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::SevenTwentyP), 340),
|
||||
// 1080p → $0.29/s
|
||||
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::TenEightyP), 145),
|
||||
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::TenEightyP), 580),
|
||||
// Defaults: duration=None/auto→5s estimate, resolution=None→720p
|
||||
(None, None, 85),
|
||||
(Some(Flux3Duration::Auto), None, 85),
|
||||
(Some(Flux3Duration::Auto), Some(Flux3Resolution::TenEightyP), 145),
|
||||
(None, Some(Flux3Resolution::TenEightyP), 145),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, resolution, expected) in COST_TABLE {
|
||||
let got = make_request(duration, resolution).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_eighty_p_is_more_expensive_than_720p() {
|
||||
let low = make_request(Some(Flux3Duration::Seconds(8)), Some(Flux3Resolution::SevenTwentyP)).calculate_cost_in_cents();
|
||||
let high = make_request(Some(Flux3Duration::Seconds(8)), Some(Flux3Resolution::TenEightyP)).calculate_cost_in_cents();
|
||||
assert!(high > low, "1080p={high}¢ should exceed 720p={low}¢");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_linearly_with_duration() {
|
||||
let five = make_request(Some(Flux3Duration::Seconds(5)), None).calculate_cost_in_cents();
|
||||
let ten = make_request(Some(Flux3Duration::Seconds(10)), None).calculate_cost_in_cents();
|
||||
assert_eq!(ten, five * 2);
|
||||
}
|
||||
|
||||
/// Audio, aspect ratio, and safety tolerance do not affect the bill.
|
||||
#[test]
|
||||
fn cost_ignores_audio_aspect_ratio_and_safety_tolerance() {
|
||||
let baseline = make_request(Some(Flux3Duration::Seconds(8)), None).calculate_cost_in_cents();
|
||||
for generate_audio in [None, Some(false), Some(true)] {
|
||||
for safety_tolerance in [None, Some(0), Some(4)] {
|
||||
let cost = Flux3TextToVideoRequest {
|
||||
aspect_ratio: Some(Flux3AspectRatio::NineBySixteen),
|
||||
generate_audio,
|
||||
safety_tolerance,
|
||||
..make_request(Some(Flux3Duration::Seconds(8)), None)
|
||||
}.calculate_cost_in_cents();
|
||||
assert_eq!(cost, baseline, "audio={generate_audio:?} safety={safety_tolerance:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -0,0 +1,45 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
|
||||
|
||||
/// Over-the-wire input shape for `blackforestlabs/flux-3/text-to-video`.
|
||||
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/text-to-video/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Flux3TextToVideoInput {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// Duration in seconds ("auto" or 5–20). fal default: "auto" (the model
|
||||
/// picks a duration that fits the prompt).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Output resolution.
|
||||
/// Possible values: "720p", "1080p". fal default: "720p".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
|
||||
/// "9:16". fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3VideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3TextToVideoOutput {
|
||||
pub video: Flux3VideoFile,
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
|
||||
use crate::requests::api::video::text::flux_3_draft::raw_request::{
|
||||
Flux3DraftTextToVideoInput, Flux3DraftTextToVideoOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Flux 3 Draft text-to-video: lower cost, lower fidelity (always 720p). The
|
||||
/// response includes a `draft_cache` that `draft-enhance` can upgrade to a
|
||||
/// full-quality 1080p render.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flux3DraftTextToVideoRequest {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// Duration in seconds. fal's default is `auto` when `None` (the model
|
||||
/// picks a duration that fits the prompt).
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Aspect ratio. fal's default is `auto` when `None`.
|
||||
pub aspect_ratio: Option<Flux3AspectRatio>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal's default is `true` when
|
||||
/// `None`. Audio does not affect the bill.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
|
||||
/// `2` when `None`.
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Flux3DraftTextToVideoRequest {
|
||||
const ENDPOINT: &str = "blackforestlabs/flux-3/text-to-video/draft";
|
||||
|
||||
type RawRequest = Flux3DraftTextToVideoInput;
|
||||
type RawResponse = Flux3DraftTextToVideoOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
duration: self.duration,
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
generate_audio: self.generate_audio,
|
||||
safety_tolerance: self.safety_tolerance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[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 = Flux3DraftTextToVideoRequest {
|
||||
prompt: "a white kitten chases a butterfly across a sunlit garden".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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 = Flux3DraftTextToVideoRequest {
|
||||
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(5)),
|
||||
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: 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(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = Flux3DraftTextToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
duration: Some(Flux3Duration::Seconds(12)),
|
||||
aspect_ratio: Some(Flux3AspectRatio::ThreeByFour),
|
||||
generate_audio: Some(false),
|
||||
safety_tolerance: Some(4),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.duration, Some(Flux3Duration::Seconds(12)));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("3:4"));
|
||||
assert_eq!(raw.generate_audio, Some(false));
|
||||
assert_eq!(raw.safety_tolerance, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Flux3DraftTextToVideoRequest {
|
||||
prompt: "minimal".to_string(),
|
||||
duration: None,
|
||||
aspect_ratio: None,
|
||||
generate_audio: None,
|
||||
safety_tolerance: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({ "prompt": "minimal" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_output_parses_video_and_draft_cache() {
|
||||
let json = serde_json::json!({
|
||||
"video": { "url": "https://example.com/draft.mp4" },
|
||||
"draft_cache": { "url": "https://example.com/cache.bin" },
|
||||
"extra_field": true,
|
||||
});
|
||||
let output: Flux3DraftTextToVideoOutput = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(output.video.url, "https://example.com/draft.mp4");
|
||||
assert_eq!(output.draft_cache.url, "https://example.com/cache.bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(Flux3DraftTextToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/text-to-video/draft");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use crate::requests::api::video::text::flux_3::cost::{
|
||||
flux_3_estimated_duration_secs, flux_3_video_cost_cents,
|
||||
};
|
||||
use crate::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// Flux 3 Draft pricing (see https://fal.ai/models/blackforestlabs/flux-3/text-to-video/draft):
|
||||
// $0.06 per second of generated draft video (always 720p)
|
||||
//
|
||||
// The same rate applies to the draft image-to-video, first-last-frame, and
|
||||
// keyframes modalities (draft extend-video bills higher; see its cost
|
||||
// module). Audio, aspect ratio, and safety tolerance are free.
|
||||
const DRAFT_RATE_CENTS_PER_SEC: u64 = 6; // $0.06/sec
|
||||
|
||||
/// Per-second rate in whole cents for the standard (non-extend) Flux 3 Draft
|
||||
/// generation modalities.
|
||||
pub(crate) fn flux_3_draft_generation_rate_cents_per_sec() -> u64 {
|
||||
DRAFT_RATE_CENTS_PER_SEC
|
||||
}
|
||||
|
||||
impl FalRequestCostCalculator for Flux3DraftTextToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
let duration_secs = flux_3_estimated_duration_secs(self.duration);
|
||||
flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
|
||||
|
||||
fn make_request(duration: Option<Flux3Duration>) -> Flux3DraftTextToVideoRequest {
|
||||
Flux3DraftTextToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
duration,
|
||||
aspect_ratio: None,
|
||||
generate_audio: Some(true),
|
||||
safety_tolerance: None,
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, expected_cents)
|
||||
// Math: 6¢ × secs; None/auto durations are estimated at the 5s floor.
|
||||
const COST_TABLE: &[(Option<Flux3Duration>, u64)] = &[
|
||||
(Some(Flux3Duration::Seconds(5)), 30),
|
||||
(Some(Flux3Duration::Seconds(10)), 60),
|
||||
(Some(Flux3Duration::Seconds(20)), 120),
|
||||
(None, 30),
|
||||
(Some(Flux3Duration::Auto), 30),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, expected) in COST_TABLE {
|
||||
let got = make_request(duration).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_is_cheaper_than_full_quality() {
|
||||
use crate::requests::api::video::text::flux_3::cost::flux_3_standard_rate_cents_per_sec;
|
||||
assert!(flux_3_draft_generation_rate_cents_per_sec() < flux_3_standard_rate_cents_per_sec(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -0,0 +1,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
|
||||
|
||||
/// Over-the-wire input shape for `blackforestlabs/flux-3/text-to-video/draft`.
|
||||
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/text-to-video/draft/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Flux3DraftTextToVideoInput {
|
||||
/// Text prompt for video generation.
|
||||
pub prompt: String,
|
||||
|
||||
/// Duration in seconds ("auto" or 5–20). fal default: "auto" (the model
|
||||
/// picks a duration that fits the prompt).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<Flux3Duration>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
|
||||
/// "9:16". fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Whether to generate synchronized audio. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety_tolerance: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
|
||||
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
|
||||
/// 1080p render.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Flux3DraftTextToVideoOutput {
|
||||
pub video: Flux3DraftVideoFile,
|
||||
pub draft_cache: Flux3DraftVideoFile,
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod flux_3;
|
||||
pub mod flux_3_draft;
|
||||
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;
|
||||
|
||||
+2
@@ -62,6 +62,8 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
|
||||
RouterVideoModel::Seedance2p0Fast => Some(CommonModelType::Seedance2p0Fast),
|
||||
RouterVideoModel::HappyHorse1p0 => Some(CommonModelType::HappyHorse1p0),
|
||||
RouterVideoModel::MinimaxH3 => Some(CommonModelType::MinimaxH3),
|
||||
RouterVideoModel::Flux3 => Some(CommonModelType::Flux3),
|
||||
RouterVideoModel::Flux3Draft => Some(CommonModelType::Flux3Draft),
|
||||
RouterVideoModel::Sora2 => Some(CommonModelType::Sora2),
|
||||
RouterVideoModel::Sora2Pro => Some(CommonModelType::Sora2Pro),
|
||||
RouterVideoModel::Veo2 => Some(CommonModelType::Veo2),
|
||||
|
||||
+18
@@ -164,6 +164,10 @@ pub enum InferenceModelType {
|
||||
GrokImagineVideo,
|
||||
#[serde(rename = "grok_imagine_video_1p5")]
|
||||
GrokImagineVideo1p5,
|
||||
#[serde(rename = "flux_3")]
|
||||
Flux3,
|
||||
#[serde(rename = "flux_3_draft")]
|
||||
Flux3Draft,
|
||||
#[serde(rename = "kling_1p6_pro")]
|
||||
Kling16Pro,
|
||||
#[serde(rename = "kling_2p1_pro")]
|
||||
@@ -360,6 +364,8 @@ impl InferenceModelType {
|
||||
Self::GrokVideo => "grok_video",
|
||||
Self::GrokImagineVideo => "grok_imagine_video",
|
||||
Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5",
|
||||
Self::Flux3 => "flux_3",
|
||||
Self::Flux3Draft => "flux_3_draft",
|
||||
Self::Kling16Pro => "kling_1p6_pro",
|
||||
Self::Kling21Pro => "kling_2p1_pro",
|
||||
Self::Kling21Master => "kling_2p1_master",
|
||||
@@ -492,6 +498,8 @@ impl InferenceModelType {
|
||||
"grok_video" => Ok(Self::GrokVideo),
|
||||
"grok_imagine_video" => Ok(Self::GrokImagineVideo),
|
||||
"grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5),
|
||||
"flux_3" => Ok(Self::Flux3),
|
||||
"flux_3_draft" => Ok(Self::Flux3Draft),
|
||||
"kling_1p6_pro" => Ok(Self::Kling16Pro),
|
||||
"kling_2p1_pro" => Ok(Self::Kling21Pro),
|
||||
"kling_2p1_master" => Ok(Self::Kling21Master),
|
||||
@@ -628,6 +636,8 @@ impl InferenceModelType {
|
||||
Self::GrokVideo,
|
||||
Self::GrokImagineVideo,
|
||||
Self::GrokImagineVideo1p5,
|
||||
Self::Flux3,
|
||||
Self::Flux3Draft,
|
||||
Self::Kling16Pro,
|
||||
Self::Kling21Pro,
|
||||
Self::Kling21Master,
|
||||
@@ -747,6 +757,8 @@ impl InferenceModelType {
|
||||
CommonModelType::GrokVideo => Self::GrokVideo,
|
||||
CommonModelType::GrokImagineVideo => Self::GrokImagineVideo,
|
||||
CommonModelType::GrokImagineVideo1p5 => Self::GrokImagineVideo1p5,
|
||||
CommonModelType::Flux3 => Self::Flux3,
|
||||
CommonModelType::Flux3Draft => Self::Flux3Draft,
|
||||
CommonModelType::Kling16Pro => Self::Kling16Pro,
|
||||
CommonModelType::Kling21Pro => Self::Kling21Pro,
|
||||
CommonModelType::Kling21Master => Self::Kling21Master,
|
||||
@@ -888,6 +900,8 @@ mod tests {
|
||||
assert_serialization(InferenceModelType::GrokVideo, "grok_video");
|
||||
assert_serialization(InferenceModelType::GrokImagineVideo, "grok_imagine_video");
|
||||
assert_serialization(InferenceModelType::GrokImagineVideo1p5, "grok_imagine_video_1p5");
|
||||
assert_serialization(InferenceModelType::Flux3, "flux_3");
|
||||
assert_serialization(InferenceModelType::Flux3Draft, "flux_3_draft");
|
||||
assert_serialization(InferenceModelType::Kling16Pro, "kling_1p6_pro");
|
||||
assert_serialization(InferenceModelType::Kling21Pro, "kling_2p1_pro");
|
||||
assert_serialization(InferenceModelType::Kling21Master, "kling_2p1_master");
|
||||
@@ -1016,6 +1030,8 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::GrokVideo.to_str(), "grok_video");
|
||||
assert_eq!(InferenceModelType::GrokImagineVideo.to_str(), "grok_imagine_video");
|
||||
assert_eq!(InferenceModelType::GrokImagineVideo1p5.to_str(), "grok_imagine_video_1p5");
|
||||
assert_eq!(InferenceModelType::Flux3.to_str(), "flux_3");
|
||||
assert_eq!(InferenceModelType::Flux3Draft.to_str(), "flux_3_draft");
|
||||
assert_eq!(InferenceModelType::Kling16Pro.to_str(), "kling_1p6_pro");
|
||||
assert_eq!(InferenceModelType::Kling21Pro.to_str(), "kling_2p1_pro");
|
||||
assert_eq!(InferenceModelType::Kling21Master.to_str(), "kling_2p1_master");
|
||||
@@ -1146,6 +1162,8 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::from_str("grok_video").unwrap(), InferenceModelType::GrokVideo);
|
||||
assert_eq!(InferenceModelType::from_str("grok_imagine_video").unwrap(), InferenceModelType::GrokImagineVideo);
|
||||
assert_eq!(InferenceModelType::from_str("grok_imagine_video_1p5").unwrap(), InferenceModelType::GrokImagineVideo1p5);
|
||||
assert_eq!(InferenceModelType::from_str("flux_3").unwrap(), InferenceModelType::Flux3);
|
||||
assert_eq!(InferenceModelType::from_str("flux_3_draft").unwrap(), InferenceModelType::Flux3Draft);
|
||||
assert_eq!(InferenceModelType::from_str("kling_1p6_pro").unwrap(), InferenceModelType::Kling16Pro);
|
||||
assert_eq!(InferenceModelType::from_str("kling_2p1_pro").unwrap(), InferenceModelType::Kling21Pro);
|
||||
assert_eq!(InferenceModelType::from_str("kling_2p1_master").unwrap(), InferenceModelType::Kling21Master);
|
||||
|
||||
@@ -135,6 +135,10 @@ pub enum CommonModelType {
|
||||
/// xAI's Grok Imagine video 1.5 preview model (grok-imagine-video-1.5-preview).
|
||||
#[serde(rename = "grok_imagine_video_1p5")]
|
||||
GrokImagineVideo1p5,
|
||||
#[serde(rename = "flux_3")]
|
||||
Flux3,
|
||||
#[serde(rename = "flux_3_draft")]
|
||||
Flux3Draft,
|
||||
#[serde(rename = "kling_1p6_pro")]
|
||||
Kling16Pro,
|
||||
#[serde(rename = "kling_2p1_pro")]
|
||||
@@ -315,6 +319,8 @@ impl CommonModelType {
|
||||
Self::GrokVideo => "grok_video",
|
||||
Self::GrokImagineVideo => "grok_imagine_video",
|
||||
Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5",
|
||||
Self::Flux3 => "flux_3",
|
||||
Self::Flux3Draft => "flux_3_draft",
|
||||
Self::Kling16Pro => "kling_1p6_pro",
|
||||
Self::Kling21Pro => "kling_2p1_pro",
|
||||
Self::Kling21Master => "kling_2p1_master",
|
||||
@@ -430,6 +436,8 @@ impl CommonModelType {
|
||||
"grok_video" => Ok(Self::GrokVideo),
|
||||
"grok_imagine_video" => Ok(Self::GrokImagineVideo),
|
||||
"grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5),
|
||||
"flux_3" => Ok(Self::Flux3),
|
||||
"flux_3_draft" => Ok(Self::Flux3Draft),
|
||||
"kling_1p6_pro" => Ok(Self::Kling16Pro),
|
||||
"kling_2p1_pro" => Ok(Self::Kling21Pro),
|
||||
"kling_2p1_master" => Ok(Self::Kling21Master),
|
||||
@@ -549,6 +557,8 @@ impl CommonModelType {
|
||||
Self::GrokVideo,
|
||||
Self::GrokImagineVideo,
|
||||
Self::GrokImagineVideo1p5,
|
||||
Self::Flux3,
|
||||
Self::Flux3Draft,
|
||||
Self::Kling16Pro,
|
||||
Self::Kling21Pro,
|
||||
Self::Kling21Master,
|
||||
@@ -665,6 +675,8 @@ impl CommonModelType {
|
||||
Self::GrokVideo => CommonModelClass::Video,
|
||||
Self::GrokImagineVideo => CommonModelClass::Video,
|
||||
Self::GrokImagineVideo1p5 => CommonModelClass::Video,
|
||||
Self::Flux3 => CommonModelClass::Video,
|
||||
Self::Flux3Draft => CommonModelClass::Video,
|
||||
Self::Kling16Pro => CommonModelClass::Video,
|
||||
Self::Kling21Pro => CommonModelClass::Video,
|
||||
Self::Kling21Master => CommonModelClass::Video,
|
||||
@@ -790,6 +802,8 @@ mod tests {
|
||||
assert_serialization(CommonModelType::GrokVideo, "grok_video");
|
||||
assert_serialization(CommonModelType::GrokImagineVideo, "grok_imagine_video");
|
||||
assert_serialization(CommonModelType::GrokImagineVideo1p5, "grok_imagine_video_1p5");
|
||||
assert_serialization(CommonModelType::Flux3, "flux_3");
|
||||
assert_serialization(CommonModelType::Flux3Draft, "flux_3_draft");
|
||||
assert_serialization(CommonModelType::Kling16Pro, "kling_1p6_pro");
|
||||
assert_serialization(CommonModelType::Kling21Pro, "kling_2p1_pro");
|
||||
assert_serialization(CommonModelType::Kling21Master, "kling_2p1_master");
|
||||
@@ -900,6 +914,8 @@ mod tests {
|
||||
assert_eq!(CommonModelType::GrokVideo.to_str(), "grok_video");
|
||||
assert_eq!(CommonModelType::GrokImagineVideo.to_str(), "grok_imagine_video");
|
||||
assert_eq!(CommonModelType::GrokImagineVideo1p5.to_str(), "grok_imagine_video_1p5");
|
||||
assert_eq!(CommonModelType::Flux3.to_str(), "flux_3");
|
||||
assert_eq!(CommonModelType::Flux3Draft.to_str(), "flux_3_draft");
|
||||
assert_eq!(CommonModelType::Kling16Pro.to_str(), "kling_1p6_pro");
|
||||
assert_eq!(CommonModelType::Kling21Pro.to_str(), "kling_2p1_pro");
|
||||
assert_eq!(CommonModelType::Kling21Master.to_str(), "kling_2p1_master");
|
||||
@@ -1012,6 +1028,8 @@ mod tests {
|
||||
assert_eq!(CommonModelType::from_str("grok_video").unwrap(), CommonModelType::GrokVideo);
|
||||
assert_eq!(CommonModelType::from_str("grok_imagine_video").unwrap(), CommonModelType::GrokImagineVideo);
|
||||
assert_eq!(CommonModelType::from_str("grok_imagine_video_1p5").unwrap(), CommonModelType::GrokImagineVideo1p5);
|
||||
assert_eq!(CommonModelType::from_str("flux_3").unwrap(), CommonModelType::Flux3);
|
||||
assert_eq!(CommonModelType::from_str("flux_3_draft").unwrap(), CommonModelType::Flux3Draft);
|
||||
assert_eq!(CommonModelType::from_str("kling_1p6_pro").unwrap(), CommonModelType::Kling16Pro);
|
||||
assert_eq!(CommonModelType::from_str("kling_2p1_pro").unwrap(), CommonModelType::Kling21Pro);
|
||||
assert_eq!(CommonModelType::from_str("kling_2p1_master").unwrap(), CommonModelType::Kling21Master);
|
||||
@@ -1080,7 +1098,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonModelType::all_variants();
|
||||
assert_eq!(variants.len(), 100);
|
||||
assert_eq!(variants.len(), 102);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
|
||||
@@ -1124,6 +1142,8 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::GrokVideo));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineVideo));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineVideo1p5));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux3));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux3Draft));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling16Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling21Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling21Master));
|
||||
|
||||
@@ -17,6 +17,12 @@ pub enum CommonVideoModel {
|
||||
#[serde(rename = "grok_imagine_video_1p5")]
|
||||
GrokImagineVideo1p5,
|
||||
|
||||
#[serde(rename = "flux_3")]
|
||||
Flux3,
|
||||
|
||||
#[serde(rename = "flux_3_draft")]
|
||||
Flux3Draft,
|
||||
|
||||
#[serde(rename = "kling_1p6_pro")]
|
||||
Kling16Pro,
|
||||
|
||||
@@ -134,6 +140,8 @@ impl CommonVideoModel {
|
||||
Self::GrokVideo => CommonModelType::GrokVideo,
|
||||
Self::GrokImagineVideo => CommonModelType::GrokImagineVideo,
|
||||
Self::GrokImagineVideo1p5 => CommonModelType::GrokImagineVideo1p5,
|
||||
Self::Flux3 => CommonModelType::Flux3,
|
||||
Self::Flux3Draft => CommonModelType::Flux3Draft,
|
||||
Self::Kling16Pro => CommonModelType::Kling16Pro,
|
||||
Self::Kling21Pro => CommonModelType::Kling21Pro,
|
||||
Self::Kling21Master => CommonModelType::Kling21Master,
|
||||
@@ -184,6 +192,8 @@ mod tests {
|
||||
assert_serialization(CommonVideoModel::GrokVideo, "grok_video");
|
||||
assert_serialization(CommonVideoModel::GrokImagineVideo, "grok_imagine_video");
|
||||
assert_serialization(CommonVideoModel::GrokImagineVideo1p5, "grok_imagine_video_1p5");
|
||||
assert_serialization(CommonVideoModel::Flux3, "flux_3");
|
||||
assert_serialization(CommonVideoModel::Flux3Draft, "flux_3_draft");
|
||||
assert_serialization(CommonVideoModel::Kling16Pro, "kling_1p6_pro");
|
||||
assert_serialization(CommonVideoModel::Kling21Pro, "kling_2p1_pro");
|
||||
assert_serialization(CommonVideoModel::Kling21Master, "kling_2p1_master");
|
||||
@@ -227,6 +237,8 @@ mod tests {
|
||||
("grok_video", CommonVideoModel::GrokVideo),
|
||||
("grok_imagine_video", CommonVideoModel::GrokImagineVideo),
|
||||
("grok_imagine_video_1p5", CommonVideoModel::GrokImagineVideo1p5),
|
||||
("flux_3", CommonVideoModel::Flux3),
|
||||
("flux_3_draft", CommonVideoModel::Flux3Draft),
|
||||
("kling_1p6_pro", CommonVideoModel::Kling16Pro),
|
||||
("kling_2p1_pro", CommonVideoModel::Kling21Pro),
|
||||
("kling_2p1_master", CommonVideoModel::Kling21Master),
|
||||
@@ -277,6 +289,8 @@ mod tests {
|
||||
CommonVideoModel::GrokVideo,
|
||||
CommonVideoModel::GrokImagineVideo,
|
||||
CommonVideoModel::GrokImagineVideo1p5,
|
||||
CommonVideoModel::Flux3,
|
||||
CommonVideoModel::Flux3Draft,
|
||||
CommonVideoModel::Kling16Pro,
|
||||
CommonVideoModel::Kling21Pro,
|
||||
CommonVideoModel::Kling21Master,
|
||||
@@ -326,6 +340,8 @@ mod tests {
|
||||
(CommonVideoModel::GrokVideo, CommonModelType::GrokVideo),
|
||||
(CommonVideoModel::GrokImagineVideo, CommonModelType::GrokImagineVideo),
|
||||
(CommonVideoModel::GrokImagineVideo1p5, CommonModelType::GrokImagineVideo1p5),
|
||||
(CommonVideoModel::Flux3, CommonModelType::Flux3),
|
||||
(CommonVideoModel::Flux3Draft, CommonModelType::Flux3Draft),
|
||||
(CommonVideoModel::Kling16Pro, CommonModelType::Kling16Pro),
|
||||
(CommonVideoModel::Kling21Pro, CommonModelType::Kling21Pro),
|
||||
(CommonVideoModel::Kling21Master, CommonModelType::Kling21Master),
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::OmniGenVideoModelDetails;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation::model_creator::ModelCreator;
|
||||
|
||||
/// Black Forest Labs Flux video models.
|
||||
///
|
||||
/// Both variants cover text-to-video, starting keyframe (image-to-video), and
|
||||
/// start/end keyframes (first-last-frame). No reference inputs.
|
||||
pub fn flux_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
let mut models = Vec::new();
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Flux3,
|
||||
model_creator: Some(ModelCreator::BlackForestLabs),
|
||||
full_name: Some("Flux 3".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
show_generate_with_sound_toggle: Some(true),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::Auto,
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::Auto),
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::SevenTwentyP,
|
||||
CommonResolution::TenEightyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
duration_seconds_min: Some(5),
|
||||
duration_seconds_max: Some(20),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Flux3Draft,
|
||||
model_creator: Some(ModelCreator::BlackForestLabs),
|
||||
full_name: Some("Flux 3 Draft".to_string()),
|
||||
extra_info_short: Some("Fast low-cost drafts".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
show_generate_with_sound_toggle: Some(true),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::Auto,
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::Auto),
|
||||
// Drafts always render 720p.
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::SevenTwentyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
duration_seconds_min: Some(5),
|
||||
duration_seconds_max: Some(20),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod flux_video_models;
|
||||
pub mod grok_video_models;
|
||||
pub mod happy_horse_video_models;
|
||||
pub mod kling_video_models;
|
||||
|
||||
@@ -3,6 +3,7 @@ use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use super::by_type::flux_video_models::flux_video_models;
|
||||
use super::by_type::grok_video_models::grok_video_models;
|
||||
use super::by_type::happy_horse_video_models::happy_horse_video_models;
|
||||
use super::by_type::kling_video_models::{kling_disabled_video_models, kling_video_models};
|
||||
@@ -27,6 +28,7 @@ pub const OMNI_GEN_VIDEO_MODELS_AND_PROVIDERS: Lazy<OmniGenVideoModelsResponse>
|
||||
fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
let mut models = Vec::new();
|
||||
|
||||
models.extend(flux_video_models());
|
||||
models.extend(grok_video_models());
|
||||
models.extend(happy_horse_video_models());
|
||||
models.extend(kling_video_models());
|
||||
@@ -105,6 +107,14 @@ fn build_omni_gen_video_model_providers() -> Vec<OmniGenVideoModelProviderDetail
|
||||
model: CommonVideoModel::MinimaxH3,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenVideoProviderModelDetails {
|
||||
model: CommonVideoModel::Flux3,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenVideoProviderModelDetails {
|
||||
model: CommonVideoModel::Flux3Draft,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenVideoProviderModelDetails {
|
||||
model: CommonVideoModel::Kling16Pro,
|
||||
overrides: None,
|
||||
|
||||
+2
@@ -133,6 +133,8 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
|
||||
CommonVideoModel::GrokVideo => RouterVideoModel::GrokVideo,
|
||||
CommonVideoModel::GrokImagineVideo => RouterVideoModel::GrokImagineVideo,
|
||||
CommonVideoModel::GrokImagineVideo1p5 => RouterVideoModel::GrokImagineVideo1p5,
|
||||
CommonVideoModel::Flux3 => RouterVideoModel::Flux3,
|
||||
CommonVideoModel::Flux3Draft => RouterVideoModel::Flux3Draft,
|
||||
CommonVideoModel::Kling16Pro => RouterVideoModel::Kling16Pro,
|
||||
CommonVideoModel::Kling21Pro => RouterVideoModel::Kling21Pro,
|
||||
CommonVideoModel::Kling21Master => RouterVideoModel::Kling21Master,
|
||||
|
||||
+24
@@ -75,6 +75,28 @@ mod tests {
|
||||
assert!(response.cost_in_credits.unwrap() > 0);
|
||||
}
|
||||
|
||||
/// Flux 3 defaults (5s, 720p) quote at 98 credits; the 1080p tier quotes
|
||||
/// at 167 for 5s. Flux 3 Draft (always 720p) quotes at 35 for 5s.
|
||||
#[tokio::test]
|
||||
async fn flux_3_quotes_by_variant_and_resolution_tier() {
|
||||
let full_default = post_cost_request(base_request(CommonVideoModel::Flux3))
|
||||
.await
|
||||
.expect("flux 3 default cost estimate should succeed");
|
||||
assert_eq!(full_default.cost_in_credits, Some(98));
|
||||
|
||||
let mut full_high_res = base_request(CommonVideoModel::Flux3);
|
||||
full_high_res.resolution = Some(CommonResolution::TenEightyP);
|
||||
let full_high_res_quote = post_cost_request(full_high_res)
|
||||
.await
|
||||
.expect("flux 3 1080p cost estimate should succeed");
|
||||
assert_eq!(full_high_res_quote.cost_in_credits, Some(167));
|
||||
|
||||
let draft_default = post_cost_request(base_request(CommonVideoModel::Flux3Draft))
|
||||
.await
|
||||
.expect("flux 3 draft cost estimate should succeed");
|
||||
assert_eq!(draft_default.cost_in_credits, Some(35));
|
||||
}
|
||||
|
||||
/// MiniMax H3 defaults (5s, 2K) quote at 150 credits; the 768P tier
|
||||
/// (720p and below) quotes at 92 for 5s.
|
||||
#[tokio::test]
|
||||
@@ -95,6 +117,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn bare_model_gets_a_quote_for_representative_models() {
|
||||
for model in [
|
||||
CommonVideoModel::Flux3,
|
||||
CommonVideoModel::Flux3Draft,
|
||||
CommonVideoModel::GrokImagineVideo,
|
||||
CommonVideoModel::Kling3p0Pro,
|
||||
CommonVideoModel::MinimaxH3,
|
||||
|
||||
Reference in New Issue
Block a user