Compare commits

...
Author SHA1 Message Date
Brandon Thomas 2aa1e762d5 cleanup and fix paging 2026-04-06 20:53:48 -04:00
Brandon Thomas 28a0725f34 cleanup 2026-04-06 20:43:26 -04:00
Brandon Thomas b68d24040f rename variant 2026-04-06 20:36:06 -04:00
Brandon Thomas 3792cf82f2 rename 2026-04-06 20:35:37 -04:00
Brandon Thomas f73add81ef usage 2026-04-06 19:56:10 -04:00
Brandon Thomas 7465da6092 refactor wip 2026-04-06 19:53:36 -04:00
Brandon Thomas fc5fda42cf refactor 2026-04-06 18:47:49 -04:00
Brandon Thomas 0eb4ceed70 refactor 2026-04-06 18:40:27 -04:00
Brandon Thomas 6f1045e58a fix session errors 2026-04-06 18:24:08 -04:00
Brandon Thomas 2b97694ebf omni gen for a few image models (#1308) 2026-04-06 04:10:14 -04:00
Brandon Thomas 0f6998cb6b bump artcraft 0.23 -> 0.24 (#1307) 2026-04-05 17:57:42 -04:00
Brandon Thomas 8076d1b830 fix builds (#1306) 2026-04-05 02:55:08 -04:00
Brandon Thomas 9842734ce7 more cleanup of deprecated or orphaned code (#1305) 2026-04-05 02:35:55 -04:00
Brandon Thomas 72761e11f3 delete public enums crate - old tech debt / cruft (#1304)
Ages ago I tried to hide what we were doing from "Uberduck". That was
stupid. As if it mattered.

Never again.
2026-04-04 23:04:32 -04:00
bflatastic 60c97ff312 characters modal bug overflowing on very small screens 2026-04-05 06:40:55 +07:00
BFlat 03ed457cb5 remove character limits on seedance 2 and nano banana models (#1303) 2026-04-05 06:24:30 +07:00
Brandon Thomas e89229896f character descriptions max length (#1302) 2026-04-04 16:50:44 -04:00
Brandon Thomas 56c1b57cce bump website version to 0.23 2026-04-03 19:16:02 -04:00
bflatastic 122c1d6688 mention text area small styling fix 2026-04-04 05:40:52 +07:00
Brandon Thomas 6768208af3 bump artcraft 0.22 -> 0.23 2026-04-03 18:31:21 -04:00
Brandon Thomas 99b5a9774a Frontend failure text updates (#1300) 2026-04-03 18:20:06 -04:00
BFlat f55a4c722e mentions text area (#1299)
- content editable div instead of textarea when mentions is needed
- no more overlayed text areas and misaligned cursors
2026-04-04 05:16:14 +07:00
Tanish Manku f337d630e3 Fix: Allow asset.localhost in CSP for Windows drag-drop (#1298)
On Windows production builds, convertFileSrc() generates
https://asset.localhost/... URLs. The connect-src CSP only had "asset:"
(covering asset:// on macOS/Linux) but not https://asset.localhost,
causing the fetch() in GlobalFileDropHandler to be silently blocked —
overlay appeared but no modal opened on drop.
2026-04-03 14:46:32 -07:00
Brandon Thomas b4e1e3ea57 marketing tracking (#1297) 2026-04-03 15:06:38 -04:00
bflatastic 71fd694368 maybe_landing_url signup params 2026-04-03 23:51:11 +07:00
bflatastic f2c386dcb9 Merge branch 'main' of https://github.com/storytold/storyteller-rust 2026-04-03 23:39:36 +07:00
bflatastic ffb8cbe433 landing videos updates 2026-04-03 23:39:09 +07:00
Brandon Thomas 5f062c4512 new optional signup params 2026-04-03 12:37:33 -04:00
Brandon Thomas 2442cda22f bump ArtCraft 0.21 -> 0.22 (#1296) 2026-04-03 11:36:17 -04:00
Brandon Thomas 0a534921ce fix mac build 2026-04-03 11:34:46 -04:00
Brandon Thomas 1fecc4405c Bump ArtCraft 0.20 -> 0.21 (#1295) 2026-04-03 11:05:47 -04:00
bflatastic cbca7e3cb4 confirm before deleting character alert dialog 2026-04-03 15:28:40 +07:00
BFlat d107b78498 characters (#1280)
- Create, list, delete characters for use as reference with seedance 2.0
- Characters are mentionable in the prompt box by using the @ sign
2026-04-03 15:19:41 +07:00
Brandon Thomas 702bdc8777 tauri characters via artcraft_router (#1293) 2026-04-03 03:37:04 -04:00
259 changed files with 5734 additions and 3468 deletions
+5 -1
View File
@@ -5,7 +5,7 @@
- Two spaces for indentation (not four)
- No minimum supported Rust version; use latest stable features freely
- `#[macro_use] extern crate serde_derive;` is used in binary crates; library crates use `use serde_derive::{Serialize, Deserialize};`
- Prefer `log` crate macros (`info!`, `warn!`, `error!`) over `println!`
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros (`info!`, `warn!`, `error!`, `debug!`, `trace!`) instead
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code
- Enum Display/Debug: use `impl_enum_display_and_debug_using_to_str!` macro, not `derive_more::Display` (CLion doesn't understand it)
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token`
@@ -51,6 +51,10 @@ then the story, then the fine print.
- Group imports: std, external crates, internal workspace crates, `crate::` imports
- Use fully qualified paths for one-off references; `use` for repeated references
- Prefer specific imports over wildcards, except in `api_doc.rs` and test modules
- When two crates export a type with the same name, alias with a suffix describing the source crate:
`use enums::...::CommonResolution as CommonResolutionEnum;`
`use artcraft_router::...::CommonResolution as CommonResolutionRouter;`
This avoids long fully-qualified paths in function signatures and bodies.
## TypeScript / Frontend
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO users\nSET\n token = ?,\n \n is_temporary = ?,\n \n username = ?,\n display_name = ?,\n\n username_is_generated = ?,\n username_is_not_customized = ?,\n\n email_address = ?,\n email_gravatar_hash = ?,\n\n email_confirmed = FALSE,\n email_confirmed_by_google = ?,\n email_is_synthetic = ?,\n \n was_eagerly_provisioned = ?,\n\n profile_markdown = ?,\n profile_rendered_html = ?,\n user_role_slug = ?,\n\n password_hash = ?,\n is_without_password = ?,\n\n maybe_feature_flags = ?,\n\n ip_address_creation = ?,\n ip_address_last_login = ?,\n ip_address_last_update = ?,\n\n maybe_source = ?,\n\n maybe_signup_method = ?,\n\n maybe_referral_url = ?\n ",
"query": "\nINSERT INTO users\nSET\n token = ?,\n \n is_temporary = ?,\n \n username = ?,\n display_name = ?,\n\n username_is_generated = ?,\n username_is_not_customized = ?,\n\n email_address = ?,\n email_gravatar_hash = ?,\n\n email_confirmed = FALSE,\n email_confirmed_by_google = ?,\n email_is_synthetic = ?,\n \n was_eagerly_provisioned = ?,\n\n profile_markdown = ?,\n profile_rendered_html = ?,\n user_role_slug = ?,\n\n password_hash = ?,\n is_without_password = ?,\n\n maybe_feature_flags = ?,\n\n ip_address_creation = ?,\n ip_address_last_login = ?,\n ip_address_last_update = ?,\n\n maybe_source = ?,\n\n maybe_signup_method = ?,\n\n maybe_referral_url = ?,\n maybe_landing_url = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 23
"Right": 24
},
"nullable": []
},
"hash": "08996721482fe897c2721298c04cf0eba5fcf1d04d57397b8afab3dd59feaa8c"
"hash": "3aacc0d270a9073ee5a38595cc32ab7793e0e8b9ce3ad81f554e7198fd0fd509"
}
+2
View File
@@ -60,6 +60,8 @@ artcraft/
- Actix-web for HTTP services
- SQLx for MySQL and SQLite
- A mix of wreq and reqwest for Rust HTTP clients
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
- When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;`
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
Generated
+16 -39
View File
@@ -508,7 +508,6 @@ version = "0.0.1"
dependencies = [
"anyhow",
"chrono",
"config",
"datetimes",
"dotenv",
"easyenv",
@@ -523,6 +522,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"tokens",
"tokio",
@@ -727,7 +727,6 @@ dependencies = [
"anyhow",
"chrono",
"enums",
"enums_public",
"serde",
"serde_derive",
"serde_json",
@@ -748,7 +747,6 @@ dependencies = [
"cloudflare_errors",
"cookie 0.18.1",
"enums",
"enums_public",
"errors",
"log",
"mimetypes",
@@ -3133,7 +3131,6 @@ dependencies = [
"bucket_paths",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
"elasticsearch",
"enums",
@@ -3144,6 +3141,7 @@ dependencies = [
"mimetypes",
"mysql_queries",
"rand 0.9.2",
"shared_env_var_config",
"sqlx",
"storyteller_root",
"tokens",
@@ -3159,7 +3157,6 @@ dependencies = [
"bucket_paths",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
"enums",
"errors",
@@ -3167,6 +3164,7 @@ dependencies = [
"log",
"mimetypes",
"mysql_queries",
"shared_env_var_config",
"sqlx",
"tokens",
"tokio",
@@ -3438,6 +3436,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"storyteller_root",
"subprocess",
@@ -3445,6 +3444,7 @@ dependencies = [
"tempdir",
"tokens",
"tokio",
"url",
"workspace-build-acceleration",
"zip 0.6.6",
]
@@ -3610,7 +3610,6 @@ version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.60",
"config",
"easyenv",
"elasticsearch",
"elasticsearch_schema",
@@ -3622,6 +3621,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"storyteller_root",
"strum",
@@ -3701,6 +3701,7 @@ dependencies = [
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"sqlx",
"workspace-build-acceleration",
]
@@ -3794,20 +3795,6 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "enums_public"
version = "0.0.1"
dependencies = [
"enums",
"serde",
"serde_derive",
"serde_json",
"strum",
"toml 1.0.7+spec-1.1.0",
"utoipa",
"workspace-build-acceleration",
]
[[package]]
name = "env"
version = "1.0.1"
@@ -3904,7 +3891,6 @@ dependencies = [
"anyhow",
"bootstrap",
"chrono",
"config",
"dotenv",
"easyenv",
"elasticsearch",
@@ -3923,6 +3909,7 @@ dependencies = [
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"sqlx",
"storyteller_root",
"tokens",
@@ -5764,6 +5751,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"subprocess",
"subprocess_common",
@@ -6101,7 +6089,6 @@ dependencies = [
name = "jwt_signer"
version = "0.0.1"
dependencies = [
"errors",
"hmac 0.10.1",
"jwt",
"sha2 0.9.9",
@@ -6146,13 +6133,13 @@ dependencies = [
name = "kubectl-pod-cleanup"
version = "0.0.1"
dependencies = [
"config",
"easyenv",
"errors",
"log",
"once_cell",
"rand 0.8.5",
"regex",
"shared_env_var_config",
"workspace-build-acceleration",
]
@@ -6856,7 +6843,6 @@ dependencies = [
"bucket_paths",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
"enums",
"errors",
@@ -6867,6 +6853,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"storyteller_root",
"strum",
@@ -8657,16 +8644,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "prompt_engineering"
version = "0.1.0"
dependencies = [
"once_cell",
"regex",
"speculoos",
"workspace-build-acceleration",
]
[[package]]
name = "prost"
version = "0.12.6"
@@ -10197,7 +10174,6 @@ dependencies = [
"bucket_paths",
"cloud_storage",
"concurrency",
"config",
"easyenv",
"enums",
"errors",
@@ -11257,7 +11233,6 @@ dependencies = [
"elasticsearch",
"elasticsearch_schema",
"enums",
"enums_public",
"errors",
"fal_client",
"ffmpeg_utils",
@@ -11288,7 +11263,6 @@ dependencies = [
"pager",
"password",
"primitives",
"prompt_engineering",
"r2d2",
"rand 0.9.2",
"redis 1.0.2",
@@ -12888,6 +12862,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"subprocess",
"tempdir",
@@ -12921,6 +12896,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"tempdir",
"tokio",
@@ -13377,7 +13353,6 @@ dependencies = [
"bucket_paths",
"cloud_storage",
"concurrency",
"config",
"easyenv",
"errors",
"ffmpeg_utils",
@@ -13459,6 +13434,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"subprocess",
"tempdir",
@@ -13489,6 +13465,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"subprocess",
"tempdir",
@@ -14760,7 +14737,6 @@ dependencies = [
"bucket_paths",
"cloud_storage",
"concurrency",
"config",
"easyenv",
"enums",
"errors",
@@ -14773,6 +14749,7 @@ dependencies = [
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"sqlx",
"tokens",
"tokio",
-3
View File
@@ -94,7 +94,6 @@ members = [
"crates/lib/deprecated/http_server_common", # TODO(bt, 2023-03-20): Move useful pieces to actix_helpers
"crates/lib/deprecated/image_algorithms",
"crates/lib/deprecated/machine_learning/ml_weights_registry",
"crates/lib/deprecated/machine_learning/prompt_engineering",
"crates/lib/deprecated/subprocess_common",
"crates/lib/deprecated/tts_common",
@@ -117,7 +116,6 @@ members = [
# Schema (public): common strongly-typed tokens, ids, payloads, etc.
"crates/schema/public/composite_identifiers",
"crates/schema/public/enums",
"crates/schema/public/enums_public",
"crates/schema/public/reusable_types", # TODO(bt, 2022-12-21): Deprecate
"crates/schema/public/tokens",
@@ -238,7 +236,6 @@ cookie_store = { path = "crates/lib/cookie_store" }
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" }
fal_client = { path = "crates/api_clients/fal_client" }
ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
DROP COLUMN maybe_landing_url;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
ADD COLUMN maybe_landing_url VARCHAR(255) DEFAULT NULL;
@@ -222,8 +222,13 @@ CREATE TABLE users (
was_eagerly_provisioned BOOLEAN NOT NULL DEFAULT false,
-- The referral URL the user arrived from when signing up.
-- This helps us know what marketing is effective.
maybe_referral_url VARCHAR(255) DEFAULT NULL,
-- The actual URL the user first arrived at when signing up, including query params.
-- This helps us know what marketing is effective.
maybe_landing_url VARCHAR(255) DEFAULT NULL,
-- ========== MODERATION DETAILS ==========
-- Different than deleted.
@@ -0,0 +1,22 @@
$kind: http-request
name: Add Wallet Banked Balance
url: "{{api_host}}/v1/moderation/wallet/wallet_2pz2h3m2bdfvna7m3b92ccwwr/add_ba\
nked_balance"
method: POST
headers:
- key: Accept
value: application/json
- key: Session
value: ""
disabled: true
- key: Session
value: ""
disabled: true
body:
type: json
content: |
{
"title": "test alert from thread 2",
"description": "testing testing test"
}
order: 3365370184335612
@@ -0,0 +1,21 @@
$kind: http-request
name: Create Wallet for User
url: "{{api_host}}/v1/moderation/wallet/create_for_user"
method: POST
headers:
- key: Accept
value: application/json
- key: Session
value: ""
disabled: true
- key: Session
value: ""
disabled: true
body:
type: json
content: |
{
"user_token": "user_jg7ng4kdt2x5y", // newuser946
"payments_namespace": "artcraft"
}
order: 4100053395280358
@@ -21,7 +21,6 @@ workspace-build-acceleration.workspace = true
# Internal
enums.workspace = true
enums_public.workspace = true
tokens.workspace = true
# External
@@ -89,7 +89,7 @@ mod tests {
#[test]
fn test_serialization_text_to_image() {
let request = EstimateImageCostRequest {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::TextToImage,
aspect_ratio: None,
@@ -103,7 +103,7 @@ mod tests {
#[test]
fn test_serialization_image_edit() {
let request = EstimateImageCostRequest {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::ImageEdit { count: 2 },
aspect_ratio: None,
@@ -124,7 +124,7 @@ pub struct OmniGenImageModelDetails {
impl Default for OmniGenImageModelDetails {
fn default() -> Self {
Self {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
full_name: None,
text_prompt_supported: None,
text_prompt_max_length: None,
@@ -12,10 +12,14 @@ pub struct StripeArtcraftCreateSubscriptionCheckoutWithUserSignupRequest {
pub cadence: Option<PlanBillingCadence>,
/// Optional: The referral URL the user arrived from when signing up.
/// Optional: The referral URL the user arrived from when first hitting the site, prior to navigation and signing up.
/// The browser can send `document.referrer` to the backend so we know how people are finding us.
/// If the browser doesn't send this parameter, we'll try the `referer` header.
pub maybe_referral_url: Option<String>,
/// Optional: The URL where the user landed when they first arrived, prior to navigation and signing up.
/// The browser can send `window.location.href` to the backend so we know how people are finding us.
pub maybe_landing_url: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema, Debug, Copy, Clone)]
@@ -26,7 +26,6 @@ workspace-build-acceleration.workspace = true
artcraft_api_defs.workspace = true
cloudflare_errors.workspace = true
enums.workspace = true
enums_public.workspace = true
errors.workspace = true
uuid_utils.workspace = true
mimetypes.workspace = true
@@ -15,7 +15,7 @@ use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use enums::by_table::model_weights::weights_types::WeightsType;
use log::debug;
use serde_derive::Deserialize;
use tokens::tokens::batch_generations::BatchGenerationToken;
@@ -193,7 +193,7 @@ pub struct GetMediaFileModeratorFields {
#[derive(Deserialize, Debug)]
pub struct GetMediaFileModelInfo {
pub weight_token: ModelWeightToken,
pub weight_type: PublicWeightsType,
pub weight_type: WeightsType,
pub weight_category: WeightsCategory,
pub title: String,
@@ -8,7 +8,6 @@ use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums::common::generation_provider::GenerationProvider;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
@@ -7,7 +7,6 @@ use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
@@ -8,7 +8,6 @@ use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums::common::generation_provider::GenerationProvider;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
@@ -5,7 +5,6 @@ use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
@@ -5,7 +5,6 @@ use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
@@ -1,6 +1,6 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub enum AudioListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
@@ -0,0 +1,9 @@
use tokens::tokens::characters::CharacterToken;
/// Forward-compatible character reference list.
#[derive(Copy, Clone, Debug)]
pub enum CharacterListRef<'a> {
CharacterTokens(&'a Vec<CharacterToken>),
// In the future, we may have other identifiers for characters.
}
@@ -16,11 +16,11 @@ pub enum CommonImageModel {
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "nano_banana")]
NanaBanana,
NanoBanana,
#[serde(rename = "nano_banana_2")]
NanaBanana2,
NanoBanana2,
#[serde(rename = "nano_banana_pro")]
NanaBananaPro,
NanoBananaPro,
#[serde(rename = "seedream_4")]
Seedream4,
#[serde(rename = "seedream_4p5")]
@@ -1,6 +1,6 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub enum ImageListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
@@ -1,6 +1,6 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub enum ImageRef<'a> {
MediaFileToken(&'a MediaFileToken),
Url(&'a str),
@@ -1,4 +1,5 @@
pub mod audio_list_ref;
pub mod character_list_ref;
pub mod common_aspect_ratio;
pub mod common_image_model;
pub mod common_resolution;
@@ -1,6 +1,6 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub enum VideoListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
@@ -37,7 +37,7 @@ mod tests {
fn estimate_usd_cents(image_batch_count: u16) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::NanaBanana,
model: CommonImageModel::NanoBanana,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
@@ -45,7 +45,7 @@ mod tests {
fn estimate_usd_cents(resolution: Option<CommonResolution>, image_batch_count: u16) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::NanaBanana2,
model: CommonImageModel::NanoBanana2,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
@@ -44,7 +44,7 @@ mod tests {
fn estimate_usd_cents(resolution: Option<CommonResolution>, image_batch_count: u16) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
@@ -0,0 +1,26 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::PlanFalFlux1Dev;
pub(crate) fn estimate_image_cost_fal_flux_1_dev(
plan: &PlanFalFlux1Dev<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.025 per megapixel, billed by rounding up to nearest megapixel.
// Default image sizes are ~1MP (1024x1024), so ~$0.025/image = 3 cents rounded up.
//
// Fal docs:
// Your request will cost $0.025 per megapixel, with images billed by rounding up
// to the nearest megapixel.
//
let cost_per_image: u64 = 3;
let num_images = plan.num_images.as_u64();
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,
}
}
@@ -0,0 +1,26 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::PlanFalFlux1Schnell;
pub(crate) fn estimate_image_cost_fal_flux_1_schnell(
plan: &PlanFalFlux1Schnell<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.003 per megapixel, billed by rounding up to nearest megapixel.
// Default image sizes are ~1MP (1024x1024), so ~$0.003/image = 1 cent rounded up.
//
// Fal docs:
// Your request will cost $0.003 per megapixel, with images billed by rounding up
// to the nearest megapixel.
//
let cost_per_image: u64 = 1;
let num_images = plan.num_images.as_u64();
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,
}
}
@@ -0,0 +1,34 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana::{
FalNbNumImages, PlanFalNanoBanana,
};
pub(crate) fn estimate_image_cost_fal_nano_banana(
plan: &PlanFalNanoBanana<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.039/image (no resolution multiplier — Gemini 25 Flash has no resolution option).
// Rounded to 4 cents per image.
//
// Fal docs:
// Your request will cost $0.039 per image. For $1.00, you can run this model 25 times.
//
let cost_per_image: u64 = 4;
let num_images: u64 = match plan.num_images {
FalNbNumImages::One => 1,
FalNbNumImages::Two => 2,
FalNbNumImages::Three => 3,
FalNbNumImages::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,
}
}
@@ -0,0 +1,44 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::{
FalNb2NumImages, FalNb2Resolution, PlanFalNanaBanana2,
};
pub(crate) fn estimate_image_cost_fal_nano_banana_2(
plan: &PlanFalNanaBanana2<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.08/image base (1K). Multipliers: 0.5K = 0.75x, 2K = 1.5x, 4K = 2x.
// (Stored as USD cents.)
//
// Fal docs:
// Your request will cost $0.08 per image. For $1.00, you can run this model 12 times.
// 2K and 4K outputs will be charged at 1.5 times and 2 times the standard rate, respectively.
// 0.5K (512px) resolution outputs will be charged at 0.75 times the standard rate. If web
// search is used, an additional $0.015 will be charged. If high thinking is used,
// an additional $0.002 will be charged. Note: Pricing is subject to change.
//
let cost_per_image: u64 = match plan.resolution {
Some(FalNb2Resolution::HalfK) => 6, // $0.06 = $0.08 * 0.75
Some(FalNb2Resolution::OneK) => 8, // $0.08 base
Some(FalNb2Resolution::TwoK) => 12, // $0.12 = $0.08 * 1.5
Some(FalNb2Resolution::FourK) => 16, // $0.16 = $0.08 * 2.0
None => 8, // default = 1K base
};
let num_images: u64 = match plan.num_images {
FalNb2NumImages::One => 1,
FalNb2NumImages::Two => 2,
FalNb2NumImages::Three => 3,
FalNb2NumImages::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,
}
}
@@ -42,7 +42,7 @@ mod tests {
fn estimate_usd_cents(resolution: Option<CommonResolution>, image_batch_count: u16) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
provider: Provider::Fal,
prompt: None,
image_inputs: None,
@@ -1 +1,5 @@
pub(crate) mod estimate_image_cost_fal_flux_1_dev;
pub(crate) mod estimate_image_cost_fal_flux_1_schnell;
pub(crate) mod estimate_image_cost_fal_nano_banana;
pub(crate) mod estimate_image_cost_fal_nano_banana_2;
pub(crate) mod estimate_image_cost_fal_nano_banana_pro;
@@ -0,0 +1,49 @@
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::PlanFalFlux1Dev;
use fal_client::requests::webhook::image::edit::enqueue_flux_1_dev_edit_image_webhook::{
enqueue_flux_1_dev_edit_image_webhook, Flux1DevEditImageArgs,
};
use fal_client::requests::webhook::image::text::enqueue_flux_1_dev_text_to_image_webhook::{
enqueue_flux_1_dev_text_to_image_webhook, Flux1DevArgs,
};
pub async fn execute_fal_flux_1_dev(
plan: &PlanFalFlux1Dev<'_>,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if let Some(image_url) = &plan.maybe_image_url {
// Image-to-image mode
let args = Flux1DevEditImageArgs {
prompt: plan.prompt.unwrap_or(""),
image_url: image_url.clone(),
num_images: plan.num_images.to_edit(),
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_flux_1_dev_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
} else {
// Text-to-image mode
let args = Flux1DevArgs {
prompt: plan.prompt.unwrap_or(""),
aspect_ratio: plan.aspect_ratio,
num_images: plan.num_images.to_t2i(),
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_flux_1_dev_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
}))
}
@@ -0,0 +1,49 @@
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::PlanFalFlux1Schnell;
use fal_client::requests::webhook::image::edit::enqueue_flux_1_schnell_edit_image_webhook::{
enqueue_flux_1_schnell_edit_image_webhook, Flux1SchnellEditImageArgs,
};
use fal_client::requests::webhook::image::text::enqueue_flux_1_schnell_text_to_image_webhook::{
enqueue_flux_1_schnell_text_to_image_webhook, Flux1SchnellArgs,
};
pub async fn execute_fal_flux_1_schnell(
plan: &PlanFalFlux1Schnell<'_>,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if let Some(image_url) = &plan.maybe_image_url {
// Image-to-image (redux) mode
let args = Flux1SchnellEditImageArgs {
image_url: image_url.clone(),
num_images: plan.num_images.to_edit(),
image_size: plan.edit_image_size,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_flux_1_schnell_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
} else {
// Text-to-image mode
let args = Flux1SchnellArgs {
prompt: plan.prompt.unwrap_or(""),
aspect_ratio: plan.t2i_aspect_ratio,
num_images: plan.num_images.to_t2i(),
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_flux_1_schnell_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
}))
}
@@ -0,0 +1,72 @@
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana::{
FalNbNumImages, PlanFalNanoBanana,
};
use fal_client::requests::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{
enqueue_gemini_25_flash_edit_webhook, Gemini25FlashEditArgs,
Gemini25FlashEditNumImages,
};
use fal_client::requests::webhook::image::text::enqueue_gemini_25_flash_text_to_image_webhook::{
enqueue_gemini_25_flash_text_to_image_webhook, Gemini25FlashTextToImageArgs,
Gemini25FlashTextToImageNumImages,
};
pub async fn execute_fal_nano_banana(
plan: &PlanFalNanoBanana<'_>,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
// Text-to-image mode
let args = Gemini25FlashTextToImageArgs {
prompt: plan.prompt.unwrap_or(""),
num_images: to_t2i_num_images(plan.num_images),
aspect_ratio: plan.t2i_aspect_ratio,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gemini_25_flash_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
} else {
// Image-edit mode
let args = Gemini25FlashEditArgs {
prompt: plan.prompt.unwrap_or(""),
image_urls: plan.image_urls.clone(),
num_images: to_edit_num_images(plan.num_images),
aspect_ratio: plan.edit_aspect_ratio,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gemini_25_flash_edit_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
}))
}
fn to_t2i_num_images(n: FalNbNumImages) -> Gemini25FlashTextToImageNumImages {
match n {
FalNbNumImages::One => Gemini25FlashTextToImageNumImages::One,
FalNbNumImages::Two => Gemini25FlashTextToImageNumImages::Two,
FalNbNumImages::Three => Gemini25FlashTextToImageNumImages::Three,
FalNbNumImages::Four => Gemini25FlashTextToImageNumImages::Four,
}
}
fn to_edit_num_images(n: FalNbNumImages) -> Gemini25FlashEditNumImages {
match n {
FalNbNumImages::One => Gemini25FlashEditNumImages::One,
FalNbNumImages::Two => Gemini25FlashEditNumImages::Two,
FalNbNumImages::Three => Gemini25FlashEditNumImages::Three,
FalNbNumImages::Four => Gemini25FlashEditNumImages::Four,
}
}
@@ -0,0 +1,92 @@
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::{
FalNb2NumImages, FalNb2Resolution, PlanFalNanaBanana2,
};
use fal_client::requests::webhook::image::edit::enqueue_nano_banana_2_edit_image_webhook::{
enqueue_nano_banana_2_edit_image_webhook, EnqueueNanoBanana2EditImageArgs,
EnqueueNanoBanana2EditImageNumImages, EnqueueNanoBanana2EditImageResolution,
};
use fal_client::requests::webhook::image::text::enqueue_nano_banana_2_text_to_image_webhook::{
enqueue_nano_banana_2_text_to_image_webhook, EnqueueNanoBanana2TextToImageArgs,
EnqueueNanoBanana2TextToImageNumImages, EnqueueNanoBanana2TextToImageResolution,
};
pub async fn execute_fal_nano_banana_2(
plan: &PlanFalNanaBanana2<'_>,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
// Text-to-image mode
let args = EnqueueNanoBanana2TextToImageArgs {
prompt: plan.prompt.unwrap_or(""),
num_images: to_t2i_num_images(plan.num_images),
resolution: plan.resolution.map(to_t2i_resolution),
aspect_ratio: plan.t2i_aspect_ratio,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_nano_banana_2_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
} else {
// Image-edit mode
let args = EnqueueNanoBanana2EditImageArgs {
prompt: plan.prompt.unwrap_or(""),
image_urls: plan.image_urls.clone(),
num_images: to_edit_num_images(plan.num_images),
resolution: plan.resolution.map(to_edit_resolution),
aspect_ratio: plan.edit_aspect_ratio,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_nano_banana_2_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
}))
}
fn to_t2i_num_images(n: FalNb2NumImages) -> EnqueueNanoBanana2TextToImageNumImages {
match n {
FalNb2NumImages::One => EnqueueNanoBanana2TextToImageNumImages::One,
FalNb2NumImages::Two => EnqueueNanoBanana2TextToImageNumImages::Two,
FalNb2NumImages::Three => EnqueueNanoBanana2TextToImageNumImages::Three,
FalNb2NumImages::Four => EnqueueNanoBanana2TextToImageNumImages::Four,
}
}
fn to_edit_num_images(n: FalNb2NumImages) -> EnqueueNanoBanana2EditImageNumImages {
match n {
FalNb2NumImages::One => EnqueueNanoBanana2EditImageNumImages::One,
FalNb2NumImages::Two => EnqueueNanoBanana2EditImageNumImages::Two,
FalNb2NumImages::Three => EnqueueNanoBanana2EditImageNumImages::Three,
FalNb2NumImages::Four => EnqueueNanoBanana2EditImageNumImages::Four,
}
}
fn to_t2i_resolution(r: FalNb2Resolution) -> EnqueueNanoBanana2TextToImageResolution {
match r {
FalNb2Resolution::HalfK => EnqueueNanoBanana2TextToImageResolution::HalfK,
FalNb2Resolution::OneK => EnqueueNanoBanana2TextToImageResolution::OneK,
FalNb2Resolution::TwoK => EnqueueNanoBanana2TextToImageResolution::TwoK,
FalNb2Resolution::FourK => EnqueueNanoBanana2TextToImageResolution::FourK,
}
}
fn to_edit_resolution(r: FalNb2Resolution) -> EnqueueNanoBanana2EditImageResolution {
match r {
FalNb2Resolution::HalfK => EnqueueNanoBanana2EditImageResolution::HalfK,
FalNb2Resolution::OneK => EnqueueNanoBanana2EditImageResolution::OneK,
FalNb2Resolution::TwoK => EnqueueNanoBanana2EditImageResolution::TwoK,
FalNb2Resolution::FourK => EnqueueNanoBanana2EditImageResolution::FourK,
}
}
@@ -1 +1,5 @@
pub mod generate_image_fal_flux_1_dev;
pub mod generate_image_fal_flux_1_schnell;
pub mod generate_image_fal_nano_banana;
pub mod generate_image_fal_nano_banana_2;
pub mod generate_image_fal_nano_banana_pro;
@@ -10,19 +10,24 @@ use crate::errors::client_error::ClientError;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_1_dev::plan_generate_image_artcraft_flux_1_dev;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_1_schnell::plan_generate_image_artcraft_flux_1_schnell;
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::artcraft::plan_generate_image_artcraft_flux_pro_1p1::plan_generate_image_artcraft_flux_pro_1p1;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1_ultra::plan_generate_image_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::plan_generate_image_artcraft_gpt_image_1p5;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::plan_generate_image_artcraft_nano_banana;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_2::plan_generate_image_artcraft_nano_banana_2;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_pro::plan_generate_image_artcraft_nano_banana_pro;
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_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_flux_1_dev::plan_generate_image_fal_flux_1_dev;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::plan_generate_image_fal_flux_1_schnell;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana::plan_generate_image_fal_nano_banana;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::plan_generate_image_fal_nano_banana_2;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_pro::plan_generate_image_fal_nano_banana_pro;
#[derive(Debug)]
pub struct GenerateImageRequest<'a> {
/// Which model to use.
pub model: CommonImageModel,
@@ -86,9 +91,9 @@ impl<'a> GenerateImageRequest<'a> {
CommonImageModel::FluxPro11 => plan_generate_image_artcraft_flux_pro_1p1(self),
CommonImageModel::FluxPro11Ultra => plan_generate_image_artcraft_flux_pro_1p1_ultra(self),
CommonImageModel::GptImage1p5 => plan_generate_image_artcraft_gpt_image_1p5(self),
CommonImageModel::NanaBanana => plan_generate_image_artcraft_nano_banana(self),
CommonImageModel::NanaBanana2 => plan_generate_image_artcraft_nano_banana_2(self),
CommonImageModel::NanaBananaPro => plan_generate_image_artcraft_nano_banana_pro(self),
CommonImageModel::NanoBanana => plan_generate_image_artcraft_nano_banana(self),
CommonImageModel::NanoBanana2 => plan_generate_image_artcraft_nano_banana_2(self),
CommonImageModel::NanoBananaPro => plan_generate_image_artcraft_nano_banana_pro(self),
CommonImageModel::Seedream4 => plan_generate_image_artcraft_seedream_4(self),
CommonImageModel::Seedream4p5 => plan_generate_image_artcraft_seedream_4p5(self),
CommonImageModel::Seedream5Lite => plan_generate_image_artcraft_seedream_5_lite(self),
@@ -99,7 +104,11 @@ impl<'a> GenerateImageRequest<'a> {
fn build_fal(&self) -> Result<ImageGenerationPlan<'_>, ArtcraftRouterError> {
match self.model {
CommonImageModel::NanaBananaPro => plan_generate_image_fal_nano_banana_pro(self),
CommonImageModel::Flux1Dev => plan_generate_image_fal_flux_1_dev(self),
CommonImageModel::Flux1Schnell => plan_generate_image_fal_flux_1_schnell(self),
CommonImageModel::NanoBanana => plan_generate_image_fal_nano_banana(self),
CommonImageModel::NanoBanana2 => plan_generate_image_fal_nano_banana_2(self),
CommonImageModel::NanoBananaPro => plan_generate_image_fal_nano_banana_pro(self),
_ => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "provider",
@@ -13,6 +13,10 @@ use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraf
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_flux_1_dev::estimate_image_cost_fal_flux_1_dev;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_1_schnell::estimate_image_cost_fal_flux_1_schnell;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_nano_banana::estimate_image_cost_fal_nano_banana;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_nano_banana_2::estimate_image_cost_fal_nano_banana_2;
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;
@@ -27,6 +31,10 @@ use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_
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_flux_1_dev::execute_fal_flux_1_dev;
use crate::generate::generate_image::execute::fal::generate_image_fal_flux_1_schnell::execute_fal_flux_1_schnell;
use crate::generate::generate_image::execute::fal::generate_image_fal_nano_banana::execute_fal_nano_banana;
use crate::generate::generate_image::execute::fal::generate_image_fal_nano_banana_2::execute_fal_nano_banana_2;
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;
@@ -43,6 +51,10 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
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_flux_1_dev::PlanFalFlux1Dev;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::PlanFalFlux1Schnell;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana::PlanFalNanoBanana;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::PlanFalNanaBanana2;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_pro::PlanFalNanaBananaPro;
#[derive(Debug)]
@@ -60,6 +72,10 @@ pub enum ImageGenerationPlan<'a> {
ArtcraftSeedream5Lite(PlanArtcraftSeedream5Lite<'a>),
ArtcraftQwenEdit2511Angles(PlanArtcraftQwenEdit2511Angles<'a>),
ArtcraftFlux2LoraAngles(PlanArtcraftFlux2LoraAngles<'a>),
FalFlux1Dev(PlanFalFlux1Dev<'a>),
FalFlux1Schnell(PlanFalFlux1Schnell<'a>),
FalNanoBanana(PlanFalNanoBanana<'a>),
FalNanaBanana2(PlanFalNanaBanana2<'a>),
FalNanaBananaPro(PlanFalNanaBananaPro<'a>),
}
@@ -121,6 +137,22 @@ impl<'a> ImageGenerationPlan<'a> {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_flux_2_lora_angles(plan, artcraft_client).await
}
ImageGenerationPlan::FalFlux1Dev(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_flux_1_dev(plan, fal_client).await
}
ImageGenerationPlan::FalFlux1Schnell(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_flux_1_schnell(plan, fal_client).await
}
ImageGenerationPlan::FalNanoBanana(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_nano_banana(plan, fal_client).await
}
ImageGenerationPlan::FalNanaBanana2(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_nano_banana_2(plan, fal_client).await
}
ImageGenerationPlan::FalNanaBananaPro(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_nano_banana_pro(plan, fal_client).await
@@ -169,6 +201,18 @@ impl<'a> ImageGenerationPlan<'a> {
ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) => {
estimate_image_cost_artcraft_flux_2_lora_angles(plan)
}
ImageGenerationPlan::FalFlux1Dev(plan) => {
estimate_image_cost_fal_flux_1_dev(plan)
}
ImageGenerationPlan::FalFlux1Schnell(plan) => {
estimate_image_cost_fal_flux_1_schnell(plan)
}
ImageGenerationPlan::FalNanoBanana(plan) => {
estimate_image_cost_fal_nano_banana(plan)
}
ImageGenerationPlan::FalNanaBanana2(plan) => {
estimate_image_cost_fal_nano_banana_2(plan)
}
ImageGenerationPlan::FalNanaBananaPro(plan) => {
estimate_image_cost_fal_nano_banana_pro(plan)
}
@@ -1 +1,5 @@
pub mod plan_generate_image_fal_flux_1_dev;
pub mod plan_generate_image_fal_flux_1_schnell;
pub mod plan_generate_image_fal_nano_banana;
pub mod plan_generate_image_fal_nano_banana_2;
pub mod plan_generate_image_fal_nano_banana_pro;
@@ -0,0 +1,158 @@
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 crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use fal_client::requests::webhook::image::edit::enqueue_flux_1_dev_edit_image_webhook::Flux1DevEditImageNumImages;
use fal_client::requests::webhook::image::text::enqueue_flux_1_dev_text_to_image_webhook::{
Flux1DevAspectRatio, Flux1DevNumImages,
};
/// Shared num-images type for both t2i and edit modes.
#[derive(Debug, Clone, Copy)]
pub enum FalFlux1DevNumImages {
One,
Two,
Three,
Four,
}
#[derive(Debug, Clone)]
pub struct PlanFalFlux1Dev<'a> {
pub prompt: Option<&'a str>,
/// Image URL for editing. None = text-to-image mode.
pub maybe_image_url: Option<String>,
pub aspect_ratio: Flux1DevAspectRatio,
pub num_images: FalFlux1DevNumImages,
}
pub fn plan_generate_image_fal_flux_1_dev<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let maybe_image_url = resolve_single_image_url(request.image_inputs)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::FalFlux1Dev(PlanFalFlux1Dev {
prompt: request.prompt,
maybe_image_url,
aspect_ratio,
num_images,
}))
}
fn resolve_single_image_url(
image_inputs: Option<ImageListRef<'_>>,
) -> Result<Option<String>, ArtcraftRouterError> {
match image_inputs {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => {
if urls.is_empty() {
Ok(None)
} else if urls.len() == 1 {
Ok(Some(urls[0].clone()))
} else {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: format!("Flux 1 Dev image-to-image supports exactly 1 image, got {}", urls.len()),
}))
}
}
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Flux1DevAspectRatio, ArtcraftRouterError> {
use Flux1DevAspectRatio as Ar;
match aspect_ratio {
None => Ok(Ar::Square),
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Square),
Some(CommonAspectRatio::Square) => Ok(Ar::Square),
Some(CommonAspectRatio::SquareHd) => Ok(Ar::SquareHd),
Some(CommonAspectRatio::WideFourByThree) => Ok(Ar::LandscapeFourByThree),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::LandscapeSixteenByNine),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Ar::PortraitThreeByFour),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::PortraitNineBySixteen),
Some(CommonAspectRatio::WideFiveByFour) => Ok(Ar::LandscapeFourByThree),
Some(CommonAspectRatio::WideThreeByTwo) => Ok(Ar::LandscapeFourByThree),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Ar::LandscapeSixteenByNine),
Some(CommonAspectRatio::TallFourByFive) => Ok(Ar::PortraitThreeByFour),
Some(CommonAspectRatio::TallTwoByThree) => Ok(Ar::PortraitThreeByFour),
Some(CommonAspectRatio::TallNineByTwentyOne) => Ok(Ar::PortraitNineBySixteen),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Ar::Square),
},
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalFlux1DevNumImages, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(FalFlux1DevNumImages::One),
2 => Ok(FalFlux1DevNumImages::Two),
3 => Ok(FalFlux1DevNumImages::Three),
4 => Ok(FalFlux1DevNumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(FalFlux1DevNumImages::Four),
},
}
}
impl FalFlux1DevNumImages {
pub fn to_t2i(self) -> Flux1DevNumImages {
match self {
Self::One => Flux1DevNumImages::One,
Self::Two => Flux1DevNumImages::Two,
Self::Three => Flux1DevNumImages::Three,
Self::Four => Flux1DevNumImages::Four,
}
}
pub fn to_edit(self) -> Flux1DevEditImageNumImages {
match self {
Self::One => Flux1DevEditImageNumImages::One,
Self::Two => Flux1DevEditImageNumImages::Two,
Self::Three => Flux1DevEditImageNumImages::Three,
Self::Four => Flux1DevEditImageNumImages::Four,
}
}
pub fn as_u64(self) -> u64 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
}
}
}
@@ -0,0 +1,169 @@
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 crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use fal_client::requests::webhook::image::edit::enqueue_flux_1_schnell_edit_image_webhook::{
Flux1SchnellEditImageNumImages, Flux1SchnellEditImageSize,
};
use fal_client::requests::webhook::image::text::enqueue_flux_1_schnell_text_to_image_webhook::{
Flux1SchnellAspectRatio, Flux1SchnellNumImages,
};
/// Shared num-images type for both t2i and edit modes.
#[derive(Debug, Clone, Copy)]
pub enum FalFlux1SchnellNumImages {
One,
Two,
Three,
Four,
}
#[derive(Debug, Clone)]
pub struct PlanFalFlux1Schnell<'a> {
pub prompt: Option<&'a str>,
/// Image URL for editing. None = text-to-image mode.
pub maybe_image_url: Option<String>,
pub t2i_aspect_ratio: Flux1SchnellAspectRatio,
pub edit_image_size: Option<Flux1SchnellEditImageSize>,
pub num_images: FalFlux1SchnellNumImages,
}
pub fn plan_generate_image_fal_flux_1_schnell<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let maybe_image_url = resolve_single_image_url(request.image_inputs)?;
let t2i_aspect_ratio = plan_t2i_aspect_ratio(request.aspect_ratio, strategy)?;
let edit_image_size = plan_edit_image_size(request.aspect_ratio);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::FalFlux1Schnell(PlanFalFlux1Schnell {
prompt: request.prompt,
maybe_image_url,
t2i_aspect_ratio,
edit_image_size,
num_images,
}))
}
fn resolve_single_image_url(
image_inputs: Option<ImageListRef<'_>>,
) -> Result<Option<String>, ArtcraftRouterError> {
match image_inputs {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => {
if urls.is_empty() {
Ok(None)
} else if urls.len() == 1 {
Ok(Some(urls[0].clone()))
} else {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: format!("Flux 1 Schnell redux supports exactly 1 image, got {}", urls.len()),
}))
}
}
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn plan_t2i_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Flux1SchnellAspectRatio, ArtcraftRouterError> {
use Flux1SchnellAspectRatio as Ar;
match aspect_ratio {
None => Ok(Ar::Square),
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto4k) => Ok(Ar::Square),
Some(CommonAspectRatio::Square) => Ok(Ar::Square),
Some(CommonAspectRatio::SquareHd) => Ok(Ar::SquareHd),
Some(CommonAspectRatio::WideFourByThree) => Ok(Ar::LandscapeFourByThree),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::LandscapeSixteenByNine),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Ar::PortraitThreeByFour),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::PortraitNineBySixteen),
Some(CommonAspectRatio::WideFiveByFour) | Some(CommonAspectRatio::WideThreeByTwo) => Ok(Ar::LandscapeFourByThree),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Ar::LandscapeSixteenByNine),
Some(CommonAspectRatio::TallFourByFive) | Some(CommonAspectRatio::TallTwoByThree) => Ok(Ar::PortraitThreeByFour),
Some(CommonAspectRatio::TallNineByTwentyOne) => Ok(Ar::PortraitNineBySixteen),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Ar::Square),
},
}
}
fn plan_edit_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<Flux1SchnellEditImageSize> {
use Flux1SchnellEditImageSize as Size;
match aspect_ratio {
None => None,
Some(CommonAspectRatio::Square) => Some(Size::Square),
Some(CommonAspectRatio::SquareHd) => Some(Size::SquareHd),
Some(CommonAspectRatio::WideFourByThree) => Some(Size::LandscapeFourByThree),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Some(Size::LandscapeSixteenByNine),
Some(CommonAspectRatio::TallThreeByFour) => Some(Size::PortraitThreeByFour),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Some(Size::PortraitNineBySixteen),
_ => Some(Size::Square),
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalFlux1SchnellNumImages, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(FalFlux1SchnellNumImages::One),
2 => Ok(FalFlux1SchnellNumImages::Two),
3 => Ok(FalFlux1SchnellNumImages::Three),
4 => Ok(FalFlux1SchnellNumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(FalFlux1SchnellNumImages::Four),
},
}
}
impl FalFlux1SchnellNumImages {
pub fn to_t2i(self) -> Flux1SchnellNumImages {
match self {
Self::One => Flux1SchnellNumImages::One,
Self::Two => Flux1SchnellNumImages::Two,
Self::Three => Flux1SchnellNumImages::Three,
Self::Four => Flux1SchnellNumImages::Four,
}
}
pub fn to_edit(self) -> Flux1SchnellEditImageNumImages {
match self {
Self::One => Flux1SchnellEditImageNumImages::One,
Self::Two => Flux1SchnellEditImageNumImages::Two,
Self::Three => Flux1SchnellEditImageNumImages::Three,
Self::Four => Flux1SchnellEditImageNumImages::Four,
}
}
pub fn as_u64(self) -> u64 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
}
}
}
@@ -0,0 +1,174 @@
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 crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use fal_client::requests::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::Gemini25FlashEditAspectRatio;
use fal_client::requests::webhook::image::text::enqueue_gemini_25_flash_text_to_image_webhook::Gemini25FlashTextToImageAspectRatio;
/// Intermediate num-images type shared between t2i and edit enums.
#[derive(Debug, Clone, Copy)]
pub enum FalNbNumImages {
One,
Two,
Three,
Four,
}
#[derive(Debug, Clone)]
pub struct PlanFalNanoBanana<'a> {
pub prompt: Option<&'a str>,
/// Image URLs for editing. Empty vec = text-to-image mode.
pub image_urls: Vec<String>,
/// Pre-resolved aspect ratio for text-to-image mode.
pub t2i_aspect_ratio: Option<Gemini25FlashTextToImageAspectRatio>,
/// Pre-resolved aspect ratio for image-edit mode.
pub edit_aspect_ratio: Option<Gemini25FlashEditAspectRatio>,
/// Pre-resolved number of images (1–4, shared for both modes).
pub num_images: FalNbNumImages,
}
pub fn plan_generate_image_fal_nano_banana<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let is_edit_mode = request.image_inputs.is_some();
let image_urls = resolve_image_list_ref(request.image_inputs)?;
let t2i_aspect_ratio = plan_t2i_aspect_ratio(request.aspect_ratio, strategy)?;
let edit_aspect_ratio = plan_edit_aspect_ratio(request.aspect_ratio, is_edit_mode, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::FalNanoBanana(PlanFalNanoBanana {
prompt: request.prompt,
image_urls,
t2i_aspect_ratio,
edit_aspect_ratio,
num_images,
}))
}
fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef<'_>>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls.clone()),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
// Gemini 25 Flash text-to-image supported aspect ratios:
// 1:1, 5:4, 4:3, 3:2, 16:9, 21:9, 4:5, 3:4, 2:3, 9:16
// (no Auto for text-to-image)
fn plan_t2i_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Gemini25FlashTextToImageAspectRatio>, ArtcraftRouterError> {
use Gemini25FlashTextToImageAspectRatio as T2iAr;
match aspect_ratio {
None => Ok(None),
// Auto not valid for text-to-image; fall back to square
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(Some(T2iAr::OneByOne)),
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(T2iAr::OneByOne)),
Some(CommonAspectRatio::WideFiveByFour) => Ok(Some(T2iAr::FiveByFour)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(T2iAr::FourByThree)),
Some(CommonAspectRatio::WideThreeByTwo) => Ok(Some(T2iAr::ThreeByTwo)),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(T2iAr::SixteenByNine)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(T2iAr::TwentyOneByNine)),
Some(CommonAspectRatio::TallFourByFive) => Ok(Some(T2iAr::FourByFive)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(T2iAr::ThreeByFour)),
Some(CommonAspectRatio::TallTwoByThree) => Ok(Some(T2iAr::TwoByThree)),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(T2iAr::NineBySixteen)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(T2iAr::NineBySixteen))
}
},
}
}
// Gemini 25 Flash image-edit supported aspect ratios: same as t2i plus Auto
fn plan_edit_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
is_edit_mode: bool,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Gemini25FlashEditAspectRatio>, ArtcraftRouterError> {
use Gemini25FlashEditAspectRatio as EditAr;
match aspect_ratio {
None => Ok(None),
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => {
if is_edit_mode {
Ok(Some(EditAr::Auto))
} else {
Ok(Some(EditAr::OneByOne))
}
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(EditAr::OneByOne)),
Some(CommonAspectRatio::WideFiveByFour) => Ok(Some(EditAr::FiveByFour)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(EditAr::FourByThree)),
Some(CommonAspectRatio::WideThreeByTwo) => Ok(Some(EditAr::ThreeByTwo)),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(EditAr::SixteenByNine)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(EditAr::TwentyOneByNine)),
Some(CommonAspectRatio::TallFourByFive) => Ok(Some(EditAr::FourByFive)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(EditAr::ThreeByFour)),
Some(CommonAspectRatio::TallTwoByThree) => Ok(Some(EditAr::TwoByThree)),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(EditAr::NineBySixteen)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(EditAr::NineBySixteen))
}
},
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalNbNumImages, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(FalNbNumImages::One),
2 => Ok(FalNbNumImages::Two),
3 => Ok(FalNbNumImages::Three),
4 => Ok(FalNbNumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(FalNbNumImages::Four),
},
}
}
@@ -0,0 +1,202 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
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 crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use fal_client::requests::webhook::image::edit::enqueue_nano_banana_2_edit_image_webhook::EnqueueNanoBanana2EditImageAspectRatio;
use fal_client::requests::webhook::image::text::enqueue_nano_banana_2_text_to_image_webhook::EnqueueNanoBanana2TextToImageAspectRatio;
/// Intermediate resolution type shared between t2i and edit enums.
#[derive(Debug, Clone, Copy)]
pub enum FalNb2Resolution {
HalfK,
OneK,
TwoK,
FourK,
}
/// Intermediate num-images type shared between t2i and edit enums.
#[derive(Debug, Clone, Copy)]
pub enum FalNb2NumImages {
One,
Two,
Three,
Four,
}
#[derive(Debug, Clone)]
pub struct PlanFalNanaBanana2<'a> {
pub prompt: Option<&'a str>,
/// Image URLs for editing. Empty vec = text-to-image mode.
pub image_urls: Vec<String>,
/// Pre-resolved aspect ratio for text-to-image mode.
pub t2i_aspect_ratio: Option<EnqueueNanoBanana2TextToImageAspectRatio>,
/// Pre-resolved aspect ratio for image-edit mode.
pub edit_aspect_ratio: Option<EnqueueNanoBanana2EditImageAspectRatio>,
/// Pre-resolved resolution (shared for both modes).
pub resolution: Option<FalNb2Resolution>,
/// Pre-resolved number of images (1–4, shared for both modes).
pub num_images: FalNb2NumImages,
}
pub fn plan_generate_image_fal_nano_banana_2<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let is_edit_mode = request.image_inputs.is_some();
let image_urls = resolve_image_list_ref(request.image_inputs)?;
let t2i_aspect_ratio = plan_t2i_aspect_ratio(request.aspect_ratio, strategy)?;
let edit_aspect_ratio = plan_edit_aspect_ratio(request.aspect_ratio, is_edit_mode, strategy)?;
let resolution = plan_resolution(request.resolution)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::FalNanaBanana2(PlanFalNanaBanana2 {
prompt: request.prompt,
image_urls,
t2i_aspect_ratio,
edit_aspect_ratio,
resolution,
num_images,
}))
}
fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef<'_>>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls.clone()),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
// NB2 text-to-image supported aspect ratios:
// auto, 1:1, 5:4, 4:3, 3:2, 16:9, 21:9, 4:5, 3:4, 2:3, 9:16
fn plan_t2i_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<EnqueueNanoBanana2TextToImageAspectRatio>, ArtcraftRouterError> {
use EnqueueNanoBanana2TextToImageAspectRatio as T2iAr;
match aspect_ratio {
None => Ok(None),
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(Some(T2iAr::Auto)),
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(T2iAr::OneByOne)),
Some(CommonAspectRatio::WideFiveByFour) => Ok(Some(T2iAr::FiveByFour)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(T2iAr::FourByThree)),
Some(CommonAspectRatio::WideThreeByTwo) => Ok(Some(T2iAr::ThreeByTwo)),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(T2iAr::SixteenByNine)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(T2iAr::TwentyOneByNine)),
Some(CommonAspectRatio::TallFourByFive) => Ok(Some(T2iAr::FourByFive)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(T2iAr::ThreeByFour)),
Some(CommonAspectRatio::TallTwoByThree) => Ok(Some(T2iAr::TwoByThree)),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(T2iAr::NineBySixteen)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(T2iAr::NineBySixteen))
}
},
}
}
// NB2 image-edit supported aspect ratios: same as t2i plus Auto
fn plan_edit_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
is_edit_mode: bool,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<EnqueueNanoBanana2EditImageAspectRatio>, ArtcraftRouterError> {
use EnqueueNanoBanana2EditImageAspectRatio as EditAr;
match aspect_ratio {
None => Ok(None),
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => {
if is_edit_mode {
Ok(Some(EditAr::Auto))
} else {
Ok(Some(EditAr::OneByOne))
}
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(EditAr::OneByOne)),
Some(CommonAspectRatio::WideFiveByFour) => Ok(Some(EditAr::FiveByFour)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(EditAr::FourByThree)),
Some(CommonAspectRatio::WideThreeByTwo) => Ok(Some(EditAr::ThreeByTwo)),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(EditAr::SixteenByNine)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(EditAr::TwentyOneByNine)),
Some(CommonAspectRatio::TallFourByFive) => Ok(Some(EditAr::FourByFive)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(EditAr::ThreeByFour)),
Some(CommonAspectRatio::TallTwoByThree) => Ok(Some(EditAr::TwoByThree)),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(EditAr::NineBySixteen)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(EditAr::NineBySixteen))
}
},
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
) -> Result<Option<FalNb2Resolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(CommonResolution::HalfK) => Ok(Some(FalNb2Resolution::HalfK)),
Some(CommonResolution::OneK) => Ok(Some(FalNb2Resolution::OneK)),
Some(CommonResolution::TwoK) => Ok(Some(FalNb2Resolution::TwoK)),
Some(CommonResolution::FourK) => Ok(Some(FalNb2Resolution::FourK)),
Some(CommonResolution::ThreeK) => Ok(Some(FalNb2Resolution::TwoK)),
Some(CommonResolution::FourEightyP) => Ok(Some(FalNb2Resolution::HalfK)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(FalNb2Resolution::OneK)),
Some(CommonResolution::TenEightyP) => Ok(Some(FalNb2Resolution::OneK)),
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalNb2NumImages, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(FalNb2NumImages::One),
2 => Ok(FalNb2NumImages::Two),
3 => Ok(FalNb2NumImages::Three),
4 => Ok(FalNb2NumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(FalNb2NumImages::Four),
},
}
}
@@ -250,7 +250,7 @@ mod tests {
fn base_fal_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
provider: Provider::Fal,
prompt: Some("a cat in space"),
image_inputs: None,
@@ -71,6 +71,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -71,6 +71,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -74,6 +74,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -69,6 +69,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -20,10 +20,10 @@ pub async fn execute_artcraft_seedance2p0(
reference_image_media_tokens: plan.reference_images.map(|tokens| tokens.to_owned()),
reference_video_media_tokens: plan.reference_videos.map(|tokens| tokens.to_owned()),
reference_audio_media_tokens: plan.reference_audio.map(|tokens| tokens.to_owned()),
reference_character_tokens: plan.reference_characters.map(|tokens| tokens.to_owned()),
aspect_ratio: plan.aspect_ratio,
duration_seconds: plan.duration_seconds,
batch_count: Some(plan.batch_count),
reference_character_tokens: None,
};
let response = seedance_2p0_multi_function_video_gen(
@@ -1,7 +1,8 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
@@ -19,6 +20,7 @@ use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
/// Plan to either (1) generate a video or (2) determine how much it costs to generate that video.
/// This works across multiple providers by shaping a generic "GenerateVideoRequest" into a provider-specific plan.
/// That plan can then be used to return a cost estimate for that given provider or return a struct that can be used to send a real generation request.
#[derive(Debug)]
pub struct GenerateVideoRequest<'a> {
/// Which model to use.
pub model: CommonVideoModel,
@@ -47,6 +49,9 @@ pub struct GenerateVideoRequest<'a> {
/// Reference audio (optional).
pub reference_audio: Option<AudioListRef<'a>>,
/// Reference characters (optional).
pub reference_character_tokens: Option<CharacterListRef<'a>>,
/// The resolution to use
pub resolution: Option<CommonResolution>,
@@ -180,6 +180,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -180,6 +180,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -197,6 +197,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -11,6 +11,7 @@ use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
@@ -21,6 +22,7 @@ pub struct PlanArtcraftSeedance2p0<'a> {
pub reference_images: Option<&'a Vec<MediaFileToken>>,
pub reference_videos: Option<&'a Vec<MediaFileToken>>,
pub reference_audio: Option<&'a Vec<MediaFileToken>>,
pub reference_characters: Option<&'a Vec<CharacterToken>>,
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
pub duration_seconds: Option<u8>,
pub batch_count: Seedance2p0BatchCount,
@@ -49,6 +51,7 @@ pub fn plan_generate_video_artcraft_seedance2p0<'a>(
reference_images,
reference_videos,
reference_audio,
reference_characters: resolve_character_list_ref(request.reference_character_tokens),
aspect_ratio,
duration_seconds,
batch_count,
@@ -56,6 +59,15 @@ pub fn plan_generate_video_artcraft_seedance2p0<'a>(
}))
}
fn resolve_character_list_ref<'a>(
character_list_ref: Option<crate::api::character_list_ref::CharacterListRef<'a>>,
) -> Option<&'a Vec<CharacterToken>> {
match character_list_ref {
None => None,
Some(crate::api::character_list_ref::CharacterListRef::CharacterTokens(tokens)) => Some(tokens),
}
}
fn resolve_image_ref<'a>(
image_ref: Option<ImageRef<'a>>,
) -> Result<Option<&'a MediaFileToken>, ArtcraftRouterError> {
@@ -23,7 +23,7 @@ pub fn get_artcraft_client() -> RouterClient {
pub fn base_image_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
provider: Provider::Artcraft,
prompt: Some("a cat in space"),
image_inputs: None,
@@ -49,7 +49,7 @@ pub fn get_fal_client() -> RouterClient {
pub fn base_fal_image_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
provider: Provider::Fal,
prompt: Some("a cat in space"),
image_inputs: None,
@@ -121,7 +121,7 @@ pub fn base_seedream_5_lite_image_request() -> GenerateImageRequest<'static> {
pub fn base_nano_banana_2_image_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::NanaBanana2,
model: CommonImageModel::NanoBanana2,
provider: Provider::Artcraft,
prompt: Some("a cat in space"),
image_inputs: None,
@@ -139,7 +139,7 @@ pub fn base_nano_banana_2_image_request() -> GenerateImageRequest<'static> {
pub fn base_nano_banana_image_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::NanaBanana,
model: CommonImageModel::NanoBanana,
provider: Provider::Artcraft,
prompt: Some("a cat in space"),
image_inputs: None,
@@ -292,6 +292,7 @@ pub fn base_video_request() -> GenerateVideoRequest<'static> {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -0,0 +1,54 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux1DevEditImageInput {
pub prompt: String,
pub image_url: String,
/// 0.0 - 1.0
/// Default: 0.95
#[serde(skip_serializing_if = "Option::is_none")]
pub strength: Option<f64>,
/// 1 - 4
/// Default: 1
#[serde(skip_serializing_if = "Option::is_none")]
pub num_images: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guidance_scale: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_inference_steps: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_safety_checker: Option<bool>,
/// Options: jpeg, png
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_mode: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux1DevEditImageFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux1DevEditImageOutput {
pub images: Vec<Flux1DevEditImageFile>,
}
pub fn flux_1_dev_edit_image(
params: Flux1DevEditImageInput,
) -> FalRequest<Flux1DevEditImageInput, Flux1DevEditImageOutput> {
FalRequest::new("fal-ai/flux/dev/image-to-image", params)
}
@@ -0,0 +1,48 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux1SchnellEditImageInput {
pub image_url: String,
/// 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>,
/// 1 - 4
/// Default: 1
#[serde(skip_serializing_if = "Option::is_none")]
pub num_images: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_inference_steps: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_safety_checker: Option<bool>,
/// Options: jpeg, png
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_mode: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux1SchnellEditImageFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux1SchnellEditImageOutput {
pub images: Vec<Flux1SchnellEditImageFile>,
}
pub fn flux_1_schnell_edit_image(
params: Flux1SchnellEditImageInput,
) -> FalRequest<Flux1SchnellEditImageInput, Flux1SchnellEditImageOutput> {
FalRequest::new("fal-ai/flux/schnell/redux", params)
}
@@ -1,3 +1,5 @@
pub mod http_flux_1_dev_edit_image;
pub mod http_flux_1_schnell_edit_image;
pub mod http_flux_pro_kontext_max_edit;
pub mod http_gemini_25_flash_edit;
pub mod http_gpt_image_1_edit_image;
@@ -0,0 +1,100 @@
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::edit::http_flux_1_dev_edit_image::{flux_1_dev_edit_image, Flux1DevEditImageInput};
use crate::requests::api::webhook_response::WebhookResponse;
use reqwest::IntoUrl;
pub struct Flux1DevEditImageArgs<'a, U: IntoUrl> {
pub prompt: &'a str,
pub image_url: String,
pub num_images: Flux1DevEditImageNumImages,
// Fulfillment
pub webhook_url: U,
pub api_key: &'a FalApiKey,
}
#[derive(Copy, Clone, Debug)]
pub enum Flux1DevEditImageNumImages {
One,
Two,
Three,
Four,
}
pub async fn enqueue_flux_1_dev_edit_image_webhook<U: IntoUrl>(
args: Flux1DevEditImageArgs<'_, U>
) -> Result<WebhookResponse, FalErrorPlus> {
let num_images = match args.num_images {
Flux1DevEditImageNumImages::One => 1,
Flux1DevEditImageNumImages::Two => 2,
Flux1DevEditImageNumImages::Three => 3,
Flux1DevEditImageNumImages::Four => 4,
};
let request = Flux1DevEditImageInput {
prompt: args.prompt.to_string(),
image_url: args.image_url,
num_images: Some(num_images),
enable_safety_checker: Some(false),
output_format: Some("png".to_string()),
..Default::default()
};
let result = flux_1_dev_edit_image(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::edit::enqueue_flux_1_dev_edit_image_webhook::{
enqueue_flux_1_dev_edit_image_webhook, Flux1DevEditImageArgs, Flux1DevEditImageNumImages,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::GHOST_IMAGE_URL;
#[tokio::test]
#[ignore] // manually run — fires a real Fal API request
async fn test_single_image() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Flux1DevEditImageArgs {
prompt: "make this image look like a watercolor painting",
image_url: GHOST_IMAGE_URL.to_string(),
num_images: Flux1DevEditImageNumImages::One,
api_key: &api_key,
webhook_url: "https://example.com/webhook",
};
let result = enqueue_flux_1_dev_edit_image_webhook(args).await?;
assert!(result.request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real Fal API request
async fn test_batch_two() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Flux1DevEditImageArgs {
prompt: "turn this into a cyberpunk scene with neon lights",
image_url: GHOST_IMAGE_URL.to_string(),
num_images: Flux1DevEditImageNumImages::Two,
api_key: &api_key,
webhook_url: "https://example.com/webhook",
};
let result = enqueue_flux_1_dev_edit_image_webhook(args).await?;
assert!(result.request_id.is_some());
Ok(())
}
}
@@ -0,0 +1,120 @@
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::edit::http_flux_1_schnell_edit_image::{flux_1_schnell_edit_image, Flux1SchnellEditImageInput};
use crate::requests::api::webhook_response::WebhookResponse;
use reqwest::IntoUrl;
pub struct Flux1SchnellEditImageArgs<'a, U: IntoUrl> {
pub image_url: String,
pub num_images: Flux1SchnellEditImageNumImages,
pub image_size: Option<Flux1SchnellEditImageSize>,
// Fulfillment
pub webhook_url: U,
pub api_key: &'a FalApiKey,
}
#[derive(Copy, Clone, Debug)]
pub enum Flux1SchnellEditImageNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
pub enum Flux1SchnellEditImageSize {
Square,
SquareHd,
LandscapeFourByThree,
LandscapeSixteenByNine,
PortraitThreeByFour,
PortraitNineBySixteen,
}
pub async fn enqueue_flux_1_schnell_edit_image_webhook<U: IntoUrl>(
args: Flux1SchnellEditImageArgs<'_, U>
) -> Result<WebhookResponse, FalErrorPlus> {
let num_images = match args.num_images {
Flux1SchnellEditImageNumImages::One => 1,
Flux1SchnellEditImageNumImages::Two => 2,
Flux1SchnellEditImageNumImages::Three => 3,
Flux1SchnellEditImageNumImages::Four => 4,
};
let image_size = args.image_size.map(|s| match s {
Flux1SchnellEditImageSize::Square => "square",
Flux1SchnellEditImageSize::SquareHd => "square_hd",
Flux1SchnellEditImageSize::LandscapeFourByThree => "landscape_4_3",
Flux1SchnellEditImageSize::LandscapeSixteenByNine => "landscape_16_9",
Flux1SchnellEditImageSize::PortraitThreeByFour => "portrait_4_3",
Flux1SchnellEditImageSize::PortraitNineBySixteen => "portrait_16_9",
}.to_string());
let request = Flux1SchnellEditImageInput {
image_url: args.image_url,
num_images: Some(num_images),
image_size,
enable_safety_checker: Some(false),
output_format: Some("png".to_string()),
..Default::default()
};
let result = flux_1_schnell_edit_image(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::edit::enqueue_flux_1_schnell_edit_image_webhook::{
enqueue_flux_1_schnell_edit_image_webhook, Flux1SchnellEditImageArgs,
Flux1SchnellEditImageNumImages, Flux1SchnellEditImageSize,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::GHOST_IMAGE_URL;
#[tokio::test]
#[ignore] // manually run — fires a real Fal API request
async fn test_single_image_no_size() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Flux1SchnellEditImageArgs {
image_url: GHOST_IMAGE_URL.to_string(),
num_images: Flux1SchnellEditImageNumImages::One,
image_size: None,
api_key: &api_key,
webhook_url: "https://example.com/webhook",
};
let result = enqueue_flux_1_schnell_edit_image_webhook(args).await?;
assert!(result.request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real Fal API request
async fn test_with_landscape_size() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Flux1SchnellEditImageArgs {
image_url: GHOST_IMAGE_URL.to_string(),
num_images: Flux1SchnellEditImageNumImages::Two,
image_size: Some(Flux1SchnellEditImageSize::LandscapeSixteenByNine),
api_key: &api_key,
webhook_url: "https://example.com/webhook",
};
let result = enqueue_flux_1_schnell_edit_image_webhook(args).await?;
assert!(result.request_id.is_some());
Ok(())
}
}
@@ -1,3 +1,5 @@
pub mod enqueue_flux_1_dev_edit_image_webhook;
pub mod enqueue_flux_1_schnell_edit_image_webhook;
pub mod enqueue_bytedance_seedream_v4_edit_image_webhook;
pub mod enqueue_bytedance_seedream_v4p5_edit_image_webhook;
pub mod enqueue_bytedance_seedream_v5_lite_edit_image_webhook;
@@ -22,7 +22,6 @@ workspace-build-acceleration = { workspace = true }
# Internal
bucket_paths.workspace = true
cloud_storage.workspace = true
config = { path = "../../../lib/deprecated/config" }
easyenv = { path = "../../../lib/easyenv" }
enums.workspace = true
errors.workspace = true
@@ -31,6 +30,7 @@ hashing = { path = "../../../lib/files/hashing" }
uuid_utils.workspace = true
mimetypes.workspace = true
mysql_queries = { path = "../../../schema/database/mysql_queries" }
shared_env_var_config.workspace = true
storyteller_root = { path = "../../../lib/storyteller_root" }
tokens.workspace = true
@@ -3,7 +3,8 @@ use log::info;
use sqlx::{MySql, Pool};
use sqlx::mysql::MySqlPoolOptions;
use config::shared_constants::{DEFAULT_MYSQL_CONNECTION_STRING, DEFAULT_RUST_LOG};
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
use errors::AnyhowResult;
use crate::cli_args::parse_cli_args;
@@ -25,10 +26,7 @@ pub async fn main() -> AnyhowResult<()> {
// NB: Read secrets (eg. ACCESS_KEY)
easyenv::from_filename(".env-secrets")?;
let db_connection_string =
easyenv::get_env_string_or_default(
"MYSQL_URL",
DEFAULT_MYSQL_CONNECTION_STRING);
let db_connection_string = env_get_mysql_connection_string_or_default();
let pool = MySqlPoolOptions::new()
.max_connections(easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 3)?)
@@ -63,6 +63,7 @@ async fn seed_user(
ip_address: "127.0.0.1",
maybe_source: None,
maybe_referral_url: None,
maybe_landing_url: None,
maybe_user_token: maybe_user_token.as_ref(),
}).await.map_err(|err| anyhow!("err: {:?}", err))?;
@@ -22,13 +22,13 @@ workspace-build-acceleration = { workspace = true }
# Internal
bucket_paths.workspace = true
cloud_storage.workspace = true
config = { path = "../../../lib/deprecated/config" }
easyenv = { path = "../../../lib/easyenv" }
enums.workspace = true
errors.workspace = true
filesys.workspace = true
mimetypes.workspace = true
mysql_queries = { path = "../../../schema/database/mysql_queries" }
shared_env_var_config.workspace = true
tokens.workspace = true
# External
@@ -5,7 +5,8 @@ use sqlx::mysql::MySqlPoolOptions;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use cloud_storage::bucket_client::BucketClient;
use config::shared_constants::{DEFAULT_MYSQL_CONNECTION_STRING, DEFAULT_RUST_LOG};
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use errors::{AnyhowResult, bail};
@@ -26,10 +27,7 @@ pub async fn main() -> AnyhowResult<()> {
// NB: Read secrets (eg. ACCESS_KEY)
easyenv::from_filename(".env-secrets")?;
let db_connection_string =
easyenv::get_env_string_or_default(
"MYSQL_URL",
DEFAULT_MYSQL_CONNECTION_STRING);
let db_connection_string = env_get_mysql_connection_string_or_default();
info!("Connecting to MySQL...");
@@ -21,7 +21,7 @@ workspace-build-acceleration = { workspace = true }
# Internal
bucket_paths.workspace = true
config = { path = "../../../lib/deprecated/config" }
shared_env_var_config.workspace = true
easyenv = { path = "../../../lib/easyenv" }
elasticsearch_schema = { path = "../../../schema/database/elasticsearch_schema" }
enums = { path = "../../../schema/public/enums" }
@@ -14,7 +14,7 @@ use sqlx::{MySql, Pool};
use std::collections::HashSet;
use std::iter::FromIterator;
use config::shared_constants::DEFAULT_RUST_LOG;
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
use elasticsearch_schema::searches::search_tts_models::search_tts_models;
use errors::AnyhowResult;
@@ -20,7 +20,7 @@ normal = ["workspace-build-acceleration"]
workspace-build-acceleration = { workspace = true }
# Internal
config = { path = "../../../lib/deprecated/config" }
shared_env_var_config.workspace = true
easyenv = { path = "../../../lib/easyenv" }
errors = { path = "../../../lib/errors" }
@@ -20,7 +20,7 @@ use std::time::Duration;
use log::info;
use config::shared_constants::DEFAULT_RUST_LOG;
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
use errors::AnyhowResult;
use crate::delete_pods_threaded::delete_pods_threaded;
@@ -22,7 +22,7 @@ workspace-build-acceleration.workspace = true
# Internal
bucket_paths.workspace = true
cloud_storage.workspace = true
config = { path = "../../../lib/deprecated/config" }
shared_env_var_config.workspace = true
easyenv = { path = "../../../lib/easyenv" }
enums.workspace = true
errors.workspace = true
@@ -10,7 +10,7 @@ use sqlx::{MySql, Pool};
use sqlx::mysql::MySqlPoolOptions;
use cloud_storage::bucket_client::BucketClient;
use config::shared_constants::DEFAULT_RUST_LOG;
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
use errors::AnyhowResult;
use crate::cli_args::{Action, parse_cli_args};
@@ -23,10 +23,10 @@ pub async fn handle_image_edit_artcraft(
match model {
ImageEditModel::FluxProKontextMax => handle_artcraft_flux_kontext_edit(request, app, app_data_root, app_env_configs, storyteller_creds_manager).await,
ImageEditModel::Gemini25Flash | ImageEditModel::NanoBanana => {
handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBanana, GenerationModel::NanoBanana).await
handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBanana, GenerationModel::NanoBanana).await
},
ImageEditModel::NanoBanana2 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBanana2, GenerationModel::NanoBanana2).await,
ImageEditModel::NanoBananaPro => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBananaPro, GenerationModel::NanoBananaPro).await,
ImageEditModel::NanoBanana2 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBanana2, GenerationModel::NanoBanana2).await,
ImageEditModel::NanoBananaPro => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBananaPro, GenerationModel::NanoBananaPro).await,
ImageEditModel::GptImage1 => handle_artcraft_gpt_image_1_edit(request, app, app_data_root, app_env_configs, storyteller_creds_manager).await,
ImageEditModel::GptImage1p5 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage1p5, GenerationModel::GptImage1p5).await,
ImageEditModel::Seedream4 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4, GenerationModel::Seedream4).await,
@@ -3,12 +3,14 @@ use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_comma
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::generation_events::common::GenerationModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_router::api::audio_list_ref::AudioListRef;
use artcraft_router::api::character_list_ref::CharacterListRef;
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::video_list_ref::VideoListRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::api::video_list_ref::VideoListRef;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
use artcraft_router::client::router_client::RouterClient;
@@ -16,7 +18,6 @@ use artcraft_router::generate::generate_video::generate_video_request::GenerateV
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use log::{error, info};
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
pub(super) async fn handle_artcraft_seedance_2p0(
request: &EnqueueImageToVideoRequest,
@@ -42,6 +43,7 @@ pub(super) async fn handle_artcraft_seedance_2p0(
reference_images,
reference_videos: request.reference_video_media_tokens.as_ref().map(VideoListRef::MediaFileTokens),
reference_audio: request.reference_audio_media_tokens.as_ref().map(AudioListRef::MediaFileTokens),
reference_character_tokens: request.reference_character_tokens.as_ref().map(CharacterListRef::CharacterTokens),
resolution: None,
aspect_ratio: request.aspect_ratio,
duration_seconds: request.duration_seconds,
@@ -3,6 +3,7 @@ use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_comma
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::generation_events::common::GenerationModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use artcraft_router::api::character_list_ref::CharacterListRef;
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::provider::Provider;
@@ -40,6 +41,7 @@ pub(super) async fn handle_artcraft_video_via_router(
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: request.reference_character_tokens.as_ref().map(CharacterListRef::CharacterTokens),
resolution: None,
aspect_ratio: request.aspect_ratio,
duration_seconds: request.duration_seconds,
@@ -29,6 +29,7 @@ use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
use tauri::{AppHandle, State};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
/// This is used in the Tauri command bridge.
@@ -123,6 +124,10 @@ pub struct EnqueueImageToVideoRequest {
/// Reference audio for the video generation (e.g. voices, lip sync, music, etc.)
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional character tokens to reference in the prompt.
/// Characters are referenced in prompts as @CharacterName.
pub reference_character_tokens: Option<Vec<CharacterToken>>,
/// Optional.
/// Text prompt used to direct the video.
pub prompt: Option<String>,
@@ -38,10 +38,10 @@ pub async fn handle_text_to_image_artcraft(
TextToImageModel::GptImage1 => handle_artcraft_gpt_image_1_text_to_image(request, app_env_configs, storyteller_creds_manager).await,
TextToImageModel::GptImage1p5 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage1p5, GenerationModel::GptImage1p5).await,
TextToImageModel::Gemini25Flash | TextToImageModel::NanoBanana => {
handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBanana, GenerationModel::NanoBanana).await
handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBanana, GenerationModel::NanoBanana).await
},
TextToImageModel::NanoBanana2 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBanana2, GenerationModel::NanoBanana2).await,
TextToImageModel::NanoBananaPro => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBananaPro, GenerationModel::NanoBananaPro).await,
TextToImageModel::NanoBanana2 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBanana2, GenerationModel::NanoBanana2).await,
TextToImageModel::NanoBananaPro => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBananaPro, GenerationModel::NanoBananaPro).await,
TextToImageModel::Seedream4 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4, GenerationModel::Seedream4).await,
TextToImageModel::Seedream4p5 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4p5, GenerationModel::Seedream4p5).await,
TextToImageModel::Seedream5Lite => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream5Lite, GenerationModel::Seedream5Lite).await,
+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.20.0";
pub const ARTCRAFT_VERSION: &str = "0.24.0";
+7 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.20.0",
"version": "0.24.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -14,6 +14,10 @@
"windows": [
],
"security": {
"assetProtocol": {
"enable": true,
"scope": ["**"]
},
"csp": {
"default-src": [
"'self'",
@@ -32,6 +36,8 @@
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"asset:",
"https://asset.localhost",
"blob:",
"data:",
"http://ipc.localhost",
+4 -4
View File
@@ -1,18 +1,17 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.20.0",
"version": "0.24.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "nx run artcraft:dev",
"beforeBuildCommand": "nx run artcraft:build"
"beforeBuildCommand": "npx run artcraft:build"
},
"app": {
"withGlobalTauri": true,
"windows": [
],
"windows": [],
"security": {
"assetProtocol": {
"enable": true,
@@ -37,6 +36,7 @@
"'unsafe-eval'",
"'unsafe-inline'",
"asset:",
"https://asset.localhost",
"blob:",
"data:",
"http://ipc.localhost",
@@ -6,12 +6,11 @@
use actix_web::cookie::time::OffsetDateTime;
use actix_web::cookie::{Cookie, SameSite};
use actix_web::HttpRequest;
use anyhow::anyhow;
use log::warn;
use crate::sessions::http_user_session_payload::HttpUserSessionPayload;
use crate::sessions::payload_signer::HttpUserSessionPayloadSigner;
use errors::AnyhowResult;
use crate::sessions::http_user_session_payload_signer::HttpUserSessionPayloadSigner;
use crate::sessions::http_user_session_payload_error::HttpUserSessionPayloadError;
use tokens::tokens::user_sessions::UserSessionToken;
use tokens::tokens::users::UserToken;
@@ -31,19 +30,41 @@ pub struct HttpUserSessionManager {
}
impl HttpUserSessionManager {
pub fn new(cookie_domain: &str, hmac_secret: &str) -> AnyhowResult<Self> {
pub fn new(cookie_domain: &str, hmac_secret: &str) -> Result<Self, HttpUserSessionPayloadError> {
let payload_signer = HttpUserSessionPayloadSigner::new(hmac_secret)
.map_err(|e| {
warn!("Failed to construct HttpUserSessionPayloadSigner: {}", e);
e
})?;
Ok(Self {
cookie_domain: cookie_domain.to_string(),
payload_signer: HttpUserSessionPayloadSigner::new(hmac_secret)?,
payload_signer,
})
}
pub fn encode_session_payload(&self, session_token: &UserSessionToken, user_token: &UserToken) -> AnyhowResult<String> {
pub fn encode_session_payload(
&self,
session_token: &UserSessionToken,
user_token: &UserToken,
) -> Result<String, HttpUserSessionPayloadError> {
self.payload_signer.encode(session_token, user_token)
.map_err(|e| {
warn!("Failed to encode session payload: {}", e);
e
})
}
pub fn create_cookie(&self, session_token: &UserSessionToken, user_token: &UserToken) -> AnyhowResult<Cookie> {
let jwt_string = self.payload_signer.encode(session_token, user_token)?;
pub fn create_cookie(
&self,
session_token: &UserSessionToken,
user_token: &UserToken,
) -> Result<Cookie, HttpUserSessionPayloadError> {
let jwt_string = self.payload_signer.encode(session_token, user_token)
.map_err(|e| {
warn!("Failed to encode session cookie payload: {}", e);
e
})?;
let make_secure = !self.cookie_domain.to_lowercase().contains("jungle.horse")
&& !self.cookie_domain.to_lowercase().contains("localhost");
@@ -76,8 +97,8 @@ impl HttpUserSessionManager {
pub fn decode_session_payload_from_request(
&self,
request: &HttpRequest
) -> AnyhowResult<Option<HttpUserSessionPayload>>
request: &HttpRequest,
) -> Result<Option<HttpUserSessionPayload>, HttpUserSessionPayloadError>
{
let signed_session_payload = self.session_payload_from_request(request)?;
@@ -88,8 +109,8 @@ impl HttpUserSessionManager {
match self.payload_signer.decode(&signed_session_payload) {
Err(e) => {
warn!("Session cookie decode error: {:?}", e);
Err(anyhow!("Could not decode session cookie: {:?}", e))
warn!("Session cookie decode error: {}", e);
Err(e)
},
Ok(payload) => Ok(Some(payload)),
}
@@ -97,7 +118,10 @@ impl HttpUserSessionManager {
// NB: THIS IS ONLY FOR A QUICK HACK FOR FREAKING CORS UGH
// THIS IS A HUGE STUPID SECURITY VULN. DAMNIT GOOGLE DAMNIT CORS.
pub fn check_and_return_session_token_decodes(&self, request: &HttpRequest) -> AnyhowResult<Option<String>> {
pub fn check_and_return_session_token_decodes(
&self,
request: &HttpRequest,
) -> Result<Option<String>, HttpUserSessionPayloadError> {
let signed_session_payload = self.session_payload_from_request(request)?;
let signed_session_payload = match signed_session_payload {
@@ -107,8 +131,8 @@ impl HttpUserSessionManager {
match self.payload_signer.decode(&signed_session_payload) {
Err(e) => {
warn!("Session cookie decode error: {:?}", e);
return Err(anyhow!("Could not decode session cookie: {:?}", e));
warn!("Session cookie decode error: {}", e);
return Err(e);
},
Ok(_payload) => {}, // Good! We'll just discard this.
}
@@ -116,15 +140,23 @@ impl HttpUserSessionManager {
Ok(Some(signed_session_payload))
}
fn session_payload_from_request(&self, request: &HttpRequest) -> AnyhowResult<Option<String>> {
let mut signed_session_payload= request.cookie(SESSION_COOKIE_NAME)
fn session_payload_from_request(
&self,
request: &HttpRequest,
) -> Result<Option<String>, HttpUserSessionPayloadError> {
let mut signed_session_payload = request.cookie(SESSION_COOKIE_NAME)
.map(|cookie| cookie.value().to_string());
if signed_session_payload.is_none() {
signed_session_payload = request.headers().get(SESSION_HEADER_NAME)
.map(|header| header.to_str())
.transpose()?
.map(|payload| payload.to_string());
let maybe_header = request.headers().get(SESSION_HEADER_NAME);
if let Some(header) = maybe_header {
let header_str = header.to_str()
.map_err(|e| {
warn!("Failed to read session HTTP header as str: {}", e);
HttpUserSessionPayloadError::HttpSessionHeaderError(e.to_string())
})?;
signed_session_payload = Some(header_str.to_string());
}
}
Ok(signed_session_payload)
@@ -0,0 +1,30 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use jwt_signer::jwt_signer_error::JwtSignerError;
#[derive(Debug)]
pub enum HttpUserSessionPayloadError {
/// Error reading HTTP header
HttpSessionHeaderError(String),
/// Error encoding, decoding, or constructing the JWT signer.
JwtSigner(JwtSignerError),
}
impl Display for HttpUserSessionPayloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::HttpSessionHeaderError(msg) => write!(f, "HTTP session header error: {}", msg),
Self::JwtSigner(e) => write!(f, "JWT signer error: {}", e),
}
}
}
impl Error for HttpUserSessionPayloadError {}
impl From<JwtSignerError> for HttpUserSessionPayloadError {
fn from(err: JwtSignerError) -> Self {
Self::JwtSigner(err)
}
}
@@ -1,8 +1,8 @@
use std::collections::BTreeMap;
use crate::sessions::http_user_session_payload::HttpUserSessionPayload;
use crate::sessions::http_user_session_payload_error::HttpUserSessionPayloadError;
use jwt_signer::jwt_signer::JwtSigner;
use errors::AnyhowResult;
use tokens::tokens::user_sessions::UserSessionToken;
use tokens::tokens::users::UserToken;
@@ -21,14 +21,18 @@ pub struct HttpUserSessionPayloadSigner {
}
impl HttpUserSessionPayloadSigner {
pub fn new(hmac_secret: &str) -> AnyhowResult<Self> {
pub fn new(hmac_secret: &str) -> Result<Self, HttpUserSessionPayloadError> {
Ok(Self {
jwt_signer: JwtSigner::new(hmac_secret)?
jwt_signer: JwtSigner::new(hmac_secret)?,
})
}
pub fn encode(&self, session_token: &UserSessionToken, user_token: &UserToken) -> AnyhowResult<String> {
let mut claims = BTreeMap::new();
pub fn encode(
&self,
session_token: &UserSessionToken,
user_token: &UserToken,
) -> Result<String, HttpUserSessionPayloadError> {
let mut claims: BTreeMap<&str, &str> = BTreeMap::new();
let payload_version = PAYLOAD_VERSION.to_string();
claims.insert("session_token", session_token.as_str());
@@ -36,12 +40,14 @@ impl HttpUserSessionPayloadSigner {
claims.insert("version", &payload_version);
let jwt_string = self.jwt_signer.claims_to_jwt(&claims)?;
Ok(jwt_string)
}
pub fn decode(&self, session_payload_contents: &str) -> AnyhowResult<HttpUserSessionPayload> {
let claims = self.jwt_signer.jwt_to_claims(&session_payload_contents)?;
pub fn decode(
&self,
session_payload_contents: &str,
) -> Result<HttpUserSessionPayload, HttpUserSessionPayloadError> {
let claims = self.jwt_signer.jwt_to_claims(session_payload_contents)?;
let session_token = claims["session_token"].clone();
let maybe_user_token = claims.get("user_token")
@@ -56,7 +62,7 @@ impl HttpUserSessionPayloadSigner {
#[cfg(test)]
mod tests {
use crate::sessions::payload_signer::HttpUserSessionPayloadSigner;
use crate::sessions::http_user_session_payload_signer::HttpUserSessionPayloadSigner;
use tokens::tokens::user_sessions::UserSessionToken;
use tokens::tokens::users::UserToken;
@@ -1,3 +1,4 @@
pub mod http_user_session_manager;
pub mod http_user_session_payload;
pub mod payload_signer;
pub mod http_user_session_payload_error;
pub mod http_user_session_payload_signer;
+36 -5
View File
@@ -1,11 +1,7 @@
//! Probably should be in its own "static_config" crate.
use crate::is_bad_download_url::is_bad_download_url;
use url::{Host, Url};
/// Reports if the URL is inappropriate for downloading as a TTS model, vocoder, etc.
/// DEPRECATE:
pub fn is_bad_tts_model_download_url(url: &str) -> anyhow::Result<bool> {
// TODO: Just use this.
is_bad_download_url(url)
}
@@ -13,6 +9,41 @@ pub fn is_bad_model_weights_download_url(url: &str) -> anyhow::Result<bool> {
is_bad_download_url(url)
}
fn is_bad_download_url(url: &str) -> anyhow::Result<bool> {
if url.trim().is_empty() {
return Ok(true);
}
let looks_malicious = url.contains("\"")
|| url.contains("\'")
|| url.contains("\\");
if looks_malicious {
return Ok(true);
}
let url = Url::parse(url)?;
match url.host() {
Some(Host::Domain(domain)) => {
let domain = domain.to_lowercase();
let bad_host = domain.contains("fb.watch")
|| domain.contains("tiktok.com")
|| domain.contains("vm.tiktok.com")
|| domain.contains("youtu.be")
|| domain.contains("youtube.com");
if bad_host {
return Ok(true)
}
}
_ => {},
}
Ok(false)
}
#[cfg(test)]
mod tests {
use crate::bad_urls::is_bad_tts_model_download_url;
@@ -1,21 +1,8 @@
use crate::shared_constants::DEFAULT_REDIS_DATABASE_0_CONNECTION_STRING;
use crate::shared_constants::DEFAULT_REDIS_DATABASE_1_CONNECTION_STRING;
// TODO: Move more shared configs here.
/// Environment variables shared between one or more jobs or the main server.
/// These do not have to have the same value for each instance, but they behave
/// similarly across all usages.
pub struct CommonEnv {
/// The Redis connection string.
/// This has FakeYou voice stats, rate limiters, etc.
pub redis_0_connection_string: String,
/// The Redis connection string.
/// This has OBS/Twitch related functionality
pub redis_1_connection_string: String,
/// The amount of time to wait between job batches (not individual jobs).
/// This prevents the outer loop of querying batches from flooding the DB.
/// (In theory, there's work within jobs that prevents rapidly pegging the DB.)
@@ -41,10 +28,6 @@ impl CommonEnv {
pub fn read_from_env() -> anyhow::Result<Self> {
Ok(Self {
redis_0_connection_string: easyenv::get_env_string_or_default("REDIS_0_URL",
DEFAULT_REDIS_DATABASE_0_CONNECTION_STRING),
redis_1_connection_string: easyenv::get_env_string_or_default("REDIS_1_URL",
DEFAULT_REDIS_DATABASE_1_CONNECTION_STRING),
job_batch_wait_millis: easyenv::get_env_num("JOB_BATCH_WAIT_MILLIS", 100)?,
job_max_attempts: easyenv::get_env_num("JOB_MAX_ATTEMPTS", 3)?,
job_batch_size: easyenv::get_env_num("JOB_BATCH_SIZE", 10)?,
-2
View File
@@ -1,5 +1,3 @@
pub mod bad_urls;
pub mod common_env;
pub mod is_bad_download_url;
pub mod is_bad_video_download_url;
pub mod shared_constants;
@@ -1,23 +0,0 @@
//! Shared constants that all binaries use.
//! Or at least that deserve extremely high visibility.
/// NB: `sqlx::query` is spammy and dumps all queries as "info"-level log lines.
/// NB: `hyper::proto::h1::io` is incredibly spammy and logs every chunk of bytes in very large files being downloaded.
pub const DEFAULT_RUST_LOG: &str = concat!(
"actix_web=info,",
"sqlx::query=warn,",
"hyper::proto::h1::io=warn,",
"storyteller_web::threads::db_health_checker_thread::db_health_checker_thread=warn,",
"http_server_common::request::get_request_ip=info,", // Debug spams Rust logs
"info", // Default level for everything else
);
/// The default Redis connection string for use in development
pub const DEFAULT_REDIS_DATABASE_0_CONNECTION_STRING: &str = "redis://localhost/0";
/// The default Redis connection string for use in development
/// Database 1
pub const DEFAULT_REDIS_DATABASE_1_CONNECTION_STRING: &str = "redis://localhost/1";
/// The default MySql connection string for use in development
pub const DEFAULT_MYSQL_CONNECTION_STRING: &str = "mysql://storyteller:password@localhost/storyteller";
@@ -1,27 +0,0 @@
[package]
name = "prompt_engineering"
version = "0.1.0"
edition = "2021"
[lib]
path = "src/lib.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# Hackari workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
#errors = { path = "../errors" }
# External
once_cell = "1.18.0"
regex = "1.5.4"
[dev-dependencies]
speculoos.workspace = true
@@ -1,100 +0,0 @@
use once_cell::sync::Lazy;
use regex::Regex;
use crate::keywords::contains_children_keyword::contains_children_keyword;
use crate::keywords::contains_potential_minor_keyword::contains_potential_minor_keyword;
use crate::keywords::contains_racist_keyword::contains_racist_keyword;
use crate::keywords::contains_sex_keyword::contains_sex_keyword;
use crate::regexes::minor_regex::lowercase_mentions_underage;
use crate::regexes::nsfw_regex::nsfw_regex;
use crate::regexes::potential_minor_regex::potential_minor_regex;
/// Classification of a text prompt
pub struct PromptClassification {
pub prompt_references_children: bool,
pub prompt_references_potential_minors: bool,
pub prompt_references_sex: bool,
pub prompt_references_racism: bool,
pub prompt_references_violence: bool,
}
impl PromptClassification {
pub fn is_abusive(&self) -> bool {
self.prompt_references_racism || self.is_child_abuse()
}
pub fn is_child_abuse(&self) -> bool {
self.prompt_references_children && ( self.prompt_references_sex || self.prompt_references_violence )
}
}
pub fn classify_prompt(text_prompt: &str) -> PromptClassification {
let text_prompt = text_prompt.to_lowercase();
let prompt_tokens_alpha = SPACES_REGEX.split(&text_prompt)
.map(|term| term.trim().to_string())
.map(|term| term.chars().filter(|c| c.is_alphanumeric()).collect::<String>())
.collect::<Vec<String>>();
let prompt_tokens_lower = SPACES_REGEX.split(&text_prompt)
.map(|term| term.trim().to_string())
.collect::<Vec<String>>();
let mut prompt_tokens= Vec::with_capacity(prompt_tokens_alpha.len() + prompt_tokens_lower.len());
prompt_tokens.extend(prompt_tokens_alpha);
prompt_tokens.extend(prompt_tokens_lower);
let prompt_references_children = contains_children_keyword(&prompt_tokens) || lowercase_mentions_underage(&text_prompt);
let prompt_references_potential_minors = contains_potential_minor_keyword(&prompt_tokens) || potential_minor_regex(&text_prompt);
let prompt_references_sex = contains_sex_keyword(&prompt_tokens) || nsfw_regex(&text_prompt);
let prompt_references_racism = contains_racist_keyword(&prompt_tokens);
let prompt_references_violence = false; // TODO
PromptClassification {
prompt_references_children,
prompt_references_potential_minors,
prompt_references_sex,
prompt_references_racism,
prompt_references_violence,
}
}
static SPACES_REGEX : Lazy<Regex> = Lazy::new(|| {
// NB: Includes non-ascii, such as ",".
Regex::new(r"[\s,;,]+").expect("regex should be valid")
});
#[cfg(test)]
mod tests {
use speculoos::asserting;
use crate::classify_prompt::SPACES_REGEX;
fn regex_split(text: &str) -> Vec<&str> {
SPACES_REGEX.split(text).map(|s| s.trim()).collect()
}
#[test]
fn test_regex() {
asserting("regex works")
.that(&regex_split("foo,bar,baz"))
.is_equal_to(vec!["foo", "bar", "baz"]);
asserting("regex works")
.that(&regex_split("foo;bar;baz"))
.is_equal_to(vec!["foo", "bar", "baz"]);
asserting("regex works")
.that(&regex_split("foo, bar, baz"))
.is_equal_to(vec!["foo", "bar", "baz"]);
asserting("regex works")
.that(&regex_split("foo\n\nbar baz"))
.is_equal_to(vec!["foo", "bar", "baz"]);
}
}
@@ -1,16 +0,0 @@
use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS : &str = include_str!("../../../../../../../includes/binary_includes/safety/dictionary_children_terms.txt");
pub (crate) fn contains_children_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
TERMS.lines()
.map(|line| line.trim().to_lowercase())
.filter(|line| !line.is_empty() && !line.starts_with("#"))
.collect::<HashSet<String>>()
});
prompt_tokens.iter().any(|term| TERM_DICTIONARY.contains(term))
}
@@ -1,16 +0,0 @@
use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS: &str = include_str!("../../../../../../../includes/binary_includes/safety/dictionary_potential_minor_terms.txt");
pub (crate) fn contains_potential_minor_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY: Lazy<HashSet<String>> = Lazy::new(|| {
TERMS.lines()
.map(|line| line.trim().to_lowercase())
.filter(|line| !line.is_empty() && !line.starts_with("#"))
.collect::<HashSet<String>>()
});
prompt_tokens.iter().any(|term| TERM_DICTIONARY.contains(term))
}
@@ -1,16 +0,0 @@
use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS : &str = include_str!("../../../../../../../includes/binary_includes/safety/dictionary_racist_terms.txt");
pub (crate) fn contains_racist_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
TERMS.lines()
.map(|line| line.trim().to_lowercase())
.filter(|line| !line.is_empty() && !line.starts_with("#"))
.collect::<HashSet<String>>()
});
prompt_tokens.iter().any(|term| TERM_DICTIONARY.contains(term))
}
@@ -1,15 +0,0 @@
use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS : &str = include_str!("../../../../../../../includes/binary_includes/safety/dictionary_sex_terms.txt");
pub (crate) fn contains_sex_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
TERMS.lines()
.map(|line| line.trim().to_lowercase())
.filter(|line| !line.is_empty() && !line.starts_with("#"))
.collect::<HashSet<String>>()
});
prompt_tokens.iter().any(|term| TERM_DICTIONARY.contains(term))
}

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