From 1e681a58252377f5ee7b45c8e264e99fc231aaff Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 9 Mar 2026 18:20:34 -0400 Subject: [PATCH] new endpoint --- .../angle/flux_2_lora_edit_image_angle.rs | 56 ++++ .../src/generate/image/angle/mod.rs | 1 + .../public/enums/src/common/model_type.rs | 11 +- .../web/storyteller_web/src/docs/api_doc.rs | 6 + .../flux_2_lora_edit_image_angle_handler.rs | 242 ++++++++++++++++++ .../endpoints/generate/image/angle/mod.rs | 1 + .../application_routes/generate_routes.rs | 5 + 7 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/flux_2_lora_edit_image_angle.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/flux_2_lora_edit_image_angle_handler.rs diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/flux_2_lora_edit_image_angle.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/flux_2_lora_edit_image_angle.rs new file mode 100644 index 0000000000..baf1e3c1fe --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/flux_2_lora_edit_image_angle.rs @@ -0,0 +1,56 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const FLUX_2_LORA_EDIT_IMAGE_ANGLE_PATH: &str = "/v1/generate/image/angle/flux_2_lora"; + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct Flux2LoraEditImageAngleRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// The image to edit with angle adjustment. + pub image_media_token: MediaFileToken, + + /// Horizontal rotation angle in degrees. + pub horizontal_angle: Option, + + /// Vertical rotation angle in degrees. + pub vertical_angle: Option, + + /// Zoom level. + pub zoom: Option, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + /// Output image size. Default is square_hd. + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Flux2LoraEditImageAngleNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Flux2LoraEditImageAngleImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Flux2LoraEditImageAngleResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/mod.rs index 5f14688361..fa3fe51645 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/mod.rs @@ -1 +1,2 @@ +pub mod flux_2_lora_edit_image_angle; pub mod qwen_edit_2511_edit_image_angle; diff --git a/crates/schema/public/enums/src/common/model_type.rs b/crates/schema/public/enums/src/common/model_type.rs index de385295be..89f4c48c87 100644 --- a/crates/schema/public/enums/src/common/model_type.rs +++ b/crates/schema/public/enums/src/common/model_type.rs @@ -27,6 +27,8 @@ pub enum ModelType { FluxPro11Ultra, #[serde(rename = "flux_pro_kontext_max")] FluxProKontextMax, + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, #[serde(rename = "gpt_image_1")] GptImage1, #[serde(rename = "gpt_image_1p5")] @@ -154,6 +156,7 @@ impl ModelType { Self::FluxPro11 => "flux_pro_1p1", Self::FluxPro11Ultra => "flux_pro_1p1_ultra", Self::FluxProKontextMax => "flux_pro_kontext_max", + Self::Flux2LoraAngles => "flux_2_lora_angles", Self::GptImage1 => "gpt_image_1", Self::GptImage1p5 => "gpt_image_1p5", Self::GrokImage => "grok_image", @@ -219,6 +222,7 @@ impl ModelType { "flux_pro_1p1" => Ok(Self::FluxPro11), "flux_pro_1p1_ultra" => Ok(Self::FluxPro11Ultra), "flux_pro_kontext_max" => Ok(Self::FluxProKontextMax), + "flux_2_lora_angles" => Ok(Self::Flux2LoraAngles), "gpt_image_1" => Ok(Self::GptImage1), "gpt_image_1p5" => Ok(Self::GptImage1p5), "grok_image" => Ok(Self::GrokImage), @@ -288,6 +292,7 @@ impl ModelType { Self::FluxPro11, Self::FluxPro11Ultra, Self::FluxProKontextMax, + Self::Flux2LoraAngles, Self::GptImage1, Self::GptImage1p5, Self::GrokImage, @@ -362,6 +367,7 @@ mod tests { assert_serialization(ModelType::FluxPro11, "flux_pro_1p1"); assert_serialization(ModelType::FluxPro11Ultra, "flux_pro_1p1_ultra"); assert_serialization(ModelType::FluxProKontextMax, "flux_pro_kontext_max"); + assert_serialization(ModelType::Flux2LoraAngles, "flux_2_lora_angles"); assert_serialization(ModelType::GptImage1, "gpt_image_1"); assert_serialization(ModelType::GptImage1p5, "gpt_image_1p5"); assert_serialization(ModelType::GrokImage, "grok_image"); @@ -423,6 +429,7 @@ mod tests { assert_eq!(ModelType::FluxPro11.to_str(), "flux_pro_1p1"); assert_eq!(ModelType::FluxPro11Ultra.to_str(), "flux_pro_1p1_ultra"); assert_eq!(ModelType::FluxProKontextMax.to_str(), "flux_pro_kontext_max"); + assert_eq!(ModelType::Flux2LoraAngles.to_str(), "flux_2_lora_angles"); assert_eq!(ModelType::GptImage1.to_str(), "gpt_image_1"); assert_eq!(ModelType::GptImage1p5.to_str(), "gpt_image_1p5"); assert_eq!(ModelType::GrokImage.to_str(), "grok_image"); @@ -486,6 +493,7 @@ mod tests { assert_eq!(ModelType::from_str("flux_pro_1p1").unwrap(), ModelType::FluxPro11); assert_eq!(ModelType::from_str("flux_pro_1p1_ultra").unwrap(), ModelType::FluxPro11Ultra); assert_eq!(ModelType::from_str("flux_pro_kontext_max").unwrap(), ModelType::FluxProKontextMax); + assert_eq!(ModelType::from_str("flux_2_lora_angles").unwrap(), ModelType::Flux2LoraAngles); assert_eq!(ModelType::from_str("gpt_image_1").unwrap(), ModelType::GptImage1); assert_eq!(ModelType::from_str("gpt_image_1p5").unwrap(), ModelType::GptImage1p5); assert_eq!(ModelType::from_str("grok_image").unwrap(), ModelType::GrokImage); @@ -541,7 +549,7 @@ mod tests { #[test] fn all_variants() { let mut variants = ModelType::all_variants(); - assert_eq!(variants.len(), 53); + assert_eq!(variants.len(), 54); // Image models assert_eq!(variants.pop_first(), Some(ModelType::Flux1Dev)); assert_eq!(variants.pop_first(), Some(ModelType::Flux1Schnell)); @@ -550,6 +558,7 @@ mod tests { assert_eq!(variants.pop_first(), Some(ModelType::FluxPro11)); assert_eq!(variants.pop_first(), Some(ModelType::FluxPro11Ultra)); assert_eq!(variants.pop_first(), Some(ModelType::FluxProKontextMax)); + assert_eq!(variants.pop_first(), Some(ModelType::Flux2LoraAngles)); assert_eq!(variants.pop_first(), Some(ModelType::GptImage1)); assert_eq!(variants.pop_first(), Some(ModelType::GptImage1p5)); assert_eq!(variants.pop_first(), Some(ModelType::GrokImage)); diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 48681ec7c3..cc91daaab9 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -241,6 +241,7 @@ use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_v4_mu use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_v4p5_multi_function_image_gen::*; use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_5_lite_multi_function_image_gen::*; // Image edit (missing handlers) +use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::*; use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::*; use artcraft_api_defs::generate::image::edit::flux_pro_kontext_max_edit_image::*; use artcraft_api_defs::generate::image::edit::gemini_25_flash_edit_image::*; @@ -427,6 +428,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux crate::http_server::endpoints::generate::image::edit::qwen_edit_image_handler::qwen_edit_image_handler, crate::http_server::endpoints::generate::image::edit::seededit_3_edit_image_handler::seededit_3_edit_image_handler, // Generate Images (Angle) + crate::http_server::endpoints::generate::image::angle::flux_2_lora_edit_image_angle_handler::flux_2_lora_edit_image_angle_handler, crate::http_server::endpoints::generate::image::angle::qwen_edit_2511_edit_image_angle_handler::qwen_edit_2511_edit_image_angle_handler, // Generate Images (Inpaint) crate::http_server::endpoints::generate::image::inpaint::flux_dev_juggernaut_inpaint_handler::flux_dev_juggernaut_inpaint_image_handler, @@ -1008,6 +1010,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux BytedanceSeedream5LiteMultiFunctionImageGenNumImages, // Image Angle types + Flux2LoraEditImageAngleRequest, + Flux2LoraEditImageAngleResponse, + Flux2LoraEditImageAngleNumImages, + Flux2LoraEditImageAngleImageSize, QwenEdit2511EditImageAngleRequest, QwenEdit2511EditImageAngleResponse, QwenEdit2511EditImageAngleNumImages, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/flux_2_lora_edit_image_angle_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/flux_2_lora_edit_image_angle_handler.rs new file mode 100644 index 0000000000..27eae22a63 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/flux_2_lora_edit_image_angle_handler.rs @@ -0,0 +1,242 @@ +use std::sync::Arc; + +use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error; +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; +use crate::state::server_state::ServerState; +use crate::util::lookup::lookup_image_urls_as_optional_list::lookup_image_urls_as_optional_list; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::{Flux2LoraEditImageAngleImageSize, Flux2LoraEditImageAngleNumImages, Flux2LoraEditImageAngleRequest, Flux2LoraEditImageAngleResponse}; +use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType; +use enums::by_table::prompts::prompt_type::PromptType; +use enums::common::generation_provider::GenerationProvider; +use enums::common::model_type::ModelType; +use enums::common::visibility::Visibility; +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; +use fal_client::requests::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{enqueue_flux_2_lora_edit_image_angle_webhook, EnqueueFlux2LoraEditImageAngleArgs, EnqueueFlux2LoraAngleNumImages, EnqueueFlux2LoraAngleImageSize}; +use http_server_common::request::get_request_ip::get_request_ip; +use log::{error, info, warn}; +use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory; +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::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 sqlx::Acquire; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +/// Flux 2 LoRA - Edit Image Angle +#[utoipa::path( + post, + tag = "Generate Images (Angle)", + path = "/v1/generate/image/angle/flux_2_lora", + responses( + (status = 200, description = "Success", body = Flux2LoraEditImageAngleResponse), + ), + params( + ("request" = Flux2LoraEditImageAngleRequest, description = "Payload for Request"), + ) +)] +pub async fn flux_2_lora_edit_image_angle_handler( + http_request: HttpRequest, + request: Json, + server_state: web::Data> +) -> Result, CommonWebError> { + + if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) { + return Err(CommonWebError::BadInputWithSimpleMessage(reason)); + } + + 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); + CommonWebError::ServerError + })?; + + let maybe_avt_token = server_state + .avt_cookie_manager + .get_avt_token_from_request(&http_request); + + let user_token = match maybe_user_session.as_ref() { + Some(session) => &session.user_token, + None => { + return Err(CommonWebError::NotAuthorized); + } + }; + + let media_tokens = vec![request.image_media_token.clone()]; + + let image_urls = lookup_image_urls_as_optional_list( + &http_request, + &mut mysql_connection, + server_state.server_environment, + &media_tokens, + ).await? + .ok_or_else(|| { + warn!("No image URLs found for media token"); + CommonWebError::BadInputWithSimpleMessage("Image media token not found".to_string()) + })?; + + insert_idempotency_token(&request.uuid_idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string()) + })?; + + info!("Fal webhook URL: {}", server_state.fal.webhook_url); + + let apriori_job_token = InferenceJobToken::generate(); + + let num_images = request.num_images.map(|n| match n { + Flux2LoraEditImageAngleNumImages::One => EnqueueFlux2LoraAngleNumImages::One, + Flux2LoraEditImageAngleNumImages::Two => EnqueueFlux2LoraAngleNumImages::Two, + Flux2LoraEditImageAngleNumImages::Three => EnqueueFlux2LoraAngleNumImages::Three, + Flux2LoraEditImageAngleNumImages::Four => EnqueueFlux2LoraAngleNumImages::Four, + }); + + let image_size = request.image_size.map(|s| match s { + Flux2LoraEditImageAngleImageSize::Square => EnqueueFlux2LoraAngleImageSize::Square, + Flux2LoraEditImageAngleImageSize::SquareHd => EnqueueFlux2LoraAngleImageSize::SquareHd, + Flux2LoraEditImageAngleImageSize::PortraitFourThree => EnqueueFlux2LoraAngleImageSize::PortraitFourThree, + Flux2LoraEditImageAngleImageSize::PortraitSixteenNine => EnqueueFlux2LoraAngleImageSize::PortraitSixteenNine, + Flux2LoraEditImageAngleImageSize::LandscapeFourThree => EnqueueFlux2LoraAngleImageSize::LandscapeFourThree, + Flux2LoraEditImageAngleImageSize::LandscapeSixteenNine => EnqueueFlux2LoraAngleImageSize::LandscapeSixteenNine, + }); + + let args = EnqueueFlux2LoraEditImageAngleArgs { + image_urls, + horizontal_angle: request.horizontal_angle, + vertical_angle: request.vertical_angle, + zoom: request.zoom, + num_images, + image_size, + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + webhook_url: &server_state.fal.webhook_url, + api_key: &server_state.fal.api_key, + }; + + let cost = args.calculate_cost_in_cents(); + + info!("Charging wallet: {}", cost); + + let wallet_deduction = attempt_wallet_deduction_else_common_web_error( + user_token, + Some(apriori_job_token.as_str()), + cost, + &mut mysql_connection, + ).await?; + + let fal_result = match enqueue_flux_2_lora_edit_image_angle_webhook(args).await { + Ok(result) => result, + Err(err) => { + warn!("Error calling enqueue_flux_2_lora_edit_image_angle_webhook: {:?}", err); + refund_wallet_after_api_failure(&wallet_deduction.ledger_entry_token, &mut mysql_connection).await?; + return Err(CommonWebError::ServerError); + } + }; + + let external_job_id = fal_result.request_id + .ok_or_else(|| { + warn!("Fal request_id is None"); + CommonWebError::ServerError + })?; + + info!("Fal request_id: {}", external_job_id); + + let ip_address = get_request_ip(&http_request); + + let mut transaction = mysql_connection + .begin() + .await + .map_err(|err| { + error!("Error starting MySQL transaction: {:?}", err); + CommonWebError::ServerError + })?; + + 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: Some(ModelType::Flux2LoraAngles), + maybe_generation_provider: Some(GenerationProvider::Artcraft), + maybe_positive_prompt: None, + maybe_negative_prompt: None, + maybe_other_args: 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 + } + }; + + if let Some(token) = prompt_token.as_ref() { + let result = insert_batch_prompt_context_items(InsertBatchArgs { + prompt_token: token.clone(), + items: media_tokens.iter().map(|token| { + PromptContextItem { + media_token: token.clone(), + context_semantic_type: PromptContextSemanticType::Imgsrc, + } + }).collect(), + transaction: &mut transaction, + }).await; + + if let Err(err) = result { + warn!("Error inserting batch prompt context items: {:?}", err); + } + } + + let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(InsertGenericInferenceForFalWithAprioriJobTokenArgs { + apriori_job_token: &apriori_job_token, + uuid_idempotency_token: &request.uuid_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, + phantom: Default::default(), + }).await; + + let job_token = match db_result { + Ok(token) => token, + Err(err) => { + warn!("Error inserting generic inference job for FAL queue: {:?}", err); + return Err(CommonWebError::ServerError); + } + }; + + let _r = transaction + .commit() + .await + .map_err(|err| { + error!("Error committing MySQL transaction: {:?}", err); + CommonWebError::ServerError + })?; + + Ok(Json(Flux2LoraEditImageAngleResponse { + success: true, + inference_job_token: job_token, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/mod.rs index 612f291341..9d8e585c21 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/mod.rs @@ -1 +1,2 @@ +pub mod flux_2_lora_edit_image_angle_handler; pub mod qwen_edit_2511_edit_image_angle_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/generate_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/generate_routes.rs index 1f1df0e344..bde21e998a 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/generate_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/generate_routes.rs @@ -20,6 +20,7 @@ use crate::http_server::endpoints::generate::image::multi_function::gpt_image_1p use crate::http_server::endpoints::generate::image::multi_function::nano_banana_2_multi_function_image_gen_handler::nano_banana_2_multi_function_image_gen_handler; use crate::http_server::endpoints::generate::image::multi_function::nano_banana_multi_function_image_gen_handler::nano_banana_multi_function_image_gen_handler; use crate::http_server::endpoints::generate::image::multi_function::nano_banana_pro_multi_function_image_gen_handler::nano_banana_pro_multi_function_image_gen_handler; +use crate::http_server::endpoints::generate::image::angle::flux_2_lora_edit_image_angle_handler::flux_2_lora_edit_image_angle_handler; use crate::http_server::endpoints::generate::image::angle::qwen_edit_2511_edit_image_angle_handler::qwen_edit_2511_edit_image_angle_handler; use crate::http_server::endpoints::generate::image::remove_image_background_handler::remove_image_background_handler; use crate::http_server::endpoints::generate::object::generate_hunyuan_2_0_image_to_3d_handler::generate_hunyuan_2_0_image_to_3d_handler; @@ -140,6 +141,10 @@ where ) ) .service(web::scope("/angle") + .service(web::resource("/flux_2_lora") + .route(web::post().to(flux_2_lora_edit_image_angle_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) .service(web::resource("/qwen_edit_2511") .route(web::post().to(qwen_edit_2511_edit_image_angle_handler)) .route(web::head().to(|| HttpResponse::Ok()))