Compare commits

...
14 Commits
Author SHA1 Message Date
Brandon Thomas 5c3138d70f refactor job 2026-03-10 17:05:39 -04:00
Brandon Thomas bf188682ba start building cli tool 2026-03-10 16:55:08 -04:00
Brandon Thomas d3acef8467 bump linkst 2026-03-10 15:14:03 -04:00
bflatastic 167a7f996e fixed angles history persistence, non blocking generations 2026-03-10 23:35:03 +07:00
Brandon Thomas 7fca050c56 bump artcraft version 2026-03-10 12:18:47 -04:00
bflatastic e9316bcdb5 bug fixes 2026-03-10 21:04:53 +07:00
bflatastic 40a2e8a514 added angles models name mapping 2026-03-10 20:10:03 +07:00
bflatastic 02ab54bc8c angles fixed and history stack 2026-03-10 20:04:46 +07:00
Brandon Thomas ff268c4e4e WorldLabs download job (#1125) 2026-03-10 08:44:03 -04:00
BFlatandBrandon Thomas f97def83e1 Angles UI (#1090)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-03-09 21:49:17 -04:00
jtoy 9e9156cd32 fix monterey (#1042)
an api for safari that is not supported was used, this fixes it as a
fallback. Tested and works.

Fixes: https://github.com/storytold/artcraft/issues/1008
2026-03-09 20:37:55 -04:00
Brandon Thomas c7b817ac3f angle edit models (fal, handlers, routing) (#1124) 2026-03-09 19:48:09 -04:00
bflatastic 66afce8889 Merge branch 'main' of https://github.com/storytold/storyteller-rust 2026-03-10 02:20:02 +07:00
bflatastic f7e7c132bc cost estimates for splats in frontend ui 2026-03-10 02:19:59 +07:00
129 changed files with 5752 additions and 72 deletions
+1
View File
@@ -6,6 +6,7 @@
.env-elasticsearch-secrets
.env-migration-tool-secrets
.env-secrets
.env-support-tool-secrets
_develop/tools/.secrets
_develop/tools/db_secrets.toml
crates/service/web/storyteller_web/config/storyteller-web.development-secrets.env
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?, \n origin_product_category = ?, \n maybe_origin_model_type = ?, \n maybe_origin_model_token = ?, \n maybe_origin_filename = ?,\n\n is_batch_generated = ?,\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?, \n maybe_video_encoding = ?, \n maybe_frame_width = ?, \n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n \n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?, \n maybe_public_bucket_prefix = ?, \n maybe_public_bucket_extension = ?, \n\n maybe_creator_user_token = ?, \n maybe_creator_anonymous_visitor_token = ?, \n\n creator_ip_address = ?, \n creator_set_visibility = ?, \n\n maybe_creator_file_synthetic_id = ?, \n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_mod_user_token = ?, \n is_generated_on_prem = ?, \n generated_by_worker = ?, \n generated_by_cluster = ?\n ",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?, \n origin_product_category = ?, \n maybe_origin_model_type = ?, \n maybe_origin_model_token = ?, \n maybe_origin_filename = ?,\n\n is_batch_generated = ?,\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?, \n maybe_video_encoding = ?, \n maybe_frame_width = ?, \n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n \n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?, \n maybe_public_bucket_prefix = ?, \n maybe_public_bucket_extension = ?, \n\n maybe_creator_user_token = ?, \n maybe_creator_anonymous_visitor_token = ?, \n\n creator_ip_address = ?, \n creator_set_visibility = ?, \n\n maybe_creator_file_synthetic_id = ?, \n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 39
"Right": 40
},
"nullable": []
},
"hash": "b4470e607c005421dd8504b9f1d695080a581936ac6ce00b6f437b2764e741be"
"hash": "87d5511700985daaabfc9f2972f018d56b6672c07be082daa7c1a782400cb18d"
}
@@ -0,0 +1,102 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `operation_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "operation_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 2,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 5,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 6,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
true,
true,
false,
false,
true,
true
]
},
"hash": "d56a26a7300495fac9741e77e390f16402e2273dbab9a15d7c17eb1f0da7a1b1"
}
Generated
+65 -20
View File
@@ -578,9 +578,9 @@ dependencies = [
[[package]]
name = "anstyle"
version = "1.0.7"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
@@ -2225,9 +2225,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.13"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbb260a053428790f3de475e304ff84cdbc4face759ea7a3e64c1edd938a7fc"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
@@ -2235,21 +2235,21 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.13"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64b17d7ea74e9f833c7dbf2cbe4fb12ff26783eda4782a8975b72f895c9b4d99"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
"clap_lex 0.7.4",
"clap_lex 1.0.0",
"strsim 0.11.1",
]
[[package]]
name = "clap_derive"
version = "4.5.13"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -2268,9 +2268,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.4"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cloud_storage"
@@ -3068,7 +3068,7 @@ version = "0.0.1"
dependencies = [
"anyhow",
"chrono",
"clap 4.5.13",
"clap 4.5.60",
"datetimes",
"dotenv",
"easyenv",
@@ -3103,7 +3103,7 @@ name = "db-cleanup"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"dotenv",
"easyenv",
"enums",
@@ -3263,7 +3263,7 @@ name = "dev-database-seed"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
@@ -3289,7 +3289,7 @@ name = "dev-upload-media-file"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
@@ -3763,7 +3763,7 @@ name = "elasticsearch-cli"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"config",
"easyenv",
"elasticsearch",
@@ -6744,7 +6744,7 @@ dependencies = [
name = "lru-delete-models"
version = "0.0.1"
dependencies = [
"clap 4.5.13",
"clap 4.5.60",
"errors",
"logging",
"walkdir",
@@ -7054,7 +7054,7 @@ name = "migration-tool"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
@@ -10316,7 +10316,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
name = "scrape-test"
version = "0.0.1"
dependencies = [
"clap 4.5.13",
"clap 4.5.60",
"errors",
"tokio",
"web_scrapers",
@@ -11749,6 +11749,20 @@ dependencies = [
"rsa",
]
[[package]]
name = "support-tool"
version = "0.0.1"
dependencies = [
"anyhow",
"clap 4.5.60",
"easyenv",
"errors",
"log",
"seedance2pro",
"serde_json",
"tokio",
]
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -14914,7 +14928,7 @@ dependencies = [
"bytes",
"cc",
"chrono",
"clap 4.5.13",
"clap 4.5.60",
"cookie 0.18.1",
"core-foundation-sys",
"crossbeam-utils",
@@ -15067,6 +15081,37 @@ dependencies = [
"wreq-util",
]
[[package]]
name = "worldlabs-job"
version = "0.0.1"
dependencies = [
"actix-web",
"actix_helpers",
"anyhow",
"bootstrap",
"bucket_paths",
"cloud_storage",
"concurrency",
"config",
"easyenv",
"enums",
"errors",
"hashing",
"jobs_common",
"log",
"mysql_queries",
"reqwest 0.12.23",
"serde",
"serde_derive",
"serde_json",
"server_environment",
"sqlx",
"tokens",
"tokio",
"workspace-build-acceleration",
"world_labs_api",
]
[[package]]
name = "wreq"
version = "6.0.0-rc.28"
+4
View File
@@ -33,6 +33,7 @@ members = [
"crates/cli/production/kubectl-pod-cleanup",
"crates/cli/production/lru-delete-models",
"crates/cli/production/migration-tool",
"crates/cli/production/support-tool",
# Desktop apps
"crates/desktop/artcraft",
@@ -120,6 +121,7 @@ members = [
"crates/service/job/es-update-job",
"crates/service/job/inference_job",
"crates/service/job/seedance2_pro_job",
"crates/service/job/worldlabs_job",
"crates/service/job/tts_download_job",
"crates/service/job/tts_inference_job",
"crates/service/job/w2l_download_job",
@@ -221,6 +223,7 @@ component_traits = { path = "crates/service/plugins/component_traits" }
cookie_store = { path = "crates/lib/cookie_store" }
cookies = { path = "crates/lib/cookies" }
crockford = { path = "crates/lib/data/crockford" }
easyenv = { path = "crates/lib/easyenv" }
enums = { path = "crates/schema/public/enums" }
enums_public = { path = "crates/schema/public/enums_public" }
errors = { path = "crates/lib/errors" }
@@ -270,6 +273,7 @@ base64 = "0.22.1"
bstr = "1.12.1"
bytes = "1.10.1"
chrono = { version = "0.4.41", features = ["serde"] }
clap = { version = "4.5.60", features = ["derive"] }
cookie = "0.18.1"
env = "1.0.1"
env_logger = "0.11.9"
+7
View File
@@ -143,6 +143,12 @@ RUN SQLX_OFFLINE=true \
--release \
--bin seedance2-pro-job
RUN SQLX_OFFLINE=true \
LD_LIBRARY_PATH=/usr/lib:${LD_LIBRARY_PATH} \
$HOME/.cargo/bin/cargo build \
--release \
--bin worldlabs-job
RUN SQLX_OFFLINE=true \
LD_LIBRARY_PATH=/usr/lib:${LD_LIBRARY_PATH} \
$HOME/.cargo/bin/cargo build \
@@ -203,6 +209,7 @@ RUN echo -n ${GIT_SHA} > GIT_SHA
# Copy all the binaries (except those that need a GPU):
COPY --from=builder /tmp/target/release/storyteller-web /
COPY --from=builder /tmp/target/release/seedance2-pro-job /
COPY --from=builder /tmp/target/release/worldlabs-job /
COPY --from=builder /tmp/target/release/dummy-service /
COPY --from=builder /tmp/target/release/analytics-job /
COPY --from=builder /tmp/target/release/email-sender-job /
@@ -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;
@@ -0,0 +1,22 @@
[package]
name = "support-tool"
edition = "2024"
version = "0.0.1"
[[bin]]
name = "support-tool"
path = "src/main.rs"
[dependencies]
easyenv.workspace = true
# Internal
errors.workspace = true
seedance2pro.workspace = true
# External
anyhow.workspace = true
clap.workspace = true
log.workspace = true
serde_json.workspace = true
tokio.workspace = true
@@ -0,0 +1,27 @@
pub mod seedance2pro;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "support-tool", about = "Production support CLI")]
pub struct Cli {
#[command(subcommand)]
pub command: TopLevelCommand,
}
#[derive(Subcommand)]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands
Seedance2pro {
#[command(subcommand)]
command: seedance2pro::Seedance2proCommand,
},
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
TopLevelCommand::Seedance2pro { command } => {
seedance2pro::run(command).await
}
}
}
@@ -0,0 +1,22 @@
use anyhow::anyhow;
use clap::Subcommand;
use super::state::Seedance2ProState;
use super::subcommands;
#[derive(Subcommand)]
pub enum Seedance2proCommand {
/// Find a job by its order ID across all pages
Findjob(subcommands::findjob::FindjobArgs),
}
pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
let cookies = easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")
.map_err(|err| anyhow!("Missing SEEDANCE2PRO_COOKIES env var: {:?}", err))?;
let state = Seedance2ProState { cookies };
match command {
Seedance2proCommand::Findjob(args) => subcommands::findjob::run(&state, args).await,
}
}
@@ -0,0 +1,6 @@
mod dispatch;
mod state;
mod subcommands;
pub use dispatch::Seedance2proCommand;
pub use dispatch::run;
@@ -0,0 +1,3 @@
pub struct Seedance2ProState {
pub cookies: String,
}
@@ -0,0 +1,66 @@
use anyhow::anyhow;
use clap::Args;
use log::info;
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro::requests::poll_orders::poll_orders::{poll_orders, PollOrdersArgs};
use super::super::state::Seedance2ProState;
#[derive(Args)]
pub struct FindjobArgs {
/// The order ID (job token) to search for
#[arg(long)]
pub token: String,
}
pub async fn run(state: &Seedance2ProState, args: FindjobArgs) -> anyhow::Result<()> {
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
let mut cursor: Option<u64> = None;
let mut page = 0usize;
loop {
page += 1;
info!("Fetching page {} (cursor: {:?})...", page, cursor);
let result = poll_orders(PollOrdersArgs {
session: &session,
cursor,
}).await
.map_err(|err| anyhow!("Error polling orders on page {}: {:?}", page, err))?;
for order in &result.orders {
if order.order_id == args.token {
println!("{}", serde_json::to_string_pretty(&order_to_json(order))?);
return Ok(());
}
}
info!("Page {}: checked {} orders, no match.", page, result.orders.len());
cursor = result.next_cursor;
if cursor.is_none() {
break;
}
}
eprintln!("Order '{}' not found after {} pages.", args.token, page);
std::process::exit(1);
}
fn order_to_json(order: &seedance2pro::requests::poll_orders::poll_orders::OrderStatus) -> serde_json::Value {
serde_json::json!({
"order_id": order.order_id,
"task_status": format!("{:?}", order.task_status),
"result_url": order.result_url,
"fail_reason": order.fail_reason,
"created_at": order.created_at,
"results": order.results.iter().map(|r| {
serde_json::json!({
"url": r.url,
"width": r.width,
"height": r.height,
})
}).collect::<Vec<_>>(),
})
}
@@ -0,0 +1 @@
pub mod findjob;
@@ -0,0 +1,19 @@
mod commands;
mod utils;
use clap::Parser;
use commands::Cli;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Parse CLI args first so --help works without the env file.
let cli = Cli::parse();
// Load secrets from .env file.
easyenv::from_filename(".env-support-tool-secrets")
.expect("Failed to load .env-support-tool-secrets");
easyenv::init_env_logger(Some("info"));
commands::run(cli).await
}
@@ -0,0 +1 @@
// Shared utilities for support-tool commands.
@@ -32,5 +32,7 @@ pub async fn handle_image_edit_artcraft(
ImageEditModel::Seedream4 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4, GenerationModel::Seedream4).await,
ImageEditModel::Seedream4p5 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4p5, GenerationModel::Seedream4p5).await,
ImageEditModel::Seedream5Lite => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream5Lite, GenerationModel::Seedream5Lite).await,
ImageEditModel::QwenEdit2511Angles => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::QwenEdit2511Angles, GenerationModel::QwenEdit2511Angles).await,
ImageEditModel::Flux2LoraAngles => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Flux2LoraAngles, GenerationModel::Flux2LoraAngles).await,
}
}
@@ -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: request.horizontal_angle,
vertical_angle: request.vertical_angle,
zoom: request.zoom,
};
let plan = router_request.build()?;
@@ -81,6 +81,12 @@ pub enum ImageEditModel {
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
Flux2LoraAngles,
// #[serde(rename = "qwen")]
// Qwen,
//
@@ -130,6 +136,18 @@ pub struct EnqueueEditImageCommand {
/// Image resolution
pub image_resolution: Option<EditImageResolution>,
/// OPTIONAL.
/// Only for image angle edit models.
pub horizontal_angle: Option<f64>,
/// OPTIONAL.
/// Only for image angle edit models.
pub vertical_angle: Option<f64>,
/// OPTIONAL.
/// Only for image angle edit models.
pub zoom: Option<f64>,
/// OPTIONAL.
/// Name of the frontend caller.
/// We'll use this to selectively trigger events.
@@ -13,5 +13,7 @@ pub fn image_edit_model_to_model_type(model: ImageEditModel) -> ModelType {
ImageEditModel::Seedream4 => ModelType::Seedream4,
ImageEditModel::Seedream4p5 => ModelType::Seedream4p5,
ImageEditModel::Seedream5Lite => ModelType::Seedream5Lite,
ImageEditModel::QwenEdit2511Angles => ModelType::QwenEdit2511Angles,
ImageEditModel::Flux2LoraAngles => ModelType::Flux2LoraAngles,
}
}
@@ -76,6 +76,8 @@ impl TaskEnqueueSuccess{
Some(GenerationModel::Seedream4) => Some(TaskModelType::Seedream4),
Some(GenerationModel::Seedream4p5) => Some(TaskModelType::Seedream4p5),
Some(GenerationModel::Seedream5Lite) => Some(TaskModelType::Seedream5Lite),
Some(GenerationModel::QwenEdit2511Angles) => Some(TaskModelType::QwenEdit2511Angles),
Some(GenerationModel::Flux2LoraAngles) => Some(TaskModelType::Flux2LoraAngles),
Some(GenerationModel::GrokImage) => Some(TaskModelType::GrokImage),
Some(GenerationModel::Recraft3) => Some(TaskModelType::Recraft3),
Some(GenerationModel::GrokVideo) => Some(TaskModelType::GrokVideo),
@@ -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()?;
@@ -50,6 +50,11 @@ pub enum GenerationModel {
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
Flux2LoraAngles,
#[serde(rename = "hunyuan_3d_2_0")]
Hunyuan3d2_0,
#[serde(rename = "hunyuan_3d_2_1")]
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.13.0";
pub const ARTCRAFT_VERSION: &str = "0.14.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.13.0",
"version": "0.14.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.13.0",
"version": "0.14.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -0,0 +1,98 @@
use anyhow::anyhow;
use log::warn;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
/// A WorldLabs job that is waiting for a result from the external API.
#[derive(Debug)]
pub struct PendingWorldlabsJob {
pub job_token: InferenceJobToken,
/// The external operation_id from World Labs.
pub operation_id: String,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
pub creator_ip_address: String,
pub creator_set_visibility: Visibility,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
}
#[derive(Debug, Default)]
struct RawRecord {
job_token: InferenceJobToken,
operation_id: Option<String>,
maybe_creator_user_token: Option<UserToken>,
maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
creator_ip_address: String,
creator_set_visibility: Visibility,
maybe_prompt_token: Option<PromptToken>,
maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
}
/// Returns all non-terminal WorldLabs jobs that have an associated operation_id.
pub async fn list_pending_worldlabs_jobs(pool: &MySqlPool) -> AnyhowResult<Vec<PendingWorldlabsJob>> {
let records = sqlx::query_as!(
RawRecord,
r#"
SELECT
jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
jobs.maybe_external_third_party_id as `operation_id`,
jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,
jobs.creator_ip_address,
jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`
FROM generic_inference_jobs as jobs
WHERE jobs.maybe_external_third_party = ?
AND jobs.status NOT IN ('complete_success', 'complete_failure')
AND jobs.maybe_external_third_party_id IS NOT NULL
LIMIT 25000
"#,
InferenceJobExternalThirdParty::Worldlabs.to_str(),
)
.fetch_all(pool)
.await
.map_err(|err| anyhow!("error querying pending worldlabs jobs: {:?}", err))?;
let jobs = records
.into_iter()
.filter_map(|record| {
let operation_id = match record.operation_id {
Some(id) => id,
None => {
warn!("PendingWorldlabsJob has no operation_id, skipping");
return None;
}
};
Some(PendingWorldlabsJob {
job_token: record.job_token,
operation_id,
maybe_creator_user_token: record.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: record.maybe_creator_anonymous_visitor_token,
creator_ip_address: record.creator_ip_address,
creator_set_visibility: record.creator_set_visibility,
maybe_prompt_token: record.maybe_prompt_token,
maybe_wallet_ledger_entry_token: record.maybe_wallet_ledger_entry_token,
})
})
.collect();
Ok(jobs)
}
@@ -1 +1,2 @@
pub mod insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token;
pub mod list_pending_worldlabs_jobs;
@@ -88,6 +88,9 @@ pub struct InsertArgs<'a> {
pub generated_by_worker: Option<&'a str>,
pub generated_by_cluster: Option<&'a str>,
// Cover image (e.g. thumbnail for 3D splats)
pub maybe_cover_image_media_file_token: Option<&'a MediaFileToken>,
// Moderation details (deprecated)
pub maybe_mod_user_token: Option<&'a UserToken>,
}
@@ -179,9 +182,11 @@ pub async fn insert_media_file_generic(
extra_file_modification_info = ?,
maybe_mod_user_token = ?,
is_generated_on_prem = ?,
generated_by_worker = ?,
maybe_cover_image_media_file_token = ?,
maybe_mod_user_token = ?,
is_generated_on_prem = ?,
generated_by_worker = ?,
generated_by_cluster = ?
"#,
result_token,
@@ -233,6 +238,8 @@ pub async fn insert_media_file_generic(
extra_file_modification_info,
args.maybe_cover_image_media_file_token.map(|t| t.as_str()),
args.maybe_mod_user_token,
args.is_generated_on_prem,
args.generated_by_worker,
@@ -120,6 +120,7 @@ pub async fn insert_media_file_generic_from_job(
maybe_text_transcript: args.maybe_text_transcript,
maybe_title: args.maybe_title,
maybe_video_encoding: args.maybe_video_encoding,
maybe_cover_image_media_file_token: None,
media_class: args.media_class,
media_type: args.media_type,
origin_category: args.origin_category,
@@ -73,6 +73,9 @@ pub struct MediaFileInsertBuilder {
// // If batch generated, this is the batch token.
maybe_batch_generation_token: Option<BatchGenerationToken>,
// Cover image (e.g. thumbnail for 3D splats)
maybe_cover_image_media_file_token: Option<MediaFileToken>,
// Storage details
public_bucket_directory_hash: Option<MediaFileBucketPath>, // NB: Non-nullable field(s)
@@ -107,6 +110,7 @@ impl MediaFileInsertBuilder {
maybe_origin_filename: None,
maybe_engine_category: None,
maybe_batch_generation_token: None,
maybe_cover_image_media_file_token: None,
public_bucket_directory_hash: None,
maybe_prompt_token: None,
}
@@ -250,6 +254,11 @@ impl MediaFileInsertBuilder {
self
}
pub fn maybe_cover_image_media_file_token(mut self, maybe_token: Option<&MediaFileToken>) -> Self {
self.maybe_cover_image_media_file_token = maybe_token.cloned();
self
}
pub fn public_bucket_directory_hash(mut self, public_bucket_directory_hash: &MediaFileBucketPath) -> Self {
self.public_bucket_directory_hash = Some(public_bucket_directory_hash.clone());
self
@@ -308,6 +317,7 @@ impl MediaFileInsertBuilder {
maybe_creator_file_synthetic_id_category: IdCategory::MediaFile, // TODO: Remove this
maybe_creator_category_synthetic_id_category: IdCategory::FileUpload, // TODO: Remove this
maybe_extra_media_info: None, // TODO
maybe_cover_image_media_file_token: self.maybe_cover_image_media_file_token.as_ref(),
is_generated_on_prem: false, // TODO
generated_by_worker: None, // TODO
generated_by_cluster: None, // TODO
@@ -112,6 +112,7 @@ pub async fn insert_media_file_from_studio_scene_render(
maybe_prompt_token: None,
maybe_mod_user_token: None,
maybe_engine_category: None,
maybe_cover_image_media_file_token: None,
}).await?;
Ok(new_media_token)
@@ -69,6 +69,9 @@ pub enum MediaFileOriginProductCategory {
#[serde(rename = "video_gen")]
VideoGeneration,
#[serde(rename = "world_gen")]
WorldGeneration,
// Media files for video filters
#[deprecated(note = "This isn't relevant product surface area anymore")]
#[serde(rename = "video_filter")]
@@ -102,6 +105,7 @@ impl MediaFileOriginProductCategory {
Self::Mocap => "mocap",
Self::ImageGeneration => "image_gen",
Self::VideoGeneration => "video_gen",
Self::WorldGeneration => "world_gen",
Self::VideoFilter => "video_filter",
Self::Workflow => "workflow",
}
@@ -122,6 +126,7 @@ impl MediaFileOriginProductCategory {
"mocap" => Ok(Self::Mocap),
"image_gen" => Ok(Self::ImageGeneration),
"video_gen" => Ok(Self::VideoGeneration),
"world_gen" => Ok(Self::WorldGeneration),
"video_filter" => Ok(Self::VideoFilter),
"workflow" => Ok(Self::Workflow),
_ => Err(format!("invalid value: {:?}", value)),
@@ -145,6 +150,7 @@ impl MediaFileOriginProductCategory {
Self::Mocap,
Self::ImageGeneration,
Self::VideoGeneration,
Self::WorldGeneration,
Self::VideoFilter,
Self::Workflow,
])
@@ -174,6 +180,7 @@ mod tests {
assert_serialization(MediaFileOriginProductCategory::Mocap, "mocap");
assert_serialization(MediaFileOriginProductCategory::ImageGeneration, "image_gen");
assert_serialization(MediaFileOriginProductCategory::VideoGeneration, "video_gen");
assert_serialization(MediaFileOriginProductCategory::WorldGeneration, "world_gen");
assert_serialization(MediaFileOriginProductCategory::VideoFilter, "video_filter");
assert_serialization(MediaFileOriginProductCategory::Workflow, "workflow");
}
@@ -193,6 +200,7 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::Mocap.to_str(), "mocap");
assert_eq!(MediaFileOriginProductCategory::ImageGeneration.to_str(), "image_gen");
assert_eq!(MediaFileOriginProductCategory::VideoGeneration.to_str(), "video_gen");
assert_eq!(MediaFileOriginProductCategory::WorldGeneration.to_str(), "world_gen");
assert_eq!(MediaFileOriginProductCategory::VideoFilter.to_str(), "video_filter");
assert_eq!(MediaFileOriginProductCategory::Workflow.to_str(), "workflow");
}
@@ -212,6 +220,7 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::from_str("mocap").unwrap(), MediaFileOriginProductCategory::Mocap);
assert_eq!(MediaFileOriginProductCategory::from_str("image_gen").unwrap(),MediaFileOriginProductCategory::ImageGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("video_gen").unwrap(), MediaFileOriginProductCategory::VideoGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("world_gen").unwrap(), MediaFileOriginProductCategory::WorldGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("video_filter").unwrap(), MediaFileOriginProductCategory::VideoFilter);
assert_eq!(MediaFileOriginProductCategory::from_str("workflow").unwrap(), MediaFileOriginProductCategory::Workflow);
}
@@ -219,7 +228,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = MediaFileOriginProductCategory::all_variants();
assert_eq!(variants.len(), 15);
assert_eq!(variants.len(), 16);
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Unknown));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceAnimator));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceFusion));
@@ -233,6 +242,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Mocap));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::ImageGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::WorldGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoFilter));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Workflow));
assert_eq!(variants.pop_first(), None);
@@ -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));
@@ -45,6 +45,10 @@ pub enum TaskModelType {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
Flux2LoraAngles,
#[serde(rename = "grok_image")]
GrokImage,
#[serde(rename = "recraft_3")]
@@ -133,6 +137,8 @@ impl TaskModelType {
Self::Seedream4 => "seedream_4",
Self::Seedream4p5 => "seedream_4p5",
Self::Seedream5Lite => "seedream_5_lite",
Self::QwenEdit2511Angles => "qwen_edit_2511_angles",
Self::Flux2LoraAngles => "flux_2_lora_angles",
Self::GrokImage => "grok_image",
Self::Recraft3 => "recraft_3",
Self::Midjourney => "midjourney",
@@ -184,6 +190,8 @@ impl TaskModelType {
"seedream_4" => Ok(Self::Seedream4),
"seedream_4p5" => Ok(Self::Seedream4p5),
"seedream_5_lite" => Ok(Self::Seedream5Lite),
"qwen_edit_2511_angles" => Ok(Self::QwenEdit2511Angles),
"flux_2_lora_angles" => Ok(Self::Flux2LoraAngles),
"grok_image" => Ok(Self::GrokImage),
"recraft_3" => Ok(Self::Recraft3),
"midjourney" => Ok(Self::Midjourney),
@@ -238,6 +246,8 @@ impl TaskModelType {
Self::Seedream4,
Self::Seedream4p5,
Self::Seedream5Lite,
Self::QwenEdit2511Angles,
Self::Flux2LoraAngles,
Self::GrokImage,
Self::Recraft3,
Self::Midjourney,
@@ -299,6 +309,8 @@ mod tests {
assert_serialization(TaskModelType::Seedream4, "seedream_4");
assert_serialization(TaskModelType::Seedream4p5, "seedream_4p5");
assert_serialization(TaskModelType::Seedream5Lite, "seedream_5_lite");
assert_serialization(TaskModelType::QwenEdit2511Angles, "qwen_edit_2511_angles");
assert_serialization(TaskModelType::Flux2LoraAngles, "flux_2_lora_angles");
assert_serialization(TaskModelType::GrokImage, "grok_image");
assert_serialization(TaskModelType::Recraft3, "recraft_3");
assert_serialization(TaskModelType::Midjourney, "midjourney");
@@ -349,6 +361,8 @@ mod tests {
assert_eq!(TaskModelType::Seedream4.to_str(), "seedream_4");
assert_eq!(TaskModelType::Seedream4p5.to_str(), "seedream_4p5");
assert_eq!(TaskModelType::Seedream5Lite.to_str(), "seedream_5_lite");
assert_eq!(TaskModelType::QwenEdit2511Angles.to_str(), "qwen_edit_2511_angles");
assert_eq!(TaskModelType::Flux2LoraAngles.to_str(), "flux_2_lora_angles");
assert_eq!(TaskModelType::GrokImage.to_str(), "grok_image");
assert_eq!(TaskModelType::Recraft3.to_str(), "recraft_3");
assert_eq!(TaskModelType::Midjourney.to_str(), "midjourney");
@@ -399,6 +413,8 @@ mod tests {
assert_eq!(TaskModelType::from_str("seedream_4").unwrap(), TaskModelType::Seedream4);
assert_eq!(TaskModelType::from_str("seedream_4p5").unwrap(), TaskModelType::Seedream4p5);
assert_eq!(TaskModelType::from_str("seedream_5_lite").unwrap(), TaskModelType::Seedream5Lite);
assert_eq!(TaskModelType::from_str("qwen_edit_2511_angles").unwrap(), TaskModelType::QwenEdit2511Angles);
assert_eq!(TaskModelType::from_str("flux_2_lora_angles").unwrap(), TaskModelType::Flux2LoraAngles);
assert_eq!(TaskModelType::from_str("grok_image").unwrap(), TaskModelType::GrokImage);
assert_eq!(TaskModelType::from_str("recraft_3").unwrap(), TaskModelType::Recraft3);
assert_eq!(TaskModelType::from_str("midjourney").unwrap(), TaskModelType::Midjourney);
@@ -444,7 +460,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = TaskModelType::all_variants();
assert_eq!(variants.len(), 43);
assert_eq!(variants.len(), 45);
// Image models
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
@@ -462,6 +478,8 @@ mod tests {
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4p5));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream5Lite));
assert_eq!(variants.pop_first(), Some(TaskModelType::QwenEdit2511Angles));
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux2LoraAngles));
assert_eq!(variants.pop_first(), Some(TaskModelType::GrokImage));
assert_eq!(variants.pop_first(), Some(TaskModelType::Recraft3));
assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney));
@@ -0,0 +1,57 @@
[package]
name = "worldlabs-job"
edition = "2021"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[[bin]]
name = "worldlabs-job"
path = "src/main.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
actix_helpers.workspace = true
bootstrap = { path = "../../../lib/bootstrap" }
bucket_paths = { path = "../../../lib/bucket_paths" }
cloud_storage.workspace = true
concurrency = { path = "../../../lib/concurrency" }
config = { path = "../../../lib/deprecated/config" }
easyenv = { path = "../../../lib/easyenv" }
enums.workspace = true
errors.workspace = true
hashing = { path = "../../../lib/files/hashing" }
jobs_common = { path = "../../../lib/jobs_common" }
mysql_queries = { path = "../../../schema/database/mysql_queries" }
world_labs_api = { path = "../../../api_clients/world_labs_api" }
server_environment = { path = "../../../lib/server_environment" }
tokens.workspace = true
# External
anyhow.workspace = true
log.workspace = true
sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ] }
# Runtime
tokio = { version = "1.40.0", features = ["macros", "sync"] }
# HTTP Client
reqwest.workspace = true
# HTTP Server
actix-web.workspace = true
# Serialization
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
@@ -0,0 +1,4 @@
# default .env configurations (these can be overridden with k8s)
# Development
SERVER_ENVIRONMENT='development'
@@ -0,0 +1,101 @@
use std::sync::Arc;
use actix_web::error::ResponseError;
use actix_web::http::header::ContentType;
use actix_web::http::StatusCode;
use actix_web::{web, HttpRequest, HttpResponse};
use log::error;
use actix_helpers::response_serializers::error_to_json_http_response::error_to_json_http_response;
use crate::http_server::http_server_shared_state::HttpServerSharedState;
#[derive(Serialize)]
pub struct HealthCheckResponse {
pub success: bool,
pub is_healthy: bool,
pub consecutive_failure_count: u64,
pub consecutive_success_count: u64,
pub total_failure_count: u64,
pub total_success_count: u64,
pub total_failure_ratio: f32,
pub total_success_ratio: f32,
}
#[derive(Debug, Serialize)]
pub enum HealthCheckError {
ServerError,
}
impl ResponseError for HealthCheckError {
fn status_code(&self) -> StatusCode {
match *self {
HealthCheckError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
error_to_json_http_response(self)
}
}
impl std::fmt::Display for HealthCheckError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
pub async fn get_health_check_handler(
_http_request: HttpRequest,
server_state: web::Data<Arc<HttpServerSharedState>>,
) -> Result<HttpResponse, HealthCheckError> {
let job_stats = server_state
.job_stats
.get_status()
.map_err(|e| {
error!("Error serving health check status: {:?}", e);
HealthCheckError::ServerError
})?;
let total_tries = job_stats
.total_failure_count
.saturating_add(job_stats.total_success_count);
let total_success_ratio = if total_tries > 0 {
(job_stats.total_success_count as f32) / (total_tries as f32)
} else {
0.0
};
let total_failure_ratio = if total_tries > 0 {
1.0 - total_success_ratio
} else {
0.0
};
let is_healthy =
job_stats.consecutive_failure_count < server_state.consecutive_failure_unhealthy_threshold;
let response = HealthCheckResponse {
success: true,
is_healthy,
consecutive_failure_count: job_stats.consecutive_failure_count,
consecutive_success_count: job_stats.consecutive_success_count,
total_failure_count: job_stats.total_failure_count,
total_success_count: job_stats.total_success_count,
total_failure_ratio,
total_success_ratio,
};
let body = serde_json::to_string(&response).map_err(|_e| HealthCheckError::ServerError)?;
if is_healthy {
Ok(HttpResponse::Ok()
.content_type(ContentType::json())
.body(body))
} else {
Ok(HttpResponse::InternalServerError()
.content_type(ContentType::json())
.body(body))
}
}
@@ -0,0 +1 @@
pub mod health_check_handler;
@@ -0,0 +1,7 @@
use jobs_common::job_stats::JobStats;
#[derive(Clone)]
pub struct HttpServerSharedState {
pub job_stats: JobStats,
pub consecutive_failure_unhealthy_threshold: u64,
}
@@ -0,0 +1,3 @@
pub mod endpoints;
pub mod http_server_shared_state;
pub mod run_http_server;
@@ -0,0 +1,67 @@
use std::sync::Arc;
use actix_web::dev::Server;
use actix_web::middleware::Logger;
use actix_web::{web, App, HttpResponse, HttpServer};
use log::info;
use bootstrap::bootstrap::ContainerEnvironment;
use errors::AnyhowResult;
use jobs_common::job_stats::JobStats;
use crate::http_server::endpoints::health_check_handler::get_health_check_handler;
use crate::http_server::http_server_shared_state::HttpServerSharedState;
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:12345";
const DEFAULT_NUM_WORKERS: usize = 2;
pub struct CreateServerArgs {
pub container_environment: ContainerEnvironment,
pub job_stats: JobStats,
}
pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server> {
let bind_address = easyenv::get_env_string_or_default("HTTP_BIND_ADDRESS", DEFAULT_BIND_ADDRESS);
let num_workers = easyenv::get_env_num("HTTP_NUM_WORKERS", DEFAULT_NUM_WORKERS)?;
let _hostname = args.container_environment.hostname.clone();
let server_state = HttpServerSharedState {
job_stats: args.job_stats.clone(),
consecutive_failure_unhealthy_threshold: easyenv::get_env_num(
"CONSECUTIVE_FAILURE_UNHEALTHY_THRESHOLD",
3,
)?,
};
let server_state_arc = web::Data::new(Arc::new(server_state));
info!("Starting HTTP service (for k8s health checking).");
let log_format = "[%{HOSTNAME}e] IP=[%{X-Forwarded-For}i] \"%r\" %s %b \"%{Referer}i\" \"%{User-Agent}i\" %T";
let handle = HttpServer::new(move || {
App::new()
.app_data(server_state_arc.clone())
.wrap(Logger::new(&log_format).exclude("/_status"))
.service(
web::resource("/")
.route(web::get().to(|| HttpResponse::Ok()))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/_status")
.route(web::get().to(get_health_check_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
})
.bind(bind_address)?
.workers(num_workers)
.run();
Ok(handle)
}
pub async fn launch_http_server(args: CreateServerArgs) -> AnyhowResult<()> {
run_http_server(args)?.await?;
Ok(())
}
@@ -0,0 +1,26 @@
use cloud_storage::bucket_client::BucketClient;
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
use jobs_common::job_stats::JobStats;
use server_environment::ServerEnvironment;
use sqlx::MySqlPool;
use world_labs_api::credentials::world_labs_api_creds::WorldLabsApiCreds;
pub struct JobDependencies {
pub mysql_pool: MySqlPool,
/// Public GCS/S3 bucket for storing generated splats.
pub public_bucket_client: BucketClient,
/// API credentials for World Labs.
pub worldlabs_creds: WorldLabsApiCreds,
pub server_environment: ServerEnvironment,
pub job_stats: JobStats,
/// How long to sleep between poll iterations (milliseconds).
pub poll_interval_millis: u64,
/// Set to `true` from another thread to trigger graceful shutdown.
pub application_shutdown: RelaxedAtomicBool,
}
@@ -0,0 +1,143 @@
// Never allow these
#![forbid(private_bounds)]
#![forbid(private_interfaces)]
#![forbid(unused_must_use)]
// Always allow
#![allow(dead_code)]
#![allow(non_snake_case)]
#[macro_use] extern crate serde_derive;
use std::time::Duration;
use anyhow::anyhow;
use log::{info, warn};
use sqlx::mysql::MySqlPoolOptions;
use bootstrap::bootstrap::{bootstrap, BootstrapArgs};
use cloud_storage::bucket_client::BucketClient;
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
use config::shared_constants::DEFAULT_MYSQL_CONNECTION_STRING;
use config::shared_constants::DEFAULT_RUST_LOG;
use errors::AnyhowResult;
use jobs_common::job_stats::JobStats;
use server_environment::ServerEnvironment;
use world_labs_api::credentials::world_labs_api_creds::WorldLabsApiCreds;
use crate::http_server::run_http_server::{launch_http_server, CreateServerArgs};
use crate::main_loop::main_loop;
use crate::job_dependencies::JobDependencies;
pub mod http_server;
pub mod job_dependencies;
pub mod main_loop;
pub mod process_job;
// Bucket config
const ENV_ACCESS_KEY: &str = "ACCESS_KEY";
const ENV_SECRET_KEY: &str = "SECRET_KEY";
const ENV_REGION_NAME: &str = "REGION_NAME";
const ENV_PUBLIC_BUCKET_NAME: &str = "PUBLIC_BUCKET_NAME";
const ENV_S3_ENDPOINT: &str = "S3_COMPATIBLE_ENDPOINT_URL";
const ENV_WORLDLABS_API_KEY: &str = "WORLDLABS_API_KEY";
#[actix_web::main]
async fn main() -> AnyhowResult<()> {
let container_environment = bootstrap(BootstrapArgs {
app_name: "worldlabs-job",
default_logging_override: Some(DEFAULT_RUST_LOG),
config_search_directories: &[".", "./config", "crates/service/job/worldlabs_job/config"],
ignore_legacy_dot_env_file: true,
})?;
info!("Hostname: {}", &container_environment.hostname);
let _k8s_node_name = easyenv::get_env_string_optional("K8S_NODE_NAME");
let _k8s_pod_name = easyenv::get_env_string_optional("K8S_POD_NAME");
let db_connection_string = easyenv::get_env_string_or_default(
"MYSQL_URL",
DEFAULT_MYSQL_CONNECTION_STRING,
);
info!("Connecting to database...");
let mysql_pool = MySqlPoolOptions::new()
.max_connections(2)
.connect(&db_connection_string)
.await?;
info!("Connected to MySQL.");
let server_environment = ServerEnvironment::from_str(
&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?,
)
.ok_or(anyhow!("invalid server environment"))?;
// Bucket setup
let access_key = easyenv::get_env_string_required(ENV_ACCESS_KEY)?;
let secret_key = easyenv::get_env_string_required(ENV_SECRET_KEY)?;
let region_name = easyenv::get_env_string_required(ENV_REGION_NAME)?;
let public_bucket_name = easyenv::get_env_string_required(ENV_PUBLIC_BUCKET_NAME)?;
let s3_compatible_endpoint_url = easyenv::get_env_string_required(ENV_S3_ENDPOINT)?;
let bucket_timeout = easyenv::get_env_duration_seconds_or_default(
"BUCKET_TIMEOUT_SECONDS",
Duration::from_secs(60 * 5),
);
let public_bucket_client = BucketClient::create(
&access_key,
&secret_key,
&region_name,
&public_bucket_name,
&s3_compatible_endpoint_url,
None,
Some(bucket_timeout),
)?;
// World Labs API credentials
let worldlabs_api_key = easyenv::get_env_string_required(ENV_WORLDLABS_API_KEY)?;
let worldlabs_creds = WorldLabsApiCreds::new(worldlabs_api_key);
// How often to poll for results (default: 15 seconds)
let poll_interval_millis: u64 = easyenv::get_env_num(
"WORLDLABS_POLL_INTERVAL_MILLIS",
15_000,
)?;
let application_shutdown = RelaxedAtomicBool::new(false);
let job_stats = JobStats::new();
let create_server_args = CreateServerArgs {
container_environment: container_environment.clone(),
job_stats: job_stats.clone(),
};
let job_dependencies = JobDependencies {
mysql_pool,
public_bucket_client,
worldlabs_creds,
server_environment,
job_stats,
poll_interval_millis,
application_shutdown: application_shutdown.clone(),
};
std::thread::spawn(move || {
let actix_runtime = actix_web::rt::System::new();
let http_server_handle = launch_http_server(create_server_args);
actix_runtime.block_on(http_server_handle)
.expect("HTTP server should not exit.");
warn!("Server thread is shut down.");
application_shutdown.set(true);
});
main_loop(job_dependencies).await;
Ok(())
}
@@ -0,0 +1,96 @@
use std::time::Duration;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::worldlabs::list_pending_worldlabs_jobs::list_pending_worldlabs_jobs;
use world_labs_api::api::api_types::operation_id::OperationId;
use world_labs_api::api::requests::get_operation::get_operation::{get_operation, GetOperationArgs};
use crate::process_job::process_failed_job::process_failed_job;
use crate::process_job::process_successful_job::process_successful_job;
use crate::job_dependencies::JobDependencies;
pub async fn main_loop(job_dependencies: JobDependencies) {
while !job_dependencies.application_shutdown.get() {
let result = run_poll_iteration(&job_dependencies).await;
if let Err(err) = result {
error!("Error in poll iteration: {:?}", err);
let _ = job_dependencies.job_stats.increment_failure_count();
}
tokio::time::sleep(Duration::from_millis(job_dependencies.poll_interval_millis)).await;
}
warn!("WorldLabs job runner main loop is shut down.");
}
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
// 1. Query all non-terminal WorldLabs jobs from DB.
let pending_jobs = list_pending_worldlabs_jobs(&deps.mysql_pool).await?;
if pending_jobs.is_empty() {
info!("No pending WorldLabs jobs.");
return Ok(());
}
info!("Found {} pending WorldLabs job(s).", pending_jobs.len());
// 2. For each pending job, poll its operation status from the World Labs API.
for job in &pending_jobs {
let operation_id = OperationId(job.operation_id.clone());
let operation = match get_operation(GetOperationArgs {
creds: &deps.worldlabs_creds,
operation_id: &operation_id,
request_timeout: Some(Duration::from_secs(30)),
}).await {
Ok(op) => op,
Err(err) => {
warn!(
"Error polling WorldLabs operation {} for job {}: {:?}",
job.operation_id, job.job_token.as_str(), err
);
continue;
}
};
// 3. Check if the operation has an error.
if let Some(ref op_error) = operation.error {
let reason = op_error.message.as_deref().unwrap_or("unknown error");
info!(
"Operation {} failed: {}. Processing job {} as failed.",
job.operation_id, reason, job.job_token.as_str()
);
process_failed_job(deps, job, reason).await;
continue;
}
// 4. Check if the operation is done.
if !operation.done {
// Still in progress — check again next poll.
info!(
"Operation {} for job {} is still in progress.",
job.operation_id, job.job_token.as_str()
);
continue;
}
// 5. Operation is done — process the result.
info!(
"Operation {} completed, processing job {}.",
job.operation_id, job.job_token.as_str()
);
if let Err(err) = process_successful_job(deps, job, &operation).await {
warn!(
"Error processing completed operation {} for job {}: {:?}",
job.operation_id, job.job_token.as_str(), err
);
let _ = deps.job_stats.increment_failure_count();
} else {
let _ = deps.job_stats.increment_success_count();
}
}
Ok(())
}
@@ -0,0 +1,2 @@
pub mod process_failed_job;
pub mod process_successful_job;
@@ -0,0 +1,116 @@
use log::{error, info, warn};
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
use mysql_queries::queries::generic_inference::worldlabs::list_pending_worldlabs_jobs::PendingWorldlabsJob;
use mysql_queries::queries::wallets::refund::try_to_refund_ledger_entry::{try_to_refund_ledger_entry, WalletRefundOutcome};
use crate::job_dependencies::JobDependencies;
pub async fn process_failed_job(
deps: &JobDependencies,
job: &PendingWorldlabsJob,
reason: &str,
) {
// --- Step 1: Attempt the refund before touching the job status. ---
//
// We do this first so that a crash or error between the refund and the status update
// can never result in a failed job with no refund. If the refund itself fails we bail
// out early: the job stays pending and will be retried on the next poll cycle.
match &job.maybe_wallet_ledger_entry_token {
None => {
warn!(
"Job {} has no wallet ledger entry token; skipping refund.",
job.job_token.as_str()
);
}
Some(ledger_token) => {
let mut transaction = match deps.mysql_pool.begin().await {
Ok(tx) => tx,
Err(err) => {
error!(
"Failed to begin refund transaction for job {} (ledger {}): {:?}. \
Job will NOT be marked failed yet and will be retried next poll.",
job.job_token.as_str(), ledger_token.as_str(), err
);
return;
}
};
match try_to_refund_ledger_entry(ledger_token, &mut transaction).await {
Ok(WalletRefundOutcome::Refunded(summary)) => {
info!(
"Refunded {} credits for failed job {} (ledger {} → refund ledger {}).",
summary.refund_amount,
job.job_token.as_str(),
ledger_token.as_str(),
summary.refund_ledger_entry_token.as_str(),
);
if let Err(err) = transaction.commit().await {
error!(
"Failed to commit refund transaction for job {} (ledger {}): {:?}. \
Job will NOT be marked failed yet and will be retried next poll.",
job.job_token.as_str(), ledger_token.as_str(), err
);
return;
}
}
Ok(WalletRefundOutcome::AlreadyRefunded) => {
info!(
"Ledger entry {} for job {} was already refunded; proceeding to mark job failed.",
ledger_token.as_str(),
job.job_token.as_str(),
);
let _ = transaction.rollback().await;
}
Err(err) => {
error!(
"Failed to refund ledger entry {} for job {}: {:?}. \
Job will NOT be marked failed yet and will be retried next poll.",
ledger_token.as_str(),
job.job_token.as_str(),
err,
);
let _ = transaction.rollback().await;
return;
}
}
}
}
// --- Step 2: Mark the job record as failed. ---
let reason_lower = reason.to_lowercase();
let platform_rules_violation = reason_lower.contains("violates") ||
reason_lower.contains("platform rules") ||
reason_lower.contains("please modify");
let frontend_failure_category = if platform_rules_violation {
Some(FrontendFailureCategory::ModelRulesViolation)
} else {
None
};
warn!(
"Operation for job {} failed: {}. Marking job failed.",
job.job_token.as_str(), reason
);
let mark_failed_result = mark_job_failed_by_token(MarkJobFailedByTokenArgs {
pool: &deps.mysql_pool,
job_token: &job.job_token,
maybe_public_failure_reason: Some(reason),
internal_debugging_failure_reason: reason,
maybe_frontend_failure_category: frontend_failure_category,
}).await;
if let Err(err) = mark_failed_result {
error!(
"Error marking job {} as failed: {:?}",
job.job_token.as_str(),
err
);
}
}
@@ -0,0 +1,249 @@
use anyhow::anyhow;
use log::{error, info, warn};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::generic_inference::worldlabs::list_pending_worldlabs_jobs::PendingWorldlabsJob;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
use tokens::tokens::media_files::MediaFileToken;
use world_labs_api::api::requests::get_operation::get_operation::GetOperationResponse;
use crate::job_dependencies::JobDependencies;
const SPLAT_PREFIX: &str = "artcraft_";
const SPLAT_SUFFIX: &str = ".ceramic.spz"; // NB: "ceramic" triggers some heuristics in Artcraft, such as flipping the model upside down
const THUMB_PREFIX: &str = "artcraft_";
const THUMB_SUFFIX: &str = ".png";
/// Download the completed splat, upload to bucket, create media file record, and mark job done.
pub async fn process_successful_job(
deps: &JobDependencies,
job: &PendingWorldlabsJob,
operation: &GetOperationResponse,
) -> AnyhowResult<()> {
// Get the full-res splat URL from the completed world.
let world = operation.world.as_ref()
.ok_or_else(|| anyhow!("Completed operation {} has no world object", operation.operation_id))?;
let splat_url = world.assets.as_ref()
.and_then(|a| a.splats.as_ref())
.and_then(|s| s.spz_url_full_res.as_ref())
.ok_or_else(|| anyhow!(
"Completed operation {} has no spz_url_full_res",
operation.operation_id
))?;
// --- Step 1: Download and upload the thumbnail as a cover image. ---
let maybe_cover_token = match world.assets.as_ref().and_then(|a| a.thumbnail_url.as_ref()) {
Some(thumbnail_url) => {
match download_and_upload_thumbnail(deps, job, operation, thumbnail_url).await {
Ok(token) => Some(token),
Err(err) => {
warn!(
"Failed to create thumbnail cover image for operation {}: {:?}. Continuing without cover.",
operation.operation_id, err
);
None
}
}
}
None => {
info!("No thumbnail_url for operation {}; skipping cover image.", operation.operation_id);
None
}
};
// --- Step 2: Download and upload the splat. ---
info!(
"Downloading splat for operation {} from: {}",
operation.operation_id, splat_url
);
// Download the splat bytes.
let splat_bytes: Vec<u8> = reqwest::get(splat_url)
.await
.map_err(|err| anyhow!("reqwest error downloading splat: {:?}", err))?
.bytes()
.await
.map_err(|err| anyhow!("error reading splat bytes: {:?}", err))?
.to_vec();
info!(
"Downloaded {} bytes for operation {}",
splat_bytes.len(),
operation.operation_id
);
// Hash the splat.
let checksum = sha256_hash_bytes(&splat_bytes)
.map_err(|err| anyhow!("error hashing splat: {:?}", err))?;
// Build the bucket path.
let bucket_path = MediaFileBucketPath::generate_new(Some(SPLAT_PREFIX), Some(SPLAT_SUFFIX));
let object_path = bucket_path.get_full_object_path_str();
info!(
"Uploading splat to public bucket at path: {}",
object_path
);
// Upload to public bucket.
deps
.public_bucket_client
.upload_file_with_content_type_process(object_path, &splat_bytes, "application/gzip")
.await
.map_err(|err| anyhow!("error uploading splat to bucket: {:?}", err))?;
info!(
"Uploaded splat for operation {}. Creating media file record.",
operation.operation_id
);
// Insert media file record.
let media_file_token = MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&job.creator_ip_address)
.creator_set_visibility(job.creator_set_visibility)
.media_file_class(MediaFileClass::Dimensional)
.media_file_type(MediaFileType::Spz)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::WorldGeneration)
.mime_type("application/gzip")
.file_size_bytes(splat_bytes.len() as u64)
.checksum_sha2(&checksum)
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.maybe_cover_image_media_file_token(maybe_cover_token.as_ref())
.public_bucket_directory_hash(&bucket_path)
.insert_pool(&deps.mysql_pool)
.await
.map_err(|err| anyhow!("error inserting media file record: {:?}", err))?;
info!(
"Created media file {} for operation {}. Marking job {} complete.",
media_file_token.as_str(),
operation.operation_id,
job.job_token.as_str()
);
// Mark inference job as successfully completed.
mark_generic_inference_job_successfully_done_by_token(
&deps.mysql_pool,
&job.job_token,
Some(InferenceResultType::MediaFile),
Some(media_file_token.as_str()),
None,
None,
)
.await
.map_err(|err| {
error!(
"Error marking job {} done: {:?}",
job.job_token.as_str(),
err
);
anyhow!("error marking job done: {:?}", err)
})?;
info!("Job {} completed successfully.", job.job_token.as_str());
Ok(())
}
/// Guess (suffix, mime_type, MediaFileType) from a URL's file extension.
/// Falls back to PNG if the extension is unrecognized or absent.
fn guess_image_format_from_url(url: &str) -> (&'static str, &'static str, MediaFileType) {
// Strip query string / fragment before checking the extension.
let path = url.split('?').next().unwrap_or(url);
let path = path.split('#').next().unwrap_or(path);
if let Some(dot) = path.rfind('.') {
match &path[dot..] {
".jpg" | ".jpeg" => (".jpg", "image/jpeg", MediaFileType::Jpg),
".png" => (".png", "image/png", MediaFileType::Png),
_ => (THUMB_SUFFIX, "image/png", MediaFileType::Png),
}
} else {
(THUMB_SUFFIX, "image/png", MediaFileType::Png)
}
}
/// Download a thumbnail image, upload it to the public bucket, and create a media file record for it.
async fn download_and_upload_thumbnail(
deps: &JobDependencies,
job: &PendingWorldlabsJob,
operation: &GetOperationResponse,
thumbnail_url: &str,
) -> AnyhowResult<MediaFileToken> {
info!(
"Downloading thumbnail for operation {} from: {}",
operation.operation_id, thumbnail_url
);
let (suffix, mime_type, media_file_type) = guess_image_format_from_url(thumbnail_url);
let thumb_bytes: Vec<u8> = reqwest::get(thumbnail_url)
.await
.map_err(|err| anyhow!("reqwest error downloading thumbnail: {:?}", err))?
.bytes()
.await
.map_err(|err| anyhow!("error reading thumbnail bytes: {:?}", err))?
.to_vec();
info!(
"Downloaded {} thumbnail bytes for operation {}",
thumb_bytes.len(),
operation.operation_id
);
let checksum = sha256_hash_bytes(&thumb_bytes)
.map_err(|err| anyhow!("error hashing thumbnail: {:?}", err))?;
let bucket_path = MediaFileBucketPath::generate_new(Some(THUMB_PREFIX), Some(suffix));
let object_path = bucket_path.get_full_object_path_str();
info!("Uploading thumbnail to public bucket at path: {}", object_path);
deps
.public_bucket_client
.upload_file_with_content_type_process(object_path, &thumb_bytes, mime_type)
.await
.map_err(|err| anyhow!("error uploading thumbnail to bucket: {:?}", err))?;
let thumb_token = MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&job.creator_ip_address)
.creator_set_visibility(job.creator_set_visibility)
.media_file_class(MediaFileClass::Image)
.media_file_type(media_file_type)
.is_intermediate_system_file(true)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::WorldGeneration)
.mime_type(mime_type)
.file_size_bytes(thumb_bytes.len() as u64)
.checksum_sha2(&checksum)
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.public_bucket_directory_hash(&bucket_path)
.insert_pool(&deps.mysql_pool)
.await
.map_err(|err| anyhow!("error inserting thumbnail media file record: {:?}", err))?;
info!(
"Created thumbnail media file {} for operation {}.",
thumb_token.as_str(),
operation.operation_id
);
Ok(thumb_token)
}
@@ -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()))
@@ -37,10 +37,12 @@ export const DOWNLOAD_LINKS = {
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.4.0/ArtCraft_0.4.0_x64-setup.exe', // BROKEN (NX)
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.5.0/ArtCraft_0.5.0_x64-setup.exe',
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.7.0/ArtCraft_0.7.0_x64-setup.exe', // 2026-02-23 Seedance
WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.11.0/ArtCraft_0.11.0_x64-setup.exe', // 2026-03-02 Credits accounting
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.11.0/ArtCraft_0.11.0_x64-setup.exe', // 2026-03-02 Credits accounting
WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.14.0/ArtCraft_0.14.0_x64-setup.exe', // 2026-03-10 WorldLabs native, Angles
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.2.0/ArtCraft_0.2.0_universal.dmg',
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.4.0/ArtCraft_0.4.0_universal.dmg', // BROKEN (NX)
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.5.0/ArtCraft_0.5.0_universal.dmg',
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.7.0/ArtCraft_0.7.0_universal.dmg', // 2026-02-23 Seedance
MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.11.0/ArtCraft_0.11.0_universal.dmg', // 2026-03-02 Credits accounting
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.11.0/ArtCraft_0.11.0_universal.dmg', // 2026-03-02 Credits accounting
MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.14.0/ArtCraft_0.14.0_universal.dmg', // 2026-03-10 WorldLabs native, Angles
} as const;
@@ -37,6 +37,11 @@ import {
useSelectedModel,
useSelectedProviderForModel,
} from "@storyteller/ui-model-selector";
import {
CostCalculatorButton,
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
type Mode = "image" | "text";
type Variant = "object" | "world";
@@ -92,7 +97,13 @@ export const ImageTo3DExperience = ({
EnqueueImageTo3dObjectModel.Hunyuan3d3,
);
const selectedWorldModel = useSelectedModel(WORLD_MODEL_PAGE);
const selectedWorldProvider = useSelectedProviderForModel(WORLD_MODEL_PAGE, selectedWorldModel?.id);
const selectedWorldProvider = useSelectedProviderForModel(
WORLD_MODEL_PAGE,
selectedWorldModel?.id,
);
const worldCredits = useCostBreakdownModalStore(
(s) => s.estimatedCreditsByPage[WORLD_MODEL_PAGE],
);
const [uploadedPreview, setUploadedPreview] = useState<string | null>(null);
const [uploadedName, setUploadedName] = useState<string | null>(null);
const [prompt, setPrompt] = useState("");
@@ -141,15 +152,17 @@ export const ImageTo3DExperience = ({
);
useEffect(() => {
if (variant === "world" && pendingExternalImage) {
// Read from store directly to avoid Strict Mode double-execution adding the image twice
const pending = useImageTo3DWorldStore.getState().pendingExternalImage;
if (variant === "world" && pending) {
setWorldImages((prev) => {
if (prev.length >= MAX_WORLD_IMAGES) return prev;
return [
...prev,
{
id: generateId(),
preview: pendingExternalImage.url,
mediaToken: pendingExternalImage.mediaToken,
preview: pending.url,
mediaToken: pending.mediaToken,
name: "Library Image",
isUploading: false,
},
@@ -389,9 +402,6 @@ export const ImageTo3DExperience = ({
if ("error_type" in result) {
throw new Error(result.error_message || result.error_type);
}
setWorldImages([]);
setWorldPrompt("");
} else {
const snapshotPrompt = prompt.trim();
const snapshotPreview = uploadedPreview || undefined;
@@ -709,7 +719,7 @@ export const ImageTo3DExperience = ({
<textarea
ref={worldTextareaRef}
rows={2}
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 focus:outline-none focus:ring-2 focus:ring-primary/60"
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 outline-none ring-2 ring-transparent transition-all focus:ring-primary/80"
style={{ maxHeight: "5em" }}
value={worldPrompt}
placeholder="Describe your 3D world (optional)..."
@@ -724,7 +734,7 @@ export const ImageTo3DExperience = ({
key={img.id}
className={twMerge(
"glass group relative aspect-square overflow-hidden rounded-lg border-2 border-white/30 transition-all",
hasResults ? "w-10" : "w-14",
hasResults ? "w-10" : "w-auto",
img.isUploading
? "cursor-default"
: "cursor-pointer hover:cursor-zoom-in hover:border-white/80",
@@ -1058,7 +1068,7 @@ export const ImageTo3DExperience = ({
<div
className={twMerge(
"flex justify-center gap-2.5",
hasResults ? "mt-3" : "mt-6",
hasResults ? "mt-3" : "mt-3",
)}
>
{variant === "object" && (
@@ -1081,6 +1091,7 @@ export const ImageTo3DExperience = ({
disabled={!canGenerate}
onClick={handleGenerate}
loading={isGenerating}
credits={variant === "world" ? worldCredits : undefined}
>
{`Generate ${variant === "object" ? "Object" : "World"}`}
</GenerateButton>
@@ -1102,17 +1113,23 @@ export const ImageTo3DExperience = ({
</animated.div>
{variant === "world" && (
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST}
page={WORLD_MODEL_PAGE}
mode="hoverSelect"
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
/>
</div>
<>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST}
page={WORLD_MODEL_PAGE}
mode="hoverSelect"
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={WORLD_MODEL_PAGE} />
<HelpMenuButton />
</div>
</>
)}
</div>
@@ -412,13 +412,21 @@ export const TaskQueue = () => {
? getProviderDisplayName(String(t.provider).toLowerCase())
: undefined;
const taskTypeStr = t.task_type ? String(t.task_type).toLowerCase() : "";
const modelTypeStr = t.model_type ? String(t.model_type).toLowerCase() : "";
const isSplatModel =
taskTypeStr.includes("gaussian") ||
modelTypeStr.includes("marble") ||
modelTypeStr.includes("worldlabs");
const is3DModel =
taskTypeStr.includes("3d") ||
taskTypeStr.includes("object") ||
taskTypeStr.includes("dimensional");
taskTypeStr.includes("dimensional") ||
isSplatModel;
let kind = undefined;
if (taskTypeStr.includes("image") && is3DModel) {
if (isSplatModel) {
kind = "3D World";
} else if (taskTypeStr.includes("image") && is3DModel) {
kind = "Image to 3D";
} else if (is3DModel) {
kind = "3D Model";
@@ -9,6 +9,7 @@ import {
faPencil,
faWandMagicSparkles,
faPenNib,
faCrosshairs,
} from "@fortawesome/pro-solid-svg-icons";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
@@ -24,7 +25,8 @@ export type AppId =
| "IMAGE_WATERMARK_REMOVAL"
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD"
| "REMOVE_BACKGROUND";
| "REMOVE_BACKGROUND"
| "ANGLES";
export interface AppDescriptor {
id: AppId;
@@ -157,6 +159,16 @@ export const ALL_APPS: FullAppItem[] = [
action: "REMOVE_BACKGROUND",
color: "bg-violet-500/40",
},
{
id: "angles",
label: "Angles",
description: "Generate new camera angles from a single photo",
icon: faCrosshairs,
category: "generate",
action: "ANGLES",
color: "bg-lime-500/40",
badge: "NEW",
},
{
id: "2d-canvas",
@@ -210,6 +222,7 @@ export const goToApp = (action?: string) => {
"IMAGE_TO_3D_OBJECT",
"IMAGE_TO_3D_WORLD",
"REMOVE_BACKGROUND",
"ANGLES",
].includes(action)
) {
if (action === "3D") {

Some files were not shown because too many files have changed in this diff Show More