diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_video_model.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_video_model.rs index 546416c83e..befbae6664 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/router_video_model.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_video_model.rs @@ -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 diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs index c464574012..e363c564d9 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs @@ -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), diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/build.rs new file mode 100644 index 0000000000..df2caeddb1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/build.rs @@ -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 { + 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 { + // 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, + strategy: RequestMismatchMitigationStrategy, +) -> Result, 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))) + } + }, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/cost.rs new file mode 100644 index 0000000000..c8d8c32012 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/cost.rs @@ -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) -> 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, + resolution: Option, + ) -> 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() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/request.rs new file mode 100644 index 0000000000..593ba3b35c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/request.rs @@ -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 { + 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 + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/build.rs new file mode 100644 index 0000000000..cd217193af --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/build.rs @@ -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 { + 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 { + // 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 }) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/cost.rs new file mode 100644 index 0000000000..b60873a35f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/cost.rs @@ -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, + resolution: Option, + ) -> 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() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/request.rs new file mode 100644 index 0000000000..05e278ae30 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/request.rs @@ -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 { + 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 + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/mod.rs index f3c93b28e0..15ee374530 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/mod.rs @@ -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; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/build.rs new file mode 100644 index 0000000000..0fe2f5e08b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/build.rs @@ -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 { + 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 { + 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) -> Result, 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, + strategy: RequestMismatchMitigationStrategy, +) -> Result, 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, + strategy: RequestMismatchMitigationStrategy, +) -> Result, 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, + strategy: RequestMismatchMitigationStrategy, +) -> Result, 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, + strategy: RequestMismatchMitigationStrategy, +) -> Result, 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() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/cost.rs new file mode 100644 index 0000000000..5038ea0d1e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/cost.rs @@ -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, + resolution: Option, + ) -> 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 + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/request.rs new file mode 100644 index 0000000000..ec978079c4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/request.rs @@ -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 { + 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(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = 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, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + 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(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/build.rs new file mode 100644 index 0000000000..e041c79368 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/build.rs @@ -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 { + 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 { + 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() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/cost.rs new file mode 100644 index 0000000000..dff4c11f7c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/cost.rs @@ -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) -> 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 + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/request.rs new file mode 100644 index 0000000000..be7701e606 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/request.rs @@ -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 { + 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(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = 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, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + 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(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/mod.rs index 3cf222133c..1476919317 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/mod.rs @@ -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; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_request.rs index 3fcea77bf0..a20df6fc34 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_request.rs @@ -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 diff --git a/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/api.rs b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/api.rs new file mode 100644 index 0000000000..851eee3680 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/api.rs @@ -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, + + /// 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, +} + +impl FalEndpoint for Flux3DraftEnhanceRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/draft-enhance"; + + type RawRequest = Flux3DraftEnhanceInput; + type RawResponse = Flux3DraftEnhanceOutput; + + fn to_raw_request(&self) -> Result { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/cost.rs new file mode 100644 index 0000000000..fc34106249 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/cost.rs @@ -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) -> 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, 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)); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/raw_request.rs new file mode 100644 index 0000000000..cc913dd493 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/raw_request.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/draft-enhance`. +/// fal's schema: +#[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, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftEnhanceVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftEnhanceOutput { + pub video: Flux3DraftEnhanceVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/enhance/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/enhance/mod.rs new file mode 100644 index 0000000000..52ac0070b1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/enhance/mod.rs @@ -0,0 +1 @@ +pub mod flux_3_draft_enhance; diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/api.rs new file mode 100644 index 0000000000..4c84f3ff0e --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/api.rs @@ -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, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +impl FalEndpoint for Flux3ExtendVideoRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/extend-video"; + + type RawRequest = Flux3ExtendVideoInput; + type RawResponse = Flux3ExtendVideoOutput; + + fn to_raw_request(&self) -> Result { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/cost.rs new file mode 100644 index 0000000000..3d188bfba7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/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, + resolution: Option, + ) -> 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, Option, 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)); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/raw_request.rs new file mode 100644 index 0000000000..90808ff8fe --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/raw_request.rs @@ -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: +#[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, + + /// Output resolution. + /// Possible values: "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 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, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3ExtendVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3ExtendVideoOutput { + pub video: Flux3ExtendVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/api.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/api.rs new file mode 100644 index 0000000000..55ae9789dd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/api.rs @@ -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, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/cost.rs new file mode 100644 index 0000000000..8918172ffb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/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) -> 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, 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); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/raw_request.rs new file mode 100644 index 0000000000..ebe4a871ed --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/raw_request.rs @@ -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: +#[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, + + /// 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, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[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, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/mod.rs index 4bd3c72f65..2ca4a6ab67 100644 --- a/crates/api_clients/fal_client/src/requests/api/video/extend/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/mod.rs @@ -1,2 +1,4 @@ +pub mod flux_3; +pub mod flux_3_draft; pub mod veo_3p1; pub mod veo_3p1_fast; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/api.rs new file mode 100644 index 0000000000..3bd9d8e5d0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/api.rs @@ -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, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/cost.rs new file mode 100644 index 0000000000..41d97632ee --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/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, + resolution: Option, + ) -> 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, Option, 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:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/raw_request.rs new file mode 100644 index 0000000000..226e6b723c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/raw_request.rs @@ -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: +#[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, + + /// Output resolution. + /// Possible values: "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 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, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3ImageToVideoOutput { + pub video: Flux3ImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/api.rs new file mode 100644 index 0000000000..ae7f686cc0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/api.rs @@ -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, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/cost.rs new file mode 100644 index 0000000000..68ec1c9464 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/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) -> 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, 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:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/raw_request.rs new file mode 100644 index 0000000000..7610f3f789 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/raw_request.rs @@ -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: +#[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, + + /// 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, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[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, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs index e869d88a37..b79cf33aa1 100644 --- a/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs @@ -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; diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/api.rs new file mode 100644 index 0000000000..acbd62e6f2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/api.rs @@ -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, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/cost.rs new file mode 100644 index 0000000000..3e31f92249 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/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, + resolution: Option, + ) -> 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, Option, 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:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/raw_request.rs new file mode 100644 index 0000000000..8e1a4c10ea --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/raw_request.rs @@ -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: +#[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, + + /// Output resolution. + /// Possible values: "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 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, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3FirstLastFrameToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3FirstLastFrameToVideoOutput { + pub video: Flux3FirstLastFrameToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/api.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/api.rs new file mode 100644 index 0000000000..061899d377 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/api.rs @@ -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, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/cost.rs new file mode 100644 index 0000000000..8c002cdaba --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/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) -> 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, 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:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/raw_request.rs new file mode 100644 index 0000000000..0dbc91873a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/raw_request.rs @@ -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: +#[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, + + /// 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, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[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, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/images/mod.rs index df6085c42a..c0dd954dfb 100644 --- a/crates/api_clients/fal_client/src/requests/api/video/images/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/video/images/mod.rs @@ -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; diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/api.rs new file mode 100644 index 0000000000..995f33728c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/api.rs @@ -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, + + /// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/cost.rs new file mode 100644 index 0000000000..66203e8d9d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/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, + resolution: Option, + 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, Option, 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); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/raw_request.rs new file mode 100644 index 0000000000..6c22ac4931 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/raw_request.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/keyframes-to-video`. +/// fal's schema: +#[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, + + /// Duration in seconds (5–20). fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 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, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +/// 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, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/api.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/api.rs new file mode 100644 index 0000000000..47f94749c7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/api.rs @@ -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, + + /// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`. + pub duration: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/cost.rs new file mode 100644 index 0000000000..0f0a2a2783 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/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) -> 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, 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:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/raw_request.rs new file mode 100644 index 0000000000..eeaf162903 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/raw_request.rs @@ -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: +#[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, + + /// Duration in seconds (5–20). fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// 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, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[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, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/mod.rs new file mode 100644 index 0000000000..b5ffdeab61 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/mod.rs @@ -0,0 +1,2 @@ +pub mod flux_3; +pub mod flux_3_draft; diff --git a/crates/api_clients/fal_client/src/requests/api/video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/mod.rs index b2dfb590f4..25e6179b59 100644 --- a/crates/api_clients/fal_client/src/requests/api/video/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/video/mod.rs @@ -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; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/api.rs new file mode 100644 index 0000000000..e02a02e05b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/api.rs @@ -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, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +/// 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(&self, serializer: S) -> Result { + match self { + Self::Auto => serializer.serialize_str("auto"), + Self::Seconds(seconds) => serializer.serialize_u8(*seconds), + } + } +} + +impl<'de> Deserialize<'de> for Flux3Duration { + fn deserialize>(deserializer: D) -> Result { + 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(self, value: &str) -> Result { + if value == "auto" { + Ok(Flux3Duration::Auto) + } else { + Err(E::invalid_value(de::Unexpected::Str(value), &self)) + } + } + + fn visit_u64(self, value: u64) -> Result { + u8::try_from(value) + .map(Flux3Duration::Seconds) + .map_err(|_| E::invalid_value(de::Unexpected::Unsigned(value), &self)) + } + + fn visit_i64(self, value: i64) -> Result { + 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 { + 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::(serde_json::json!("auto")).unwrap(), Flux3Duration::Auto); + assert_eq!(serde_json::from_value::(serde_json::json!(7)).unwrap(), Flux3Duration::Seconds(7)); + assert!(serde_json::from_value::(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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/cost.rs new file mode 100644 index 0000000000..349243924f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/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) -> 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) -> 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, + resolution: Option, + ) -> 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, Option, 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:?}"); + } + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/raw_request.rs new file mode 100644 index 0000000000..08e7992c09 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/raw_request.rs @@ -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: +#[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, + + /// Output resolution. + /// Possible values: "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 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, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3VideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3TextToVideoOutput { + pub video: Flux3VideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/api.rs new file mode 100644 index 0000000000..782013aa4c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/api.rs @@ -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, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/cost.rs new file mode 100644 index 0000000000..b1cae04fe9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/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) -> 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, 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)); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/raw_request.rs new file mode 100644 index 0000000000..018f862456 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/raw_request.rs @@ -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: +#[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, + + /// 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, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[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, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs index bd756098da..7f3c38e5fa 100644 --- a/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs @@ -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; diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs index 3b4009773a..36bed1cdb5 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs @@ -62,6 +62,8 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option 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), diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs index 7905cfdb54..a37591e105 100644 --- a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs @@ -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); diff --git a/crates/schema/public/enums/src/common/generation/common_model_type.rs b/crates/schema/public/enums/src/common/generation/common_model_type.rs index 42a3d53801..e0d43ab1cf 100644 --- a/crates/schema/public/enums/src/common/generation/common_model_type.rs +++ b/crates/schema/public/enums/src/common/generation/common_model_type.rs @@ -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)); diff --git a/crates/schema/public/enums/src/common/generation/common_video_model.rs b/crates/schema/public/enums/src/common/generation/common_video_model.rs index 6bb5197139..b8a1aba6af 100644 --- a/crates/schema/public/enums/src/common/generation/common_video_model.rs +++ b/crates/schema/public/enums/src/common/generation/common_video_model.rs @@ -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), diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/flux_video_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/flux_video_models.rs new file mode 100644 index 0000000000..eb3fc40f4f --- /dev/null +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/flux_video_models.rs @@ -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 { + 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 +} diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/mod.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/mod.rs index 7ffbb5db56..8ea1b1f636 100644 --- a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/mod.rs +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/video_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/video_models.rs index e60ca97e78..b9d703f011 100644 --- a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/video_models.rs +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/video_models.rs @@ -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 fn build_omni_gen_video_models() -> Vec { 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 Result 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, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs index 5ba3977a69..621de1bfd5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs @@ -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,