From c7b817ac3f72d34fe603bb40ef27949174be4eef Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 9 Mar 2026 19:48:09 -0400 Subject: [PATCH] angle edit models (fal, handlers, routing) (#1124) --- .../angle/flux_2_lora_edit_image_angle.rs | 56 ++++ .../src/generate/image/angle/mod.rs | 2 + .../angle/qwen_edit_2511_edit_image_angle.rs | 59 ++++ .../src/generate/image/mod.rs | 1 + .../angle/flux_2_lora_edit_image_angle.rs | 21 ++ .../src/endpoints/generate/image/angle/mod.rs | 2 + .../angle/qwen_edit_2511_edit_image_angle.rs | 21 ++ .../src/endpoints/generate/image/mod.rs | 1 + .../src/api/common_image_model.rs | 4 + ...estimate_image_cost_artcraft_flux_1_dev.rs | 3 + ...mate_image_cost_artcraft_flux_1_schnell.rs | 3 + ..._image_cost_artcraft_flux_2_lora_angles.rs | 72 +++++ ...timate_image_cost_artcraft_flux_pro_1p1.rs | 3 + ..._image_cost_artcraft_flux_pro_1p1_ultra.rs | 3 + ...imate_image_cost_artcraft_gpt_image_1p5.rs | 3 + ...stimate_image_cost_artcraft_nano_banana.rs | 3 + ...imate_image_cost_artcraft_nano_banana_2.rs | 3 + ...ate_image_cost_artcraft_nano_banana_pro.rs | 3 + ...age_cost_artcraft_qwen_edit_2511_angles.rs | 72 +++++ ...estimate_image_cost_artcraft_seedream_4.rs | 3 + ...timate_image_cost_artcraft_seedream_4p5.rs | 3 + ...ate_image_cost_artcraft_seedream_5_lite.rs | 3 + .../generate_image/cost/artcraft/mod.rs | 2 + ...estimate_image_cost_fal_nano_banana_pro.rs | 3 + ...erate_image_artcraft_flux_2_lora_angles.rs | 72 +++++ ...te_image_artcraft_qwen_edit_2511_angles.rs | 74 +++++ .../generate_image/execute/artcraft/mod.rs | 2 + .../generate_image/generate_image_request.rs | 21 +- .../generate_image/image_generation_plan.rs | 22 ++ .../generate_image/plan/artcraft/mod.rs | 2 + ...erate_image_artcraft_flux_2_lora_angles.rs | 254 +++++++++++++++++ ...te_image_artcraft_qwen_edit_2511_angles.rs | 256 ++++++++++++++++++ ...plan_generate_image_fal_nano_banana_pro.rs | 3 + .../artcraft_router/src/test_helpers.rs | 72 +++++ .../http_flux_2_lora_edit_image_angle.rs | 82 ++++++ .../http_qwen_edit_2511_edit_image_angle.rs | 88 ++++++ .../src/requests/http/image/angle/mod.rs | 2 + .../fal_client/src/requests/http/image/mod.rs | 1 + ...ue_flux_2_lora_edit_image_angle_webhook.rs | 180 ++++++++++++ ...qwen_edit_2511_edit_image_angle_webhook.rs | 187 +++++++++++++ .../src/requests/webhook/image/angle/mod.rs | 2 + .../src/requests/webhook/image/mod.rs | 1 + .../handle_image_edit_artcraft_via_router.rs | 3 + ...andle_text_to_image_artcraft_via_router.rs | 3 + .../public/enums/src/common/model_type.rs | 20 +- .../web/storyteller_web/src/docs/api_doc.rs | 15 + .../image/estimate_image_cost_handler.rs | 3 + .../flux_2_lora_edit_image_angle_handler.rs | 242 +++++++++++++++++ .../endpoints/generate/image/angle/mod.rs | 2 + ...qwen_edit_2511_edit_image_angle_handler.rs | 243 +++++++++++++++++ .../endpoints/generate/image/mod.rs | 3 +- .../application_routes/generate_routes.rs | 12 + 52 files changed, 2213 insertions(+), 3 deletions(-) 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/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/qwen_edit_2511_edit_image_angle.rs create mode 100644 crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/flux_2_lora_edit_image_angle.rs create mode 100644 crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_2_lora_angles.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_qwen_edit_2511_angles.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/generate_image_artcraft_flux_2_lora_angles.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/generate_image_artcraft_qwen_edit_2511_angles.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_flux_2_lora_angles.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_qwen_edit_2511_angles.rs create mode 100644 crates/api_clients/fal_client/src/requests/http/image/angle/http_flux_2_lora_edit_image_angle.rs create mode 100644 crates/api_clients/fal_client/src/requests/http/image/angle/http_qwen_edit_2511_edit_image_angle.rs create mode 100644 crates/api_clients/fal_client/src/requests/http/image/angle/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/webhook/image/angle/enqueue_flux_2_lora_edit_image_angle_webhook.rs create mode 100644 crates/api_clients/fal_client/src/requests/webhook/image/angle/enqueue_qwen_edit_2511_edit_image_angle_webhook.rs create mode 100644 crates/api_clients/fal_client/src/requests/webhook/image/angle/mod.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/flux_2_lora_edit_image_angle_handler.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/mod.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/qwen_edit_2511_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 000000000..baf1e3c1f --- /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 new file mode 100644 index 000000000..fa3fe5164 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/mod.rs @@ -0,0 +1,2 @@ +pub mod flux_2_lora_edit_image_angle; +pub mod qwen_edit_2511_edit_image_angle; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/qwen_edit_2511_edit_image_angle.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/qwen_edit_2511_edit_image_angle.rs new file mode 100644 index 000000000..5efc396dc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/qwen_edit_2511_edit_image_angle.rs @@ -0,0 +1,59 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const QWEN_EDIT_2511_EDIT_IMAGE_ANGLE_PATH: &str = "/v1/generate/image/angle/qwen_edit_2511"; + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct QwenEdit2511EditImageAngleRequest { + /// 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, + + /// Additional text prompt to guide the generation. + pub additional_prompt: 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 QwenEdit2511EditImageAngleNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum QwenEdit2511EditImageAngleImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct QwenEdit2511EditImageAngleResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/mod.rs index 271a82aea..068f2ef5f 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/mod.rs @@ -1,3 +1,4 @@ +pub mod angle; pub mod bg_removal; pub mod edit; pub mod inpaint; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/flux_2_lora_edit_image_angle.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/flux_2_lora_edit_image_angle.rs new file mode 100644 index 000000000..785d92c09 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/flux_2_lora_edit_image_angle.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::{ + Flux2LoraEditImageAngleRequest, Flux2LoraEditImageAngleResponse, + FLUX_2_LORA_EDIT_IMAGE_ANGLE_PATH, +}; + +pub async fn flux_2_lora_edit_image_angle( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Flux2LoraEditImageAngleRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + FLUX_2_LORA_EDIT_IMAGE_ANGLE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/mod.rs new file mode 100644 index 000000000..fa3fe5164 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/mod.rs @@ -0,0 +1,2 @@ +pub mod flux_2_lora_edit_image_angle; +pub mod qwen_edit_2511_edit_image_angle; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle.rs new file mode 100644 index 000000000..aec899c9f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::{ + QwenEdit2511EditImageAngleRequest, QwenEdit2511EditImageAngleResponse, + QWEN_EDIT_2511_EDIT_IMAGE_ANGLE_PATH, +}; + +pub async fn qwen_edit_2511_edit_image_angle( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: QwenEdit2511EditImageAngleRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + QWEN_EDIT_2511_EDIT_IMAGE_ANGLE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/mod.rs index 271a82aea..068f2ef5f 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/mod.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/mod.rs @@ -1,3 +1,4 @@ +pub mod angle; pub mod bg_removal; pub mod edit; pub mod inpaint; 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 ca4977099..390db7ce5 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 @@ -27,4 +27,8 @@ pub enum CommonImageModel { Seedream4p5, #[serde(rename = "seedream_5_lite")] Seedream5Lite, + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_1_dev.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_1_dev.rs index e5f0376b3..d139f4080 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_1_dev.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_1_dev.rs @@ -47,6 +47,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; request.build() .expect("build should succeed") diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_1_schnell.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_1_schnell.rs index c2a5d1f1d..eb31099d1 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_1_schnell.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_1_schnell.rs @@ -35,6 +35,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; let estimate = request.build() .expect("build should succeed") diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_2_lora_angles.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_2_lora_angles.rs new file mode 100644 index 000000000..666ada852 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_2_lora_angles.rs @@ -0,0 +1,72 @@ +use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::Flux2LoraEditImageAngleNumImages; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::PlanArtcraftFlux2LoraAngles; + +pub(crate) fn estimate_image_cost_artcraft_flux_2_lora_angles( + plan: &PlanArtcraftFlux2LoraAngles<'_>, +) -> ImageGenerationCostEstimate { + // Pricing: $0.04/image (4 cents). 1 credit = 1 USD cent. + let cost_per_image: u64 = 4; + + let num_images: u64 = match plan.num_images { + Flux2LoraEditImageAngleNumImages::One => 1, + Flux2LoraEditImageAngleNumImages::Two => 2, + Flux2LoraEditImageAngleNumImages::Three => 3, + Flux2LoraEditImageAngleNumImages::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, + } +} + +#[cfg(test)] +mod tests { + use crate::api::common_image_model::CommonImageModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::provider::Provider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request::GenerateImageRequest; + use tokens::tokens::media_files::MediaFileToken; + + fn estimate_usd_cents(image_batch_count: u16) -> u64 { + let tokens = vec![MediaFileToken::new_from_str("test_token")]; + let request = GenerateImageRequest { + model: CommonImageModel::Flux2LoraAngles, + provider: Provider::Artcraft, + prompt: None, + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + resolution: None, + aspect_ratio: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + request.build() + .expect("build should succeed") + .estimate_costs() + .cost_in_usd_cents + .expect("cost_in_usd_cents should be present") + } + + #[test] + fn test_estimate_cost_usd_cents() { + // $0.04/image = 4 cents each + assert_eq!(estimate_usd_cents(1), 4); + assert_eq!(estimate_usd_cents(2), 8); + assert_eq!(estimate_usd_cents(3), 12); + assert_eq!(estimate_usd_cents(4), 16); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_pro_1p1.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_pro_1p1.rs index 19ccfb273..ad5fd2824 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_pro_1p1.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_pro_1p1.rs @@ -47,6 +47,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; request.build() .expect("build should succeed") diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_pro_1p1_ultra.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_pro_1p1_ultra.rs index 407dff5ff..ce1c080e2 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_pro_1p1_ultra.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_flux_pro_1p1_ultra.rs @@ -47,6 +47,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; request.build() .expect("build should succeed") diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_gpt_image_1p5.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_gpt_image_1p5.rs index eb89a0454..5ff0af3d2 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_gpt_image_1p5.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_gpt_image_1p5.rs @@ -86,6 +86,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; request.build() .expect("build should succeed") 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 a4b68e068..9d0ea6de3 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 @@ -47,6 +47,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; request.build() .expect("build should succeed") 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 d9ce31944..e6c64e0d1 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 @@ -55,6 +55,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; request.build() .expect("build should succeed") 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 fbe50cd42..50f78da1d 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 @@ -54,6 +54,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; request.build() .expect("build should succeed") diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_qwen_edit_2511_angles.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_qwen_edit_2511_angles.rs new file mode 100644 index 000000000..4933a4132 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_qwen_edit_2511_angles.rs @@ -0,0 +1,72 @@ +use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::QwenEdit2511EditImageAngleNumImages; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_qwen_edit_2511_angles::PlanArtcraftQwenEdit2511Angles; + +pub(crate) fn estimate_image_cost_artcraft_qwen_edit_2511_angles( + plan: &PlanArtcraftQwenEdit2511Angles<'_>, +) -> ImageGenerationCostEstimate { + // Pricing: $0.04/image (4 cents). 1 credit = 1 USD cent. + let cost_per_image: u64 = 4; + + let num_images: u64 = match plan.num_images { + QwenEdit2511EditImageAngleNumImages::One => 1, + QwenEdit2511EditImageAngleNumImages::Two => 2, + QwenEdit2511EditImageAngleNumImages::Three => 3, + QwenEdit2511EditImageAngleNumImages::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, + } +} + +#[cfg(test)] +mod tests { + use crate::api::common_image_model::CommonImageModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::provider::Provider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request::GenerateImageRequest; + use tokens::tokens::media_files::MediaFileToken; + + fn estimate_usd_cents(image_batch_count: u16) -> u64 { + let tokens = vec![MediaFileToken::new_from_str("test_token")]; + let request = GenerateImageRequest { + model: CommonImageModel::QwenEdit2511Angles, + provider: Provider::Artcraft, + prompt: None, + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + resolution: None, + aspect_ratio: None, + image_batch_count: Some(image_batch_count), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + }; + request.build() + .expect("build should succeed") + .estimate_costs() + .cost_in_usd_cents + .expect("cost_in_usd_cents should be present") + } + + #[test] + fn test_estimate_cost_usd_cents() { + // $0.04/image = 4 cents each + assert_eq!(estimate_usd_cents(1), 4); + assert_eq!(estimate_usd_cents(2), 8); + assert_eq!(estimate_usd_cents(3), 12); + assert_eq!(estimate_usd_cents(4), 16); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_4.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_4.rs index b7fe5f2a0..b65a8ddc6 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_4.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_4.rs @@ -47,6 +47,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; request.build() .expect("build should succeed") diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_4p5.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_4p5.rs index a255aae2a..111e5f850 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_4p5.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_4p5.rs @@ -47,6 +47,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; request.build() .expect("build should succeed") diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_5_lite.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_5_lite.rs index a746fa320..08baf8b08 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_5_lite.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/estimate_image_cost_artcraft_seedream_5_lite.rs @@ -47,6 +47,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; request.build() .expect("build should succeed") diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/mod.rs index 6edfe32f8..9e4e3d2f9 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/cost/artcraft/mod.rs @@ -9,3 +9,5 @@ pub mod estimate_image_cost_artcraft_nano_banana_pro; pub mod estimate_image_cost_artcraft_seedream_4; pub mod estimate_image_cost_artcraft_seedream_4p5; pub mod estimate_image_cost_artcraft_seedream_5_lite; +pub mod estimate_image_cost_artcraft_qwen_edit_2511_angles; +pub mod estimate_image_cost_artcraft_flux_2_lora_angles; 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 b72a798a2..9466a2a16 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 @@ -52,6 +52,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; request.build() .expect("build should succeed") diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/generate_image_artcraft_flux_2_lora_angles.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/generate_image_artcraft_flux_2_lora_angles.rs new file mode 100644 index 000000000..198d8bb30 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/generate_image_artcraft_flux_2_lora_angles.rs @@ -0,0 +1,72 @@ +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + ArtcraftImageResponsePayload, GenerateImageResponse, +}; +use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::PlanArtcraftFlux2LoraAngles; +use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::Flux2LoraEditImageAngleRequest; +use artcraft_client::endpoints::generate::image::angle::flux_2_lora_edit_image_angle::flux_2_lora_edit_image_angle; + +pub async fn execute_artcraft_flux_2_lora_angles( + plan: &PlanArtcraftFlux2LoraAngles<'_>, + artcraft_client: &RouterArtcraftClient, +) -> Result { + let request = Flux2LoraEditImageAngleRequest { + uuid_idempotency_token: plan.idempotency_token.clone(), + image_media_token: plan.image_input.clone(), + horizontal_angle: plan.horizontal_angle, + vertical_angle: plan.vertical_angle, + zoom: plan.zoom, + num_images: Some(plan.num_images), + image_size: plan.image_size, + }; + + let response = flux_2_lora_edit_image_angle( + &artcraft_client.api_host, + Some(&artcraft_client.credentials), + request, + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload { + inference_job_token: response.inference_job_token, + })) +} + +#[cfg(test)] +mod tests { + use crate::api::common_aspect_ratio::CommonAspectRatio; + use crate::api::image_list_ref::ImageListRef; + use crate::generate::generate_image::generate_image_request::GenerateImageRequest; + use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan; + use crate::test_helpers::{base_flux_2_lora_angles_image_request, get_artcraft_client}; + use tokens::tokens::media_files::MediaFileToken; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_angle_edit_flux_2_lora() { + let client = get_artcraft_client(); + let tokens = vec![MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8")]; + let request = GenerateImageRequest { + aspect_ratio: Some(CommonAspectRatio::SquareHd), + image_batch_count: Some(1), + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + horizontal_angle: Some(30.0), + vertical_angle: Some(0.0), + zoom: Some(1.0), + ..base_flux_2_lora_angles_image_request() + }; + + let plan = request.build().unwrap(); + let result = plan.generate_image(&client).await; + + println!("Result: {:?}", result); + let response = result.expect("generate_image request failed"); + let payload = response.get_artcraft_payload().expect("expected Artcraft payload"); + println!("Job token: {:?}", payload.inference_job_token); + + assert_eq!(1, 2); // NB: Intentional failure to inspect the response above. + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/generate_image_artcraft_qwen_edit_2511_angles.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/generate_image_artcraft_qwen_edit_2511_angles.rs new file mode 100644 index 000000000..d48d06833 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/generate_image_artcraft_qwen_edit_2511_angles.rs @@ -0,0 +1,74 @@ +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + ArtcraftImageResponsePayload, GenerateImageResponse, +}; +use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_qwen_edit_2511_angles::PlanArtcraftQwenEdit2511Angles; +use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::QwenEdit2511EditImageAngleRequest; +use artcraft_client::endpoints::generate::image::angle::qwen_edit_2511_edit_image_angle::qwen_edit_2511_edit_image_angle; + +pub async fn execute_artcraft_qwen_edit_2511_angles( + plan: &PlanArtcraftQwenEdit2511Angles<'_>, + artcraft_client: &RouterArtcraftClient, +) -> Result { + let request = QwenEdit2511EditImageAngleRequest { + uuid_idempotency_token: plan.idempotency_token.clone(), + image_media_token: plan.image_input.clone(), + horizontal_angle: plan.horizontal_angle, + vertical_angle: plan.vertical_angle, + zoom: plan.zoom, + additional_prompt: plan.prompt.map(|p| p.to_string()), + num_images: Some(plan.num_images), + image_size: plan.image_size, + }; + + let response = qwen_edit_2511_edit_image_angle( + &artcraft_client.api_host, + Some(&artcraft_client.credentials), + request, + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload { + inference_job_token: response.inference_job_token, + })) +} + +#[cfg(test)] +mod tests { + use crate::api::common_aspect_ratio::CommonAspectRatio; + use crate::api::image_list_ref::ImageListRef; + use crate::generate::generate_image::generate_image_request::GenerateImageRequest; + use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan; + use crate::test_helpers::{base_qwen_edit_2511_angles_image_request, get_artcraft_client}; + use tokens::tokens::media_files::MediaFileToken; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_angle_edit_qwen_edit_2511() { + let client = get_artcraft_client(); + let tokens = vec![MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8")]; + let request = GenerateImageRequest { + aspect_ratio: Some(CommonAspectRatio::SquareHd), + image_batch_count: Some(1), + prompt: None, + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + horizontal_angle: Some(30.0), + vertical_angle: Some(0.0), + zoom: Some(1.0), + ..base_qwen_edit_2511_angles_image_request() + }; + + let plan = request.build().unwrap(); + let result = plan.generate_image(&client).await; + + println!("Result: {:?}", result); + let response = result.expect("generate_image request failed"); + let payload = response.get_artcraft_payload().expect("expected Artcraft payload"); + println!("Job token: {:?}", payload.inference_job_token); + + assert_eq!(1, 2); // NB: Intentional failure to inspect the response above. + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/mod.rs index 679dd8da8..e0a618cac 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/execute/artcraft/mod.rs @@ -9,3 +9,5 @@ pub mod generate_image_artcraft_nano_banana_pro; pub mod generate_image_artcraft_seedream_4; pub mod generate_image_artcraft_seedream_4p5; pub mod generate_image_artcraft_seedream_5_lite; +pub mod generate_image_artcraft_qwen_edit_2511_angles; +pub mod generate_image_artcraft_flux_2_lora_angles; 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 b5564e38d..7f9c4514a 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 @@ -19,6 +19,8 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf 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_nano_banana_pro::plan_generate_image_fal_nano_banana_pro; pub struct GenerateImageRequest<'a> { @@ -45,6 +47,15 @@ pub struct GenerateImageRequest<'a> { /// How many images to generate. pub image_batch_count: Option, + /// Only for angle manipulation models. + pub horizontal_angle: Option, + + /// Only for angle manipulation models. + pub vertical_angle: Option, + + /// Only for angle manipulation models. + pub zoom: Option, + /// If the request is a mismatch with the (model/provider), how to mitigate it. pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy, @@ -96,6 +107,12 @@ impl<'a> GenerateImageRequest<'a> { CommonImageModel::Seedream5Lite => { plan_generate_image_artcraft_seedream_5_lite(self).map(ImageGenerationPlan::ArtcraftSeedream5Lite) } + CommonImageModel::QwenEdit2511Angles => { + plan_generate_image_artcraft_qwen_edit_2511_angles(self).map(ImageGenerationPlan::ArtcraftQwenEdit2511Angles) + } + CommonImageModel::Flux2LoraAngles => { + plan_generate_image_artcraft_flux_2_lora_angles(self).map(ImageGenerationPlan::ArtcraftFlux2LoraAngles) + } }, Provider::Fal => match self.model { CommonImageModel::NanaBananaPro => { @@ -110,7 +127,9 @@ impl<'a> GenerateImageRequest<'a> { | CommonImageModel::NanaBanana2 | CommonImageModel::Seedream4 | CommonImageModel::Seedream4p5 - | CommonImageModel::Seedream5Lite => { + | CommonImageModel::Seedream5Lite + | CommonImageModel::QwenEdit2511Angles + | CommonImageModel::Flux2LoraAngles => { Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "provider", value: format!("{:?} is only available on the Artcraft provider", self.model), 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 78165afbc..34568f316 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 @@ -11,6 +11,8 @@ use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraf use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_seedream_4::estimate_image_cost_artcraft_seedream_4; use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_seedream_4p5::estimate_image_cost_artcraft_seedream_4p5; 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_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; @@ -23,6 +25,8 @@ use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_ use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_seedream_4::execute_artcraft_seedream_4; use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_seedream_4p5::execute_artcraft_seedream_4p5; 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_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; @@ -37,6 +41,8 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4::PlanArtcraftSeedream4; use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4p5::PlanArtcraftSeedream4p5; 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_nano_banana_pro::PlanFalNanaBananaPro; #[derive(Debug)] @@ -52,6 +58,8 @@ pub enum ImageGenerationPlan<'a> { ArtcraftSeedream4(PlanArtcraftSeedream4<'a>), ArtcraftSeedream4p5(PlanArtcraftSeedream4p5<'a>), ArtcraftSeedream5Lite(PlanArtcraftSeedream5Lite<'a>), + ArtcraftQwenEdit2511Angles(PlanArtcraftQwenEdit2511Angles<'a>), + ArtcraftFlux2LoraAngles(PlanArtcraftFlux2LoraAngles<'a>), FalNanaBananaPro(PlanFalNanaBananaPro<'a>), } @@ -105,6 +113,14 @@ impl<'a> ImageGenerationPlan<'a> { let artcraft_client = client.get_artcraft_client_ref()?; execute_artcraft_seedream_5_lite(plan, artcraft_client).await } + ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) => { + let artcraft_client = client.get_artcraft_client_ref()?; + execute_artcraft_qwen_edit_2511_angles(plan, artcraft_client).await + } + ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) => { + let artcraft_client = client.get_artcraft_client_ref()?; + execute_artcraft_flux_2_lora_angles(plan, artcraft_client).await + } ImageGenerationPlan::FalNanaBananaPro(plan) => { let fal_client = client.get_fal_client_ref()?; execute_fal_nano_banana_pro(plan, fal_client).await @@ -147,6 +163,12 @@ impl<'a> ImageGenerationPlan<'a> { ImageGenerationPlan::ArtcraftSeedream5Lite(plan) => { estimate_image_cost_artcraft_seedream_5_lite(plan) } + ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) => { + estimate_image_cost_artcraft_qwen_edit_2511_angles(plan) + } + ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) => { + estimate_image_cost_artcraft_flux_2_lora_angles(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/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/mod.rs index d34495771..09339c9df 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/mod.rs @@ -9,3 +9,5 @@ pub mod plan_generate_image_artcraft_nano_banana_pro; pub mod plan_generate_image_artcraft_seedream_4; pub mod plan_generate_image_artcraft_seedream_4p5; pub mod plan_generate_image_artcraft_seedream_5_lite; +pub mod plan_generate_image_artcraft_qwen_edit_2511_angles; +pub mod plan_generate_image_artcraft_flux_2_lora_angles; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_flux_2_lora_angles.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_flux_2_lora_angles.rs new file mode 100644 index 000000000..3f5a2ee9b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_flux_2_lora_angles.rs @@ -0,0 +1,254 @@ +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 artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::{ + Flux2LoraEditImageAngleImageSize, + Flux2LoraEditImageAngleNumImages, +}; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Debug, Clone)] +pub struct PlanArtcraftFlux2LoraAngles<'a> { + pub image_input: &'a MediaFileToken, + pub horizontal_angle: Option, + pub vertical_angle: Option, + pub zoom: Option, + pub image_size: Option, + pub num_images: Flux2LoraEditImageAngleNumImages, + pub idempotency_token: String, +} + +pub fn plan_generate_image_artcraft_flux_2_lora_angles<'a>( + request: &'a GenerateImageRequest<'a>, +) -> Result, ArtcraftRouterError> { + let strategy = request.request_mismatch_mitigation_strategy; + + // Angle models require exactly one input image. + let image_input = resolve_single_image_input(request.image_inputs)?; + let image_size = plan_image_size(request.aspect_ratio, strategy)?; + let num_images = plan_num_images(request.image_batch_count, strategy)?; + + Ok(PlanArtcraftFlux2LoraAngles { + image_input, + horizontal_angle: request.horizontal_angle, + vertical_angle: request.vertical_angle, + zoom: request.zoom, + image_size, + num_images, + idempotency_token: request.get_or_generate_idempotency_token(), + }) +} + +fn resolve_single_image_input<'a>( + image_list_ref: Option>, +) -> Result<&'a MediaFileToken, ArtcraftRouterError> { + match image_list_ref { + None => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "Angle models require exactly one input image".to_string(), + })) + } + Some(ImageListRef::MediaFileTokens(tokens)) => { + if tokens.len() != 1 { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: format!("Angle models require exactly one input image, got {}", tokens.len()), + })); + } + Ok(&tokens[0]) + } + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +// Flux 2 LoRA supported image sizes: +// Square, SquareHd +// PortraitFourThree, PortraitSixteenNine +// LandscapeFourThree, LandscapeSixteenNine +fn plan_image_size( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Flux2LoraEditImageAngleImageSize as S; + match aspect_ratio { + None => Ok(None), + + Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto4k) => { + Ok(Some(S::SquareHd)) + } + + Some(CommonAspectRatio::Square) => Ok(Some(S::Square)), + Some(CommonAspectRatio::SquareHd) => Ok(Some(S::SquareHd)), + + Some(CommonAspectRatio::Wide) | Some(CommonAspectRatio::WideSixteenByNine) => Ok(Some(S::LandscapeSixteenNine)), + Some(CommonAspectRatio::WideFourByThree) => Ok(Some(S::LandscapeFourThree)), + + Some(CommonAspectRatio::Tall) | Some(CommonAspectRatio::TallNineBySixteen) => Ok(Some(S::PortraitSixteenNine)), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(S::PortraitFourThree)), + + Some(unsupported @ CommonAspectRatio::WideFiveByFour) + | Some(unsupported @ CommonAspectRatio::WideThreeByTwo) + | Some(unsupported @ CommonAspectRatio::WideTwentyOneByNine) + | Some(unsupported @ CommonAspectRatio::TallFourByFive) + | Some(unsupported @ CommonAspectRatio::TallTwoByThree) + | Some(unsupported @ CommonAspectRatio::TallNineByTwentyOne) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(S::SquareHd)), + }, + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use Flux2LoraEditImageAngleNumImages as N; + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(N::One), + 2 => Ok(N::Two), + 3 => Ok(N::Three), + 4 => Ok(N::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(N::Four), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::image_list_ref::ImageListRef; + use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan; + use crate::test_helpers::base_flux_2_lora_angles_image_request; + use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::{ + Flux2LoraEditImageAngleImageSize as S, + Flux2LoraEditImageAngleNumImages as N, + }; + use tokens::tokens::media_files::MediaFileToken; + + fn make_token() -> MediaFileToken { + MediaFileToken::new_from_str("test_token_123") + } + + #[test] + fn requires_image_input() { + let request = GenerateImageRequest { + image_inputs: None, + ..base_flux_2_lora_angles_image_request() + }; + let result = request.build(); + assert!(result.is_err()); + } + + #[test] + fn rejects_urls() { + let urls = vec!["https://example.com/img.png".to_string()]; + let request = GenerateImageRequest { + image_inputs: Some(ImageListRef::Urls(&urls)), + ..base_flux_2_lora_angles_image_request() + }; + let result = request.build(); + assert!(result.is_err()); + } + + #[test] + fn accepts_single_media_token() { + let tokens = vec![make_token()]; + let request = GenerateImageRequest { + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_flux_2_lora_angles_image_request() + }; + let result = request.build(); + assert!(result.is_ok()); + } + + #[test] + fn rejects_multiple_media_tokens() { + let tokens = vec![make_token(), make_token()]; + let request = GenerateImageRequest { + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_flux_2_lora_angles_image_request() + }; + let result = request.build(); + assert!(result.is_err()); + } + + #[test] + fn image_size_none_is_none() { + let tokens = vec![make_token()]; + let request = GenerateImageRequest { + aspect_ratio: None, + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_flux_2_lora_angles_image_request() + }; + let ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) = request.build().unwrap() else { panic!("expected ArtcraftFlux2LoraAngles") }; + assert!(plan.image_size.is_none()); + } + + #[test] + fn image_size_square_maps_directly() { + let tokens = vec![make_token()]; + let request = GenerateImageRequest { + aspect_ratio: Some(CommonAspectRatio::Square), + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_flux_2_lora_angles_image_request() + }; + let ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) = request.build().unwrap() else { panic!("expected ArtcraftFlux2LoraAngles") }; + assert!(matches!(plan.image_size, Some(S::Square))); + } + + #[test] + fn num_images_direct_mapping() { + let tokens = vec![make_token()]; + let cases = [(1, N::One), (2, N::Two), (3, N::Three), (4, N::Four)]; + for (count, expected) in cases { + let request = GenerateImageRequest { + image_batch_count: Some(count), + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_flux_2_lora_angles_image_request() + }; + let ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) = request.build().unwrap() else { panic!("expected ArtcraftFlux2LoraAngles") }; + assert!( + std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected), + "expected {:?} for count {}", expected, count, + ); + } + } + + #[test] + fn angle_fields_are_passed_through() { + let tokens = vec![make_token()]; + let request = GenerateImageRequest { + horizontal_angle: Some(45.0), + vertical_angle: Some(-30.0), + zoom: Some(1.5), + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_flux_2_lora_angles_image_request() + }; + let ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) = request.build().unwrap() else { panic!("expected ArtcraftFlux2LoraAngles") }; + assert_eq!(plan.horizontal_angle, Some(45.0)); + assert_eq!(plan.vertical_angle, Some(-30.0)); + assert_eq!(plan.zoom, Some(1.5)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_qwen_edit_2511_angles.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_qwen_edit_2511_angles.rs new file mode 100644 index 000000000..ca4642e1b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_qwen_edit_2511_angles.rs @@ -0,0 +1,256 @@ +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 artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::{ + QwenEdit2511EditImageAngleImageSize, + QwenEdit2511EditImageAngleNumImages, +}; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Debug, Clone)] +pub struct PlanArtcraftQwenEdit2511Angles<'a> { + pub prompt: Option<&'a str>, + pub image_input: &'a MediaFileToken, + pub horizontal_angle: Option, + pub vertical_angle: Option, + pub zoom: Option, + pub image_size: Option, + pub num_images: QwenEdit2511EditImageAngleNumImages, + pub idempotency_token: String, +} + +pub fn plan_generate_image_artcraft_qwen_edit_2511_angles<'a>( + request: &'a GenerateImageRequest<'a>, +) -> Result, ArtcraftRouterError> { + let strategy = request.request_mismatch_mitigation_strategy; + + // Angle models require exactly one input image. + let image_input = resolve_single_image_input(request.image_inputs)?; + let image_size = plan_image_size(request.aspect_ratio, strategy)?; + let num_images = plan_num_images(request.image_batch_count, strategy)?; + + Ok(PlanArtcraftQwenEdit2511Angles { + prompt: request.prompt, + image_input, + horizontal_angle: request.horizontal_angle, + vertical_angle: request.vertical_angle, + zoom: request.zoom, + image_size, + num_images, + idempotency_token: request.get_or_generate_idempotency_token(), + }) +} + +fn resolve_single_image_input<'a>( + image_list_ref: Option>, +) -> Result<&'a MediaFileToken, ArtcraftRouterError> { + match image_list_ref { + None => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "Angle models require exactly one input image".to_string(), + })) + } + Some(ImageListRef::MediaFileTokens(tokens)) => { + if tokens.len() != 1 { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: format!("Angle models require exactly one input image, got {}", tokens.len()), + })); + } + Ok(&tokens[0]) + } + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +// Qwen Edit 2511 supported image sizes: +// Square, SquareHd +// PortraitFourThree, PortraitSixteenNine +// LandscapeFourThree, LandscapeSixteenNine +fn plan_image_size( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use QwenEdit2511EditImageAngleImageSize as S; + match aspect_ratio { + None => Ok(None), + + Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto4k) => { + Ok(Some(S::SquareHd)) + } + + Some(CommonAspectRatio::Square) => Ok(Some(S::Square)), + Some(CommonAspectRatio::SquareHd) => Ok(Some(S::SquareHd)), + + Some(CommonAspectRatio::Wide) | Some(CommonAspectRatio::WideSixteenByNine) => Ok(Some(S::LandscapeSixteenNine)), + Some(CommonAspectRatio::WideFourByThree) => Ok(Some(S::LandscapeFourThree)), + + Some(CommonAspectRatio::Tall) | Some(CommonAspectRatio::TallNineBySixteen) => Ok(Some(S::PortraitSixteenNine)), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(S::PortraitFourThree)), + + Some(unsupported @ CommonAspectRatio::WideFiveByFour) + | Some(unsupported @ CommonAspectRatio::WideThreeByTwo) + | Some(unsupported @ CommonAspectRatio::WideTwentyOneByNine) + | Some(unsupported @ CommonAspectRatio::TallFourByFive) + | Some(unsupported @ CommonAspectRatio::TallTwoByThree) + | Some(unsupported @ CommonAspectRatio::TallNineByTwentyOne) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(S::SquareHd)), + }, + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use QwenEdit2511EditImageAngleNumImages as N; + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(N::One), + 2 => Ok(N::Two), + 3 => Ok(N::Three), + 4 => Ok(N::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(N::Four), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::image_list_ref::ImageListRef; + use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan; + use crate::test_helpers::base_qwen_edit_2511_angles_image_request; + use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::{ + QwenEdit2511EditImageAngleImageSize as S, + QwenEdit2511EditImageAngleNumImages as N, + }; + use tokens::tokens::media_files::MediaFileToken; + + fn make_token() -> MediaFileToken { + MediaFileToken::new_from_str("test_token_123") + } + + #[test] + fn requires_image_input() { + let request = GenerateImageRequest { + image_inputs: None, + ..base_qwen_edit_2511_angles_image_request() + }; + let result = request.build(); + assert!(result.is_err()); + } + + #[test] + fn rejects_urls() { + let urls = vec!["https://example.com/img.png".to_string()]; + let request = GenerateImageRequest { + image_inputs: Some(ImageListRef::Urls(&urls)), + ..base_qwen_edit_2511_angles_image_request() + }; + let result = request.build(); + assert!(result.is_err()); + } + + #[test] + fn accepts_single_media_token() { + let tokens = vec![make_token()]; + let request = GenerateImageRequest { + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_qwen_edit_2511_angles_image_request() + }; + let result = request.build(); + assert!(result.is_ok()); + } + + #[test] + fn rejects_multiple_media_tokens() { + let tokens = vec![make_token(), make_token()]; + let request = GenerateImageRequest { + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_qwen_edit_2511_angles_image_request() + }; + let result = request.build(); + assert!(result.is_err()); + } + + #[test] + fn image_size_none_is_none() { + let tokens = vec![make_token()]; + let request = GenerateImageRequest { + aspect_ratio: None, + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_qwen_edit_2511_angles_image_request() + }; + let ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) = request.build().unwrap() else { panic!("expected ArtcraftQwenEdit2511Angles") }; + assert!(plan.image_size.is_none()); + } + + #[test] + fn image_size_square_maps_directly() { + let tokens = vec![make_token()]; + let request = GenerateImageRequest { + aspect_ratio: Some(CommonAspectRatio::Square), + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_qwen_edit_2511_angles_image_request() + }; + let ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) = request.build().unwrap() else { panic!("expected ArtcraftQwenEdit2511Angles") }; + assert!(matches!(plan.image_size, Some(S::Square))); + } + + #[test] + fn num_images_direct_mapping() { + let tokens = vec![make_token()]; + let cases = [(1, N::One), (2, N::Two), (3, N::Three), (4, N::Four)]; + for (count, expected) in cases { + let request = GenerateImageRequest { + image_batch_count: Some(count), + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_qwen_edit_2511_angles_image_request() + }; + let ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) = request.build().unwrap() else { panic!("expected ArtcraftQwenEdit2511Angles") }; + assert!( + std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected), + "expected {:?} for count {}", expected, count, + ); + } + } + + #[test] + fn angle_fields_are_passed_through() { + let tokens = vec![make_token()]; + let request = GenerateImageRequest { + horizontal_angle: Some(45.0), + vertical_angle: Some(-30.0), + zoom: Some(1.5), + image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)), + ..base_qwen_edit_2511_angles_image_request() + }; + let ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) = request.build().unwrap() else { panic!("expected ArtcraftQwenEdit2511Angles") }; + assert_eq!(plan.horizontal_angle, Some(45.0)); + assert_eq!(plan.vertical_angle, Some(-30.0)); + assert_eq!(plan.zoom, Some(1.5)); + } +} 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 9f2d8f79c..568d5136f 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 @@ -254,6 +254,9 @@ mod tests { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } 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 7768143a4..a3fff8d53 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs @@ -33,6 +33,9 @@ pub fn base_image_request() -> GenerateImageRequest<'static> { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } @@ -56,6 +59,9 @@ pub fn base_fal_image_request() -> GenerateImageRequest<'static> { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } @@ -71,6 +77,9 @@ pub fn base_seedream_4_image_request() -> GenerateImageRequest<'static> { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } @@ -86,6 +95,9 @@ pub fn base_seedream_4p5_image_request() -> GenerateImageRequest<'static> { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } @@ -101,6 +113,9 @@ pub fn base_seedream_5_lite_image_request() -> GenerateImageRequest<'static> { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } @@ -116,6 +131,9 @@ pub fn base_nano_banana_2_image_request() -> GenerateImageRequest<'static> { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } @@ -131,6 +149,9 @@ pub fn base_nano_banana_image_request() -> GenerateImageRequest<'static> { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } @@ -146,6 +167,9 @@ pub fn base_gpt_image_1p5_image_request() -> GenerateImageRequest<'static> { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } @@ -161,6 +185,9 @@ pub fn base_flux_1_dev_image_request() -> GenerateImageRequest<'static> { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } @@ -176,6 +203,9 @@ pub fn base_flux_1_schnell_image_request() -> GenerateImageRequest<'static> { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } @@ -191,6 +221,45 @@ pub fn base_flux_pro_1p1_image_request() -> GenerateImageRequest<'static> { request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_qwen_edit_2511_angles_image_request() -> GenerateImageRequest<'static> { + GenerateImageRequest { + model: CommonImageModel::QwenEdit2511Angles, + provider: Provider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_flux_2_lora_angles_image_request() -> GenerateImageRequest<'static> { + GenerateImageRequest { + model: CommonImageModel::Flux2LoraAngles, + provider: Provider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } @@ -206,6 +275,9 @@ pub fn base_flux_pro_1p1_ultra_image_request() -> GenerateImageRequest<'static> request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, } } diff --git a/crates/api_clients/fal_client/src/requests/http/image/angle/http_flux_2_lora_edit_image_angle.rs b/crates/api_clients/fal_client/src/requests/http/image/angle/http_flux_2_lora_edit_image_angle.rs new file mode 100644 index 000000000..bb4b95f76 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/http/image/angle/http_flux_2_lora_edit_image_angle.rs @@ -0,0 +1,82 @@ +use fal::request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux2LoraEditImageAngleInput { + /// The URL of the image to adjust camera angle for. + pub image_urls: Vec, + + /// Horizontal rotation angle around the object in degrees. + /// 0°=front view, 90°=right side, 180°=back view, 270°=left side. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub horizontal_angle: Option, + + /// Vertical camera angle in degrees. + /// 0°=eye-level shot, 30°=elevated shot, 60°=high-angle shot. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub vertical_angle: Option, + + /// Camera zoom/distance. 0=wide shot, 5=medium shot, 10=close-up. + /// Default: 5 + #[serde(skip_serializing_if = "Option::is_none")] + pub zoom: Option, + + /// The strength of the multiple angles effect. + /// Default: 1, range: 0-2 + #[serde(skip_serializing_if = "Option::is_none")] + pub lora_scale: Option, + + /// The size of the generated image. + /// 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, + + /// The CFG (Classifier Free Guidance) scale. + /// Default: 2.5, range: 0-20 + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// The number of inference steps to perform. + /// Default: 40, range: 1-50 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// Random seed for reproducibility. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Number of images to generate. + /// Default: 1, max: 4 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// Whether to enable the safety checker. + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Output format: png, jpeg, webp. + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux2LoraEditImageAngleFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux2LoraEditImageAngleOutput { + pub images: Vec, + pub seed: u64, + pub prompt: Option, +} + +pub fn http_flux_2_lora_edit_image_angle( + params: Flux2LoraEditImageAngleInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux-2-lora-gallery/multiple-angles", params) +} diff --git a/crates/api_clients/fal_client/src/requests/http/image/angle/http_qwen_edit_2511_edit_image_angle.rs b/crates/api_clients/fal_client/src/requests/http/image/angle/http_qwen_edit_2511_edit_image_angle.rs new file mode 100644 index 000000000..91d579a99 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/http/image/angle/http_qwen_edit_2511_edit_image_angle.rs @@ -0,0 +1,88 @@ +use fal::request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct QwenEdit2511EditImageAngleInput { + /// The URL of the image to adjust camera angle for. + pub image_urls: Vec, + + /// Horizontal rotation angle around the object in degrees. 0°=front view. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub horizontal_angle: Option, + + /// Vertical camera angle in degrees. -30°=low-angle shot, 30°=high-angle shot. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub vertical_angle: Option, + + /// Camera zoom/distance. 0=wide shot, 5=medium shot, 10=close-up. + /// Default: 5 + #[serde(skip_serializing_if = "Option::is_none")] + pub zoom: Option, + + /// Additional text to append to the automatically generated prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_prompt: Option, + + /// The scale factor for the LoRA model. Controls the strength of the effect. + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub lora_scale: Option, + + /// The size of the generated image. + /// 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, + + /// The CFG (Classifier Free Guidance) scale. + /// Default: 4.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// The number of inference steps to perform. + /// Default: 28 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// The negative prompt for the generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Random seed for reproducibility. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Number of images to generate. + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// Whether to enable the safety checker. + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Output format: png, jpeg, webp. + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QwenEdit2511EditImageAngleFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QwenEdit2511EditImageAngleOutput { + pub images: Vec, + pub seed: u64, + pub prompt: Option, +} + +pub fn http_qwen_edit_2511_edit_image_angle( + params: QwenEdit2511EditImageAngleInput, +) -> FalRequest { + FalRequest::new("fal-ai/qwen-image-edit-2511-multiple-angles", params) +} diff --git a/crates/api_clients/fal_client/src/requests/http/image/angle/mod.rs b/crates/api_clients/fal_client/src/requests/http/image/angle/mod.rs new file mode 100644 index 000000000..c5f7d8ee6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/http/image/angle/mod.rs @@ -0,0 +1,2 @@ +pub mod http_flux_2_lora_edit_image_angle; +pub mod http_qwen_edit_2511_edit_image_angle; diff --git a/crates/api_clients/fal_client/src/requests/http/image/mod.rs b/crates/api_clients/fal_client/src/requests/http/image/mod.rs index cdb5f0787..da0aa2cbe 100644 --- a/crates/api_clients/fal_client/src/requests/http/image/mod.rs +++ b/crates/api_clients/fal_client/src/requests/http/image/mod.rs @@ -1,3 +1,4 @@ +pub mod angle; pub mod http_nano_banana_2_edit_image; pub mod http_nano_banana_2_text_to_image; pub mod http_seedream_5_edit_image; diff --git a/crates/api_clients/fal_client/src/requests/webhook/image/angle/enqueue_flux_2_lora_edit_image_angle_webhook.rs b/crates/api_clients/fal_client/src/requests/webhook/image/angle/enqueue_flux_2_lora_edit_image_angle_webhook.rs new file mode 100644 index 000000000..34eca383f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/webhook/image/angle/enqueue_flux_2_lora_edit_image_angle_webhook.rs @@ -0,0 +1,180 @@ +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::angle::http_flux_2_lora_edit_image_angle::{http_flux_2_lora_edit_image_angle, Flux2LoraEditImageAngleInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use fal::webhook::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueFlux2LoraEditImageAngleArgs<'a, R: IntoUrl> { + // Request required + pub image_urls: Vec, + + // Camera parameters + pub horizontal_angle: Option, + pub vertical_angle: Option, + pub zoom: Option, + + // Optional args + pub num_images: Option, + pub image_size: Option, + pub lora_scale: Option, + pub guidance_scale: Option, + pub num_inference_steps: Option, + + // Fulfillment + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueFlux2LoraAngleNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueFlux2LoraAngleImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, +} + +impl FalRequestCostCalculator for EnqueueFlux2LoraEditImageAngleArgs<'_, U> { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.021 per megapixel. + // For a 1024x1024 image (~1 MP), that's ~2 cents per image. + let unit_cost = 2; + let cost = match self.num_images { + None => unit_cost, + Some(EnqueueFlux2LoraAngleNumImages::One) => unit_cost, + Some(EnqueueFlux2LoraAngleNumImages::Two) => unit_cost * 2, + Some(EnqueueFlux2LoraAngleNumImages::Three) => unit_cost * 3, + Some(EnqueueFlux2LoraAngleNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + +pub async fn enqueue_flux_2_lora_edit_image_angle_webhook( + args: EnqueueFlux2LoraEditImageAngleArgs<'_, R> +) -> Result { + + let num_images = args.num_images + .map(|n| match n { + EnqueueFlux2LoraAngleNumImages::One => 1, + EnqueueFlux2LoraAngleNumImages::Two => 2, + EnqueueFlux2LoraAngleNumImages::Three => 3, + EnqueueFlux2LoraAngleNumImages::Four => 4, + }); + + let image_size = args.image_size + .map(|s| match s { + EnqueueFlux2LoraAngleImageSize::Square => "square", + EnqueueFlux2LoraAngleImageSize::SquareHd => "square_hd", + EnqueueFlux2LoraAngleImageSize::PortraitFourThree => "portrait_4_3", + EnqueueFlux2LoraAngleImageSize::PortraitSixteenNine => "portrait_16_9", + EnqueueFlux2LoraAngleImageSize::LandscapeFourThree => "landscape_4_3", + EnqueueFlux2LoraAngleImageSize::LandscapeSixteenNine => "landscape_16_9", + }) + .map(|s| s.to_string()); + + let request = Flux2LoraEditImageAngleInput { + image_urls: args.image_urls, + horizontal_angle: args.horizontal_angle, + vertical_angle: args.vertical_angle, + zoom: args.zoom, + lora_scale: args.lora_scale, + image_size, + guidance_scale: args.guidance_scale, + num_inference_steps: args.num_inference_steps, + num_images, + // Constants + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + // Unused + seed: None, + }; + + let result = http_flux_2_lora_edit_image_angle(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::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{enqueue_flux_2_lora_edit_image_angle_webhook, EnqueueFlux2LoraEditImageAngleArgs, EnqueueFlux2LoraAngleNumImages, EnqueueFlux2LoraAngleImageSize}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test_single() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueFlux2LoraEditImageAngleArgs { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(45.0), + vertical_angle: Some(15.0), + zoom: Some(5.0), + num_images: Some(EnqueueFlux2LoraAngleNumImages::One), + image_size: Some(EnqueueFlux2LoraAngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_flux_2_lora_edit_image_angle_webhook(args).await?; + Ok(()) + } + + /// Enqueues 27 angle combinations: 3 horizontal × 3 vertical × 3 zoom levels. + #[tokio::test] + #[ignore] + async fn test_angle_grid() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let horizontal_angles: [f64; 3] = [-45.0, 0.0, 45.0]; + let vertical_angles: [f64; 3] = [-30.0, 0.0, 30.0]; + let zoom_levels: [f64; 3] = [0.0, 1.0, 3.0]; + + for &h in &horizontal_angles { + for &v in &vertical_angles { + for &z in &zoom_levels { + let args = EnqueueFlux2LoraEditImageAngleArgs { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(h), + vertical_angle: Some(v), + zoom: Some(z), + num_images: Some(EnqueueFlux2LoraAngleNumImages::One), + image_size: Some(EnqueueFlux2LoraAngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_flux_2_lora_edit_image_angle_webhook(args).await?; + println!("Enqueued: h={}, v={}, z={}", h, v, z); + } + } + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests/webhook/image/angle/enqueue_qwen_edit_2511_edit_image_angle_webhook.rs b/crates/api_clients/fal_client/src/requests/webhook/image/angle/enqueue_qwen_edit_2511_edit_image_angle_webhook.rs new file mode 100644 index 000000000..e36ccb49b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/webhook/image/angle/enqueue_qwen_edit_2511_edit_image_angle_webhook.rs @@ -0,0 +1,187 @@ +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::angle::http_qwen_edit_2511_edit_image_angle::{http_qwen_edit_2511_edit_image_angle, QwenEdit2511EditImageAngleInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use fal::webhook::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueQwenEdit2511EditImageAngleArgs<'a, R: IntoUrl> { + // Request required + pub image_urls: Vec, + + // Camera parameters + pub horizontal_angle: Option, + pub vertical_angle: Option, + pub zoom: Option, + + // Optional args + pub additional_prompt: Option<&'a str>, + pub num_images: Option, + pub image_size: Option, + pub lora_scale: Option, + pub guidance_scale: Option, + pub num_inference_steps: Option, + + // Fulfillment + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueQwenEdit2511AngleNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueQwenEdit2511AngleImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, +} + +impl FalRequestCostCalculator for EnqueueQwenEdit2511EditImageAngleArgs<'_, U> { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.035 per megapixel. + // For a 1024x1024 image (~1 MP), that's ~4 cents per image. + let unit_cost = 4; + let cost = match self.num_images { + None => unit_cost, + Some(EnqueueQwenEdit2511AngleNumImages::One) => unit_cost, + Some(EnqueueQwenEdit2511AngleNumImages::Two) => unit_cost * 2, + Some(EnqueueQwenEdit2511AngleNumImages::Three) => unit_cost * 3, + Some(EnqueueQwenEdit2511AngleNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + +pub async fn enqueue_qwen_edit_2511_edit_image_angle_webhook( + args: EnqueueQwenEdit2511EditImageAngleArgs<'_, R> +) -> Result { + + let num_images = args.num_images + .map(|n| match n { + EnqueueQwenEdit2511AngleNumImages::One => 1, + EnqueueQwenEdit2511AngleNumImages::Two => 2, + EnqueueQwenEdit2511AngleNumImages::Three => 3, + EnqueueQwenEdit2511AngleNumImages::Four => 4, + }); + + let image_size = args.image_size + .map(|s| match s { + EnqueueQwenEdit2511AngleImageSize::Square => "square", + EnqueueQwenEdit2511AngleImageSize::SquareHd => "square_hd", + EnqueueQwenEdit2511AngleImageSize::PortraitFourThree => "portrait_4_3", + EnqueueQwenEdit2511AngleImageSize::PortraitSixteenNine => "portrait_16_9", + EnqueueQwenEdit2511AngleImageSize::LandscapeFourThree => "landscape_4_3", + EnqueueQwenEdit2511AngleImageSize::LandscapeSixteenNine => "landscape_16_9", + }) + .map(|s| s.to_string()); + + let request = QwenEdit2511EditImageAngleInput { + image_urls: args.image_urls, + horizontal_angle: args.horizontal_angle, + vertical_angle: args.vertical_angle, + zoom: args.zoom, + additional_prompt: args.additional_prompt.map(|s| s.to_string()), + lora_scale: args.lora_scale, + image_size, + guidance_scale: args.guidance_scale, + num_inference_steps: args.num_inference_steps, + num_images, + // Constants + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + // Unused + negative_prompt: None, + seed: None, + }; + + let result = http_qwen_edit_2511_edit_image_angle(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::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{enqueue_qwen_edit_2511_edit_image_angle_webhook, EnqueueQwenEdit2511EditImageAngleArgs, EnqueueQwenEdit2511AngleNumImages, EnqueueQwenEdit2511AngleImageSize}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test_single() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueQwenEdit2511EditImageAngleArgs { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(45.0), + vertical_angle: Some(15.0), + zoom: Some(5.0), + additional_prompt: Some("cinematic lighting"), + num_images: Some(EnqueueQwenEdit2511AngleNumImages::One), + image_size: Some(EnqueueQwenEdit2511AngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_qwen_edit_2511_edit_image_angle_webhook(args).await?; + Ok(()) + } + + /// Enqueues 9 angle combinations: 3 horizontal × 3 vertical × 3 zoom levels. + /// Each combination generates one image, for a total of 27 enqueued requests. + #[tokio::test] + #[ignore] + async fn test_angle_grid() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let horizontal_angles: [f64; 3] = [-45.0, 0.0, 45.0]; + let vertical_angles: [f64; 3] = [-30.0, 0.0, 30.0]; + let zoom_levels: [f64; 3] = [0.0, 1.0, 3.0]; + + for &h in &horizontal_angles { + for &v in &vertical_angles { + for &z in &zoom_levels { + let args = EnqueueQwenEdit2511EditImageAngleArgs { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(h), + vertical_angle: Some(v), + zoom: Some(z), + additional_prompt: None, + num_images: Some(EnqueueQwenEdit2511AngleNumImages::One), + image_size: Some(EnqueueQwenEdit2511AngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_qwen_edit_2511_edit_image_angle_webhook(args).await?; + println!("Enqueued: h={}, v={}, z={}", h, v, z); + } + } + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests/webhook/image/angle/mod.rs b/crates/api_clients/fal_client/src/requests/webhook/image/angle/mod.rs new file mode 100644 index 000000000..6cf5971a9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/webhook/image/angle/mod.rs @@ -0,0 +1,2 @@ +pub mod enqueue_flux_2_lora_edit_image_angle_webhook; +pub mod enqueue_qwen_edit_2511_edit_image_angle_webhook; diff --git a/crates/api_clients/fal_client/src/requests/webhook/image/mod.rs b/crates/api_clients/fal_client/src/requests/webhook/image/mod.rs index 37b6108e7..8578d164d 100644 --- a/crates/api_clients/fal_client/src/requests/webhook/image/mod.rs +++ b/crates/api_clients/fal_client/src/requests/webhook/image/mod.rs @@ -1,3 +1,4 @@ +pub mod angle; pub mod edit; pub mod infill; pub mod remove_background_rembg_webhook; diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs index 505abc226..b97a14fa9 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs @@ -76,6 +76,9 @@ pub(super) async fn handle_image_edit_artcraft_via_router( request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; let plan = router_request.build()?; diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs b/crates/desktop/artcraft/src/core/commands/enqueue/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs index 6063ff48e..0a375585d 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs @@ -54,6 +54,9 @@ pub(super) async fn handle_text_to_image_artcraft_via_router( request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, generation_mode_mismatch_strategy: None, idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; let plan = router_request.build()?; diff --git a/crates/schema/public/enums/src/common/model_type.rs b/crates/schema/public/enums/src/common/model_type.rs index caeb56733..89f4c48c8 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")] @@ -40,6 +42,8 @@ pub enum ModelType { SeedEdit3, #[serde(rename = "qwen")] Qwen, + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, /// Gemini 2.5 Flash, AKA "Nano Banana" #[serde(rename = "gemini_25_flash")] Gemini25Flash, @@ -152,12 +156,14 @@ 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", Self::Recraft3 => "recraft_3", Self::SeedEdit3 => "seededit_3", Self::Qwen => "qwen", + Self::QwenEdit2511Angles => "qwen_edit_2511_angles", Self::Gemini25Flash => "gemini_25_flash", Self::NanoBanana => "nano_banana", Self::NanoBanana2 => "nano_banana_2", @@ -216,12 +222,14 @@ 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), "recraft_3" => Ok(Self::Recraft3), "seededit_3" => Ok(Self::SeedEdit3), "qwen" => Ok(Self::Qwen), + "qwen_edit_2511_angles" => Ok(Self::QwenEdit2511Angles), "gemini_25_flash" => Ok(Self::Gemini25Flash), "nano_banana" => Ok(Self::NanoBanana), "nano_banana_2" => Ok(Self::NanoBanana2), @@ -284,12 +292,14 @@ impl ModelType { Self::FluxPro11, Self::FluxPro11Ultra, Self::FluxProKontextMax, + Self::Flux2LoraAngles, Self::GptImage1, Self::GptImage1p5, Self::GrokImage, Self::Recraft3, Self::SeedEdit3, Self::Qwen, + Self::QwenEdit2511Angles, Self::Gemini25Flash, Self::NanoBanana, Self::NanoBanana2, @@ -357,12 +367,14 @@ 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"); assert_serialization(ModelType::Recraft3, "recraft_3"); assert_serialization(ModelType::SeedEdit3, "seededit_3"); assert_serialization(ModelType::Qwen, "qwen"); + assert_serialization(ModelType::QwenEdit2511Angles, "qwen_edit_2511_angles"); assert_serialization(ModelType::Gemini25Flash, "gemini_25_flash"); assert_serialization(ModelType::NanoBanana, "nano_banana"); assert_serialization(ModelType::NanoBanana2, "nano_banana_2"); @@ -417,12 +429,14 @@ 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"); assert_eq!(ModelType::Recraft3.to_str(), "recraft_3"); assert_eq!(ModelType::SeedEdit3.to_str(), "seededit_3"); assert_eq!(ModelType::Qwen.to_str(), "qwen"); + assert_eq!(ModelType::QwenEdit2511Angles.to_str(), "qwen_edit_2511_angles"); assert_eq!(ModelType::Gemini25Flash.to_str(), "gemini_25_flash"); assert_eq!(ModelType::NanoBanana.to_str(), "nano_banana"); assert_eq!(ModelType::NanoBanana2.to_str(), "nano_banana_2"); @@ -479,12 +493,14 @@ 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); assert_eq!(ModelType::from_str("recraft_3").unwrap(), ModelType::Recraft3); assert_eq!(ModelType::from_str("seededit_3").unwrap(), ModelType::SeedEdit3); assert_eq!(ModelType::from_str("qwen").unwrap(), ModelType::Qwen); + assert_eq!(ModelType::from_str("qwen_edit_2511_angles").unwrap(), ModelType::QwenEdit2511Angles); assert_eq!(ModelType::from_str("gemini_25_flash").unwrap(), ModelType::Gemini25Flash); assert_eq!(ModelType::from_str("nano_banana").unwrap(), ModelType::NanoBanana); assert_eq!(ModelType::from_str("nano_banana_2").unwrap(), ModelType::NanoBanana2); @@ -533,7 +549,7 @@ mod tests { #[test] fn all_variants() { let mut variants = ModelType::all_variants(); - assert_eq!(variants.len(), 52); + assert_eq!(variants.len(), 54); // Image models assert_eq!(variants.pop_first(), Some(ModelType::Flux1Dev)); assert_eq!(variants.pop_first(), Some(ModelType::Flux1Schnell)); @@ -542,12 +558,14 @@ 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)); assert_eq!(variants.pop_first(), Some(ModelType::Recraft3)); assert_eq!(variants.pop_first(), Some(ModelType::SeedEdit3)); assert_eq!(variants.pop_first(), Some(ModelType::Qwen)); + assert_eq!(variants.pop_first(), Some(ModelType::QwenEdit2511Angles)); assert_eq!(variants.pop_first(), Some(ModelType::Gemini25Flash)); assert_eq!(variants.pop_first(), Some(ModelType::NanoBanana)); assert_eq!(variants.pop_first(), Some(ModelType::NanoBanana2)); 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 cd9c4f9d4..cc91daaab 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,8 @@ 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::*; use artcraft_api_defs::generate::image::edit::qwen_edit_image::*; @@ -425,6 +427,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux crate::http_server::endpoints::generate::image::edit::gemini_25_flash_edit_image_handler::gemini_25_flash_edit_image_handler, 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, crate::http_server::endpoints::generate::image::inpaint::flux_pro_1_inpaint_handler::flux_pro_1_inpaint_image_handler, @@ -1004,6 +1009,16 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux BytedanceSeedream5LiteMultiFunctionImageGenImageSize, BytedanceSeedream5LiteMultiFunctionImageGenNumImages, + // Image Angle types + Flux2LoraEditImageAngleRequest, + Flux2LoraEditImageAngleResponse, + Flux2LoraEditImageAngleNumImages, + Flux2LoraEditImageAngleImageSize, + QwenEdit2511EditImageAngleRequest, + QwenEdit2511EditImageAngleResponse, + QwenEdit2511EditImageAngleNumImages, + QwenEdit2511EditImageAngleImageSize, + // Image Edit types FluxProKontextMaxEditImageRequest, FluxProKontextMaxEditImageResponse, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs index 5b7ef7e00..24e3871f6 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs @@ -49,6 +49,9 @@ pub async fn estimate_image_cost_handler( request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, generation_mode_mismatch_strategy: Some(GenerationModeMismatchStrategy::GenerateAnyway), idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, }; let plan = router_request.build() 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 000000000..27eae22a6 --- /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 new file mode 100644 index 000000000..9d8e585c2 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/mod.rs @@ -0,0 +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/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle_handler.rs new file mode 100644 index 000000000..8df236946 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle_handler.rs @@ -0,0 +1,243 @@ +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::qwen_edit_2511_edit_image_angle::{QwenEdit2511EditImageAngleImageSize, QwenEdit2511EditImageAngleNumImages, QwenEdit2511EditImageAngleRequest, QwenEdit2511EditImageAngleResponse}; +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_qwen_edit_2511_edit_image_angle_webhook::{enqueue_qwen_edit_2511_edit_image_angle_webhook, EnqueueQwenEdit2511EditImageAngleArgs, EnqueueQwenEdit2511AngleNumImages, EnqueueQwenEdit2511AngleImageSize}; +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; + +/// Qwen Edit 2511 - Edit Image Angle +#[utoipa::path( + post, + tag = "Generate Images (Angle)", + path = "/v1/generate/image/angle/qwen_edit_2511", + responses( + (status = 200, description = "Success", body = QwenEdit2511EditImageAngleResponse), + ), + params( + ("request" = QwenEdit2511EditImageAngleRequest, description = "Payload for Request"), + ) +)] +pub async fn qwen_edit_2511_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 { + QwenEdit2511EditImageAngleNumImages::One => EnqueueQwenEdit2511AngleNumImages::One, + QwenEdit2511EditImageAngleNumImages::Two => EnqueueQwenEdit2511AngleNumImages::Two, + QwenEdit2511EditImageAngleNumImages::Three => EnqueueQwenEdit2511AngleNumImages::Three, + QwenEdit2511EditImageAngleNumImages::Four => EnqueueQwenEdit2511AngleNumImages::Four, + }); + + let image_size = request.image_size.map(|s| match s { + QwenEdit2511EditImageAngleImageSize::Square => EnqueueQwenEdit2511AngleImageSize::Square, + QwenEdit2511EditImageAngleImageSize::SquareHd => EnqueueQwenEdit2511AngleImageSize::SquareHd, + QwenEdit2511EditImageAngleImageSize::PortraitFourThree => EnqueueQwenEdit2511AngleImageSize::PortraitFourThree, + QwenEdit2511EditImageAngleImageSize::PortraitSixteenNine => EnqueueQwenEdit2511AngleImageSize::PortraitSixteenNine, + QwenEdit2511EditImageAngleImageSize::LandscapeFourThree => EnqueueQwenEdit2511AngleImageSize::LandscapeFourThree, + QwenEdit2511EditImageAngleImageSize::LandscapeSixteenNine => EnqueueQwenEdit2511AngleImageSize::LandscapeSixteenNine, + }); + + let args = EnqueueQwenEdit2511EditImageAngleArgs { + image_urls, + horizontal_angle: request.horizontal_angle, + vertical_angle: request.vertical_angle, + zoom: request.zoom, + additional_prompt: request.additional_prompt.as_deref(), + 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_qwen_edit_2511_edit_image_angle_webhook(args).await { + Ok(result) => result, + Err(err) => { + warn!("Error calling enqueue_qwen_edit_2511_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::QwenEdit2511Angles), + maybe_generation_provider: Some(GenerationProvider::Artcraft), + maybe_positive_prompt: request.additional_prompt.as_deref(), + 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(QwenEdit2511EditImageAngleResponse { + success: true, + inference_job_token: job_token, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/mod.rs index f1cd6ccbd..b5acb9a26 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/mod.rs @@ -6,4 +6,5 @@ pub mod generate_flux_pro_11_ultra_text_to_image_handler; pub mod generate_gpt_image_1_text_to_image_handler; pub mod inpaint; pub mod remove_image_background_handler; -pub mod multi_function; \ No newline at end of file +pub mod multi_function; +pub mod angle; \ No newline at end of file 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 1a458e883..bde21e998 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,8 @@ 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; use crate::http_server::endpoints::generate::splat::generate_worldlabs_marble_0p1_mini_splat_handler::generate_worldlabs_marble_0p1_mini_splat_handler; @@ -138,6 +140,16 @@ where .route(web::head().to(|| HttpResponse::Ok())) ) ) + .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())) + ) + ) .service(web::resource("/flux_1_dev_text_to_image") .route(web::post().to(generate_flux_1_dev_text_to_image_handler)) .route(web::head().to(|| HttpResponse::Ok()))