Compare commits

...
9 Commits
Author SHA1 Message Date
Brandon Thomas c1b8128036 flux lora angle artcraft_router 2026-03-09 19:36:20 -04:00
Brandon Thomas bf1bc62346 artcraft router - qwen angle 2026-03-09 19:28:25 -04:00
Brandon Thomas 1e681a5825 new endpoint 2026-03-09 18:20:34 -04:00
Brandon Thomas 4fbf8625cc fixes 2026-03-09 18:12:52 -04:00
Brandon Thomas c4d063beb9 endpoint 2026-03-09 17:59:38 -04:00
Brandon Thomas 1bd8c46e51 refactor 2026-03-09 17:40:22 -04:00
Brandon Thomas 288ba16ab4 flux 2 version 2026-03-09 17:35:51 -04:00
Brandon Thomas e106b2179f tests 2026-03-09 15:41:13 -04:00
Brandon Thomas fa9e95187f endpoint bindings 2026-03-09 15:36:59 -04:00
52 changed files with 2213 additions and 3 deletions
@@ -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<f64>,
/// Vertical rotation angle in degrees.
pub vertical_angle: Option<f64>,
/// Zoom level.
pub zoom: Option<f64>,
/// Number of images to generate. Default is one.
pub num_images: Option<Flux2LoraEditImageAngleNumImages>,
/// Output image size. Default is square_hd.
pub image_size: Option<Flux2LoraEditImageAngleImageSize>,
}
#[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,
}
@@ -0,0 +1,2 @@
pub mod flux_2_lora_edit_image_angle;
pub mod qwen_edit_2511_edit_image_angle;
@@ -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<f64>,
/// Vertical rotation angle in degrees.
pub vertical_angle: Option<f64>,
/// Zoom level.
pub zoom: Option<f64>,
/// Additional text prompt to guide the generation.
pub additional_prompt: Option<String>,
/// Number of images to generate. Default is one.
pub num_images: Option<QwenEdit2511EditImageAngleNumImages>,
/// Output image size. Default is square_hd.
pub image_size: Option<QwenEdit2511EditImageAngleImageSize>,
}
#[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,
}
@@ -1,3 +1,4 @@
pub mod angle;
pub mod bg_removal;
pub mod edit;
pub mod inpaint;
@@ -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<Flux2LoraEditImageAngleResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
FLUX_2_LORA_EDIT_IMAGE_ANGLE_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1,2 @@
pub mod flux_2_lora_edit_image_angle;
pub mod qwen_edit_2511_edit_image_angle;
@@ -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<QwenEdit2511EditImageAngleResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
QWEN_EDIT_2511_EDIT_IMAGE_ANGLE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,3 +1,4 @@
pub mod angle;
pub mod bg_removal;
pub mod edit;
pub mod inpaint;
@@ -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,
}
@@ -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")
@@ -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")
@@ -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);
}
}
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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);
}
}
@@ -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")
@@ -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")
@@ -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")
@@ -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;
@@ -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")
@@ -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<GenerateImageResponse, ArtcraftRouterError> {
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.
}
}
@@ -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<GenerateImageResponse, ArtcraftRouterError> {
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.
}
}
@@ -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;
@@ -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<u16>,
/// Only for angle manipulation models.
pub horizontal_angle: Option<f64>,
/// Only for angle manipulation models.
pub vertical_angle: Option<f64>,
/// Only for angle manipulation models.
pub zoom: Option<f64>,
/// 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),
@@ -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)
}
@@ -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;
@@ -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<f64>,
pub vertical_angle: Option<f64>,
pub zoom: Option<f64>,
pub image_size: Option<Flux2LoraEditImageAngleImageSize>,
pub num_images: Flux2LoraEditImageAngleNumImages,
pub idempotency_token: String,
}
pub fn plan_generate_image_artcraft_flux_2_lora_angles<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftFlux2LoraAngles<'a>, 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<ImageListRef<'a>>,
) -> 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<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Flux2LoraEditImageAngleImageSize>, 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<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Flux2LoraEditImageAngleNumImages, ArtcraftRouterError> {
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));
}
}
@@ -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<f64>,
pub vertical_angle: Option<f64>,
pub zoom: Option<f64>,
pub image_size: Option<QwenEdit2511EditImageAngleImageSize>,
pub num_images: QwenEdit2511EditImageAngleNumImages,
pub idempotency_token: String,
}
pub fn plan_generate_image_artcraft_qwen_edit_2511_angles<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftQwenEdit2511Angles<'a>, 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<ImageListRef<'a>>,
) -> 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<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<QwenEdit2511EditImageAngleImageSize>, 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<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<QwenEdit2511EditImageAngleNumImages, ArtcraftRouterError> {
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));
}
}
@@ -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,
}
}
@@ -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,
}
}
@@ -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<String>,
/// 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<f64>,
/// 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<f64>,
/// Camera zoom/distance. 0=wide shot, 5=medium shot, 10=close-up.
/// Default: 5
#[serde(skip_serializing_if = "Option::is_none")]
pub zoom: Option<f64>,
/// The strength of the multiple angles effect.
/// Default: 1, range: 0-2
#[serde(skip_serializing_if = "Option::is_none")]
pub lora_scale: Option<f64>,
/// 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<String>,
/// The CFG (Classifier Free Guidance) scale.
/// Default: 2.5, range: 0-20
#[serde(skip_serializing_if = "Option::is_none")]
pub guidance_scale: Option<f64>,
/// The number of inference steps to perform.
/// Default: 40, range: 1-50
#[serde(skip_serializing_if = "Option::is_none")]
pub num_inference_steps: Option<u32>,
/// Random seed for reproducibility.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
/// Number of images to generate.
/// Default: 1, max: 4
#[serde(skip_serializing_if = "Option::is_none")]
pub num_images: Option<u8>,
/// Whether to enable the safety checker.
/// Default: true
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_safety_checker: Option<bool>,
/// Output format: png, jpeg, webp.
/// Default: "png"
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux2LoraEditImageAngleFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux2LoraEditImageAngleOutput {
pub images: Vec<Flux2LoraEditImageAngleFile>,
pub seed: u64,
pub prompt: Option<String>,
}
pub fn http_flux_2_lora_edit_image_angle(
params: Flux2LoraEditImageAngleInput,
) -> FalRequest<Flux2LoraEditImageAngleInput, Flux2LoraEditImageAngleOutput> {
FalRequest::new("fal-ai/flux-2-lora-gallery/multiple-angles", params)
}
@@ -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<String>,
/// Horizontal rotation angle around the object in degrees. 0°=front view.
/// Default: 0
#[serde(skip_serializing_if = "Option::is_none")]
pub horizontal_angle: Option<f64>,
/// 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<f64>,
/// Camera zoom/distance. 0=wide shot, 5=medium shot, 10=close-up.
/// Default: 5
#[serde(skip_serializing_if = "Option::is_none")]
pub zoom: Option<f64>,
/// Additional text to append to the automatically generated prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_prompt: Option<String>,
/// 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<f64>,
/// 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<String>,
/// The CFG (Classifier Free Guidance) scale.
/// Default: 4.5
#[serde(skip_serializing_if = "Option::is_none")]
pub guidance_scale: Option<f64>,
/// The number of inference steps to perform.
/// Default: 28
#[serde(skip_serializing_if = "Option::is_none")]
pub num_inference_steps: Option<u32>,
/// The negative prompt for the generation.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Random seed for reproducibility.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
/// Number of images to generate.
/// Default: 1
#[serde(skip_serializing_if = "Option::is_none")]
pub num_images: Option<u8>,
/// Whether to enable the safety checker.
/// Default: true
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_safety_checker: Option<bool>,
/// Output format: png, jpeg, webp.
/// Default: "png"
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QwenEdit2511EditImageAngleFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QwenEdit2511EditImageAngleOutput {
pub images: Vec<QwenEdit2511EditImageAngleFile>,
pub seed: u64,
pub prompt: Option<String>,
}
pub fn http_qwen_edit_2511_edit_image_angle(
params: QwenEdit2511EditImageAngleInput,
) -> FalRequest<QwenEdit2511EditImageAngleInput, QwenEdit2511EditImageAngleOutput> {
FalRequest::new("fal-ai/qwen-image-edit-2511-multiple-angles", params)
}
@@ -0,0 +1,2 @@
pub mod http_flux_2_lora_edit_image_angle;
pub mod http_qwen_edit_2511_edit_image_angle;
@@ -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;
@@ -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<String>,
// Camera parameters
pub horizontal_angle: Option<f64>,
pub vertical_angle: Option<f64>,
pub zoom: Option<f64>,
// Optional args
pub num_images: Option<EnqueueFlux2LoraAngleNumImages>,
pub image_size: Option<EnqueueFlux2LoraAngleImageSize>,
pub lora_scale: Option<f64>,
pub guidance_scale: Option<f64>,
pub num_inference_steps: Option<u32>,
// 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 <U: IntoUrl> 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<R: IntoUrl>(
args: EnqueueFlux2LoraEditImageAngleArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
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(())
}
}
@@ -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<String>,
// Camera parameters
pub horizontal_angle: Option<f64>,
pub vertical_angle: Option<f64>,
pub zoom: Option<f64>,
// Optional args
pub additional_prompt: Option<&'a str>,
pub num_images: Option<EnqueueQwenEdit2511AngleNumImages>,
pub image_size: Option<EnqueueQwenEdit2511AngleImageSize>,
pub lora_scale: Option<f64>,
pub guidance_scale: Option<f64>,
pub num_inference_steps: Option<u32>,
// 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 <U: IntoUrl> 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<R: IntoUrl>(
args: EnqueueQwenEdit2511EditImageAngleArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
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(())
}
}
@@ -0,0 +1,2 @@
pub mod enqueue_flux_2_lora_edit_image_angle_webhook;
pub mod enqueue_qwen_edit_2511_edit_image_angle_webhook;
@@ -1,3 +1,4 @@
pub mod angle;
pub mod edit;
pub mod infill;
pub mod remove_background_rembg_webhook;
@@ -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()?;
@@ -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()?;
@@ -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));
@@ -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,
@@ -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()
@@ -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<Flux2LoraEditImageAngleRequest>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<Flux2LoraEditImageAngleResponse>, 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,
}))
}
@@ -0,0 +1,2 @@
pub mod flux_2_lora_edit_image_angle_handler;
pub mod qwen_edit_2511_edit_image_angle_handler;
@@ -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<QwenEdit2511EditImageAngleRequest>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<QwenEdit2511EditImageAngleResponse>, 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,
}))
}
@@ -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;
pub mod multi_function;
pub mod angle;
@@ -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()))