diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/router_fal_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/router_fal_client.rs index 2bd63ae9de..77a834e876 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/client/router_fal_client.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/client/router_fal_client.rs @@ -9,4 +9,11 @@ impl RouterFalClient { pub fn new(api_key: FalApiKey, webhook_url: String) -> Self { Self { api_key, webhook_url } } + + pub fn new_from_raw_key(api_key: &str, webhook_url: String) -> Self { + Self { + api_key: FalApiKey::from_str(api_key), + webhook_url, + } + } } diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs index 1fa3e6eff7..d9deb28e6b 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs @@ -1,7 +1,8 @@ use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors; -use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason}; use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; use crate::core::commands::generate::generate_image::providers::artcraft::handle_artcraft; +use crate::core::commands::generate::generate_image::providers::router::handle_router::handle_router; use crate::core::commands::generate::generate_image::tauri_generate_image_request::{ TauriGenerateImageErrorType, TauriGenerateImageRequest, TauriGenerateImageResponse, }; @@ -10,6 +11,7 @@ use crate::core::commands::response::shorthand::Response; use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent; use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache; use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; use crate::core::state::task_database::TaskDatabase; use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; @@ -24,32 +26,37 @@ pub async fn generate_image_command( app_env_configs: State<'_, AppEnvConfigs>, task_database: State<'_, TaskDatabase>, storyteller_creds_manager: State<'_, StorytellerCredentialManager>, + credential_cache: State<'_, ProviderCredentialLoadingCache>, ) -> Response { info!("generate_image_command called, request: {:?}", request); - // Only Artcraft provider is supported via this command. - // Other providers (Grok, Sora, Midjourney) should use the legacy commands. + let provider = request.provider.unwrap_or(GenerationProvider::Artcraft); - let is_artcraft_provider = matches!( - request.provider, - None | Some(GenerationProvider::Artcraft) - ); - - if !is_artcraft_provider { - return Err(CommandErrorResponseWrapper { - status: CommandErrorStatus::BadRequest, - error_message: Some("Only Artcraft is supported.".to_string()), - error_type: Some(TauriGenerateImageErrorType::NoProviderAvailable), - error_details: None, - }); - } - - let result = handle_artcraft( - &request, - &app_env_configs, - &storyteller_creds_manager, - ).await; + let result = match provider { + GenerationProvider::Artcraft => { + handle_artcraft( + &request, + &app_env_configs, + &storyteller_creds_manager, + ).await + } + // Midjourney uses its own legacy command path, not this one. + GenerationProvider::Midjourney => { + Err(GenerateError::NotYetImplemented( + "Midjourney should use its dedicated command".to_string(), + )) + } + // All other providers go through the router. + other => { + handle_router( + &request, + other, + &app_env_configs, + &credential_cache, + ).await + } + }; match result { Ok(success) => handle_success_behavior(&app, &task_database, &request, success).await, @@ -119,6 +126,8 @@ async fn handle_error_behavior( let error_type = match &err { GenerateError::BadInput(_) => TauriGenerateImageErrorType::BadInput, + GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) => TauriGenerateImageErrorType::NeedsFalApiKey, + GenerateError::MissingCredentials(MissingCredentialsReason::NeedsGrokCredentials) => TauriGenerateImageErrorType::NeedsGrokCredentials, GenerateError::MissingCredentials(_) => TauriGenerateImageErrorType::NeedsStorytellerCredentials, GenerateError::NoProviderAvailable => TauriGenerateImageErrorType::NoProviderAvailable, GenerateError::BillingIssue(_) => TauriGenerateImageErrorType::BillingIssue, diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/mod.rs index 8a4247a99f..cd026817bf 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/mod.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/mod.rs @@ -1 +1,2 @@ -pub mod artcraft; \ No newline at end of file +pub mod artcraft; +pub mod router; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/handle_api_providers.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/handle_api_providers.rs new file mode 100644 index 0000000000..4c7ec88ac6 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/handle_api_providers.rs @@ -0,0 +1,126 @@ +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::client::router_client::RouterClient; +use artcraft_router::client::router_fal_client::RouterFalClient; +use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest; +use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse; +use artcraft_client::utils::api_host::ApiHost; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use log::info; +use tokens::tokens::media_files::MediaFileToken; + +use crate::core::api_adapters::models::image::tauri_image_model_to_generation_model::tauri_image_model_to_generation_model; +use crate::core::api_adapters::models::image::tauri_image_model_to_router_model::tauri_image_model_to_router_model; +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::providers::router::utils::convert_enums_to_router::{convert_aspect_ratio, convert_quality, convert_resolution}; +use crate::core::commands::generate::generate_image::providers::router::utils::map_media_files_to_urls::map_media_file_tokens_to_cdn_urls; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel; + +/// Handle image generation via FAL using the user's own API key. +pub async fn handle_fal( + request: &TauriGenerateImageRequest, + api_key: &str, + api_host: &ApiHost, +) -> Result { + let tauri_model = request.model.ok_or(GenerateError::no_model_specified())?; + + let router_model = tauri_image_model_to_router_model(tauri_model) + .ok_or(GenerateError::NotYetImplemented( + format!("Model {:?} is not supported via the FAL router path", tauri_model), + ))?; + + // Collect all media file tokens that need resolving. + let image_inputs = resolve_image_inputs(request, api_host).await?; + + let webhook_url = build_fal_webhook_url(api_host); + + let router_request = GenerateImageRequest { + model: router_model, + provider: Provider::Fal, + prompt: request.prompt.clone(), + image_inputs, + resolution: request.resolution.map(convert_resolution), + aspect_ratio: request.aspect_ratio.map(convert_aspect_ratio), + quality: request.quality.map(convert_quality), + image_batch_count: request.batch_size.map(|n| n as u16), + horizontal_angle: request.adjust_horizontal_angle, + vertical_angle: request.adjust_vertical_angle, + zoom: request.adjust_zoom, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + + info!("Building FAL image generation plan: model={:?}", router_model); + let plan = router_request.build()?; + + let fal_client = RouterFalClient::new_from_raw_key(api_key, webhook_url); + let client = RouterClient::Fal(fal_client); + + info!("Executing FAL image generation..."); + let response = plan.generate_image(&client).await?; + + build_task_enqueue_success(tauri_model, response) +} + +// ── Helpers ── + +/// Collect media file tokens from the request and resolve them to CDN URLs. +/// +/// FAL accepts image URLs directly, so we map our media file tokens to their +/// CDN URLs rather than downloading the bytes locally. +async fn resolve_image_inputs( + request: &TauriGenerateImageRequest, + api_host: &ApiHost, +) -> Result, GenerateError> { + let mut tokens: Vec = Vec::new(); + + if let Some(canvas_token) = &request.canvas_image_media_token { + tokens.push(canvas_token.clone()); + } + + if let Some(scene_token) = &request.scene_image_media_token { + tokens.push(scene_token.clone()); + } + + if let Some(media_tokens) = &request.image_media_tokens { + tokens.extend(media_tokens.clone()); + } + + if tokens.is_empty() { + return Ok(None); + } + + let urls = map_media_file_tokens_to_cdn_urls(&tokens, api_host).await?; + Ok(Some(ImageListRef::Urls(urls))) +} + +fn build_fal_webhook_url(api_host: &ApiHost) -> String { + let base = api_host.to_api_hostname_and_scheme(); + format!("{}/v1/webhooks/fal", base) +} + +fn build_task_enqueue_success( + tauri_model: TauriImageModel, + response: GenerateImageResponse, +) -> Result { + let fal_payload = response.get_fal_payload() + .ok_or(GenerateError::ResponseHadNoJobTokens)?; + + let provider_job_id = fal_payload.request_id + .or(fal_payload.gateway_request_id) + .ok_or(GenerateError::ResponseHadNoJobTokens)?; + + let generation_model = tauri_image_model_to_generation_model(tauri_model); + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ImageGeneration, + model: Some(generation_model), + provider: GenerationProvider::Fal, + provider_job_id: Some(provider_job_id), + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/handle_router.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/handle_router.rs new file mode 100644 index 0000000000..0702e2ecd3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/handle_router.rs @@ -0,0 +1,84 @@ +use enums::common::generation_provider::GenerationProvider; +use log::info; + +use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::providers::router::handle_api_providers::handle_fal; +use crate::core::commands::generate::generate_image::providers::router::handle_web_login_providers::handle_web_login_provider; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::providers::credentials::payload::provider_credential_payload::ProviderCredentialPayload; +use crate::core::providers::credentials::provider_credential_key::ProviderCredentialKey; +use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; + +/// Dispatch an image generation request to a third-party provider via the router. +/// +/// This handles providers that authenticate with the user's own credentials +/// (API key or web login) rather than going through the Artcraft backend. +pub async fn handle_router( + request: &TauriGenerateImageRequest, + provider: GenerationProvider, + app_env_configs: &AppEnvConfigs, + credential_cache: &ProviderCredentialLoadingCache, +) -> Result { + let credential_key = map_provider_to_credential_key(provider)?; + + info!("handle_router: provider={:?}, credential_key={:?}", provider, credential_key); + + let payload = credential_cache.get_credentials(credential_key) + .map_err(|err| { + GenerateError::AnyhowError(anyhow::anyhow!("Failed to load credentials: {:?}", err)) + })? + .ok_or_else(|| map_provider_to_missing_credentials_error(provider))?; + + match payload { + ProviderCredentialPayload::ApiKey(api_key_data) => { + handle_api_key_provider(request, provider, api_key_data.as_str(), app_env_configs).await + } + ProviderCredentialPayload::WebLogin(web_login_data) => { + handle_web_login_provider(request, provider, &web_login_data).await + } + } +} + +// ── Helpers ── + +async fn handle_api_key_provider( + request: &TauriGenerateImageRequest, + provider: GenerationProvider, + api_key: &str, + app_env_configs: &AppEnvConfigs, +) -> Result { + match provider { + GenerationProvider::Fal => { + handle_fal(request, api_key, &app_env_configs.storyteller_host).await + } + _ => { + Err(GenerateError::NotYetImplemented( + format!("API key provider {:?} is not yet supported via the router path", provider), + )) + } + } +} + +fn map_provider_to_credential_key( + provider: GenerationProvider, +) -> Result { + match provider { + GenerationProvider::Fal => Ok(ProviderCredentialKey::FalApiKey), + _ => Err(GenerateError::NotYetImplemented( + format!("Provider {:?} does not have a mapped credential key yet", provider), + )), + } +} + +fn map_provider_to_missing_credentials_error(provider: GenerationProvider) -> GenerateError { + match provider { + GenerationProvider::Fal => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey), + GenerationProvider::Grok => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsGrokCredentials), + GenerationProvider::Midjourney => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyCredentials), + GenerationProvider::Sora => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsSoraCredentials), + GenerationProvider::WorldLabs => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsWorldLabsCredentials), + _ => GenerateError::NoProviderAvailable, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/handle_web_login_providers.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/handle_web_login_providers.rs new file mode 100644 index 0000000000..d9359f325f --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/handle_web_login_providers.rs @@ -0,0 +1,17 @@ +use enums::common::generation_provider::GenerationProvider; + +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::providers::credentials::payload::web_login::WebLoginData; + +/// Handle image generation for providers that authenticate via web login. +/// +/// Not yet implemented — placeholder for Grok, Runway, Higgsfield, etc. +pub async fn handle_web_login_provider( + _request: &TauriGenerateImageRequest, + _provider: GenerationProvider, + _web_login: &WebLoginData, +) -> Result { + unimplemented!("Web login provider image generation is not yet supported via the router path") +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/mod.rs new file mode 100644 index 0000000000..ea74c39d9a --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/mod.rs @@ -0,0 +1,4 @@ +pub mod handle_router; +mod handle_api_providers; +mod handle_web_login_providers; +mod utils; diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/utils/convert_enums_to_router.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/utils/convert_enums_to_router.rs new file mode 100644 index 0000000000..f81bbd5779 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/utils/convert_enums_to_router.rs @@ -0,0 +1,50 @@ +use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as RouterAspectRatio; +use artcraft_router::api::common_quality::CommonQuality as RouterQuality; +use artcraft_router::api::common_resolution::CommonResolution as RouterResolution; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as EnumsAspectRatio; +use enums::common::generation::common_quality::CommonQuality as EnumsQuality; +use enums::common::generation::common_resolution::CommonResolution as EnumsResolution; + +pub fn convert_aspect_ratio(value: EnumsAspectRatio) -> RouterAspectRatio { + match value { + EnumsAspectRatio::Auto => RouterAspectRatio::Auto, + EnumsAspectRatio::Square => RouterAspectRatio::Square, + EnumsAspectRatio::WideThreeByTwo => RouterAspectRatio::WideThreeByTwo, + EnumsAspectRatio::WideFourByThree => RouterAspectRatio::WideFourByThree, + EnumsAspectRatio::WideFiveByFour => RouterAspectRatio::WideFiveByFour, + EnumsAspectRatio::WideSixteenByNine => RouterAspectRatio::WideSixteenByNine, + EnumsAspectRatio::WideTwentyOneByNine => RouterAspectRatio::WideTwentyOneByNine, + EnumsAspectRatio::TallTwoByThree => RouterAspectRatio::TallTwoByThree, + EnumsAspectRatio::TallThreeByFour => RouterAspectRatio::TallThreeByFour, + EnumsAspectRatio::TallFourByFive => RouterAspectRatio::TallFourByFive, + EnumsAspectRatio::TallNineBySixteen => RouterAspectRatio::TallNineBySixteen, + EnumsAspectRatio::TallNineByTwentyOne => RouterAspectRatio::TallNineByTwentyOne, + EnumsAspectRatio::Wide => RouterAspectRatio::Wide, + EnumsAspectRatio::Tall => RouterAspectRatio::Tall, + EnumsAspectRatio::Auto2k => RouterAspectRatio::Auto2k, + EnumsAspectRatio::Auto3k => RouterAspectRatio::Auto3k, + EnumsAspectRatio::Auto4k => RouterAspectRatio::Auto4k, + EnumsAspectRatio::SquareHd => RouterAspectRatio::SquareHd, + } +} + +pub fn convert_quality(value: EnumsQuality) -> RouterQuality { + match value { + EnumsQuality::High => RouterQuality::High, + EnumsQuality::Medium => RouterQuality::Medium, + EnumsQuality::Low => RouterQuality::Low, + } +} + +pub fn convert_resolution(value: EnumsResolution) -> RouterResolution { + match value { + EnumsResolution::OneK => RouterResolution::OneK, + EnumsResolution::TwoK => RouterResolution::TwoK, + EnumsResolution::ThreeK => RouterResolution::ThreeK, + EnumsResolution::FourK => RouterResolution::FourK, + EnumsResolution::HalfK => RouterResolution::HalfK, + EnumsResolution::FourEightyP => RouterResolution::FourEightyP, + EnumsResolution::SevenTwentyP => RouterResolution::SevenTwentyP, + EnumsResolution::TenEightyP => RouterResolution::TenEightyP, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/utils/map_media_files_to_urls.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/utils/map_media_files_to_urls.rs new file mode 100644 index 0000000000..81f47670e7 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/utils/map_media_files_to_urls.rs @@ -0,0 +1,27 @@ +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::utils::api_host::ApiHost; +use log::info; +use tokens::tokens::media_files::MediaFileToken; + +use crate::core::commands::enqueue::generate_error::GenerateError; + +/// Resolve a list of media file tokens to their CDN URLs. +/// +/// FAL (and other third-party providers) can fetch images by URL directly, +/// so we just need the CDN link — no need to download the bytes locally. +pub async fn map_media_file_tokens_to_cdn_urls( + tokens: &[MediaFileToken], + api_host: &ApiHost, +) -> Result, GenerateError> { + let mut urls = Vec::with_capacity(tokens.len()); + + for token in tokens { + info!("Resolving media file token {} to CDN URL...", token.as_str()); + let response = get_media_file(api_host, token).await?; + let cdn_url = response.media_file.media_links.cdn_url.to_string(); + info!("Resolved {} -> {}", token.as_str(), &cdn_url); + urls.push(cdn_url); + } + + Ok(urls) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/utils/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/utils/mod.rs new file mode 100644 index 0000000000..d9e43ac9c4 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/router/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod convert_enums_to_router; +pub mod map_media_files_to_urls; diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/tauri_generate_image_request.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/tauri_generate_image_request.rs index 1a4efafaea..1e37568ff6 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/tauri_generate_image_request.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/tauri_generate_image_request.rs @@ -116,6 +116,8 @@ pub enum TauriGenerateImageErrorType { ServerError, /// Needs to be logged into Artcraft NeedsStorytellerCredentials, + /// Needs a FAL API key + NeedsFalApiKey, /// Needs Grok credentials NeedsGrokCredentials, /// Billing issue