diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 68a6baa054..4e1b5f8b48 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -51,6 +51,10 @@ then the story, then the fine print. - Group imports: std, external crates, internal workspace crates, `crate::` imports - Use fully qualified paths for one-off references; `use` for repeated references - Prefer specific imports over wildcards, except in `api_doc.rs` and test modules +- When two crates export a type with the same name, alias with a suffix describing the source crate: + `use enums::...::CommonResolution as CommonResolutionEnum;` + `use artcraft_router::...::CommonResolution as CommonResolutionRouter;` + This avoids long fully-qualified paths in function signatures and bodies. ## TypeScript / Frontend diff --git a/CLAUDE.md b/CLAUDE.md index 0017d21b02..3c05924808 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ artcraft/ - SQLx for MySQL and SQLite - A mix of wreq and reqwest for Rust HTTP clients - Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead +- When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;` - TypeScript with Nx, React, Vite, Zustand, and Three.js - Use two spaces for indentation diff --git a/_tools/postman/collections/ArtCraft API/moderation/Add Wallet Banked Balance.request.yaml b/_tools/postman/collections/ArtCraft API/moderation/Add Wallet Banked Balance.request.yaml new file mode 100644 index 0000000000..a81d4b47be --- /dev/null +++ b/_tools/postman/collections/ArtCraft API/moderation/Add Wallet Banked Balance.request.yaml @@ -0,0 +1,22 @@ +$kind: http-request +name: Add Wallet Banked Balance +url: "{{api_host}}/v1/moderation/wallet/wallet_2pz2h3m2bdfvna7m3b92ccwwr/add_ba\ + nked_balance" +method: POST +headers: + - key: Accept + value: application/json + - key: Session + value: "" + disabled: true + - key: Session + value: "" + disabled: true +body: + type: json + content: | + { + "title": "test alert from thread 2", + "description": "testing testing test" + } +order: 3365370184335612 diff --git a/_tools/postman/collections/ArtCraft API/moderation/Create Wallet for User.request.yaml b/_tools/postman/collections/ArtCraft API/moderation/Create Wallet for User.request.yaml new file mode 100644 index 0000000000..103447ba28 --- /dev/null +++ b/_tools/postman/collections/ArtCraft API/moderation/Create Wallet for User.request.yaml @@ -0,0 +1,21 @@ +$kind: http-request +name: Create Wallet for User +url: "{{api_host}}/v1/moderation/wallet/create_for_user" +method: POST +headers: + - key: Accept + value: application/json + - key: Session + value: "" + disabled: true + - key: Session + value: "" + disabled: true +body: + type: json + content: | + { + "user_token": "user_jg7ng4kdt2x5y", // newuser946 + "payments_namespace": "artcraft" + } +order: 4100053395280358 diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_image_cost.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_image_cost.rs index a0dbc17491..68bece3cfb 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_image_cost.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_image_cost.rs @@ -89,7 +89,7 @@ mod tests { #[test] fn test_serialization_text_to_image() { let request = EstimateImageCostRequest { - model: CommonImageModel::NanaBananaPro, + model: CommonImageModel::NanoBananaPro, provider: GenerationProvider::Artcraft, generation_mode: GenerationMode::TextToImage, aspect_ratio: None, @@ -103,7 +103,7 @@ mod tests { #[test] fn test_serialization_image_edit() { let request = EstimateImageCostRequest { - model: CommonImageModel::NanaBananaPro, + model: CommonImageModel::NanoBananaPro, provider: GenerationProvider::Artcraft, generation_mode: GenerationMode::ImageEdit { count: 2 }, aspect_ratio: None, diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_image_models.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_image_models.rs index c968262a4a..23fc313d74 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_image_models.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_image_models.rs @@ -124,7 +124,7 @@ pub struct OmniGenImageModelDetails { impl Default for OmniGenImageModelDetails { fn default() -> Self { Self { - model: CommonImageModel::NanaBananaPro, + model: CommonImageModel::NanoBananaPro, full_name: None, text_prompt_supported: None, text_prompt_max_length: None, diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/audio_list_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/audio_list_ref.rs index b6740b2006..32e08eeb16 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/audio_list_ref.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/audio_list_ref.rs @@ -1,6 +1,6 @@ use tokens::tokens::media_files::MediaFileToken; -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub enum AudioListRef<'a> { MediaFileTokens(&'a Vec), Urls(&'a Vec), diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/character_list_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/character_list_ref.rs index b986f30941..d7cf8c4d0c 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/character_list_ref.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/character_list_ref.rs @@ -1,7 +1,7 @@ use tokens::tokens::characters::CharacterToken; /// Forward-compatible character reference list. -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub enum CharacterListRef<'a> { CharacterTokens(&'a Vec), diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/common_image_model.rs b/crates/api_clients/artcraft/artcraft_router/src/api/common_image_model.rs index 390db7ce57..da3bd38560 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/common_image_model.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/common_image_model.rs @@ -16,11 +16,11 @@ pub enum CommonImageModel { #[serde(rename = "gpt_image_1p5")] GptImage1p5, #[serde(rename = "nano_banana")] - NanaBanana, + NanoBanana, #[serde(rename = "nano_banana_2")] - NanaBanana2, + NanoBanana2, #[serde(rename = "nano_banana_pro")] - NanaBananaPro, + NanoBananaPro, #[serde(rename = "seedream_4")] Seedream4, #[serde(rename = "seedream_4p5")] diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/image_list_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/image_list_ref.rs index b029b50f2f..f6a8f8baec 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/image_list_ref.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/image_list_ref.rs @@ -1,6 +1,6 @@ use tokens::tokens::media_files::MediaFileToken; -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub enum ImageListRef<'a> { MediaFileTokens(&'a Vec), Urls(&'a Vec), diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/image_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/image_ref.rs index ca3d0a7e73..ce6aa5cc47 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/image_ref.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/image_ref.rs @@ -1,6 +1,6 @@ use tokens::tokens::media_files::MediaFileToken; -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub enum ImageRef<'a> { MediaFileToken(&'a MediaFileToken), Url(&'a str), diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/video_list_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/video_list_ref.rs index 1059a84839..bb431fee70 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/video_list_ref.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/video_list_ref.rs @@ -1,6 +1,6 @@ use tokens::tokens::media_files::MediaFileToken; -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub enum VideoListRef<'a> { MediaFileTokens(&'a Vec), Urls(&'a Vec), diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana.rs index 9d0ea6de3b..65bcb2ed81 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana.rs @@ -37,7 +37,7 @@ mod tests { fn estimate_usd_cents(image_batch_count: u16) -> u64 { let request = GenerateImageRequest { - model: CommonImageModel::NanaBanana, + model: CommonImageModel::NanoBanana, provider: Provider::Artcraft, prompt: None, image_inputs: None, diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana_2.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana_2.rs index e6c64e0d1b..99c8243fae 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana_2.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana_2.rs @@ -45,7 +45,7 @@ mod tests { fn estimate_usd_cents(resolution: Option, image_batch_count: u16) -> u64 { let request = GenerateImageRequest { - model: CommonImageModel::NanaBanana2, + model: CommonImageModel::NanoBanana2, provider: Provider::Artcraft, prompt: None, image_inputs: None, diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana_pro.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana_pro.rs index 50f78da1d4..a708e79b5b 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana_pro.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_nano_banana_pro.rs @@ -44,7 +44,7 @@ mod tests { fn estimate_usd_cents(resolution: Option, image_batch_count: u16) -> u64 { let request = GenerateImageRequest { - model: CommonImageModel::NanaBananaPro, + model: CommonImageModel::NanoBananaPro, provider: Provider::Artcraft, prompt: None, image_inputs: None, diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_flux_1_dev.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_flux_1_dev.rs new file mode 100644 index 0000000000..77cb2a100a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_flux_1_dev.rs @@ -0,0 +1,26 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::PlanFalFlux1Dev; + +pub(crate) fn estimate_image_cost_fal_flux_1_dev( + plan: &PlanFalFlux1Dev<'_>, +) -> ImageGenerationCostEstimate { + // Pricing: $0.025 per megapixel, billed by rounding up to nearest megapixel. + // Default image sizes are ~1MP (1024x1024), so ~$0.025/image = 3 cents rounded up. + // + // Fal docs: + // Your request will cost $0.025 per megapixel, with images billed by rounding up + // to the nearest megapixel. + // + let cost_per_image: u64 = 3; + let num_images = plan.num_images.as_u64(); + let cost_in_usd_cents = cost_per_image * num_images; + + ImageGenerationCostEstimate { + 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, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_flux_1_schnell.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_flux_1_schnell.rs new file mode 100644 index 0000000000..d86e51f36f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_flux_1_schnell.rs @@ -0,0 +1,26 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::PlanFalFlux1Schnell; + +pub(crate) fn estimate_image_cost_fal_flux_1_schnell( + plan: &PlanFalFlux1Schnell<'_>, +) -> ImageGenerationCostEstimate { + // Pricing: $0.003 per megapixel, billed by rounding up to nearest megapixel. + // Default image sizes are ~1MP (1024x1024), so ~$0.003/image = 1 cent rounded up. + // + // Fal docs: + // Your request will cost $0.003 per megapixel, with images billed by rounding up + // to the nearest megapixel. + // + let cost_per_image: u64 = 1; + let num_images = plan.num_images.as_u64(); + let cost_in_usd_cents = cost_per_image * num_images; + + ImageGenerationCostEstimate { + 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, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_nano_banana.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_nano_banana.rs new file mode 100644 index 0000000000..f2dcbc09af --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_nano_banana.rs @@ -0,0 +1,34 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana::{ + FalNbNumImages, PlanFalNanoBanana, +}; + +pub(crate) fn estimate_image_cost_fal_nano_banana( + plan: &PlanFalNanoBanana<'_>, +) -> ImageGenerationCostEstimate { + // Pricing: $0.039/image (no resolution multiplier — Gemini 25 Flash has no resolution option). + // Rounded to 4 cents per image. + // + // Fal docs: + // Your request will cost $0.039 per image. For $1.00, you can run this model 25 times. + // + let cost_per_image: u64 = 4; + + let num_images: u64 = match plan.num_images { + FalNbNumImages::One => 1, + FalNbNumImages::Two => 2, + FalNbNumImages::Three => 3, + FalNbNumImages::Four => 4, + }; + + let cost_in_usd_cents = cost_per_image * num_images; + + ImageGenerationCostEstimate { + 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, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_nano_banana_2.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_nano_banana_2.rs new file mode 100644 index 0000000000..6f9a40f37a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_nano_banana_2.rs @@ -0,0 +1,44 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::{ + FalNb2NumImages, FalNb2Resolution, PlanFalNanaBanana2, +}; + +pub(crate) fn estimate_image_cost_fal_nano_banana_2( + plan: &PlanFalNanaBanana2<'_>, +) -> ImageGenerationCostEstimate { + // Pricing: $0.08/image base (1K). Multipliers: 0.5K = 0.75x, 2K = 1.5x, 4K = 2x. + // (Stored as USD cents.) + // + // Fal docs: + // Your request will cost $0.08 per image. For $1.00, you can run this model 12 times. + // 2K and 4K outputs will be charged at 1.5 times and 2 times the standard rate, respectively. + // 0.5K (512px) resolution outputs will be charged at 0.75 times the standard rate. If web + // search is used, an additional $0.015 will be charged. If high thinking is used, + // an additional $0.002 will be charged. Note: Pricing is subject to change. + // + let cost_per_image: u64 = match plan.resolution { + Some(FalNb2Resolution::HalfK) => 6, // $0.06 = $0.08 * 0.75 + Some(FalNb2Resolution::OneK) => 8, // $0.08 base + Some(FalNb2Resolution::TwoK) => 12, // $0.12 = $0.08 * 1.5 + Some(FalNb2Resolution::FourK) => 16, // $0.16 = $0.08 * 2.0 + None => 8, // default = 1K base + }; + + let num_images: u64 = match plan.num_images { + FalNb2NumImages::One => 1, + FalNb2NumImages::Two => 2, + FalNb2NumImages::Three => 3, + FalNb2NumImages::Four => 4, + }; + + let cost_in_usd_cents = cost_per_image * num_images; + + ImageGenerationCostEstimate { + 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, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_nano_banana_pro.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_nano_banana_pro.rs index 9466a2a160..fe072f47b7 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_nano_banana_pro.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/estimate_image_cost_fal_nano_banana_pro.rs @@ -42,7 +42,7 @@ mod tests { fn estimate_usd_cents(resolution: Option, image_batch_count: u16) -> u64 { let request = GenerateImageRequest { - model: CommonImageModel::NanaBananaPro, + model: CommonImageModel::NanoBananaPro, provider: Provider::Fal, prompt: None, image_inputs: None, diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/mod.rs index ef3d0a420c..04ceba2786 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/fal/mod.rs @@ -1 +1,5 @@ +pub(crate) mod estimate_image_cost_fal_flux_1_dev; +pub(crate) mod estimate_image_cost_fal_flux_1_schnell; +pub(crate) mod estimate_image_cost_fal_nano_banana; +pub(crate) mod estimate_image_cost_fal_nano_banana_2; pub(crate) mod estimate_image_cost_fal_nano_banana_pro; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_flux_1_dev.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_flux_1_dev.rs new file mode 100644 index 0000000000..33754bf563 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_flux_1_dev.rs @@ -0,0 +1,49 @@ +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::PlanFalFlux1Dev; +use fal_client::requests::webhook::image::edit::enqueue_flux_1_dev_edit_image_webhook::{ + enqueue_flux_1_dev_edit_image_webhook, Flux1DevEditImageArgs, +}; +use fal_client::requests::webhook::image::text::enqueue_flux_1_dev_text_to_image_webhook::{ + enqueue_flux_1_dev_text_to_image_webhook, Flux1DevArgs, +}; + +pub async fn execute_fal_flux_1_dev( + plan: &PlanFalFlux1Dev<'_>, + fal_client: &RouterFalClient, +) -> Result { + let webhook_response = if let Some(image_url) = &plan.maybe_image_url { + // Image-to-image mode + let args = Flux1DevEditImageArgs { + prompt: plan.prompt.unwrap_or(""), + image_url: image_url.clone(), + num_images: plan.num_images.to_edit(), + webhook_url: fal_client.webhook_url.as_str(), + api_key: &fal_client.api_key, + }; + enqueue_flux_1_dev_edit_image_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))? + } else { + // Text-to-image mode + let args = Flux1DevArgs { + prompt: plan.prompt.unwrap_or(""), + aspect_ratio: plan.aspect_ratio, + num_images: plan.num_images.to_t2i(), + webhook_url: fal_client.webhook_url.as_str(), + api_key: &fal_client.api_key, + }; + enqueue_flux_1_dev_text_to_image_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))? + }; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + })) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_flux_1_schnell.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_flux_1_schnell.rs new file mode 100644 index 0000000000..843c21634a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_flux_1_schnell.rs @@ -0,0 +1,49 @@ +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::PlanFalFlux1Schnell; +use fal_client::requests::webhook::image::edit::enqueue_flux_1_schnell_edit_image_webhook::{ + enqueue_flux_1_schnell_edit_image_webhook, Flux1SchnellEditImageArgs, +}; +use fal_client::requests::webhook::image::text::enqueue_flux_1_schnell_text_to_image_webhook::{ + enqueue_flux_1_schnell_text_to_image_webhook, Flux1SchnellArgs, +}; + +pub async fn execute_fal_flux_1_schnell( + plan: &PlanFalFlux1Schnell<'_>, + fal_client: &RouterFalClient, +) -> Result { + let webhook_response = if let Some(image_url) = &plan.maybe_image_url { + // Image-to-image (redux) mode + let args = Flux1SchnellEditImageArgs { + image_url: image_url.clone(), + num_images: plan.num_images.to_edit(), + image_size: plan.edit_image_size, + webhook_url: fal_client.webhook_url.as_str(), + api_key: &fal_client.api_key, + }; + enqueue_flux_1_schnell_edit_image_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))? + } else { + // Text-to-image mode + let args = Flux1SchnellArgs { + prompt: plan.prompt.unwrap_or(""), + aspect_ratio: plan.t2i_aspect_ratio, + num_images: plan.num_images.to_t2i(), + webhook_url: fal_client.webhook_url.as_str(), + api_key: &fal_client.api_key, + }; + enqueue_flux_1_schnell_text_to_image_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))? + }; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + })) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_nano_banana.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_nano_banana.rs new file mode 100644 index 0000000000..58b721628b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_nano_banana.rs @@ -0,0 +1,72 @@ +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana::{ + FalNbNumImages, PlanFalNanoBanana, +}; +use fal_client::requests::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{ + enqueue_gemini_25_flash_edit_webhook, Gemini25FlashEditArgs, + Gemini25FlashEditNumImages, +}; +use fal_client::requests::webhook::image::text::enqueue_gemini_25_flash_text_to_image_webhook::{ + enqueue_gemini_25_flash_text_to_image_webhook, Gemini25FlashTextToImageArgs, + Gemini25FlashTextToImageNumImages, +}; + +pub async fn execute_fal_nano_banana( + plan: &PlanFalNanoBanana<'_>, + fal_client: &RouterFalClient, +) -> Result { + let webhook_response = if plan.image_urls.is_empty() { + // Text-to-image mode + let args = Gemini25FlashTextToImageArgs { + prompt: plan.prompt.unwrap_or(""), + num_images: to_t2i_num_images(plan.num_images), + aspect_ratio: plan.t2i_aspect_ratio, + webhook_url: fal_client.webhook_url.as_str(), + api_key: &fal_client.api_key, + }; + enqueue_gemini_25_flash_text_to_image_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))? + } else { + // Image-edit mode + let args = Gemini25FlashEditArgs { + prompt: plan.prompt.unwrap_or(""), + image_urls: plan.image_urls.clone(), + num_images: to_edit_num_images(plan.num_images), + aspect_ratio: plan.edit_aspect_ratio, + webhook_url: fal_client.webhook_url.as_str(), + api_key: &fal_client.api_key, + }; + enqueue_gemini_25_flash_edit_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))? + }; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + })) +} + +fn to_t2i_num_images(n: FalNbNumImages) -> Gemini25FlashTextToImageNumImages { + match n { + FalNbNumImages::One => Gemini25FlashTextToImageNumImages::One, + FalNbNumImages::Two => Gemini25FlashTextToImageNumImages::Two, + FalNbNumImages::Three => Gemini25FlashTextToImageNumImages::Three, + FalNbNumImages::Four => Gemini25FlashTextToImageNumImages::Four, + } +} + +fn to_edit_num_images(n: FalNbNumImages) -> Gemini25FlashEditNumImages { + match n { + FalNbNumImages::One => Gemini25FlashEditNumImages::One, + FalNbNumImages::Two => Gemini25FlashEditNumImages::Two, + FalNbNumImages::Three => Gemini25FlashEditNumImages::Three, + FalNbNumImages::Four => Gemini25FlashEditNumImages::Four, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_nano_banana_2.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_nano_banana_2.rs new file mode 100644 index 0000000000..e9891b9eb0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/generate_image_fal_nano_banana_2.rs @@ -0,0 +1,92 @@ +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::{ + FalNb2NumImages, FalNb2Resolution, PlanFalNanaBanana2, +}; +use fal_client::requests::webhook::image::edit::enqueue_nano_banana_2_edit_image_webhook::{ + enqueue_nano_banana_2_edit_image_webhook, EnqueueNanoBanana2EditImageArgs, + EnqueueNanoBanana2EditImageNumImages, EnqueueNanoBanana2EditImageResolution, +}; +use fal_client::requests::webhook::image::text::enqueue_nano_banana_2_text_to_image_webhook::{ + enqueue_nano_banana_2_text_to_image_webhook, EnqueueNanoBanana2TextToImageArgs, + EnqueueNanoBanana2TextToImageNumImages, EnqueueNanoBanana2TextToImageResolution, +}; + +pub async fn execute_fal_nano_banana_2( + plan: &PlanFalNanaBanana2<'_>, + fal_client: &RouterFalClient, +) -> Result { + let webhook_response = if plan.image_urls.is_empty() { + // Text-to-image mode + let args = EnqueueNanoBanana2TextToImageArgs { + prompt: plan.prompt.unwrap_or(""), + num_images: to_t2i_num_images(plan.num_images), + resolution: plan.resolution.map(to_t2i_resolution), + aspect_ratio: plan.t2i_aspect_ratio, + webhook_url: fal_client.webhook_url.as_str(), + api_key: &fal_client.api_key, + }; + enqueue_nano_banana_2_text_to_image_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))? + } else { + // Image-edit mode + let args = EnqueueNanoBanana2EditImageArgs { + prompt: plan.prompt.unwrap_or(""), + image_urls: plan.image_urls.clone(), + num_images: to_edit_num_images(plan.num_images), + resolution: plan.resolution.map(to_edit_resolution), + aspect_ratio: plan.edit_aspect_ratio, + webhook_url: fal_client.webhook_url.as_str(), + api_key: &fal_client.api_key, + }; + enqueue_nano_banana_2_edit_image_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))? + }; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + })) +} + +fn to_t2i_num_images(n: FalNb2NumImages) -> EnqueueNanoBanana2TextToImageNumImages { + match n { + FalNb2NumImages::One => EnqueueNanoBanana2TextToImageNumImages::One, + FalNb2NumImages::Two => EnqueueNanoBanana2TextToImageNumImages::Two, + FalNb2NumImages::Three => EnqueueNanoBanana2TextToImageNumImages::Three, + FalNb2NumImages::Four => EnqueueNanoBanana2TextToImageNumImages::Four, + } +} + +fn to_edit_num_images(n: FalNb2NumImages) -> EnqueueNanoBanana2EditImageNumImages { + match n { + FalNb2NumImages::One => EnqueueNanoBanana2EditImageNumImages::One, + FalNb2NumImages::Two => EnqueueNanoBanana2EditImageNumImages::Two, + FalNb2NumImages::Three => EnqueueNanoBanana2EditImageNumImages::Three, + FalNb2NumImages::Four => EnqueueNanoBanana2EditImageNumImages::Four, + } +} + +fn to_t2i_resolution(r: FalNb2Resolution) -> EnqueueNanoBanana2TextToImageResolution { + match r { + FalNb2Resolution::HalfK => EnqueueNanoBanana2TextToImageResolution::HalfK, + FalNb2Resolution::OneK => EnqueueNanoBanana2TextToImageResolution::OneK, + FalNb2Resolution::TwoK => EnqueueNanoBanana2TextToImageResolution::TwoK, + FalNb2Resolution::FourK => EnqueueNanoBanana2TextToImageResolution::FourK, + } +} + +fn to_edit_resolution(r: FalNb2Resolution) -> EnqueueNanoBanana2EditImageResolution { + match r { + FalNb2Resolution::HalfK => EnqueueNanoBanana2EditImageResolution::HalfK, + FalNb2Resolution::OneK => EnqueueNanoBanana2EditImageResolution::OneK, + FalNb2Resolution::TwoK => EnqueueNanoBanana2EditImageResolution::TwoK, + FalNb2Resolution::FourK => EnqueueNanoBanana2EditImageResolution::FourK, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/mod.rs index 3f02544188..2316648143 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/fal/mod.rs @@ -1 +1,5 @@ +pub mod generate_image_fal_flux_1_dev; +pub mod generate_image_fal_flux_1_schnell; +pub mod generate_image_fal_nano_banana; +pub mod generate_image_fal_nano_banana_2; pub mod generate_image_fal_nano_banana_pro; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request.rs index 1be375d7cc..9807b01dbd 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request.rs @@ -10,19 +10,24 @@ use crate::errors::client_error::ClientError; use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_1_dev::plan_generate_image_artcraft_flux_1_dev; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_1_schnell::plan_generate_image_artcraft_flux_1_schnell; +use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::plan_generate_image_artcraft_flux_2_lora_angles; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1::plan_generate_image_artcraft_flux_pro_1p1; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1_ultra::plan_generate_image_artcraft_flux_pro_1p1_ultra; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::plan_generate_image_artcraft_gpt_image_1p5; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::plan_generate_image_artcraft_nano_banana; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_2::plan_generate_image_artcraft_nano_banana_2; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_pro::plan_generate_image_artcraft_nano_banana_pro; +use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_qwen_edit_2511_angles::plan_generate_image_artcraft_qwen_edit_2511_angles; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4::plan_generate_image_artcraft_seedream_4; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4p5::plan_generate_image_artcraft_seedream_4p5; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_5_lite::plan_generate_image_artcraft_seedream_5_lite; -use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_qwen_edit_2511_angles::plan_generate_image_artcraft_qwen_edit_2511_angles; -use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::plan_generate_image_artcraft_flux_2_lora_angles; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::plan_generate_image_fal_flux_1_dev; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::plan_generate_image_fal_flux_1_schnell; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana::plan_generate_image_fal_nano_banana; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::plan_generate_image_fal_nano_banana_2; use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_pro::plan_generate_image_fal_nano_banana_pro; +#[derive(Debug)] pub struct GenerateImageRequest<'a> { /// Which model to use. pub model: CommonImageModel, @@ -86,9 +91,9 @@ impl<'a> GenerateImageRequest<'a> { CommonImageModel::FluxPro11 => plan_generate_image_artcraft_flux_pro_1p1(self), CommonImageModel::FluxPro11Ultra => plan_generate_image_artcraft_flux_pro_1p1_ultra(self), CommonImageModel::GptImage1p5 => plan_generate_image_artcraft_gpt_image_1p5(self), - CommonImageModel::NanaBanana => plan_generate_image_artcraft_nano_banana(self), - CommonImageModel::NanaBanana2 => plan_generate_image_artcraft_nano_banana_2(self), - CommonImageModel::NanaBananaPro => plan_generate_image_artcraft_nano_banana_pro(self), + CommonImageModel::NanoBanana => plan_generate_image_artcraft_nano_banana(self), + CommonImageModel::NanoBanana2 => plan_generate_image_artcraft_nano_banana_2(self), + CommonImageModel::NanoBananaPro => plan_generate_image_artcraft_nano_banana_pro(self), CommonImageModel::Seedream4 => plan_generate_image_artcraft_seedream_4(self), CommonImageModel::Seedream4p5 => plan_generate_image_artcraft_seedream_4p5(self), CommonImageModel::Seedream5Lite => plan_generate_image_artcraft_seedream_5_lite(self), @@ -99,7 +104,11 @@ impl<'a> GenerateImageRequest<'a> { fn build_fal(&self) -> Result, ArtcraftRouterError> { match self.model { - CommonImageModel::NanaBananaPro => plan_generate_image_fal_nano_banana_pro(self), + CommonImageModel::Flux1Dev => plan_generate_image_fal_flux_1_dev(self), + CommonImageModel::Flux1Schnell => plan_generate_image_fal_flux_1_schnell(self), + CommonImageModel::NanoBanana => plan_generate_image_fal_nano_banana(self), + CommonImageModel::NanoBanana2 => plan_generate_image_fal_nano_banana_2(self), + CommonImageModel::NanoBananaPro => plan_generate_image_fal_nano_banana_pro(self), _ => { Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "provider", diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_plan.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_plan.rs index 34568f316e..b9630ff27b 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_plan.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_plan.rs @@ -13,6 +13,10 @@ use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraf use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_seedream_5_lite::estimate_image_cost_artcraft_seedream_5_lite; use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_qwen_edit_2511_angles::estimate_image_cost_artcraft_qwen_edit_2511_angles; use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_flux_2_lora_angles::estimate_image_cost_artcraft_flux_2_lora_angles; +use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_1_dev::estimate_image_cost_fal_flux_1_dev; +use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_1_schnell::estimate_image_cost_fal_flux_1_schnell; +use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_nano_banana::estimate_image_cost_fal_nano_banana; +use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_nano_banana_2::estimate_image_cost_fal_nano_banana_2; use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_nano_banana_pro::estimate_image_cost_fal_nano_banana_pro; use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_1_dev::execute_artcraft_flux_1_dev; use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_1_schnell::execute_artcraft_flux_1_schnell; @@ -27,6 +31,10 @@ use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_ use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_seedream_5_lite::execute_artcraft_seedream_5_lite; use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_qwen_edit_2511_angles::execute_artcraft_qwen_edit_2511_angles; use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_2_lora_angles::execute_artcraft_flux_2_lora_angles; +use crate::generate::generate_image::execute::fal::generate_image_fal_flux_1_dev::execute_fal_flux_1_dev; +use crate::generate::generate_image::execute::fal::generate_image_fal_flux_1_schnell::execute_fal_flux_1_schnell; +use crate::generate::generate_image::execute::fal::generate_image_fal_nano_banana::execute_fal_nano_banana; +use crate::generate::generate_image::execute::fal::generate_image_fal_nano_banana_2::execute_fal_nano_banana_2; use crate::generate::generate_image::execute::fal::generate_image_fal_nano_banana_pro::execute_fal_nano_banana_pro; use crate::generate::generate_image::generate_image_response::GenerateImageResponse; use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; @@ -43,6 +51,10 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_5_lite::PlanArtcraftSeedream5Lite; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_qwen_edit_2511_angles::PlanArtcraftQwenEdit2511Angles; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::PlanArtcraftFlux2LoraAngles; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::PlanFalFlux1Dev; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::PlanFalFlux1Schnell; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana::PlanFalNanoBanana; +use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::PlanFalNanaBanana2; use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_pro::PlanFalNanaBananaPro; #[derive(Debug)] @@ -60,6 +72,10 @@ pub enum ImageGenerationPlan<'a> { ArtcraftSeedream5Lite(PlanArtcraftSeedream5Lite<'a>), ArtcraftQwenEdit2511Angles(PlanArtcraftQwenEdit2511Angles<'a>), ArtcraftFlux2LoraAngles(PlanArtcraftFlux2LoraAngles<'a>), + FalFlux1Dev(PlanFalFlux1Dev<'a>), + FalFlux1Schnell(PlanFalFlux1Schnell<'a>), + FalNanoBanana(PlanFalNanoBanana<'a>), + FalNanaBanana2(PlanFalNanaBanana2<'a>), FalNanaBananaPro(PlanFalNanaBananaPro<'a>), } @@ -121,6 +137,22 @@ impl<'a> ImageGenerationPlan<'a> { let artcraft_client = client.get_artcraft_client_ref()?; execute_artcraft_flux_2_lora_angles(plan, artcraft_client).await } + ImageGenerationPlan::FalFlux1Dev(plan) => { + let fal_client = client.get_fal_client_ref()?; + execute_fal_flux_1_dev(plan, fal_client).await + } + ImageGenerationPlan::FalFlux1Schnell(plan) => { + let fal_client = client.get_fal_client_ref()?; + execute_fal_flux_1_schnell(plan, fal_client).await + } + ImageGenerationPlan::FalNanoBanana(plan) => { + let fal_client = client.get_fal_client_ref()?; + execute_fal_nano_banana(plan, fal_client).await + } + ImageGenerationPlan::FalNanaBanana2(plan) => { + let fal_client = client.get_fal_client_ref()?; + execute_fal_nano_banana_2(plan, fal_client).await + } ImageGenerationPlan::FalNanaBananaPro(plan) => { let fal_client = client.get_fal_client_ref()?; execute_fal_nano_banana_pro(plan, fal_client).await @@ -169,6 +201,18 @@ impl<'a> ImageGenerationPlan<'a> { ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) => { estimate_image_cost_artcraft_flux_2_lora_angles(plan) } + ImageGenerationPlan::FalFlux1Dev(plan) => { + estimate_image_cost_fal_flux_1_dev(plan) + } + ImageGenerationPlan::FalFlux1Schnell(plan) => { + estimate_image_cost_fal_flux_1_schnell(plan) + } + ImageGenerationPlan::FalNanoBanana(plan) => { + estimate_image_cost_fal_nano_banana(plan) + } + ImageGenerationPlan::FalNanaBanana2(plan) => { + estimate_image_cost_fal_nano_banana_2(plan) + } ImageGenerationPlan::FalNanaBananaPro(plan) => { estimate_image_cost_fal_nano_banana_pro(plan) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/mod.rs index b0916422f1..ede63ac3c9 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/mod.rs @@ -1 +1,5 @@ +pub mod plan_generate_image_fal_flux_1_dev; +pub mod plan_generate_image_fal_flux_1_schnell; +pub mod plan_generate_image_fal_nano_banana; +pub mod plan_generate_image_fal_nano_banana_2; pub mod plan_generate_image_fal_nano_banana_pro; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_flux_1_dev.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_flux_1_dev.rs new file mode 100644 index 0000000000..7c3e0eb35e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_flux_1_dev.rs @@ -0,0 +1,158 @@ +use crate::api::common_aspect_ratio::CommonAspectRatio; +use crate::api::image_list_ref::ImageListRef; +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_image::generate_image_request::GenerateImageRequest; +use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan; +use fal_client::requests::webhook::image::edit::enqueue_flux_1_dev_edit_image_webhook::Flux1DevEditImageNumImages; +use fal_client::requests::webhook::image::text::enqueue_flux_1_dev_text_to_image_webhook::{ + Flux1DevAspectRatio, Flux1DevNumImages, +}; + +/// Shared num-images type for both t2i and edit modes. +#[derive(Debug, Clone, Copy)] +pub enum FalFlux1DevNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Debug, Clone)] +pub struct PlanFalFlux1Dev<'a> { + pub prompt: Option<&'a str>, + /// Image URL for editing. None = text-to-image mode. + pub maybe_image_url: Option, + pub aspect_ratio: Flux1DevAspectRatio, + pub num_images: FalFlux1DevNumImages, +} + +pub fn plan_generate_image_fal_flux_1_dev<'a>( + request: &'a GenerateImageRequest<'a>, +) -> Result, ArtcraftRouterError> { + let strategy = request.request_mismatch_mitigation_strategy; + let maybe_image_url = resolve_single_image_url(request.image_inputs)?; + let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?; + let num_images = plan_num_images(request.image_batch_count, strategy)?; + + Ok(ImageGenerationPlan::FalFlux1Dev(PlanFalFlux1Dev { + prompt: request.prompt, + maybe_image_url, + aspect_ratio, + num_images, + })) +} + +fn resolve_single_image_url( + image_inputs: Option>, +) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(None), + Some(ImageListRef::Urls(urls)) => { + if urls.is_empty() { + Ok(None) + } else if urls.len() == 1 { + Ok(Some(urls[0].clone())) + } else { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: format!("Flux 1 Dev image-to-image supports exactly 1 image, got {}", urls.len()), + })) + } + } + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use Flux1DevAspectRatio as Ar; + match aspect_ratio { + None => Ok(Ar::Square), + + Some(CommonAspectRatio::Auto) + | Some(CommonAspectRatio::Auto2k) + | Some(CommonAspectRatio::Auto4k) => Ok(Ar::Square), + + Some(CommonAspectRatio::Square) => Ok(Ar::Square), + Some(CommonAspectRatio::SquareHd) => Ok(Ar::SquareHd), + Some(CommonAspectRatio::WideFourByThree) => Ok(Ar::LandscapeFourByThree), + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::LandscapeSixteenByNine), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Ar::PortraitThreeByFour), + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::PortraitNineBySixteen), + + Some(CommonAspectRatio::WideFiveByFour) => Ok(Ar::LandscapeFourByThree), + Some(CommonAspectRatio::WideThreeByTwo) => Ok(Ar::LandscapeFourByThree), + Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Ar::LandscapeSixteenByNine), + Some(CommonAspectRatio::TallFourByFive) => Ok(Ar::PortraitThreeByFour), + Some(CommonAspectRatio::TallTwoByThree) => Ok(Ar::PortraitThreeByFour), + Some(CommonAspectRatio::TallNineByTwentyOne) => Ok(Ar::PortraitNineBySixteen), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square), + }, + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(FalFlux1DevNumImages::One), + 2 => Ok(FalFlux1DevNumImages::Two), + 3 => Ok(FalFlux1DevNumImages::Three), + 4 => Ok(FalFlux1DevNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(FalFlux1DevNumImages::Four), + }, + } +} + +impl FalFlux1DevNumImages { + pub fn to_t2i(self) -> Flux1DevNumImages { + match self { + Self::One => Flux1DevNumImages::One, + Self::Two => Flux1DevNumImages::Two, + Self::Three => Flux1DevNumImages::Three, + Self::Four => Flux1DevNumImages::Four, + } + } + + pub fn to_edit(self) -> Flux1DevEditImageNumImages { + match self { + Self::One => Flux1DevEditImageNumImages::One, + Self::Two => Flux1DevEditImageNumImages::Two, + Self::Three => Flux1DevEditImageNumImages::Three, + Self::Four => Flux1DevEditImageNumImages::Four, + } + } + + pub fn as_u64(self) -> u64 { + match self { + Self::One => 1, + Self::Two => 2, + Self::Three => 3, + Self::Four => 4, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_flux_1_schnell.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_flux_1_schnell.rs new file mode 100644 index 0000000000..2ce42f0778 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_flux_1_schnell.rs @@ -0,0 +1,169 @@ +use crate::api::common_aspect_ratio::CommonAspectRatio; +use crate::api::image_list_ref::ImageListRef; +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_image::generate_image_request::GenerateImageRequest; +use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan; +use fal_client::requests::webhook::image::edit::enqueue_flux_1_schnell_edit_image_webhook::{ + Flux1SchnellEditImageNumImages, Flux1SchnellEditImageSize, +}; +use fal_client::requests::webhook::image::text::enqueue_flux_1_schnell_text_to_image_webhook::{ + Flux1SchnellAspectRatio, Flux1SchnellNumImages, +}; + +/// Shared num-images type for both t2i and edit modes. +#[derive(Debug, Clone, Copy)] +pub enum FalFlux1SchnellNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Debug, Clone)] +pub struct PlanFalFlux1Schnell<'a> { + pub prompt: Option<&'a str>, + /// Image URL for editing. None = text-to-image mode. + pub maybe_image_url: Option, + pub t2i_aspect_ratio: Flux1SchnellAspectRatio, + pub edit_image_size: Option, + pub num_images: FalFlux1SchnellNumImages, +} + +pub fn plan_generate_image_fal_flux_1_schnell<'a>( + request: &'a GenerateImageRequest<'a>, +) -> Result, ArtcraftRouterError> { + let strategy = request.request_mismatch_mitigation_strategy; + let maybe_image_url = resolve_single_image_url(request.image_inputs)?; + let t2i_aspect_ratio = plan_t2i_aspect_ratio(request.aspect_ratio, strategy)?; + let edit_image_size = plan_edit_image_size(request.aspect_ratio); + let num_images = plan_num_images(request.image_batch_count, strategy)?; + + Ok(ImageGenerationPlan::FalFlux1Schnell(PlanFalFlux1Schnell { + prompt: request.prompt, + maybe_image_url, + t2i_aspect_ratio, + edit_image_size, + num_images, + })) +} + +fn resolve_single_image_url( + image_inputs: Option>, +) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(None), + Some(ImageListRef::Urls(urls)) => { + if urls.is_empty() { + Ok(None) + } else if urls.len() == 1 { + Ok(Some(urls[0].clone())) + } else { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: format!("Flux 1 Schnell redux supports exactly 1 image, got {}", urls.len()), + })) + } + } + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn plan_t2i_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use Flux1SchnellAspectRatio as Ar; + match aspect_ratio { + None => Ok(Ar::Square), + Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto4k) => Ok(Ar::Square), + Some(CommonAspectRatio::Square) => Ok(Ar::Square), + Some(CommonAspectRatio::SquareHd) => Ok(Ar::SquareHd), + Some(CommonAspectRatio::WideFourByThree) => Ok(Ar::LandscapeFourByThree), + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::LandscapeSixteenByNine), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Ar::PortraitThreeByFour), + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::PortraitNineBySixteen), + Some(CommonAspectRatio::WideFiveByFour) | Some(CommonAspectRatio::WideThreeByTwo) => Ok(Ar::LandscapeFourByThree), + Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Ar::LandscapeSixteenByNine), + Some(CommonAspectRatio::TallFourByFive) | Some(CommonAspectRatio::TallTwoByThree) => Ok(Ar::PortraitThreeByFour), + Some(CommonAspectRatio::TallNineByTwentyOne) => Ok(Ar::PortraitNineBySixteen), + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square), + }, + } +} + +fn plan_edit_image_size(aspect_ratio: Option) -> Option { + use Flux1SchnellEditImageSize as Size; + match aspect_ratio { + None => None, + Some(CommonAspectRatio::Square) => Some(Size::Square), + Some(CommonAspectRatio::SquareHd) => Some(Size::SquareHd), + Some(CommonAspectRatio::WideFourByThree) => Some(Size::LandscapeFourByThree), + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Some(Size::LandscapeSixteenByNine), + Some(CommonAspectRatio::TallThreeByFour) => Some(Size::PortraitThreeByFour), + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Some(Size::PortraitNineBySixteen), + _ => Some(Size::Square), + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(FalFlux1SchnellNumImages::One), + 2 => Ok(FalFlux1SchnellNumImages::Two), + 3 => Ok(FalFlux1SchnellNumImages::Three), + 4 => Ok(FalFlux1SchnellNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(FalFlux1SchnellNumImages::Four), + }, + } +} + +impl FalFlux1SchnellNumImages { + pub fn to_t2i(self) -> Flux1SchnellNumImages { + match self { + Self::One => Flux1SchnellNumImages::One, + Self::Two => Flux1SchnellNumImages::Two, + Self::Three => Flux1SchnellNumImages::Three, + Self::Four => Flux1SchnellNumImages::Four, + } + } + + pub fn to_edit(self) -> Flux1SchnellEditImageNumImages { + match self { + Self::One => Flux1SchnellEditImageNumImages::One, + Self::Two => Flux1SchnellEditImageNumImages::Two, + Self::Three => Flux1SchnellEditImageNumImages::Three, + Self::Four => Flux1SchnellEditImageNumImages::Four, + } + } + + pub fn as_u64(self) -> u64 { + match self { + Self::One => 1, + Self::Two => 2, + Self::Three => 3, + Self::Four => 4, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_nano_banana.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_nano_banana.rs new file mode 100644 index 0000000000..e442556862 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_nano_banana.rs @@ -0,0 +1,174 @@ +use crate::api::common_aspect_ratio::CommonAspectRatio; +use crate::api::image_list_ref::ImageListRef; +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_image::generate_image_request::GenerateImageRequest; +use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan; +use fal_client::requests::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::Gemini25FlashEditAspectRatio; +use fal_client::requests::webhook::image::text::enqueue_gemini_25_flash_text_to_image_webhook::Gemini25FlashTextToImageAspectRatio; + +/// Intermediate num-images type shared between t2i and edit enums. +#[derive(Debug, Clone, Copy)] +pub enum FalNbNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Debug, Clone)] +pub struct PlanFalNanoBanana<'a> { + pub prompt: Option<&'a str>, + /// Image URLs for editing. Empty vec = text-to-image mode. + pub image_urls: Vec, + /// Pre-resolved aspect ratio for text-to-image mode. + pub t2i_aspect_ratio: Option, + /// Pre-resolved aspect ratio for image-edit mode. + pub edit_aspect_ratio: Option, + /// Pre-resolved number of images (1–4, shared for both modes). + pub num_images: FalNbNumImages, +} + +pub fn plan_generate_image_fal_nano_banana<'a>( + request: &'a GenerateImageRequest<'a>, +) -> Result, ArtcraftRouterError> { + let strategy = request.request_mismatch_mitigation_strategy; + let is_edit_mode = request.image_inputs.is_some(); + let image_urls = resolve_image_list_ref(request.image_inputs)?; + let t2i_aspect_ratio = plan_t2i_aspect_ratio(request.aspect_ratio, strategy)?; + let edit_aspect_ratio = plan_edit_aspect_ratio(request.aspect_ratio, is_edit_mode, strategy)?; + let num_images = plan_num_images(request.image_batch_count, strategy)?; + + Ok(ImageGenerationPlan::FalNanoBanana(PlanFalNanoBanana { + prompt: request.prompt, + image_urls, + t2i_aspect_ratio, + edit_aspect_ratio, + num_images, + })) +} + +fn resolve_image_list_ref( + image_list_ref: Option>, +) -> Result, ArtcraftRouterError> { + match image_list_ref { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls.clone()), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +// Gemini 25 Flash text-to-image supported aspect ratios: +// 1:1, 5:4, 4:3, 3:2, 16:9, 21:9, 4:5, 3:4, 2:3, 9:16 +// (no Auto for text-to-image) +fn plan_t2i_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Gemini25FlashTextToImageAspectRatio as T2iAr; + match aspect_ratio { + None => Ok(None), + + // Auto not valid for text-to-image; fall back to square + Some(CommonAspectRatio::Auto) + | Some(CommonAspectRatio::Auto2k) + | Some(CommonAspectRatio::Auto4k) => Ok(Some(T2iAr::OneByOne)), + + Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(T2iAr::OneByOne)), + Some(CommonAspectRatio::WideFiveByFour) => Ok(Some(T2iAr::FiveByFour)), + Some(CommonAspectRatio::WideFourByThree) => Ok(Some(T2iAr::FourByThree)), + Some(CommonAspectRatio::WideThreeByTwo) => Ok(Some(T2iAr::ThreeByTwo)), + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(T2iAr::SixteenByNine)), + Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(T2iAr::TwentyOneByNine)), + Some(CommonAspectRatio::TallFourByFive) => Ok(Some(T2iAr::FourByFive)), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(T2iAr::ThreeByFour)), + Some(CommonAspectRatio::TallTwoByThree) => Ok(Some(T2iAr::TwoByThree)), + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(T2iAr::NineBySixteen)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(T2iAr::NineBySixteen)) + } + }, + } +} + +// Gemini 25 Flash image-edit supported aspect ratios: same as t2i plus Auto +fn plan_edit_aspect_ratio( + aspect_ratio: Option, + is_edit_mode: bool, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Gemini25FlashEditAspectRatio as EditAr; + match aspect_ratio { + None => Ok(None), + + Some(CommonAspectRatio::Auto) + | Some(CommonAspectRatio::Auto2k) + | Some(CommonAspectRatio::Auto4k) => { + if is_edit_mode { + Ok(Some(EditAr::Auto)) + } else { + Ok(Some(EditAr::OneByOne)) + } + } + + Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(EditAr::OneByOne)), + Some(CommonAspectRatio::WideFiveByFour) => Ok(Some(EditAr::FiveByFour)), + Some(CommonAspectRatio::WideFourByThree) => Ok(Some(EditAr::FourByThree)), + Some(CommonAspectRatio::WideThreeByTwo) => Ok(Some(EditAr::ThreeByTwo)), + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(EditAr::SixteenByNine)), + Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(EditAr::TwentyOneByNine)), + Some(CommonAspectRatio::TallFourByFive) => Ok(Some(EditAr::FourByFive)), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(EditAr::ThreeByFour)), + Some(CommonAspectRatio::TallTwoByThree) => Ok(Some(EditAr::TwoByThree)), + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(EditAr::NineBySixteen)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(EditAr::NineBySixteen)) + } + }, + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(FalNbNumImages::One), + 2 => Ok(FalNbNumImages::Two), + 3 => Ok(FalNbNumImages::Three), + 4 => Ok(FalNbNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(FalNbNumImages::Four), + }, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_nano_banana_2.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_nano_banana_2.rs new file mode 100644 index 0000000000..4f541661e4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_nano_banana_2.rs @@ -0,0 +1,202 @@ +use crate::api::common_aspect_ratio::CommonAspectRatio; +use crate::api::common_resolution::CommonResolution; +use crate::api::image_list_ref::ImageListRef; +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_image::generate_image_request::GenerateImageRequest; +use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan; +use fal_client::requests::webhook::image::edit::enqueue_nano_banana_2_edit_image_webhook::EnqueueNanoBanana2EditImageAspectRatio; +use fal_client::requests::webhook::image::text::enqueue_nano_banana_2_text_to_image_webhook::EnqueueNanoBanana2TextToImageAspectRatio; + +/// Intermediate resolution type shared between t2i and edit enums. +#[derive(Debug, Clone, Copy)] +pub enum FalNb2Resolution { + HalfK, + OneK, + TwoK, + FourK, +} + +/// Intermediate num-images type shared between t2i and edit enums. +#[derive(Debug, Clone, Copy)] +pub enum FalNb2NumImages { + One, + Two, + Three, + Four, +} + +#[derive(Debug, Clone)] +pub struct PlanFalNanaBanana2<'a> { + pub prompt: Option<&'a str>, + /// Image URLs for editing. Empty vec = text-to-image mode. + pub image_urls: Vec, + /// Pre-resolved aspect ratio for text-to-image mode. + pub t2i_aspect_ratio: Option, + /// Pre-resolved aspect ratio for image-edit mode. + pub edit_aspect_ratio: Option, + /// Pre-resolved resolution (shared for both modes). + pub resolution: Option, + /// Pre-resolved number of images (1–4, shared for both modes). + pub num_images: FalNb2NumImages, +} + +pub fn plan_generate_image_fal_nano_banana_2<'a>( + request: &'a GenerateImageRequest<'a>, +) -> Result, ArtcraftRouterError> { + let strategy = request.request_mismatch_mitigation_strategy; + let is_edit_mode = request.image_inputs.is_some(); + let image_urls = resolve_image_list_ref(request.image_inputs)?; + let t2i_aspect_ratio = plan_t2i_aspect_ratio(request.aspect_ratio, strategy)?; + let edit_aspect_ratio = plan_edit_aspect_ratio(request.aspect_ratio, is_edit_mode, strategy)?; + let resolution = plan_resolution(request.resolution)?; + let num_images = plan_num_images(request.image_batch_count, strategy)?; + + Ok(ImageGenerationPlan::FalNanaBanana2(PlanFalNanaBanana2 { + prompt: request.prompt, + image_urls, + t2i_aspect_ratio, + edit_aspect_ratio, + resolution, + num_images, + })) +} + +fn resolve_image_list_ref( + image_list_ref: Option>, +) -> Result, ArtcraftRouterError> { + match image_list_ref { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls.clone()), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +// NB2 text-to-image supported aspect ratios: +// auto, 1:1, 5:4, 4:3, 3:2, 16:9, 21:9, 4:5, 3:4, 2:3, 9:16 +fn plan_t2i_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use EnqueueNanoBanana2TextToImageAspectRatio as T2iAr; + match aspect_ratio { + None => Ok(None), + + Some(CommonAspectRatio::Auto) + | Some(CommonAspectRatio::Auto2k) + | Some(CommonAspectRatio::Auto4k) => Ok(Some(T2iAr::Auto)), + + Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(T2iAr::OneByOne)), + Some(CommonAspectRatio::WideFiveByFour) => Ok(Some(T2iAr::FiveByFour)), + Some(CommonAspectRatio::WideFourByThree) => Ok(Some(T2iAr::FourByThree)), + Some(CommonAspectRatio::WideThreeByTwo) => Ok(Some(T2iAr::ThreeByTwo)), + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(T2iAr::SixteenByNine)), + Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(T2iAr::TwentyOneByNine)), + Some(CommonAspectRatio::TallFourByFive) => Ok(Some(T2iAr::FourByFive)), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(T2iAr::ThreeByFour)), + Some(CommonAspectRatio::TallTwoByThree) => Ok(Some(T2iAr::TwoByThree)), + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(T2iAr::NineBySixteen)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(T2iAr::NineBySixteen)) + } + }, + } +} + +// NB2 image-edit supported aspect ratios: same as t2i plus Auto +fn plan_edit_aspect_ratio( + aspect_ratio: Option, + is_edit_mode: bool, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use EnqueueNanoBanana2EditImageAspectRatio as EditAr; + match aspect_ratio { + None => Ok(None), + + Some(CommonAspectRatio::Auto) + | Some(CommonAspectRatio::Auto2k) + | Some(CommonAspectRatio::Auto4k) => { + if is_edit_mode { + Ok(Some(EditAr::Auto)) + } else { + Ok(Some(EditAr::OneByOne)) + } + } + + Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(EditAr::OneByOne)), + Some(CommonAspectRatio::WideFiveByFour) => Ok(Some(EditAr::FiveByFour)), + Some(CommonAspectRatio::WideFourByThree) => Ok(Some(EditAr::FourByThree)), + Some(CommonAspectRatio::WideThreeByTwo) => Ok(Some(EditAr::ThreeByTwo)), + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(EditAr::SixteenByNine)), + Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(EditAr::TwentyOneByNine)), + Some(CommonAspectRatio::TallFourByFive) => Ok(Some(EditAr::FourByFive)), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(EditAr::ThreeByFour)), + Some(CommonAspectRatio::TallTwoByThree) => Ok(Some(EditAr::TwoByThree)), + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(EditAr::NineBySixteen)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(EditAr::NineBySixteen)) + } + }, + } +} + +fn plan_resolution( + resolution: Option, +) -> Result, ArtcraftRouterError> { + match resolution { + None => Ok(None), + Some(CommonResolution::HalfK) => Ok(Some(FalNb2Resolution::HalfK)), + Some(CommonResolution::OneK) => Ok(Some(FalNb2Resolution::OneK)), + Some(CommonResolution::TwoK) => Ok(Some(FalNb2Resolution::TwoK)), + Some(CommonResolution::FourK) => Ok(Some(FalNb2Resolution::FourK)), + Some(CommonResolution::ThreeK) => Ok(Some(FalNb2Resolution::TwoK)), + Some(CommonResolution::FourEightyP) => Ok(Some(FalNb2Resolution::HalfK)), + Some(CommonResolution::SevenTwentyP) => Ok(Some(FalNb2Resolution::OneK)), + Some(CommonResolution::TenEightyP) => Ok(Some(FalNb2Resolution::OneK)), + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(FalNb2NumImages::One), + 2 => Ok(FalNb2NumImages::Two), + 3 => Ok(FalNb2NumImages::Three), + 4 => Ok(FalNb2NumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(FalNb2NumImages::Four), + }, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_nano_banana_pro.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_nano_banana_pro.rs index 9e7a82d612..f286d709f3 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_nano_banana_pro.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_nano_banana_pro.rs @@ -250,7 +250,7 @@ mod tests { fn base_fal_request() -> GenerateImageRequest<'static> { GenerateImageRequest { - model: CommonImageModel::NanaBananaPro, + model: CommonImageModel::NanoBananaPro, provider: Provider::Fal, prompt: Some("a cat in space"), image_inputs: None, diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request.rs index 27688d4a32..938b531db8 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request.rs @@ -1,9 +1,8 @@ -use tokens::tokens::characters::CharacterToken; +use crate::api::audio_list_ref::AudioListRef; +use crate::api::character_list_ref::CharacterListRef; use crate::api::common_aspect_ratio::CommonAspectRatio; use crate::api::common_resolution::CommonResolution; use crate::api::common_video_model::CommonVideoModel; -use crate::api::audio_list_ref::AudioListRef; -use crate::api::character_list_ref::CharacterListRef; use crate::api::image_list_ref::ImageListRef; use crate::api::image_ref::ImageRef; use crate::api::provider::Provider; @@ -21,6 +20,7 @@ use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan; /// Plan to either (1) generate a video or (2) determine how much it costs to generate that video. /// This works across multiple providers by shaping a generic "GenerateVideoRequest" into a provider-specific plan. /// That plan can then be used to return a cost estimate for that given provider or return a struct that can be used to send a real generation request. +#[derive(Debug)] pub struct GenerateVideoRequest<'a> { /// Which model to use. pub model: CommonVideoModel, diff --git a/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs b/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs index 275840441c..acc8024d1d 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs @@ -23,7 +23,7 @@ pub fn get_artcraft_client() -> RouterClient { pub fn base_image_request() -> GenerateImageRequest<'static> { GenerateImageRequest { - model: CommonImageModel::NanaBananaPro, + model: CommonImageModel::NanoBananaPro, provider: Provider::Artcraft, prompt: Some("a cat in space"), image_inputs: None, @@ -49,7 +49,7 @@ pub fn get_fal_client() -> RouterClient { pub fn base_fal_image_request() -> GenerateImageRequest<'static> { GenerateImageRequest { - model: CommonImageModel::NanaBananaPro, + model: CommonImageModel::NanoBananaPro, provider: Provider::Fal, prompt: Some("a cat in space"), image_inputs: None, @@ -121,7 +121,7 @@ pub fn base_seedream_5_lite_image_request() -> GenerateImageRequest<'static> { pub fn base_nano_banana_2_image_request() -> GenerateImageRequest<'static> { GenerateImageRequest { - model: CommonImageModel::NanaBanana2, + model: CommonImageModel::NanoBanana2, provider: Provider::Artcraft, prompt: Some("a cat in space"), image_inputs: None, @@ -139,7 +139,7 @@ pub fn base_nano_banana_2_image_request() -> GenerateImageRequest<'static> { pub fn base_nano_banana_image_request() -> GenerateImageRequest<'static> { GenerateImageRequest { - model: CommonImageModel::NanaBanana, + model: CommonImageModel::NanoBanana, provider: Provider::Artcraft, prompt: Some("a cat in space"), image_inputs: None, diff --git a/crates/api_clients/fal_client/src/requests/http/image/edit/http_flux_1_dev_edit_image.rs b/crates/api_clients/fal_client/src/requests/http/image/edit/http_flux_1_dev_edit_image.rs new file mode 100644 index 0000000000..e6f7610f3b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/http/image/edit/http_flux_1_dev_edit_image.rs @@ -0,0 +1,54 @@ +use crate::requests::api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux1DevEditImageInput { + pub prompt: String, + + pub image_url: String, + + /// 0.0 - 1.0 + /// Default: 0.95 + #[serde(skip_serializing_if = "Option::is_none")] + pub strength: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Options: jpeg, png + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1DevEditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1DevEditImageOutput { + pub images: Vec, +} + +pub fn flux_1_dev_edit_image( + params: Flux1DevEditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux/dev/image-to-image", params) +} diff --git a/crates/api_clients/fal_client/src/requests/http/image/edit/http_flux_1_schnell_edit_image.rs b/crates/api_clients/fal_client/src/requests/http/image/edit/http_flux_1_schnell_edit_image.rs new file mode 100644 index 0000000000..b98d0b97d4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/http/image/edit/http_flux_1_schnell_edit_image.rs @@ -0,0 +1,48 @@ +use crate::requests::api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux1SchnellEditImageInput { + pub image_url: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Options: jpeg, png + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1SchnellEditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1SchnellEditImageOutput { + pub images: Vec, +} + +pub fn flux_1_schnell_edit_image( + params: Flux1SchnellEditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux/schnell/redux", params) +} diff --git a/crates/api_clients/fal_client/src/requests/http/image/edit/mod.rs b/crates/api_clients/fal_client/src/requests/http/image/edit/mod.rs index 29143524ae..91977b2682 100644 --- a/crates/api_clients/fal_client/src/requests/http/image/edit/mod.rs +++ b/crates/api_clients/fal_client/src/requests/http/image/edit/mod.rs @@ -1,3 +1,5 @@ +pub mod http_flux_1_dev_edit_image; +pub mod http_flux_1_schnell_edit_image; pub mod http_flux_pro_kontext_max_edit; pub mod http_gemini_25_flash_edit; pub mod http_gpt_image_1_edit_image; diff --git a/crates/api_clients/fal_client/src/requests/webhook/image/edit/enqueue_flux_1_dev_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests/webhook/image/edit/enqueue_flux_1_dev_edit_image_webhook.rs new file mode 100644 index 0000000000..761614d087 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/webhook/image/edit/enqueue_flux_1_dev_edit_image_webhook.rs @@ -0,0 +1,100 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::http::image::edit::http_flux_1_dev_edit_image::{flux_1_dev_edit_image, Flux1DevEditImageInput}; +use crate::requests::api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Flux1DevEditImageArgs<'a, U: IntoUrl> { + pub prompt: &'a str, + pub image_url: String, + pub num_images: Flux1DevEditImageNumImages, + + // Fulfillment + pub webhook_url: U, + pub api_key: &'a FalApiKey, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1DevEditImageNumImages { + One, + Two, + Three, + Four, +} + +pub async fn enqueue_flux_1_dev_edit_image_webhook( + args: Flux1DevEditImageArgs<'_, U> +) -> Result { + let num_images = match args.num_images { + Flux1DevEditImageNumImages::One => 1, + Flux1DevEditImageNumImages::Two => 2, + Flux1DevEditImageNumImages::Three => 3, + Flux1DevEditImageNumImages::Four => 4, + }; + + let request = Flux1DevEditImageInput { + prompt: args.prompt.to_string(), + image_url: args.image_url, + num_images: Some(num_images), + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + ..Default::default() + }; + + let result = flux_1_dev_edit_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::webhook::image::edit::enqueue_flux_1_dev_edit_image_webhook::{ + enqueue_flux_1_dev_edit_image_webhook, Flux1DevEditImageArgs, Flux1DevEditImageNumImages, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::GHOST_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real Fal API request + async fn test_single_image() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Flux1DevEditImageArgs { + prompt: "make this image look like a watercolor painting", + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1DevEditImageNumImages::One, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_1_dev_edit_image_webhook(args).await?; + assert!(result.request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real Fal API request + async fn test_batch_two() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Flux1DevEditImageArgs { + prompt: "turn this into a cyberpunk scene with neon lights", + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1DevEditImageNumImages::Two, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_1_dev_edit_image_webhook(args).await?; + assert!(result.request_id.is_some()); + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests/webhook/image/edit/enqueue_flux_1_schnell_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests/webhook/image/edit/enqueue_flux_1_schnell_edit_image_webhook.rs new file mode 100644 index 0000000000..f6dcf73844 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/webhook/image/edit/enqueue_flux_1_schnell_edit_image_webhook.rs @@ -0,0 +1,120 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::http::image::edit::http_flux_1_schnell_edit_image::{flux_1_schnell_edit_image, Flux1SchnellEditImageInput}; +use crate::requests::api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Flux1SchnellEditImageArgs<'a, U: IntoUrl> { + pub image_url: String, + pub num_images: Flux1SchnellEditImageNumImages, + pub image_size: Option, + + // Fulfillment + pub webhook_url: U, + pub api_key: &'a FalApiKey, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1SchnellEditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1SchnellEditImageSize { + Square, + SquareHd, + LandscapeFourByThree, + LandscapeSixteenByNine, + PortraitThreeByFour, + PortraitNineBySixteen, +} + +pub async fn enqueue_flux_1_schnell_edit_image_webhook( + args: Flux1SchnellEditImageArgs<'_, U> +) -> Result { + let num_images = match args.num_images { + Flux1SchnellEditImageNumImages::One => 1, + Flux1SchnellEditImageNumImages::Two => 2, + Flux1SchnellEditImageNumImages::Three => 3, + Flux1SchnellEditImageNumImages::Four => 4, + }; + + let image_size = args.image_size.map(|s| match s { + Flux1SchnellEditImageSize::Square => "square", + Flux1SchnellEditImageSize::SquareHd => "square_hd", + Flux1SchnellEditImageSize::LandscapeFourByThree => "landscape_4_3", + Flux1SchnellEditImageSize::LandscapeSixteenByNine => "landscape_16_9", + Flux1SchnellEditImageSize::PortraitThreeByFour => "portrait_4_3", + Flux1SchnellEditImageSize::PortraitNineBySixteen => "portrait_16_9", + }.to_string()); + + let request = Flux1SchnellEditImageInput { + image_url: args.image_url, + num_images: Some(num_images), + image_size, + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + ..Default::default() + }; + + let result = flux_1_schnell_edit_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::webhook::image::edit::enqueue_flux_1_schnell_edit_image_webhook::{ + enqueue_flux_1_schnell_edit_image_webhook, Flux1SchnellEditImageArgs, + Flux1SchnellEditImageNumImages, Flux1SchnellEditImageSize, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::GHOST_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real Fal API request + async fn test_single_image_no_size() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Flux1SchnellEditImageArgs { + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1SchnellEditImageNumImages::One, + image_size: None, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_1_schnell_edit_image_webhook(args).await?; + assert!(result.request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real Fal API request + async fn test_with_landscape_size() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Flux1SchnellEditImageArgs { + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1SchnellEditImageNumImages::Two, + image_size: Some(Flux1SchnellEditImageSize::LandscapeSixteenByNine), + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_1_schnell_edit_image_webhook(args).await?; + assert!(result.request_id.is_some()); + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests/webhook/image/edit/mod.rs b/crates/api_clients/fal_client/src/requests/webhook/image/edit/mod.rs index 902837f20b..d855b91c64 100644 --- a/crates/api_clients/fal_client/src/requests/webhook/image/edit/mod.rs +++ b/crates/api_clients/fal_client/src/requests/webhook/image/edit/mod.rs @@ -1,3 +1,5 @@ +pub mod enqueue_flux_1_dev_edit_image_webhook; +pub mod enqueue_flux_1_schnell_edit_image_webhook; pub mod enqueue_bytedance_seedream_v4_edit_image_webhook; pub mod enqueue_bytedance_seedream_v4p5_edit_image_webhook; pub mod enqueue_bytedance_seedream_v5_lite_edit_image_webhook; diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft.rs index 794ecc9dbc..0901d8755d 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft.rs @@ -23,10 +23,10 @@ pub async fn handle_image_edit_artcraft( match model { ImageEditModel::FluxProKontextMax => handle_artcraft_flux_kontext_edit(request, app, app_data_root, app_env_configs, storyteller_creds_manager).await, ImageEditModel::Gemini25Flash | ImageEditModel::NanoBanana => { - handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBanana, GenerationModel::NanoBanana).await + handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBanana, GenerationModel::NanoBanana).await }, - ImageEditModel::NanoBanana2 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBanana2, GenerationModel::NanoBanana2).await, - ImageEditModel::NanoBananaPro => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBananaPro, GenerationModel::NanoBananaPro).await, + ImageEditModel::NanoBanana2 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBanana2, GenerationModel::NanoBanana2).await, + ImageEditModel::NanoBananaPro => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBananaPro, GenerationModel::NanoBananaPro).await, ImageEditModel::GptImage1 => handle_artcraft_gpt_image_1_edit(request, app, app_data_root, app_env_configs, storyteller_creds_manager).await, ImageEditModel::GptImage1p5 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage1p5, GenerationModel::GptImage1p5).await, ImageEditModel::Seedream4 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4, GenerationModel::Seedream4).await, diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/text_to_image/artcraft/handle_text_to_image_artcraft.rs b/crates/desktop/artcraft/src/core/commands/enqueue/text_to_image/artcraft/handle_text_to_image_artcraft.rs index 8c680f1e0e..54a383ecad 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/text_to_image/artcraft/handle_text_to_image_artcraft.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/text_to_image/artcraft/handle_text_to_image_artcraft.rs @@ -38,10 +38,10 @@ pub async fn handle_text_to_image_artcraft( TextToImageModel::GptImage1 => handle_artcraft_gpt_image_1_text_to_image(request, app_env_configs, storyteller_creds_manager).await, TextToImageModel::GptImage1p5 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage1p5, GenerationModel::GptImage1p5).await, TextToImageModel::Gemini25Flash | TextToImageModel::NanoBanana => { - handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBanana, GenerationModel::NanoBanana).await + handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBanana, GenerationModel::NanoBanana).await }, - TextToImageModel::NanoBanana2 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBanana2, GenerationModel::NanoBanana2).await, - TextToImageModel::NanoBananaPro => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBananaPro, GenerationModel::NanoBananaPro).await, + TextToImageModel::NanoBanana2 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBanana2, GenerationModel::NanoBanana2).await, + TextToImageModel::NanoBananaPro => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBananaPro, GenerationModel::NanoBananaPro).await, TextToImageModel::Seedream4 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4, GenerationModel::Seedream4).await, TextToImageModel::Seedream4p5 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4p5, GenerationModel::Seedream4p5).await, TextToImageModel::Seedream5Lite => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream5Lite, GenerationModel::Seedream5Lite).await, diff --git a/crates/schema/public/enums/src/common/generation/common_image_model.rs b/crates/schema/public/enums/src/common/generation/common_image_model.rs index 52d21cc68a..f1e0019d5d 100644 --- a/crates/schema/public/enums/src/common/generation/common_image_model.rs +++ b/crates/schema/public/enums/src/common/generation/common_image_model.rs @@ -16,11 +16,11 @@ pub enum CommonImageModel { #[serde(rename = "gpt_image_1p5")] GptImage1p5, #[serde(rename = "nano_banana")] - NanaBanana, + NanoBanana, #[serde(rename = "nano_banana_2")] - NanaBanana2, + NanoBanana2, #[serde(rename = "nano_banana_pro")] - NanaBananaPro, + NanoBananaPro, #[serde(rename = "seedream_4")] Seedream4, #[serde(rename = "seedream_4p5")] @@ -28,3 +28,48 @@ pub enum CommonImageModel { #[serde(rename = "seedream_5_lite")] Seedream5Lite, } + +impl CommonImageModel { + pub fn to_common_model_type(&self) -> crate::common::generation::common_model_type::CommonModelType { + use crate::common::generation::common_model_type::CommonModelType; + match self { + Self::Flux1Dev => CommonModelType::Flux1Dev, + Self::Flux1Schnell => CommonModelType::Flux1Schnell, + Self::FluxPro11 => CommonModelType::FluxPro11, + Self::FluxPro11Ultra => CommonModelType::FluxPro11Ultra, + Self::GptImage1p5 => CommonModelType::GptImage1p5, + Self::NanoBanana => CommonModelType::NanoBanana, + Self::NanoBanana2 => CommonModelType::NanoBanana2, + Self::NanoBananaPro => CommonModelType::NanoBananaPro, + Self::Seedream4 => CommonModelType::Seedream4, + Self::Seedream4p5 => CommonModelType::Seedream4p5, + Self::Seedream5Lite => CommonModelType::Seedream5Lite, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::generation::common_model_type::CommonModelType; + + #[test] + fn all_image_models_convert_to_common_model_type() { + let models = [ + (CommonImageModel::Flux1Dev, CommonModelType::Flux1Dev), + (CommonImageModel::Flux1Schnell, CommonModelType::Flux1Schnell), + (CommonImageModel::FluxPro11, CommonModelType::FluxPro11), + (CommonImageModel::FluxPro11Ultra, CommonModelType::FluxPro11Ultra), + (CommonImageModel::GptImage1p5, CommonModelType::GptImage1p5), + (CommonImageModel::NanoBanana, CommonModelType::NanoBanana), + (CommonImageModel::NanoBanana2, CommonModelType::NanoBanana2), + (CommonImageModel::NanoBananaPro, CommonModelType::NanoBananaPro), + (CommonImageModel::Seedream4, CommonModelType::Seedream4), + (CommonImageModel::Seedream4p5, CommonModelType::Seedream4p5), + (CommonImageModel::Seedream5Lite, CommonModelType::Seedream5Lite), + ]; + for (image_model, expected) in models { + assert_eq!(image_model.to_common_model_type(), expected); + } + } +} 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 2902d5f3f5..372d2ed2e3 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 @@ -59,3 +59,62 @@ pub enum CommonVideoModel { #[serde(rename = "veo_3p1_fast")] Veo3p1Fast, } + +impl CommonVideoModel { + pub fn to_common_model_type(&self) -> crate::common::generation::common_model_type::CommonModelType { + use crate::common::generation::common_model_type::CommonModelType; + match self { + Self::GrokVideo => CommonModelType::GrokVideo, + Self::Kling16Pro => CommonModelType::Kling16Pro, + Self::Kling21Pro => CommonModelType::Kling21Pro, + Self::Kling21Master => CommonModelType::Kling21Master, + Self::Kling2p5TurboPro => CommonModelType::Kling2p5TurboPro, + Self::Kling2p6Pro => CommonModelType::Kling2p6Pro, + Self::Kling3p0Standard => CommonModelType::Kling3p0Standard, + Self::Kling3p0Pro => CommonModelType::Kling3p0Pro, + Self::Seedance10Lite => CommonModelType::Seedance10Lite, + Self::Seedance1p5Pro => CommonModelType::Seedance1p5Pro, + Self::Seedance2p0 => CommonModelType::Seedance2p0, + Self::Sora2 => CommonModelType::Sora2, + Self::Sora2Pro => CommonModelType::Sora2Pro, + Self::Veo2 => CommonModelType::Veo2, + Self::Veo3 => CommonModelType::Veo3, + Self::Veo3Fast => CommonModelType::Veo3Fast, + Self::Veo3p1 => CommonModelType::Veo3p1, + Self::Veo3p1Fast => CommonModelType::Veo3p1Fast, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::generation::common_model_type::CommonModelType; + + #[test] + fn all_video_models_convert_to_common_model_type() { + let models = [ + (CommonVideoModel::GrokVideo, CommonModelType::GrokVideo), + (CommonVideoModel::Kling16Pro, CommonModelType::Kling16Pro), + (CommonVideoModel::Kling21Pro, CommonModelType::Kling21Pro), + (CommonVideoModel::Kling21Master, CommonModelType::Kling21Master), + (CommonVideoModel::Kling2p5TurboPro, CommonModelType::Kling2p5TurboPro), + (CommonVideoModel::Kling2p6Pro, CommonModelType::Kling2p6Pro), + (CommonVideoModel::Kling3p0Standard, CommonModelType::Kling3p0Standard), + (CommonVideoModel::Kling3p0Pro, CommonModelType::Kling3p0Pro), + (CommonVideoModel::Seedance10Lite, CommonModelType::Seedance10Lite), + (CommonVideoModel::Seedance1p5Pro, CommonModelType::Seedance1p5Pro), + (CommonVideoModel::Seedance2p0, CommonModelType::Seedance2p0), + (CommonVideoModel::Sora2, CommonModelType::Sora2), + (CommonVideoModel::Sora2Pro, CommonModelType::Sora2Pro), + (CommonVideoModel::Veo2, CommonModelType::Veo2), + (CommonVideoModel::Veo3, CommonModelType::Veo3), + (CommonVideoModel::Veo3Fast, CommonModelType::Veo3Fast), + (CommonVideoModel::Veo3p1, CommonModelType::Veo3p1), + (CommonVideoModel::Veo3p1Fast, CommonModelType::Veo3p1Fast), + ]; + for (video_model, expected) in models { + assert_eq!(video_model.to_common_model_type(), expected); + } + } +} diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs index 3edf4ab2dd..2d8f7bee8f 100644 --- a/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs @@ -59,7 +59,7 @@ fn build_omni_gen_image_models() -> Vec { // ... models.push(OmniGenImageModelDetails { - model: CommonImageModel::NanaBanana, // NB: currently Gemini25Flash in our system + model: CommonImageModel::NanoBanana, // NB: currently Gemini25Flash in our system full_name: None, text_prompt_supported: Some(true), image_refs_supported: Some(true), @@ -85,7 +85,7 @@ fn build_omni_gen_image_models() -> Vec { }); models.push(OmniGenImageModelDetails { - model: CommonImageModel::NanaBanana2, + model: CommonImageModel::NanoBanana2, full_name: None, text_prompt_supported: Some(true), image_refs_supported: Some(true), @@ -118,7 +118,7 @@ fn build_omni_gen_image_models() -> Vec { }); models.push(OmniGenImageModelDetails { - model: CommonImageModel::NanaBananaPro, + model: CommonImageModel::NanoBananaPro, full_name: None, text_prompt_supported: Some(true), image_refs_supported: Some(true), @@ -167,15 +167,15 @@ fn build_omni_gen_image_model_providers() -> Vec Result RouterImageModel::FluxPro11, CommonImageModel::FluxPro11Ultra => RouterImageModel::FluxPro11Ultra, CommonImageModel::GptImage1p5 => RouterImageModel::GptImage1p5, - CommonImageModel::NanaBanana => RouterImageModel::NanaBanana, - CommonImageModel::NanaBanana2 => RouterImageModel::NanaBanana2, - CommonImageModel::NanaBananaPro => RouterImageModel::NanaBananaPro, + CommonImageModel::NanoBanana => RouterImageModel::NanoBanana, + CommonImageModel::NanoBanana2 => RouterImageModel::NanoBanana2, + CommonImageModel::NanoBananaPro => RouterImageModel::NanoBananaPro, CommonImageModel::Seedream4 => RouterImageModel::Seedream4, CommonImageModel::Seedream4p5 => RouterImageModel::Seedream4p5, CommonImageModel::Seedream5Lite => RouterImageModel::Seedream5Lite, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/image/omni_gen_image_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/image/omni_gen_image_cost_handler.rs index db69908229..b7ee34ebd6 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/image/omni_gen_image_cost_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/image/omni_gen_image_cost_handler.rs @@ -2,11 +2,13 @@ use std::sync::Arc; use actix_web::web::Json; use actix_web::{web, HttpRequest}; - use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; use artcraft_api_defs::omni_gen::cost_response::omni_gen_image_cost_response::OmniGenImageCostResponse; +use artcraft_router::api::provider::Provider; +use log::warn; -use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::http_server::endpoints::omni_gen::generate::image::transform_request::transform_request; use crate::state::server_state::ServerState; /// Estimate the cost of an image generation. @@ -18,14 +20,32 @@ use crate::state::server_state::ServerState; responses( (status = 200, description = "Success", body = OmniGenImageCostResponse), (status = 400, description = "Bad input"), - (status = 401, description = "Unauthorized"), (status = 500, description = "Server error"), ), )] pub async fn omni_gen_image_cost_handler( _http_request: HttpRequest, - _request: Json, + request: Json, _server_state: web::Data>, -) -> Result, CommonWebError> { - todo!() +) -> Result, AdvancedCommonWebError> { + let mut generate_request = transform_request(&request)?; + generate_request.provider = Provider::Artcraft; + + let plan = generate_request.build() + .map_err(|e| { + warn!("Failed to build cost plan: {}", e); + AdvancedCommonWebError::from_error(e) + })?; + + let estimate = plan.estimate_costs(); + + Ok(Json(OmniGenImageCostResponse { + success: true, + cost_in_credits: estimate.cost_in_credits, + cost_in_usd_cents: estimate.cost_in_usd_cents, + is_free: estimate.is_free, + is_unlimited: estimate.is_unlimited, + is_rate_limited: estimate.is_rate_limited, + has_watermark: estimate.has_watermark, + })) } 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 d0cef885e1..80cb29d3cf 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 @@ -2,11 +2,13 @@ use std::sync::Arc; use actix_web::web::Json; use actix_web::{web, HttpRequest}; - use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; use artcraft_api_defs::omni_gen::cost_response::omni_gen_video_cost_response::OmniGenVideoCostResponse; +use artcraft_router::api::provider::Provider; +use log::warn; -use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::http_server::endpoints::omni_gen::generate::video::transform_request::transform_request; use crate::state::server_state::ServerState; /// Estimate the cost of a video generation. @@ -18,14 +20,32 @@ use crate::state::server_state::ServerState; responses( (status = 200, description = "Success", body = OmniGenVideoCostResponse), (status = 400, description = "Bad input"), - (status = 401, description = "Unauthorized"), (status = 500, description = "Server error"), ), )] pub async fn omni_gen_video_cost_handler( _http_request: HttpRequest, - _request: Json, + request: Json, _server_state: web::Data>, -) -> Result, CommonWebError> { - todo!() +) -> Result, AdvancedCommonWebError> { + let mut generate_request = transform_request(&request)?; + generate_request.provider = Provider::Artcraft; + + let plan = generate_request.build() + .map_err(|e| { + warn!("Failed to build cost plan: {}", e); + AdvancedCommonWebError::from_error(e) + })?; + + let estimate = plan.estimate_costs(); + + Ok(Json(OmniGenVideoCostResponse { + success: true, + cost_in_credits: estimate.cost_in_credits, + cost_in_usd_cents: estimate.cost_in_usd_cents, + is_free: estimate.is_free, + is_unlimited: estimate.is_unlimited, + is_rate_limited: estimate.is_rate_limited, + has_watermark: estimate.has_watermark, + })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/mod.rs index 76043b3426..e094c493df 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/mod.rs @@ -1 +1,5 @@ pub mod omni_gen_image_generate_handler; +mod request_to_costs; +mod request_to_plan; +mod resolve_media_tokens; +pub(crate) mod transform_request; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs index 424bc816db..a8b4f1c33d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs @@ -2,13 +2,41 @@ use std::sync::Arc; use actix_web::web::Json; use actix_web::{web, HttpRequest}; +use log::{error, info, warn}; +use sqlx::Acquire; use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse; +use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType; +use enums::by_table::prompts::prompt_type::PromptType; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation_provider::GenerationProvider; +use enums::common::visibility::Visibility; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{ + insert_generic_inference_job_for_fal_queue_with_apriori_job_token, + InsertGenericInferenceForFalWithAprioriJobTokenArgs, +}; +use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{ + insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem, +}; +use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; -use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error; +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::state::server_state::ServerState; +use super::request_to_costs::request_to_costs; +use super::request_to_plan::request_to_plan; +use super::resolve_media_tokens::{resolve_media_tokens, apply_resolved_media}; +use super::transform_request::transform_request; + /// Generate an image using the omni-gen unified endpoint. #[utoipa::path( post, @@ -24,9 +52,231 @@ use crate::state::server_state::ServerState; ), )] pub async fn omni_gen_image_generate_handler( - _http_request: HttpRequest, - _request: Json, - _server_state: web::Data>, -) -> Result, CommonWebError> { - todo!() + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, AdvancedCommonWebError> { + + payments_error_test(&request.prompt.as_deref().unwrap_or(""))?; + + // ==================== TRANSFORM REQUEST + PLAN ==================== // + + let mut generate_request = transform_request(&request)?; + + let maybe_prompt_model_type: Option = request.model + .as_ref() + .map(|m| m.to_common_model_type()); + + // ==================== COST ==================== // + + let cost_estimate = request_to_costs(&generate_request)?; + + // ==================== SESSION ==================== // + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + AdvancedCommonWebError::from_anyhow_error(e) + })?; + + let user_token = match maybe_user_session.as_ref() { + Some(session) => &session.user_token, + None => return Err(AdvancedCommonWebError::NotAuthorized), + }; + + let maybe_avt_token = server_state + .avt_cookie_manager + .get_avt_token_from_request(&http_request); + + // ==================== IDEMPOTENCY ==================== // + + let idempotency_token = request.idempotency_token.as_deref() + .unwrap_or("") + .to_string(); + + if let Err(reason) = validate_idempotency_token_format(&idempotency_token) { + return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(reason)); + } + + insert_idempotency_token(&idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + AdvancedCommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string()) + })?; + + // ==================== RESOLVE MEDIA TOKENS ==================== // + + let resolved_media = resolve_media_tokens( + &request, + &http_request, + &mut mysql_connection, + server_state.server_environment, + ).await?; + + apply_resolved_media(&mut generate_request, &resolved_media); + + // ==================== PLAN ==================== // + + let plan = request_to_plan(&mut generate_request)?; + + // ==================== BILLING ==================== // + + let cost = cost_estimate.cost_in_credits.unwrap_or(0); + + info!("Charging wallet: {} credits", cost); + + let apriori_job_token = InferenceJobToken::generate(); + + if cost > 0 { + attempt_wallet_deduction_else_common_web_error( + user_token, + Some(apriori_job_token.as_str()), + cost, + &mut mysql_connection, + ).await?; + } + + // ==================== EXECUTE GENERATION ==================== // + + let fal_client = artcraft_router::client::router_fal_client::RouterFalClient::new( + server_state.fal.api_key.clone(), + server_state.fal.webhook_url.clone(), + ); + + let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client); + + let generation_response = plan.generate_image(&router_client) + .await + .map_err(|e| { + warn!("Image generation failed: {:?}", e); + AdvancedCommonWebError::from_error(e) + })?; + + let external_job_id = match &generation_response { + artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse::Artcraft(p) => { + p.inference_job_token.as_str().to_string() + } + artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse::Fal(p) => { + p.request_id.clone().unwrap_or_default() + } + }; + + // ==================== DB TRANSACTION ==================== // + + let ip_address = get_request_ip(&http_request); + + let mut transaction = mysql_connection + .begin() + .await + .map_err(|err| { + error!("Error starting MySQL transaction: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + // -- Prompt -- + + let generation_mode = if request.image_media_tokens.is_some() { + CommonGenerationMode::Edit + } else { + CommonGenerationMode::Text + }; + + let prompt_result = insert_prompt(InsertPromptArgs { + maybe_apriori_prompt_token: None, + prompt_type: PromptType::ArtcraftApp, + maybe_creator_user_token: Some(user_token), + maybe_model_type: maybe_prompt_model_type, + maybe_generation_provider: Some(GenerationProvider::Artcraft), + maybe_positive_prompt: request.prompt.as_deref(), + maybe_negative_prompt: None, + maybe_other_args: None, + maybe_generation_mode: Some(generation_mode), + maybe_aspect_ratio: None, + maybe_resolution: None, + maybe_batch_count: request.image_batch_count.map(|c| c as u8), + maybe_generate_audio: None, + maybe_duration_seconds: None, + creator_ip_address: &ip_address, + mysql_executor: &mut *transaction, + phantom: Default::default(), + }).await; + + let prompt_token = match prompt_result { + Ok(token) => Some(token), + Err(err) => { + warn!("Error inserting prompt: {:?}", err); + None + } + }; + + // -- Prompt context items -- + + if let Some(token) = prompt_token.as_ref() { + let mut context_items = Vec::new(); + + if let Some(ref_tokens) = &request.image_media_tokens { + for media_token in ref_tokens { + context_items.push(PromptContextItem { + media_token: media_token.clone(), + context_semantic_type: PromptContextSemanticType::Imgsrc, + }); + } + } + + if !context_items.is_empty() { + if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs { + prompt_token: token.clone(), + items: context_items, + transaction: &mut transaction, + }).await { + warn!("Error inserting batch prompt context items: {:?}", err); + } + } + } + + // -- Inference job -- + + let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token( + InsertGenericInferenceForFalWithAprioriJobTokenArgs { + apriori_job_token: &apriori_job_token, + uuid_idempotency_token: &idempotency_token, + maybe_external_third_party_id: &external_job_id, + fal_category: FalCategory::ImageGeneration, + maybe_inference_args: None, + maybe_prompt_token: prompt_token.as_ref(), + maybe_creator_user_token: Some(user_token), + maybe_avt_token: maybe_avt_token.as_ref(), + creator_ip_address: &ip_address, + creator_set_visibility: Visibility::Public, + mysql_executor: &mut *transaction, + starting_job_status_override: None, + maybe_frontend_failure_category: None, + maybe_failure_reason: None, + phantom: Default::default(), + } + ).await; + + let job_token = match db_result { + Ok(token) => token, + Err(err) => { + warn!("Error inserting inference job: {:?}", err); + return Err(AdvancedCommonWebError::from_error(err)); + } + }; + + transaction.commit().await.map_err(|err| { + error!("Error committing transaction: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + Ok(Json(OmniGenImageGenerateResponse { + success: true, + inference_job_token: job_token, + })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/request_to_costs.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/request_to_costs.rs new file mode 100644 index 0000000000..8778c0bf89 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/request_to_costs.rs @@ -0,0 +1,25 @@ +use artcraft_router::api::provider::Provider; +use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest; +use artcraft_router::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use log::warn; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; + +/// Estimate costs for an image generation request. +/// Always uses the Artcraft provider for costing regardless of the execution provider. +pub fn request_to_costs( + request: &GenerateImageRequest<'_>, +) -> Result { + let mut cost_request = GenerateImageRequest { + provider: Provider::Artcraft, + ..*request + }; + + let plan = cost_request.build() + .map_err(|e| { + warn!("Failed to build cost plan: {}", e); + AdvancedCommonWebError::from_error(e) + })?; + + Ok(plan.estimate_costs()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/request_to_plan.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/request_to_plan.rs new file mode 100644 index 0000000000..1cad6f7ecf --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/request_to_plan.rs @@ -0,0 +1,20 @@ +use artcraft_router::api::provider::Provider; +use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest; +use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan; +use log::warn; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; + +/// Build an image generation plan from a transformed request. +/// Overrides the provider to Fal for execution (costing uses Artcraft separately). +pub fn request_to_plan<'a>( + request: &'a mut GenerateImageRequest<'a>, +) -> Result, AdvancedCommonWebError> { + request.provider = Provider::Fal; + + request.build() + .map_err(|e| { + warn!("Failed to build image generation plan: {}", e); + AdvancedCommonWebError::from_error(e) + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/resolve_media_tokens.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/resolve_media_tokens.rs new file mode 100644 index 0000000000..e11ba1f238 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/resolve_media_tokens.rs @@ -0,0 +1,74 @@ +use std::collections::HashMap; + +use actix_web::HttpRequest; +use log::info; +use sqlx::pool::PoolConnection; +use sqlx::MySql; + +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest; +use tokens::tokens::media_files::MediaFileToken; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map; +use server_environment::ServerEnvironment; + +/// Resolved media URLs that can be referenced by a GenerateImageRequest. +/// The owned Vecs here outlive the request so the GenerateImageRequest can borrow them. +pub struct ResolvedImageMedia { + pub url_map: HashMap, + pub image_input_urls: Vec, +} + +/// Collect all media file tokens from the raw HTTP request, query them from the database, +/// and return resolved CDN URLs. +pub async fn resolve_media_tokens( + omni_request: &OmniGenImageCostAndGenerateRequest, + http_request: &HttpRequest, + mysql_connection: &mut PoolConnection, + server_environment: ServerEnvironment, +) -> Result { + let mut all_tokens: Vec = Vec::new(); + + if let Some(tokens) = &omni_request.image_media_tokens { + all_tokens.extend(tokens.iter().cloned()); + } + + if all_tokens.is_empty() { + return Ok(ResolvedImageMedia { + url_map: HashMap::new(), + image_input_urls: Vec::new(), + }); + } + + info!("Resolving {} media file tokens to CDN URLs", all_tokens.len()); + + let url_map = lookup_image_urls_as_map( + http_request, + mysql_connection, + server_environment, + &all_tokens, + ).await?; + + let image_input_urls = omni_request.image_media_tokens.as_ref() + .map(|tokens| tokens.iter().filter_map(|t| url_map.get(t).cloned()).collect()) + .unwrap_or_default(); + + Ok(ResolvedImageMedia { + url_map, + image_input_urls, + }) +} + +/// Apply resolved media URLs to a GenerateImageRequest, replacing MediaFileToken refs with URL refs. +pub fn apply_resolved_media<'a>( + request: &mut GenerateImageRequest<'a>, + resolved: &'a ResolvedImageMedia, +) { + if !resolved.image_input_urls.is_empty() { + request.image_inputs = Some(ImageListRef::Urls(&resolved.image_input_urls)); + } else { + request.image_inputs = None; + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/transform_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/transform_request.rs new file mode 100644 index 0000000000..228d4bcd9c --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/transform_request.rs @@ -0,0 +1,82 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; +use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioRouter; +use artcraft_router::api::common_image_model::CommonImageModel as CommonImageModelRouter; +use artcraft_router::api::common_resolution::CommonResolution as CommonResolutionRouter; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::provider::Provider; +use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; + +fn convert_model( + model: &CommonImageModelEnum, +) -> Result { + let json = serde_json::to_string(model)?; + serde_json::from_str(&json).map_err(|e| { + AdvancedCommonWebError::BadInputWithSimpleMessage( + format!("Unsupported image model: {}", e), + ) + }) +} + +fn convert_aspect_ratio( + ar: &CommonAspectRatioEnum, +) -> Result { + let json = serde_json::to_string(ar)?; + serde_json::from_str(&json).map_err(|e| { + AdvancedCommonWebError::BadInputWithSimpleMessage( + format!("Unsupported aspect ratio: {}", e), + ) + }) +} + +fn convert_resolution( + res: &CommonResolutionEnum, +) -> Result { + let json = serde_json::to_string(res)?; + serde_json::from_str(&json).map_err(|e| { + AdvancedCommonWebError::BadInputWithSimpleMessage( + format!("Unsupported resolution: {}", e), + ) + }) +} + +pub fn transform_request( + request: &OmniGenImageCostAndGenerateRequest, +) -> Result, AdvancedCommonWebError> { + let api_model = request.model.as_ref() + .ok_or_else(|| AdvancedCommonWebError::BadInputWithSimpleMessage( + "model is required".to_string(), + ))?; + + let model = convert_model(api_model)?; + + let aspect_ratio = request.aspect_ratio.as_ref() + .map(convert_aspect_ratio) + .transpose()?; + + let resolution = request.resolution.as_ref() + .map(convert_resolution) + .transpose()?; + + Ok(GenerateImageRequest { + model, + provider: Provider::Artcraft, + prompt: request.prompt.as_deref(), + image_inputs: request.image_media_tokens.as_ref() + .map(ImageListRef::MediaFileTokens), + resolution, + aspect_ratio, + image_batch_count: request.image_batch_count, + horizontal_angle: request.horizontal_angle, + vertical_angle: request.vertical_angle, + zoom: request.zoom, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + generation_mode_mismatch_strategy: None, + idempotency_token: request.idempotency_token.as_deref(), + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs index 16f6d59c37..212954a26e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs @@ -1 +1,5 @@ pub mod omni_gen_video_generate_handler; +mod request_to_costs; +mod request_to_plan; +mod resolve_media_tokens; +pub(crate) mod transform_request; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs index 46f0f11f0a..226111169e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs @@ -2,13 +2,41 @@ use std::sync::Arc; use actix_web::web::Json; use actix_web::{web, HttpRequest}; +use log::{error, info, warn}; +use sqlx::Acquire; use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse; +use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType; +use enums::by_table::prompts::prompt_type::PromptType; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation_provider::GenerationProvider; +use enums::common::visibility::Visibility; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{ + insert_generic_inference_job_for_fal_queue_with_apriori_job_token, + InsertGenericInferenceForFalWithAprioriJobTokenArgs, +}; +use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{ + insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem, +}; +use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; -use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error; +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::state::server_state::ServerState; +use super::request_to_costs::request_to_costs; +use super::request_to_plan::request_to_plan; +use super::resolve_media_tokens::{resolve_media_tokens, apply_resolved_media}; +use super::transform_request::transform_request; + /// Generate a video using the omni-gen unified endpoint. #[utoipa::path( post, @@ -24,9 +52,256 @@ use crate::state::server_state::ServerState; ), )] pub async fn omni_gen_video_generate_handler( - _http_request: HttpRequest, - _request: Json, - _server_state: web::Data>, -) -> Result, CommonWebError> { - todo!() + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, AdvancedCommonWebError> { + + payments_error_test(&request.prompt.as_deref().unwrap_or(""))?; + + // ==================== TRANSFORM REQUEST + PLAN ==================== // + + let mut generate_request = transform_request(&request)?; + + let maybe_prompt_model_type: Option = request.model + .as_ref() + .map(|m| m.to_common_model_type()); + + // ==================== COST ==================== // + + let cost_estimate = request_to_costs(&generate_request)?; + + // ==================== SESSION ==================== // + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + AdvancedCommonWebError::from_anyhow_error(e) + })?; + + let user_token = match maybe_user_session.as_ref() { + Some(session) => &session.user_token, + None => return Err(AdvancedCommonWebError::NotAuthorized), + }; + + let maybe_avt_token = server_state + .avt_cookie_manager + .get_avt_token_from_request(&http_request); + + // ==================== IDEMPOTENCY ==================== // + + let idempotency_token = request.idempotency_token.as_deref() + .unwrap_or("") + .to_string(); + + if let Err(reason) = validate_idempotency_token_format(&idempotency_token) { + return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(reason)); + } + + insert_idempotency_token(&idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + AdvancedCommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string()) + })?; + + // ==================== RESOLVE MEDIA TOKENS ==================== // + + let resolved_media = resolve_media_tokens( + &request, + &http_request, + &mut mysql_connection, + server_state.server_environment, + ).await?; + + apply_resolved_media(&mut generate_request, &resolved_media); + + // ==================== PLAN ==================== // + + let plan = request_to_plan(&mut generate_request)?; + + // ==================== BILLING ==================== // + + let cost = cost_estimate.cost_in_credits.unwrap_or(0); + + info!("Charging wallet: {} credits", cost); + + let apriori_job_token = InferenceJobToken::generate(); + + if cost > 0 { + attempt_wallet_deduction_else_common_web_error( + user_token, + Some(apriori_job_token.as_str()), + cost, + &mut mysql_connection, + ).await?; + } + + // ==================== EXECUTE GENERATION ==================== // + + let fal_client = artcraft_router::client::router_fal_client::RouterFalClient::new( + server_state.fal.api_key.clone(), + server_state.fal.webhook_url.clone(), + ); + let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client); + + let generation_response = plan.generate_video(&router_client) + .await + .map_err(|e| { + warn!("Video generation failed: {:?}", e); + AdvancedCommonWebError::from_error(e) + })?; + + let external_job_id = match &generation_response { + artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse::Artcraft(p) => { + p.inference_job_token.as_str().to_string() + } + artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse::Muapi(p) => { + p.request_id.as_str().to_string() + } + artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse::Seedance2Pro(p) => { + p.order_id.clone() + } + }; + + // ==================== DB TRANSACTION ==================== // + + let ip_address = get_request_ip(&http_request); + + let mut transaction = mysql_connection + .begin() + .await + .map_err(|err| { + error!("Error starting MySQL transaction: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + // -- Prompt -- + + let generation_mode = if request.start_frame_image_media_token.is_some() { + CommonGenerationMode::Keyframe + } else { + CommonGenerationMode::Text + }; + + let prompt_result = insert_prompt(InsertPromptArgs { + maybe_apriori_prompt_token: None, + prompt_type: PromptType::ArtcraftApp, + maybe_creator_user_token: Some(user_token), + maybe_model_type: maybe_prompt_model_type, + maybe_generation_provider: Some(GenerationProvider::Artcraft), + maybe_positive_prompt: request.prompt.as_deref(), + maybe_negative_prompt: request.negative_prompt.as_deref(), + maybe_other_args: None, + maybe_generation_mode: Some(generation_mode), + maybe_aspect_ratio: None, + maybe_resolution: None, + maybe_batch_count: request.video_batch_count.map(|c| c as u8), + maybe_generate_audio: request.generate_audio, + maybe_duration_seconds: request.duration_seconds.map(|d| d as u32), + creator_ip_address: &ip_address, + mysql_executor: &mut *transaction, + phantom: Default::default(), + }).await; + + let prompt_token = match prompt_result { + Ok(token) => Some(token), + Err(err) => { + warn!("Error inserting prompt: {:?}", err); + None + } + }; + + // -- Prompt context items -- + + if let Some(token) = prompt_token.as_ref() { + let mut context_items = Vec::new(); + + if let Some(media_token) = &request.start_frame_image_media_token { + context_items.push(PromptContextItem { + media_token: media_token.clone(), + context_semantic_type: PromptContextSemanticType::VidStartFrame, + }); + } + + if let Some(media_token) = &request.end_frame_image_media_token { + context_items.push(PromptContextItem { + media_token: media_token.clone(), + context_semantic_type: PromptContextSemanticType::VidEndFrame, + }); + } + + if let Some(ref_tokens) = &request.reference_image_media_tokens { + for media_token in ref_tokens { + context_items.push(PromptContextItem { + media_token: media_token.clone(), + context_semantic_type: PromptContextSemanticType::Imgref, + }); + } + } + + if let Some(ref_tokens) = &request.reference_video_media_tokens { + for media_token in ref_tokens { + context_items.push(PromptContextItem { + media_token: media_token.clone(), + context_semantic_type: PromptContextSemanticType::VidRef, + }); + } + } + + if !context_items.is_empty() { + if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs { + prompt_token: token.clone(), + items: context_items, + transaction: &mut transaction, + }).await { + warn!("Error inserting batch prompt context items: {:?}", err); + } + } + } + + // -- Inference job -- + + let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token( + InsertGenericInferenceForFalWithAprioriJobTokenArgs { + apriori_job_token: &apriori_job_token, + uuid_idempotency_token: &idempotency_token, + maybe_external_third_party_id: &external_job_id, + fal_category: FalCategory::VideoGeneration, + maybe_inference_args: None, + maybe_prompt_token: prompt_token.as_ref(), + maybe_creator_user_token: Some(user_token), + maybe_avt_token: maybe_avt_token.as_ref(), + creator_ip_address: &ip_address, + creator_set_visibility: Visibility::Public, + mysql_executor: &mut *transaction, + starting_job_status_override: None, + maybe_frontend_failure_category: None, + maybe_failure_reason: None, + phantom: Default::default(), + } + ).await; + + let job_token = match db_result { + Ok(token) => token, + Err(err) => { + warn!("Error inserting inference job: {:?}", err); + return Err(AdvancedCommonWebError::from_error(err)); + } + }; + + transaction.commit().await.map_err(|err| { + error!("Error committing transaction: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + Ok(Json(OmniGenVideoGenerateResponse { + success: true, + inference_job_token: job_token, + })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_to_costs.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_to_costs.rs new file mode 100644 index 0000000000..d3205b1d2b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_to_costs.rs @@ -0,0 +1,25 @@ +use artcraft_router::api::provider::Provider; +use artcraft_router::generate::generate_video::generate_video_request::GenerateVideoRequest; +use artcraft_router::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use log::warn; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; + +/// Estimate costs for a video generation request. +/// Always uses the Artcraft provider for costing regardless of the execution provider. +pub fn request_to_costs( + request: &GenerateVideoRequest<'_>, +) -> Result { + let mut cost_request = GenerateVideoRequest { + provider: Provider::Artcraft, + ..*request + }; + + let plan = cost_request.build() + .map_err(|e| { + warn!("Failed to build cost plan: {}", e); + AdvancedCommonWebError::from_error(e) + })?; + + Ok(plan.estimate_costs()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_to_plan.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_to_plan.rs new file mode 100644 index 0000000000..9b743cf784 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/request_to_plan.rs @@ -0,0 +1,20 @@ +use artcraft_router::api::provider::Provider; +use artcraft_router::generate::generate_video::generate_video_request::GenerateVideoRequest; +use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan; +use log::warn; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; + +/// Build a video generation plan from a transformed request. +/// Overrides the provider to Fal for execution (costing uses Artcraft separately). +pub fn request_to_plan<'a>( + request: &'a mut GenerateVideoRequest<'a>, +) -> Result, AdvancedCommonWebError> { + request.provider = Provider::Fal; + + request.build() + .map_err(|e| { + warn!("Failed to build video generation plan: {}", e); + AdvancedCommonWebError::from_error(e) + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/resolve_media_tokens.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/resolve_media_tokens.rs new file mode 100644 index 0000000000..5381b459be --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/resolve_media_tokens.rs @@ -0,0 +1,133 @@ +use std::collections::HashMap; + +use actix_web::HttpRequest; +use log::info; +use sqlx::pool::PoolConnection; +use sqlx::MySql; + +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +use artcraft_router::api::audio_list_ref::AudioListRef; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::image_ref::ImageRef; +use artcraft_router::api::video_list_ref::VideoListRef; +use artcraft_router::generate::generate_video::generate_video_request::GenerateVideoRequest; +use tokens::tokens::media_files::MediaFileToken; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map; +use server_environment::ServerEnvironment; + +/// Resolved media URLs that can be referenced by a GenerateVideoRequest. +/// The owned Vecs here outlive the request so the GenerateVideoRequest can borrow them. +pub struct ResolvedVideoMedia { + pub url_map: HashMap, + pub maybe_start_frame_url: Option, + pub maybe_end_frame_url: Option, + pub reference_image_urls: Vec, + pub reference_video_urls: Vec, + pub reference_audio_urls: Vec, +} + +/// Collect all media file tokens from the raw HTTP request, query them from the database, +/// and return resolved CDN URLs. +pub async fn resolve_media_tokens( + omni_request: &OmniGenVideoCostAndGenerateRequest, + http_request: &HttpRequest, + mysql_connection: &mut PoolConnection, + server_environment: ServerEnvironment, +) -> Result { + let mut all_tokens: Vec = Vec::new(); + + if let Some(token) = &omni_request.start_frame_image_media_token { + all_tokens.push(token.clone()); + } + if let Some(token) = &omni_request.end_frame_image_media_token { + all_tokens.push(token.clone()); + } + if let Some(tokens) = &omni_request.reference_image_media_tokens { + all_tokens.extend(tokens.iter().cloned()); + } + if let Some(tokens) = &omni_request.reference_video_media_tokens { + all_tokens.extend(tokens.iter().cloned()); + } + if let Some(tokens) = &omni_request.reference_audio_media_tokens { + all_tokens.extend(tokens.iter().cloned()); + } + + if all_tokens.is_empty() { + return Ok(ResolvedVideoMedia { + url_map: HashMap::new(), + maybe_start_frame_url: None, + maybe_end_frame_url: None, + reference_image_urls: Vec::new(), + reference_video_urls: Vec::new(), + reference_audio_urls: Vec::new(), + }); + } + + info!("Resolving {} media file tokens to CDN URLs", all_tokens.len()); + + let url_map = lookup_image_urls_as_map( + http_request, + mysql_connection, + server_environment, + &all_tokens, + ).await?; + + let maybe_start_frame_url = omni_request.start_frame_image_media_token.as_ref() + .and_then(|t| url_map.get(t).cloned()); + + let maybe_end_frame_url = omni_request.end_frame_image_media_token.as_ref() + .and_then(|t| url_map.get(t).cloned()); + + let reference_image_urls = omni_request.reference_image_media_tokens.as_ref() + .map(|tokens| tokens.iter().filter_map(|t| url_map.get(t).cloned()).collect()) + .unwrap_or_default(); + + let reference_video_urls = omni_request.reference_video_media_tokens.as_ref() + .map(|tokens| tokens.iter().filter_map(|t| url_map.get(t).cloned()).collect()) + .unwrap_or_default(); + + let reference_audio_urls = omni_request.reference_audio_media_tokens.as_ref() + .map(|tokens| tokens.iter().filter_map(|t| url_map.get(t).cloned()).collect()) + .unwrap_or_default(); + + Ok(ResolvedVideoMedia { + url_map, + maybe_start_frame_url, + maybe_end_frame_url, + reference_image_urls, + reference_video_urls, + reference_audio_urls, + }) +} + +/// Apply resolved media URLs to a GenerateVideoRequest, replacing MediaFileToken refs with URL refs. +pub fn apply_resolved_media<'a>( + request: &mut GenerateVideoRequest<'a>, + resolved: &'a ResolvedVideoMedia, +) { + request.start_frame = resolved.maybe_start_frame_url.as_deref() + .map(ImageRef::Url); + + request.end_frame = resolved.maybe_end_frame_url.as_deref() + .map(ImageRef::Url); + + if !resolved.reference_image_urls.is_empty() { + request.reference_images = Some(ImageListRef::Urls(&resolved.reference_image_urls)); + } else { + request.reference_images = None; + } + + if !resolved.reference_video_urls.is_empty() { + request.reference_videos = Some(VideoListRef::Urls(&resolved.reference_video_urls)); + } else { + request.reference_videos = None; + } + + if !resolved.reference_audio_urls.is_empty() { + request.reference_audio = Some(AudioListRef::Urls(&resolved.reference_audio_urls)); + } else { + request.reference_audio = None; + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/transform_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/transform_request.rs new file mode 100644 index 0000000000..4d5ed89ecf --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/transform_request.rs @@ -0,0 +1,92 @@ +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +use artcraft_router::api::audio_list_ref::AudioListRef; +use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioRouter; +use artcraft_router::api::common_resolution::CommonResolution as CommonResolutionRouter; +use artcraft_router::api::common_video_model::CommonVideoModel as CommonVideoModelRouter; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::image_ref::ImageRef; +use artcraft_router::api::provider::Provider; +use artcraft_router::api::video_list_ref::VideoListRef; +use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use artcraft_router::generate::generate_video::generate_video_request::GenerateVideoRequest; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +fn convert_model( + model: &CommonVideoModelEnum, +) -> Result { + let json = serde_json::to_string(model)?; + serde_json::from_str(&json).map_err(|e| { + AdvancedCommonWebError::BadInputWithSimpleMessage( + format!("Unsupported video model: {}", e), + ) + }) +} + +fn convert_aspect_ratio( + ar: &CommonAspectRatioEnum, +) -> Result { + let json = serde_json::to_string(ar)?; + serde_json::from_str(&json).map_err(|e| { + AdvancedCommonWebError::BadInputWithSimpleMessage( + format!("Unsupported aspect ratio: {}", e), + ) + }) +} + +fn convert_resolution( + res: &CommonResolutionEnum, +) -> Result { + let json = serde_json::to_string(res)?; + serde_json::from_str(&json).map_err(|e| { + AdvancedCommonWebError::BadInputWithSimpleMessage( + format!("Unsupported resolution: {}", e), + ) + }) +} + +pub fn transform_request( + request: &OmniGenVideoCostAndGenerateRequest, +) -> Result, AdvancedCommonWebError> { + let api_model = request.model.as_ref() + .ok_or_else(|| AdvancedCommonWebError::BadInputWithSimpleMessage( + "model is required".to_string(), + ))?; + + let model = convert_model(api_model)?; + + let aspect_ratio = request.aspect_ratio.as_ref() + .map(convert_aspect_ratio) + .transpose()?; + + let resolution = request.resolution.as_ref() + .map(convert_resolution) + .transpose()?; + + Ok(GenerateVideoRequest { + model, + provider: Provider::Artcraft, + prompt: request.prompt.as_deref(), + negative_prompt: request.negative_prompt.as_deref(), + start_frame: request.start_frame_image_media_token.as_ref() + .map(ImageRef::MediaFileToken), + end_frame: request.end_frame_image_media_token.as_ref() + .map(ImageRef::MediaFileToken), + reference_images: request.reference_image_media_tokens.as_ref() + .map(ImageListRef::MediaFileTokens), + reference_videos: request.reference_video_media_tokens.as_ref() + .map(VideoListRef::MediaFileTokens), + reference_audio: request.reference_audio_media_tokens.as_ref() + .map(AudioListRef::MediaFileTokens), + reference_character_tokens: None, + resolution, + aspect_ratio, + duration_seconds: request.duration_seconds, + video_batch_count: request.video_batch_count, + generate_audio: request.generate_audio, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + idempotency_token: request.idempotency_token.as_deref(), + }) +}