Compare commits

..
Author SHA1 Message Date
Brandon Thomas 957d510a04 wip 2026-01-29 19:44:07 -05:00
Brandon Thomas 20bf5608eb create session 2026-01-29 10:28:39 -05:00
Brandon Thomas 03e5d735d8 pass payload back 2026-01-29 10:15:30 -05:00
Brandon Thomas 114171f985 wip 2026-01-29 10:05:30 -05:00
Brandon Thomas 17fbc3f841 plumbing database fields 2026-01-29 09:58:06 -05:00
Brandon Thomas 972420b5d5 wip 2026-01-29 09:26:04 -05:00
Brandon Thomas 6face4ab4f undo change 2026-01-29 02:39:54 -05:00
Brandon Thomas dffc831ce1 wip 2026-01-29 02:24:34 -05:00
Brandon Thomas 671ac9310f endpoints wip 2026-01-28 21:25:59 -05:00
Brandon Thomas a752c64a99 new users table fields 2026-01-28 20:01:31 -05:00
822 changed files with 4551 additions and 66043 deletions
+2 -2
View File
@@ -233,8 +233,8 @@ jobs:
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "ArtCraft v__VERSION__"
releaseBody: "See the assets below to download this version and install."
releaseName: "Artcraft v__VERSION__"
releaseBody: "See the assets to download this version and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
@@ -99,9 +99,9 @@ jobs:
SQLX_OFFLINE: true
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "ArtCraft v__VERSION__"
releaseBody: "Production build of ArtCraft. See the assets below to download and install."
tagName: artcraft-windows-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "Artcraft Windows v__VERSION__"
releaseBody: "Windows build - See the assets to download this version and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
-1
View File
@@ -9,7 +9,6 @@ on:
# NB: Default-branch doesn't work, despite Github's documentation.
#- $default-branch
- main
- test
env:
IMAGE_NAME: storyteller-rust
-9
View File
@@ -32,12 +32,6 @@ script/production_database_secret_backup.sh
script/production_database_secret_connect.sh
script/socat_dev_database_connect.sh
# Python virtual envs (named by convention)
venv/
# Testing
/requests/
# Scratch data
scratch/
test_data/temp/
@@ -68,9 +62,6 @@ __pycache__/
# We use diesel for DB migrations only. We don't care about its codgen.
_ignored_schema.rs
# Diesel lock file
.diesel_lock
# AI Chat Sidecar has its own schema managed by diesel, but we don't care for diesel codegen.
crates/programs/aichatbot-sidecar/_ignored_schema.rs
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE wallets\nSET\n banked_credits = ?,\n version = version + 1\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "01f6369e0ab7b0fa5b95b4595827dcf15897f3b50cce5b1dc421101b157b7008"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n password_hash = ?,\n ip_address_last_update = ?,\n is_without_password = false,\n password_version = password_version + 1,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "03761e50f94f2805f1c5c64f598a23af8d708e514a01c6090570b2e0bfabfe2c"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n email_confirmed = true,\n password_hash = ?,\n ip_address_last_update = ?,\n password_version = password_version + 1,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "05de463f1c408793d64fb011b1732e3c1b3cf485076b3e1a4a50d16683b99e46"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n failure_reason = ?,\n internal_debugging_failure_reason = ?,\n frontend_failure_category = ?,\n retry_at = NULL\nWHERE token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "15939605e0da2722693371799ba94c7d0cc2f77a5c5b37e346855996d16033ec"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as user_token,\n users.username,\n users.display_name,\n\n users.email_address,\n users.email_gravatar_hash,\n \n users.email_confirmed,\n users.email_confirmed_by_google,\n users.email_is_synthetic,\n users.is_without_password,\n users.username_is_not_customized,\n\n users.maybe_stripe_customer_id,\n users.maybe_loyalty_program_key,\n\n users.disable_gravatar,\n users.auto_play_audio_preference,\n users.auto_play_video_preference,\n users.preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility`,\n users.preferred_w2l_result_visibility as `preferred_w2l_result_visibility: enums::common::visibility::Visibility`,\n\n users.user_role_slug,\n users.is_banned,\n\n users.can_access_studio,\n users.maybe_feature_flags,\n\n user_roles.can_use_tts,\n user_roles.can_use_w2l,\n user_roles.can_delete_own_tts_results,\n user_roles.can_delete_own_w2l_results,\n user_roles.can_delete_own_account,\n\n user_roles.can_upload_tts_models,\n user_roles.can_upload_w2l_templates,\n user_roles.can_delete_own_tts_models,\n user_roles.can_delete_own_w2l_templates,\n\n user_roles.can_approve_w2l_templates,\n user_roles.can_edit_other_users_profiles,\n user_roles.can_edit_other_users_tts_models,\n user_roles.can_edit_other_users_w2l_templates,\n user_roles.can_delete_other_users_tts_models,\n user_roles.can_delete_other_users_tts_results,\n user_roles.can_delete_other_users_w2l_templates,\n user_roles.can_delete_other_users_w2l_results,\n user_roles.can_ban_users,\n user_roles.can_delete_users\n\nFROM users\nLEFT OUTER JOIN user_sessions\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\n ",
"query": "\nSELECT\n users.token as user_token,\n users.username,\n users.display_name,\n\n users.email_address,\n users.email_confirmed,\n users.email_gravatar_hash,\n\n users.maybe_stripe_customer_id,\n users.maybe_loyalty_program_key,\n\n users.disable_gravatar,\n users.auto_play_audio_preference,\n users.auto_play_video_preference,\n users.preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility`,\n users.preferred_w2l_result_visibility as `preferred_w2l_result_visibility: enums::common::visibility::Visibility`,\n\n users.user_role_slug,\n users.is_banned,\n\n users.can_access_studio,\n users.maybe_feature_flags,\n\n user_roles.can_use_tts,\n user_roles.can_use_w2l,\n user_roles.can_delete_own_tts_results,\n user_roles.can_delete_own_w2l_results,\n user_roles.can_delete_own_account,\n\n user_roles.can_upload_tts_models,\n user_roles.can_upload_w2l_templates,\n user_roles.can_delete_own_tts_models,\n user_roles.can_delete_own_w2l_templates,\n\n user_roles.can_approve_w2l_templates,\n user_roles.can_edit_other_users_profiles,\n user_roles.can_edit_other_users_tts_models,\n user_roles.can_edit_other_users_w2l_templates,\n user_roles.can_delete_other_users_tts_models,\n user_roles.can_delete_other_users_tts_results,\n user_roles.can_delete_other_users_w2l_templates,\n user_roles.can_delete_other_users_w2l_results,\n user_roles.can_ban_users,\n user_roles.can_delete_users\n\nFROM users\nLEFT OUTER JOIN user_sessions\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\n ",
"describe": {
"columns": [
{
@@ -45,16 +45,6 @@
},
{
"ordinal": 4,
"name": "email_gravatar_hash",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "email_confirmed",
"type_info": {
"type": "Tiny",
@@ -63,48 +53,18 @@
"max_size": 1
}
},
{
"ordinal": 5,
"name": "email_gravatar_hash",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "email_confirmed_by_google",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "email_is_synthetic",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 8,
"name": "is_without_password",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 9,
"name": "username_is_not_customized",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 10,
"name": "maybe_stripe_customer_id",
"type_info": {
"type": "VarString",
@@ -114,7 +74,7 @@
}
},
{
"ordinal": 11,
"ordinal": 7,
"name": "maybe_loyalty_program_key",
"type_info": {
"type": "VarString",
@@ -124,7 +84,7 @@
}
},
{
"ordinal": 12,
"ordinal": 8,
"name": "disable_gravatar",
"type_info": {
"type": "Tiny",
@@ -134,7 +94,7 @@
}
},
{
"ordinal": 13,
"ordinal": 9,
"name": "auto_play_audio_preference",
"type_info": {
"type": "Tiny",
@@ -144,7 +104,7 @@
}
},
{
"ordinal": 14,
"ordinal": 10,
"name": "auto_play_video_preference",
"type_info": {
"type": "Tiny",
@@ -154,7 +114,7 @@
}
},
{
"ordinal": 15,
"ordinal": 11,
"name": "preferred_tts_result_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
@@ -164,7 +124,7 @@
}
},
{
"ordinal": 16,
"ordinal": 12,
"name": "preferred_w2l_result_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
@@ -174,7 +134,7 @@
}
},
{
"ordinal": 17,
"ordinal": 13,
"name": "user_role_slug",
"type_info": {
"type": "VarString",
@@ -184,7 +144,7 @@
}
},
{
"ordinal": 18,
"ordinal": 14,
"name": "is_banned",
"type_info": {
"type": "Tiny",
@@ -194,7 +154,7 @@
}
},
{
"ordinal": 19,
"ordinal": 15,
"name": "can_access_studio",
"type_info": {
"type": "Tiny",
@@ -204,7 +164,7 @@
}
},
{
"ordinal": 20,
"ordinal": 16,
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
@@ -214,7 +174,7 @@
}
},
{
"ordinal": 21,
"ordinal": 17,
"name": "can_use_tts",
"type_info": {
"type": "Tiny",
@@ -224,7 +184,7 @@
}
},
{
"ordinal": 22,
"ordinal": 18,
"name": "can_use_w2l",
"type_info": {
"type": "Tiny",
@@ -234,7 +194,7 @@
}
},
{
"ordinal": 23,
"ordinal": 19,
"name": "can_delete_own_tts_results",
"type_info": {
"type": "Tiny",
@@ -244,7 +204,7 @@
}
},
{
"ordinal": 24,
"ordinal": 20,
"name": "can_delete_own_w2l_results",
"type_info": {
"type": "Tiny",
@@ -254,7 +214,7 @@
}
},
{
"ordinal": 25,
"ordinal": 21,
"name": "can_delete_own_account",
"type_info": {
"type": "Tiny",
@@ -264,7 +224,7 @@
}
},
{
"ordinal": 26,
"ordinal": 22,
"name": "can_upload_tts_models",
"type_info": {
"type": "Tiny",
@@ -274,7 +234,7 @@
}
},
{
"ordinal": 27,
"ordinal": 23,
"name": "can_upload_w2l_templates",
"type_info": {
"type": "Tiny",
@@ -284,7 +244,7 @@
}
},
{
"ordinal": 28,
"ordinal": 24,
"name": "can_delete_own_tts_models",
"type_info": {
"type": "Tiny",
@@ -294,7 +254,7 @@
}
},
{
"ordinal": 29,
"ordinal": 25,
"name": "can_delete_own_w2l_templates",
"type_info": {
"type": "Tiny",
@@ -304,7 +264,7 @@
}
},
{
"ordinal": 30,
"ordinal": 26,
"name": "can_approve_w2l_templates",
"type_info": {
"type": "Tiny",
@@ -314,7 +274,7 @@
}
},
{
"ordinal": 31,
"ordinal": 27,
"name": "can_edit_other_users_profiles",
"type_info": {
"type": "Tiny",
@@ -324,7 +284,7 @@
}
},
{
"ordinal": 32,
"ordinal": 28,
"name": "can_edit_other_users_tts_models",
"type_info": {
"type": "Tiny",
@@ -334,7 +294,7 @@
}
},
{
"ordinal": 33,
"ordinal": 29,
"name": "can_edit_other_users_w2l_templates",
"type_info": {
"type": "Tiny",
@@ -344,7 +304,7 @@
}
},
{
"ordinal": 34,
"ordinal": 30,
"name": "can_delete_other_users_tts_models",
"type_info": {
"type": "Tiny",
@@ -354,7 +314,7 @@
}
},
{
"ordinal": 35,
"ordinal": 31,
"name": "can_delete_other_users_tts_results",
"type_info": {
"type": "Tiny",
@@ -364,7 +324,7 @@
}
},
{
"ordinal": 36,
"ordinal": 32,
"name": "can_delete_other_users_w2l_templates",
"type_info": {
"type": "Tiny",
@@ -374,7 +334,7 @@
}
},
{
"ordinal": 37,
"ordinal": 33,
"name": "can_delete_other_users_w2l_results",
"type_info": {
"type": "Tiny",
@@ -384,7 +344,7 @@
}
},
{
"ordinal": 38,
"ordinal": 34,
"name": "can_ban_users",
"type_info": {
"type": "Tiny",
@@ -394,7 +354,7 @@
}
},
{
"ordinal": 39,
"ordinal": 35,
"name": "can_delete_users",
"type_info": {
"type": "Tiny",
@@ -414,10 +374,6 @@
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
@@ -450,5 +406,5 @@
true
]
},
"hash": "3ca99449c071cb0a8d90a735e1a767a94be224a02e0d16e81997d569af8d4cbb"
"hash": "1c32bdb79ed2bc1b8988bbb6ea3f2a1db4ed4552d53cb4f31ff4b08de01691f7"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO users\nSET\n token = ?,\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\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 ",
"describe": {
"columns": [],
"parameters": {
"Right": 19
},
"nullable": []
},
"hash": "453f0386cfe34e533501ba29dc2022fc449266f9858f16886bde9a0df6b82026"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n email_address = ?,\n email_confirmed = false,\n email_confirmed_by_google = false,\n email_is_synthetic = false,\n email_gravatar_hash = ?,\n ip_address_last_update = ?,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "4737c44e7b1c73b21a245dd9d1f96aecfc4c614ed19ac4b590a26809c98fee03"
}
@@ -1,12 +0,0 @@
{
"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 ",
"describe": {
"columns": [],
"parameters": {
"Right": 22
},
"nullable": []
},
"hash": "56746bcb993cb33f5492f401ce4fc3fd44d91fed660c2f106cb22e482dffa740"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE wallet_ledger_entries\nSET\n is_refunded = TRUE,\n maybe_linked_refund_ledger_token = ?\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "6701a7adad65bc351e4600dad3ebf0f5dbdefa7dc9a0f890790cfaf368bf572d"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n email_address = ?,\n email_gravatar_hash = ?,\n email_confirmed = false,\n email_confirmed_by_google = false,\n email_is_synthetic = false,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "6fa90a16784c7b7e3436877c2a751bf0b60054640ebe69cb5f33938238def0fe"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n email_confirmed = true,\n email_is_synthetic = false,\n is_without_password = false,\n is_temporary = false,\n password_hash = ?,\n ip_address_last_update = ?,\n password_version = password_version + 1,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "9cd3a581f6857e5c1db68a103f47eba9714ee4b5eefc8ba02fc49a4b3a19310e"
}
@@ -1,102 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n wallet_token as `wallet_token: tokens::tokens::wallets::WalletToken`,\n entry_type,\n is_refunded as `is_refunded: bool`,\n credits_delta,\n banked_credits_before,\n banked_credits_after,\n monthly_credits_before,\n monthly_credits_after\nFROM wallet_ledger_entries\nWHERE token = ?\nLIMIT 1\nFOR UPDATE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "wallet_token: tokens::tokens::wallets::WalletToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "entry_type",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "is_refunded: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 3,
"name": "credits_delta",
"type_info": {
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
}
},
{
"ordinal": 4,
"name": "banked_credits_before",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 5,
"name": "banked_credits_after",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 6,
"name": "monthly_credits_before",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 7,
"name": "monthly_credits_after",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "a7365998006f4d40ad26efb42e6958a7aed689562a4c5b8c91cbe0033d0270e7"
}
@@ -1,36 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n email_address,\n email_is_synthetic\nFROM users\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 1,
"name": "email_is_synthetic",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "c2dd5120b34d62153772acc94f97c3b7a22b7c53216721e54882c0019d2467e6"
}
@@ -1,102 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "order_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 2,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 5,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 6,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
true,
true,
false,
false,
true,
true
]
},
"hash": "cb33fd483f0fef7ec2a1f5950218f312a19e38c7971b88db949ccaa05bf7b7c4"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_wallet_ledger_entry_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 15
},
"nullable": []
},
"hash": "ee228ab4a079fde6b3683b6cde2a2384d3def27a5d09baa5caf00a521d5d2bcd"
}
Generated
+406 -1337
View File
File diff suppressed because it is too large Load Diff
+10 -33
View File
@@ -7,7 +7,7 @@ members = [
# API clients for external services
# These might be custom implementations or wrappers over other client libraries.
"crates/api_clients/artcraft/artcraft_api_defs", # TODO: api_clients/{artcraft}/{api_defs,client}
"crates/api_clients/artcraft/artcraft_client",
"crates/api_clients/artcraft/storyteller_client",
"crates/api_clients/cloud_storage",
"crates/api_clients/fal_client",
"crates/api_clients/google_drive_common",
@@ -16,7 +16,6 @@ members = [
"crates/api_clients/midjourney_client",
"crates/api_clients/openai_sora_client",
"crates/api_clients/openart_client",
"crates/api_clients/seedance2pro",
"crates/api_clients/runwayml_client",
"crates/api_clients/thumbnail_generator",
"crates/api_clients/world_labs_client",
@@ -72,7 +71,6 @@ members = [
"crates/lib/files/videos",
"crates/lib/http_headers",
"crates/lib/idempotency",
"crates/api_clients/artcraft/artcraft_router",
"crates/lib/image_algorithms",
"crates/lib/jobs_common",
"crates/lib/jwt_light",
@@ -118,7 +116,6 @@ members = [
"crates/service/job/email_sender_job",
"crates/service/job/es-update-job",
"crates/service/job/inference_job",
"crates/service/job/seedance2_pro_job",
"crates/service/job/tts_download_job",
"crates/service/job/tts_inference_job",
"crates/service/job/w2l_download_job",
@@ -139,23 +136,6 @@ members = [
"crates/vendor/fal-rs/generate",
"crates/vendor/limitation",
"crates/vendor/tauri-plugin-http",
"crates/vendor/Symphonia/symphonia",
"crates/vendor/Symphonia/symphonia-bundle-flac",
"crates/vendor/Symphonia/symphonia-bundle-mp3",
"crates/vendor/Symphonia/symphonia-codec-aac",
"crates/vendor/Symphonia/symphonia-codec-alac",
"crates/vendor/Symphonia/symphonia-codec-opus",
"crates/vendor/Symphonia/symphonia-codec-pcm",
"crates/vendor/Symphonia/symphonia-codec-vorbis",
"crates/vendor/Symphonia/symphonia-codec-wavpack",
"crates/vendor/Symphonia/symphonia-core",
"crates/vendor/Symphonia/symphonia-format-isomp4",
"crates/vendor/Symphonia/symphonia-format-mkv",
"crates/vendor/Symphonia/symphonia-format-ogg",
"crates/vendor/Symphonia/symphonia-format-wav",
"crates/vendor/Symphonia/symphonia-metadata",
"crates/vendor/Symphonia/symphonia-play",
"crates/vendor/Symphonia/symphonia-utils-xiph",
# NB(bt,2025-05-25): Too many failures on Mac. Disable for now.
#"crates/vendor/candle/candle-core",
#"crates/vendor/candle/candle-datasets",
@@ -208,7 +188,6 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
# Setting paths here ensures we don't need to do so in every crate's Cargo.toml
# It makes refactoring much easier.
actix_artcraft = { path = "crates/lib/actix_artcraft" }
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
actix_helpers = { path = "crates/lib/actix_helpers" }
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
build_metadata = { path = "crates/lib/build_metadata" }
@@ -236,11 +215,10 @@ memory_store = { path = "crates/lib/caching/memory_store" }
midjourney_client = { path = "crates/api_clients/midjourney_client" }
mimetypes = { path = "crates/lib/files/mimetypes" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
seedance2pro = { path = "crates/api_clients/seedance2pro" }
primitives = { path = "crates/lib/data/primitives" }
shared_service_components = { path = "crates/lib/shared_service_components" }
sqlite_tasks = { path = "crates/schema/database/sqlite_tasks" }
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
storyteller_client = { path = "crates/api_clients/artcraft/storyteller_client" }
subprocess_common = { path = "crates/lib/subprocess_common" }
test_data = { path = "crates/lib/test_data" }
testing = { path = "crates/lib/testing" }
@@ -263,25 +241,25 @@ actix-utils = "3.0.1"
actix-web = "4.7.0" # TODO(bt): We're behind and need to bump versions.
actix-web-actors = "4.3.1+deprecated"
actix-web-lab = "0.20.2"
anyhow = "1.0.102"
anyhow = "1.0.98"
base64 = "0.22.1"
bstr = "1.12.1"
bytes = "1.10.1"
chrono = { version = "0.4.41", features = ["serde"] }
cookie = "0.18.1"
env = "1.0.1"
env_logger = "0.11.9"
env_logger = "0.11.8"
futures = { version = "0.3.31" }
hex-literal = "1.0.0"
indexmap = "2.9.0"
log = "0.4.29"
log = "0.4.27"
md-5 = "0.10.6" # insecure but used for gravatar
once_cell = "1.21.3"
r2d2 = "0.8.10"
rand = "0.9.1"
redis = { version = "1.0.2", features = ["r2d2", "tokio-rustls-comp", "tokio-comp", "cluster-async", "aio"] }
regex = "1.12.3"
reqwest = { version = "0.12.23", default-features = false, features = [ "charset", "http2", "system-proxy", "json", "cookies", "rustls-tls", "gzip" ] }
regex = "1.12.2"
reqwest = { version = "0.12.23", default-features = false, features = [ "charset", "http2", "system-proxy", "json", "cookies", "rustls-tls" ] }
reqwest-websocket = { version = "0.5.0" }
serde = { version = "1.0.219", features = ["derive"] }
serde_derive = "1.0.219"
@@ -298,7 +276,7 @@ tempdir = "0.3.7"
tempfile = "3.20.0"
thiserror = "2.0.12"
tokio = { version = "1.45.1", features = ["macros"] }
toml = "0.8.23"
toml = "0.8.22"
url = { version = "2.5.4", features = ["serde"] }
uuid = "1.18.1"
@@ -312,6 +290,5 @@ uuid = "1.18.1"
# choco install pkgconfiglite -y
# choco install llvm -y
# choco install nasm -y
wreq-util = "3.0.0-rc.10"
wreq = { version = "6.0.0-rc.28", features = ["json", "query", "form", "multipart", "cookies", "ws", "stream", "gzip", "brotli", "zstd", "deflate"] }
wreq = { version = "6.0.0-rc.20", features = ["json", "multipart", "cookies", "ws", "stream"] }
wreq-util = "3.0.0-rc.6"
+1 -1
View File
@@ -55,7 +55,7 @@ We haven't abandoned text-to-asset generation for quick prototyping and ideation
| Provider | Features |
|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedance 2 / Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Seedance, Sora 2 / Pro, Hunyuan 3d 2 / 3 |
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Seedance, Sora 2 / Pro, Hunyuan 3d 2 / 3 |
| Grok | Grok Imagine, Grok Video |
| Midjourney | Image Gen (all versions) |
| Sora | Sora 1, Sora 2, GPT-Image-1 |
@@ -3,7 +3,7 @@
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN origin_product_category VARCHAR(16) NOT NULL DEFAULT 'unknown'
ADD COLUMN origin_product_category VARCHAR(16) NOT NULL DEFAULT "unknown"
AFTER origin_category;
ALTER TABLE media_files
@@ -3,7 +3,7 @@
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN media_class VARCHAR(16) NOT NULL DEFAULT 'unknown'
ADD COLUMN media_class VARCHAR(16) NOT NULL DEFAULT "unknown"
AFTER media_type;
ALTER TABLE media_files
@@ -3,7 +3,7 @@
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN nsfw_status VARCHAR(32) NOT NULL DEFAULT 'unknown'
ADD COLUMN nsfw_status VARCHAR(32) NOT NULL DEFAULT "unknown"
AFTER maybe_scene_source_media_file_token;
ALTER TABLE media_files
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN
maybe_wallet_ledger_entry_token;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN
maybe_wallet_ledger_entry_token VARCHAR(32) DEFAULT NULL;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE wallet_ledger_entries
DROP COLUMN
is_refunded;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE wallet_ledger_entries
ADD COLUMN
is_refunded BOOLEAN NOT NULL DEFAULT FALSE;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE wallet_ledger_entries
DROP COLUMN
maybe_linked_refund_ledger_token;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE wallet_ledger_entries
ADD COLUMN
maybe_linked_refund_ledger_token VARCHAR(32) DEFAULT NULL;
@@ -182,9 +182,6 @@ CREATE TABLE generic_inference_jobs (
is_from_api_user BOOLEAN NOT NULL DEFAULT FALSE,
is_for_twitch BOOLEAN NOT NULL DEFAULT FALSE,
-- If credits were spent for this job, this references the corresponding wallet ledger entry.
maybe_wallet_ledger_entry_token VARCHAR(32) DEFAULT NULL,
-- ========== DEVELOPMENT AND DEBUGGING METADATA ==========
-- If true, the request gets a "debug" flag, which may do different
@@ -40,12 +40,6 @@ CREATE TABLE wallet_ledger_entries (
-- Balance of monthly credits after the event.
monthly_credits_after INTEGER UNSIGNED NOT NULL DEFAULT 0,
-- Whether this ledger entry has been refunded.
is_refunded BOOLEAN NOT NULL DEFAULT FALSE,
-- If this entry was refunded, this points to the refund ledger entry.
maybe_linked_refund_ledger_token VARCHAR(32) DEFAULT NULL,
-- ========== RECORD TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-45
View File
@@ -1,45 +0,0 @@
Dev Setup
=========
# ArtCraft
ArtCraft is a Rust / Tauri app.
To set up the ArtCraft development environment, install the following:
1. [Install Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html).
2. [Install npm](https://nodejs.org/en/download) or [nvm](https://github.com/nvm-sh/nvm). (Node version `v24.13.0` works at time of writing.)
3. [Install nx](https://nx.dev/docs/getting-started/installation). (Nx version `v22.4.5` works at time of writing.)
4. [Install Tauri CLI](https://v2.tauri.app/reference/cli/). (Version `tauri-cli 2.10.0` works at time of writing.)
An easy way to get started with running the app in development is to run the two commands (in separate terminals):
**Mac and Linux Development**
```bash
# Run the frontend dev server
./script/artcraft/unix_frontend_dev.sh
# Run the Tauri Rust application
./script/artcraft/unix_rust_dev.sh
```
**Windows Development**
```powershell
# Run the frontend dev server
.\script\artcraft\windows_frontend_dev.ps1
# Run the Tauri Rust application
.\script\artcraft\windows_rust_dev.ps1
```
# ArtCraft Server
ArtCraft's server is a Rust / Actix app called `storyteller-web`.
You don't need to run this to develop the ArtCraft application, but it can be useful to spin up
a development instance for adding new server functions or as your own private local copy.
See [dev_setup_server.md](./dev_setup_server.md) for instructions.
-105
View File
@@ -1,105 +0,0 @@
# ArtCraft Server
ArtCraft's server is a Rust / Actix app called `storyteller-web`.
You don't need to run this to develop the ArtCraft application, but it can be useful to spin up
a development instance for adding new server functions or as your own private local copy.
`storyteller-web` can function entirely within the development environment, and ArtCraft can be
pointed to a local instance of the server.
**TODO**: Concisely describe setting up server components. Note: we have
previous docs in `old/` that may still be relevant, though they're perhaps
slightly out of date.
## Server Setup (Mac)
### Install and Migrate MySQL Database
```bash
# For now, the tooling doesn't support MySQL 9.6 as it was just released in January 2026.
# This will probably change in the future to no longer need to specify the 8.* series.
brew install mysql@8.4
# Try to connect
mysql -uroot
# If connection fails, start service, then try again.
brew services start mysql
mysql -uroot
# If that fails, reboot and try connecting again.
```
Create the `storyteller` database table. Connect to the database:
```bash
sudo mysql -u root
# If it needs a password, use argument -p and when prompted use "root" as the typical default password
```
Then run this:
```mysql
use mysql;
CREATE DATABASE storyteller;
CREATE USER 'storyteller'@'localhost' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON storyteller.* TO 'storyteller'@'localhost';
```
Then verify access with `./script/dev_mysql_connect.sh`
```bash
cargo install diesel_cli \
--no-default-features \
--features mysql,sqlite
```
If this fails,
```bash
brew install zstd
brew install openssl
```
Possibly,
```bash
export RUSTFLAGS="-L$(brew --prefix zstd)/lib -L$(brew --prefix openssl)/lib"
```
In older Macs, there [have been some issues with Diesel CLI](https://github.com/diesel-rs/diesel/issues/2605)
that require a few extra dependencies to be installed:
```bash
# If you're on Mac and the above command didn't work, run the following and then retry:
brew install libpq
```
You're ready to run the migrations:
```bash
diesel migration run
```
You might get a scary message about `"Encountered unknown type for Mysql: enum"` -- you can safely ignore this
error if you see it in isolation. It doesn't impact the migrations whatsoever.
Finally, you'll need the sqlx CLI tool to run codegen.
You likely won't need this now, but if you change any queries, this will be necessary:
```bash
cargo install sqlx-cli --features rustls,mysql,sqlite
```
### Install Redis (Mac)
```bash
# Install Redis
brew install redis
# Start the service:
brew services start redis
```
+1 -12
View File
@@ -33,7 +33,7 @@ RUN apt-get update \
FROM ubuntu:jammy as rust-base
# NB: This can be "stable" or another version.
ARG RUST_TOOLCHAIN="1.93.0"
ARG RUST_TOOLCHAIN="1.86.0"
WORKDIR /tmp
@@ -48,12 +48,8 @@ RUN apt-get update \
curl \
ffmpeg \
fontconfig \
git \
libclang-dev \
libfontconfig1-dev \
libssl-dev \
musl-tools \
perl \
pkg-config
# NB: Fix for fontconfig (servo-fontconfig-sys): https://github.com/alacritty/alacritty/issues/4423#issuecomment-727277235
@@ -137,12 +133,6 @@ RUN SQLX_OFFLINE=true \
--release \
--bin storyteller-web
RUN SQLX_OFFLINE=true \
LD_LIBRARY_PATH=/usr/lib:${LD_LIBRARY_PATH} \
$HOME/.cargo/bin/cargo build \
--release \
--bin seedance2-pro-job
RUN SQLX_OFFLINE=true \
LD_LIBRARY_PATH=/usr/lib:${LD_LIBRARY_PATH} \
$HOME/.cargo/bin/cargo build \
@@ -202,7 +192,6 @@ RUN echo -n ${GIT_SHA} > GIT_SHA
# Copy all the binaries (except those that need a GPU):
COPY --from=builder /tmp/target/release/storyteller-web /
COPY --from=builder /tmp/target/release/seedance2-pro-job /
COPY --from=builder /tmp/target/release/dummy-service /
COPY --from=builder /tmp/target/release/analytics-job /
COPY --from=builder /tmp/target/release/email-sender-job /
@@ -36,4 +36,3 @@ serde_json.workspace = true
utoipa = { version = "4", features = ["chrono", "actix_extras"] }
[dev-dependencies]
anyhow.workspace = true
@@ -1,116 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_video_resolution::CommonVideoResolution;
use enums::common::generation_provider::GenerationProvider;
pub const ESTIMATE_IMAGE_COST_PATH: &str = "/v1/generate/cost_estimate/image";
/// Request body for the image cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateImageCostRequest {
/// The image model to estimate costs for.
pub model: CommonImageModel,
/// The provider to route the generation through.
pub provider: GenerationProvider,
/// The type of generation (determines whether input images are involved).
/// This is a tagged enum, so it looks like:
/// "generation_mode": {"type": "text_to_image"}
/// "generation_mode": {"type": "image_edit", "count": 2}
pub generation_mode: GenerationMode,
/// Optional aspect ratio.
pub aspect_ratio: Option<CommonAspectRatio>,
/// Optional resolution.
pub resolution: Option<CommonVideoResolution>,
/// Number of images to generate in parallel.
pub image_batch_count: Option<u16>,
}
/// Describes the type of image generation being requested.
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GenerationMode {
/// No input images — generate from prompt only.
TextToImage,
/// One or more input images are provided for editing/compositing.
ImageEdit { count: u32 },
}
/// Response body for the image cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateImageCostResponse {
pub success: bool,
/// Estimated cost in credits.
pub cost_in_credits: Option<u64>,
/// Estimated cost in USD cents.
pub cost_in_usd_cents: Option<u64>,
/// Whether the generation is free for this user/plan.
pub is_free: bool,
/// Whether the user has unlimited generations.
pub is_unlimited: bool,
/// Whether the user is rate limited.
pub is_rate_limited: bool,
/// Whether the output will have a watermark.
pub has_watermark: bool,
}
/// Error response for the image cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateImageCostError {
pub success: bool,
pub error_type: EstimateImageCostErrorType,
pub error_message: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum EstimateImageCostErrorType {
InvalidProviderForModel,
InvalidInput,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialization_text_to_image() {
let request = EstimateImageCostRequest {
model: CommonImageModel::NanaBananaPro,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::TextToImage,
aspect_ratio: None,
resolution: None,
image_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
assert!(serialized.contains("\"generation_mode\":{\"type\":\"text_to_image\"}"));
}
#[test]
fn test_serialization_image_edit() {
let request = EstimateImageCostRequest {
model: CommonImageModel::NanaBananaPro,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::ImageEdit { count: 2 },
aspect_ratio: None,
resolution: None,
image_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
assert!(serialized.contains("\"generation_mode\":{\"type\":\"image_edit\",\"count\":2}"));
}
}
@@ -1,122 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_resolution::CommonVideoResolution;
use enums::common::generation_provider::GenerationProvider;
pub const ESTIMATE_VIDEO_COST_PATH: &str = "/v1/generate/cost_estimate/video";
/// Request body for the video cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateVideoCostRequest {
/// The video model to estimate costs for.
pub model: CommonVideoModel,
/// The provider to route the generation through.
pub provider: GenerationProvider,
/// The type of generation (determines how many input images are involved).
/// This is a tagged enum, so it looks like:
/// "generation_mode": {"type": "text_to_video"}
/// "generation_mode": {"type": "reference_image_to_video", "count": 1}
pub generation_mode: GenerationMode,
/// Optional aspect ratio.
pub aspect_ratio: Option<CommonAspectRatio>,
/// Optional resolution.
pub resolution: Option<CommonVideoResolution>,
/// Duration in seconds.
pub duration_seconds: Option<u16>,
/// Number of videos to generate in parallel.
pub video_batch_count: Option<u16>,
}
/// Describes the type of video generation being requested.
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GenerationMode {
TextToVideo,
StartFrameToVideo,
StartAndEndFrameToVideo,
ReferenceImageToVideo { count: u32 },
}
/// Response body for the video cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateVideoCostResponse {
pub success: bool,
/// Estimated cost in credits.
pub cost_in_credits: Option<u64>,
/// Estimated cost in USD cents.
pub cost_in_usd_cents: Option<u64>,
/// Whether the generation is free for this user/plan.
pub is_free: bool,
/// Whether the user has unlimited generations.
pub is_unlimited: bool,
/// Whether the user is rate limited.
pub is_rate_limited: bool,
/// Whether the output will have a watermark.
pub has_watermark: bool,
}
/// Error response for the video cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateVideoCostError {
pub success: bool,
pub error_type: EstimateVideoCostErrorType,
pub error_message: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum EstimateVideoCostErrorType {
InvalidProviderForModel,
InvalidInput,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialization_tagging1() {
let request = EstimateVideoCostRequest {
model: CommonVideoModel::GrokVideo,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::TextToVideo,
aspect_ratio: None,
resolution: None,
duration_seconds: None,
video_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
assert!(serialized.contains("\"generation_mode\":{\"type\":\"text_to_video\"}"));
//assert_eq!(serialized, "{}");
}
#[test]
fn test_serialization_tagging2() {
let request = EstimateVideoCostRequest {
model: CommonVideoModel::GrokVideo,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::ReferenceImageToVideo { count: 1 },
aspect_ratio: None,
resolution: None,
duration_seconds: None,
video_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
assert!(serialized.contains("\"generation_mode\":{\"type\":\"reference_image_to_video\",\"count\":1}"));
}
}
@@ -1,2 +0,0 @@
pub mod estimate_image_cost;
pub mod estimate_video_cost;
@@ -1,4 +1,3 @@
pub mod cost_estimate;
pub mod image;
pub mod object;
pub mod video;
@@ -1,6 +1,5 @@
pub mod kling_2_5_turbo_multi_function_video_gen;
pub mod kling_2_6_multi_function_video_gen;
pub mod seedance_2p0_multi_function_video_gen;
pub mod sora_2_multi_function_video_gen;
pub mod sora_2_pro_multi_function_video_gen;
pub mod veo_3p1_fast_multi_function_video_gen;
@@ -1,85 +0,0 @@
use serde::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
pub const SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH: &str = "/v1/generate/video/multi_function/seedance_2p0";
/// Seedance 2.0 multi-function video generation (text-to-video, image-to-video, and reference-based).
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Idempotency token to prevent duplicate requests.
pub uuid_idempotency_token: String,
/// Text prompt.
pub prompt: Option<String>,
/// Optional start frame image.
pub start_frame_media_token: Option<MediaFileToken>,
/// Optional end frame image.
pub end_frame_media_token: Option<MediaFileToken>,
/// Optional reference images (reference mode). When present, takes priority over start/end frames.
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// Video aspect ratio / resolution.
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
/// Duration in seconds (4–15).
pub duration_seconds: Option<u8>,
/// Number of videos to generate.
pub batch_count: Option<Seedance2p0BatchCount>,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0AspectRatio {
/// 16:9 landscape (1280x720)
Landscape16x9,
/// 9:16 portrait (720x1280)
Portrait9x16,
/// 1:1 square (720x720)
Square1x1,
/// 4:3 standard (960x720)
Standard4x3,
/// 3:4 portrait (720x960)
Portrait3x4,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0BatchCount {
One,
Two,
Four,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenResponse {
pub success: bool,
/// The first inference job token (always present).
pub inference_job_token: InferenceJobToken,
/// All inference job tokens for the batch (includes `inference_job_token` as the first entry).
pub all_inference_job_tokens: Vec<InferenceJobToken>,
}
#[cfg(test)]
mod tests {
use super::*;
fn aspect_to_string(input: Seedance2p0AspectRatio) -> anyhow::Result<String> {
let val = serde_json::to_string(&input)?;
Ok(val.replace("\"", ""))
}
#[test]
fn just_testing_snake_case() -> anyhow::Result<()> {
// Ugh, not sure what these are...
assert_eq!(aspect_to_string(Seedance2p0AspectRatio::Landscape16x9)?, "landscape16x9");
Ok(())
}
}
@@ -7,5 +7,4 @@ pub mod media_file;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod users;
pub mod utils;
@@ -1,13 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ChangePasswordRequest {
pub password: String,
pub password_confirmation: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ChangePasswordResponse {
pub success: bool,
}
@@ -1,12 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EditEmailRequest {
pub email_address: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EditEmailResponse {
pub success: bool,
}
@@ -1,12 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EditUsernameRequest {
pub display_name: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EditUsernameResponse {
pub success: bool,
}
@@ -1,4 +0,0 @@
pub mod change_password;
pub mod edit_email;
pub mod edit_username;
pub mod session_info;
@@ -1,99 +0,0 @@
use crate::common::responses::user_details_light::UserDetailsLight;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use serde_derive::Serialize;
use std::collections::BTreeSet;
use tokens::tokens::users::UserToken;
use utoipa::ToSchema;
#[derive(Serialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum FakeYouPlan {
Free,
Basic,
Standard,
Pro,
}
#[derive(Serialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum StorytellerStreamPlan {
Free,
Basic,
Standard,
Pro,
}
#[derive(Serialize, ToSchema)]
pub struct SessionUserInfo {
pub core_info: UserDetailsLight,
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
pub onboarding: SessionOnboardingState,
// TODO(bt,2024-03-30): Remove legacy feature flag
#[deprecated(note = "DO NOT USE. Use `maybe_feature_flags` instead! The flag you're looking for is `studio`.")]
pub can_access_studio: bool,
/// Collection of feature / rollout flags
/// This is the proper place to detect if a user has access to some rollout (non-paywall) feature.
/// NB: The BTreeSet maintains order so React doesn't introduce re-render state bugs when order changes
pub maybe_feature_flags: BTreeSet<UserFeatureFlag>,
// Premium plans:
#[deprecated(note = "DO NOT USE. This was never used and is 100% meaningless.")]
pub fakeyou_plan: FakeYouPlan,
#[deprecated(note = "DO NOT USE. This was never used and is 100% meaningless.")]
pub storyteller_stream_plan: StorytellerStreamPlan,
// Usage permissions:
pub can_use_tts: bool,
pub can_use_w2l: bool,
pub can_delete_own_tts_results: bool,
pub can_delete_own_w2l_results: bool,
pub can_delete_own_account: bool,
// Contribution permissions:
pub can_upload_tts_models: bool,
pub can_upload_w2l_templates: bool,
pub can_delete_own_tts_models: bool,
pub can_delete_own_w2l_templates: bool,
// Moderation permissions:
pub can_approve_w2l_templates: bool,
pub can_edit_other_users_profiles: bool,
pub can_edit_other_users_tts_models: bool,
pub can_edit_other_users_w2l_templates: bool,
pub can_delete_other_users_tts_models: bool,
pub can_delete_other_users_tts_results: bool,
pub can_delete_other_users_w2l_templates: bool,
pub can_delete_other_users_w2l_results: bool,
pub can_ban_users: bool,
pub can_delete_users: bool,
}
#[derive(Serialize, ToSchema)]
pub struct SessionOnboardingState {
/// If true, the user hasn't set their email.
pub email_not_set: bool,
/// If true, the user hasn't confirmed their email.
pub email_not_confirmed: bool,
/// If true, the user hasn't set their password.
pub password_not_set: bool,
/// If true, the user hasn't set their username (the username they have is random).
pub username_not_customized: bool,
}
#[derive(Serialize, ToSchema)]
pub struct SessionInfoSuccessResponse {
pub success: bool,
pub logged_in: bool,
pub user: Option<SessionUserInfo>,
}
@@ -1,35 +0,0 @@
use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie;
use cookie::Cookie;
/// Parses a multi-cookie header string (e.g. `"visitor=abc; session=xyz"`) and extracts
/// the known Storyteller cookies by name.
///
/// Returns `Ok(Some(...))` if at least one recognised cookie is present,
/// `Ok(None)` if the input is empty or contains no recognised cookies,
/// and `Err` if any individual cookie segment fails to parse.
pub fn parse_multi_cookie_header(header: &str) -> Result<Option<StorytellerCredentialSet>, cookie::ParseError> {
let mut avt: Option<StorytellerAvtCookie> = None;
let mut session: Option<StorytellerSessionCookie> = None;
for segment in header.split(';') {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let cookie = Cookie::parse(segment.to_owned())?;
if avt.is_none() {
avt = StorytellerAvtCookie::maybe_from_cookie(&cookie);
}
if session.is_none() {
session = StorytellerSessionCookie::maybe_from_cookie(&cookie);
}
}
if avt.is_none() && session.is_none() {
return Ok(None);
}
Ok(Some(StorytellerCredentialSet::initialize(avt, session)))
}
@@ -1,20 +0,0 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::cost_estimate::estimate_image_cost::{
EstimateImageCostRequest, EstimateImageCostResponse, ESTIMATE_IMAGE_COST_PATH,
};
pub async fn estimate_image_cost(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: EstimateImageCostRequest,
) -> Result<EstimateImageCostResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
ESTIMATE_IMAGE_COST_PATH,
maybe_creds,
request,
).await?)
}
@@ -1 +0,0 @@
pub mod estimate_image_cost;
@@ -1,2 +0,0 @@
pub mod image;
pub mod video;
@@ -1,20 +0,0 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::cost_estimate::estimate_video_cost::{
EstimateVideoCostRequest, EstimateVideoCostResponse, ESTIMATE_VIDEO_COST_PATH,
};
pub async fn estimate_video_cost(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: EstimateVideoCostRequest,
) -> Result<EstimateVideoCostResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
ESTIMATE_VIDEO_COST_PATH,
maybe_creds,
request,
).await?)
}
@@ -1 +0,0 @@
pub mod estimate_video_cost;
@@ -1,18 +0,0 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0MultiFunctionVideoGenResponse, SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH};
pub async fn seedance_2p0_multi_function_video_gen(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: Seedance2p0MultiFunctionVideoGenRequest,
) -> Result<Seedance2p0MultiFunctionVideoGenResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,34 +0,0 @@
[package]
name = "artcraft_router"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "artcraft_router"
path = "src/lib.rs"
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs.workspace = true
fal_client.workspace = true
seedance2pro.workspace = true
artcraft_client.workspace = true
tokens.workspace = true
# External
anyhow.workspace = true
log.workspace = true
serde.workspace = true
serde_derive.workspace = true
tokio.workspace = true
uuid.workspace = true
[dev-dependencies]
test_data.workspace = true
@@ -1,42 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common aspect ratios you can specify when enqueuing a generation.
/// Not every model will support every aspect ratio.
/// In the case a model doesn't support the aspect ratio, gracefully pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonAspectRatio {
// Auto (eg. for image editing to use the source; used by Nano Banana Pro edit image, but not text-to-image)
Auto,
// Square
Square,
// Wide
WideThreeByTwo,
WideFourByThree,
WideFiveByFour,
WideSixteenByNine,
WideTwentyOneByNine,
// Tall
TallTwoByThree,
TallThreeByFour,
TallFourByFive,
TallNineBySixteen,
TallNineByTwentyOne,
// Imprecise semantic values that we probably remap to other meanings
// on a model-by-model basis.
Wide,
Tall,
// Auto values that bake in resolution
// These are from the Seedream models
Auto2k,
Auto4k,
// Defined aspect ratios that bake in resolution
// These are from the Seedream models
SquareHd,
}
@@ -1,10 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// Common image models supported by the router.
/// Not all models are available through all providers.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonImageModel {
#[serde(rename = "nano_banana_pro")]
NanaBananaPro,
}
@@ -1,17 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common resolutions you can specify when enqueuing a generation.
/// Not every model will support every resolution.
/// In the case a model doesn't support the resolution, gracefully pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonResolution {
/// Models: Nano Banana Pro
OneK,
/// Models: Nano Banana Pro
TwoK,
/// Models: (None)
ThreeK,
/// Models: Nano Banana Pro
FourK,
}
@@ -1,52 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// Common video models supported by the router.
/// Not all models are available through all providers.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoModel {
#[serde(rename = "grok_video")]
GrokVideo,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
#[serde(rename = "kling_2p1_pro")]
Kling21Pro,
#[serde(rename = "kling_2p1_master")]
Kling21Master,
#[serde(rename = "kling_2p5_turbo_pro")]
Kling2p5TurboPro,
#[serde(rename = "kling_2p6_pro")]
Kling2p6Pro,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
Sora2Pro,
#[serde(rename = "veo_2")]
Veo2,
#[serde(rename = "veo_3")]
Veo3,
#[serde(rename = "veo_3_fast")]
Veo3Fast,
#[serde(rename = "veo_3p1")]
Veo3p1,
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
}
@@ -1,7 +0,0 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
pub enum ImageListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
}
@@ -1,7 +0,0 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
pub enum ImageRef<'a> {
MediaFileToken(&'a MediaFileToken),
Url(&'a str),
}
@@ -1,7 +0,0 @@
pub mod common_aspect_ratio;
pub mod common_image_model;
pub mod common_resolution;
pub mod common_video_model;
pub mod image_list_ref;
pub mod image_ref;
pub mod provider;
@@ -1,9 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// The provider to route a generation request to.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Provider {
Artcraft,
Fal,
}
@@ -1,6 +0,0 @@
pub mod multi_router_client;
pub mod multi_router_client_builder;
pub mod request_mismatch_mitigation_strategy;
pub mod router_artcraft_client;
pub mod router_client;
pub mod router_fal_client;
@@ -1,20 +0,0 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::client_error::{ClientError, ClientType};
pub struct MultiRouterClient {
pub(crate) artcraft_client: Option<RouterArtcraftClient>,
pub(crate) fal_client: Option<RouterFalClient>,
}
impl MultiRouterClient {
pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> {
self.artcraft_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Artcraft))
}
pub fn get_fal_client_ref(&self) -> Result<&RouterFalClient, ClientError> {
self.fal_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Fal))
}
}
@@ -1,34 +0,0 @@
use crate::client::multi_router_client::MultiRouterClient;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
pub struct MultiRouterClientBuilder {
artcraft_client: Option<RouterArtcraftClient>,
fal_client: Option<RouterFalClient>,
}
impl MultiRouterClientBuilder {
pub fn new() -> Self {
Self {
artcraft_client: None,
fal_client: None,
}
}
pub fn set_artcraft_client(mut self, client: RouterArtcraftClient) -> Self {
self.artcraft_client = Some(client);
self
}
pub fn set_fal_client(mut self, client: RouterFalClient) -> Self {
self.fal_client = Some(client);
self
}
pub fn build(self) -> MultiRouterClient {
MultiRouterClient {
artcraft_client: self.artcraft_client,
fal_client: self.fal_client,
}
}
}
@@ -1,12 +0,0 @@
#[derive(Copy, Clone, Debug)]
pub enum RequestMismatchMitigationStrategy {
/// Upgrade the request to pay more.
PayMoreUpgrade,
/// Downgrade the request to pay less
PayLessDowngrade,
/// Fail the request.
ErrorOut,
}
@@ -1,13 +0,0 @@
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
pub struct RouterArtcraftClient {
pub(crate) api_host: ApiHost,
pub(crate) credentials: StorytellerCredentialSet,
}
impl RouterArtcraftClient {
pub fn new(api_host: ApiHost, credentials: StorytellerCredentialSet) -> Self {
Self { api_host, credentials }
}
}
@@ -1,28 +0,0 @@
use crate::client::multi_router_client::MultiRouterClient;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::client_error::{ClientError, ClientType};
pub enum RouterClient {
Multi(MultiRouterClient),
Artcraft(RouterArtcraftClient),
Fal(RouterFalClient),
}
impl RouterClient {
pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> {
match self {
RouterClient::Artcraft(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_artcraft_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::Artcraft)),
}
}
pub fn get_fal_client_ref(&self) -> Result<&RouterFalClient, ClientError> {
match self {
RouterClient::Fal(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_fal_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::Fal)),
}
}
}
@@ -1,12 +0,0 @@
use fal_client::creds::fal_api_key::FalApiKey;
pub struct RouterFalClient {
pub(crate) api_key: FalApiKey,
pub(crate) webhook_url: String,
}
impl RouterFalClient {
pub fn new(api_key: FalApiKey, webhook_url: String) -> Self {
Self { api_key, webhook_url }
}
}
@@ -1,44 +0,0 @@
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum ArtcraftRouterError {
/// A client configuration error.
Client(ClientError),
/// The requested model is not yet supported by the router.
UnsupportedModel(String),
/// Invalid or missing input arguments.
InvalidInput(String),
/// An error from an underlying provider.
Provider(ProviderError),
}
impl Error for ArtcraftRouterError {}
impl Display for ArtcraftRouterError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "Client error: {}", e),
Self::UnsupportedModel(model) => write!(f, "Unsupported model: {}", model),
Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
Self::Provider(e) => write!(f, "Provider error: {}", e),
}
}
}
impl From<ClientError> for ArtcraftRouterError {
fn from(error: ClientError) -> Self {
Self::Client(error)
}
}
impl From<ProviderError> for ArtcraftRouterError {
fn from(error: ProviderError) -> Self {
Self::Provider(error)
}
}
@@ -1,60 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug, Clone, Copy)]
pub enum ClientType {
Artcraft,
Fal,
}
impl Display for ClientType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Artcraft => write!(f, "Artcraft"),
Self::Fal => write!(f, "Fal"),
}
}
}
#[derive(Debug)]
pub enum ClientError {
/// The requested client is not configured on the RouterClient.
ClientNotConfigured(ClientType),
/// The model does not support the given option value.
/// `field` is the request field name, `value` is what was requested.
ModelDoesNotSupportOption { field: &'static str, value: String },
/// The caller requested zero generations, which is never valid.
UserRequestedZeroGenerations,
/// ArtCraft only accepts media tokens for image inputs, not raw URLs.
ArtcraftOnlySupportsMediaTokens,
/// Fal only accepts image URLs for image inputs, not media tokens.
FalOnlySupportsUrls,
}
impl Error for ClientError {}
impl Display for ClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ClientNotConfigured(client_type) => {
write!(f, "{} client is not configured on the RouterClient", client_type)
}
Self::ModelDoesNotSupportOption { field, value } => {
write!(f, "Model does not support '{}' for field '{}'", value, field)
}
Self::UserRequestedZeroGenerations => {
write!(f, "Cannot request zero generations")
}
Self::ArtcraftOnlySupportsMediaTokens => {
write!(f, "ArtCraft only supports media tokens for image inputs; upload the image first to obtain a media token")
}
Self::FalOnlySupportsUrls => {
write!(f, "Fal only supports image URLs for image inputs, not media tokens")
}
}
}
}
@@ -1,3 +0,0 @@
pub mod artcraft_router_error;
pub mod client_error;
pub mod provider_error;
@@ -1,33 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use artcraft_client::error::storyteller_error::StorytellerError;
use fal_client::error::fal_error_plus::FalErrorPlus;
#[derive(Debug)]
pub enum ProviderError {
Storyteller(StorytellerError),
Fal(FalErrorPlus),
}
impl Error for ProviderError {}
impl Display for ProviderError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e),
Self::Fal(e) => write!(f, "Fal provider error: {}", e),
}
}
}
impl From<StorytellerError> for ProviderError {
fn from(error: StorytellerError) -> Self {
Self::Storyteller(error)
}
}
impl From<FalErrorPlus> for ProviderError {
fn from(error: FalErrorPlus) -> Self {
Self::Fal(error)
}
}
@@ -1,79 +0,0 @@
use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::{
NanoBananaProMultiFunctionImageGenImageResolution, NanoBananaProMultiFunctionImageGenNumImages,
};
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_pro::PlanArtcraftNanaBananaPro;
pub(crate) fn estimate_image_cost_artcraft_nano_banana_pro(
plan: &PlanArtcraftNanaBananaPro<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.15/image at 1K or 2K resolution, $0.30/image at 4K.
// (Stored as USD cents.)
let cost_per_image: u64 = match plan.resolution {
Some(NanoBananaProMultiFunctionImageGenImageResolution::FourK) => 30,
_ => 15,
};
let num_images: u64 = match plan.num_images {
NanoBananaProMultiFunctionImageGenNumImages::One => 1,
NanoBananaProMultiFunctionImageGenNumImages::Two => 2,
NanoBananaProMultiFunctionImageGenNumImages::Three => 3,
NanoBananaProMultiFunctionImageGenNumImages::Four => 4,
};
let cost_in_usd_cents = cost_per_image * num_images;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_resolution::CommonResolution;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
fn estimate_usd_cents(resolution: Option<CommonResolution>, image_batch_count: u16) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::NanaBananaPro,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
resolution,
aspect_ratio: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[test]
fn test_estimate_cost_usd_cents() {
// 1K/2K: $0.15/image = 15 cents
assert_eq!(estimate_usd_cents(None, 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 2), 30);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 3), 45);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 4), 60);
// 4K: $0.30/image = 30 cents
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 1), 30);
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 2), 60);
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 4), 120);
}
}
@@ -1 +0,0 @@
pub mod estimate_image_cost_artcraft_nano_banana_pro;
@@ -1,77 +0,0 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_pro::{
FalNbpNumImages, FalNbpResolution, PlanFalNanaBananaPro,
};
pub(crate) fn estimate_image_cost_fal_nano_banana_pro(
plan: &PlanFalNanaBananaPro<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.15/image at 1K or 2K resolution, $0.30/image at 4K.
// (Stored as USD cents.)
let cost_per_image: u64 = match plan.resolution {
Some(FalNbpResolution::FourK) => 30,
_ => 15,
};
let num_images: u64 = match plan.num_images {
FalNbpNumImages::One => 1,
FalNbpNumImages::Two => 2,
FalNbpNumImages::Three => 3,
FalNbpNumImages::Four => 4,
};
let cost_in_usd_cents = cost_per_image * num_images;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_resolution::CommonResolution;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
fn estimate_usd_cents(resolution: Option<CommonResolution>, image_batch_count: u16) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::NanaBananaPro,
provider: Provider::Fal,
prompt: None,
image_inputs: None,
resolution,
aspect_ratio: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[test]
fn test_estimate_cost_usd_cents() {
// 1K/2K: $0.15/image = 15 cents
assert_eq!(estimate_usd_cents(None, 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 2), 30);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 3), 45);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 4), 60);
// 4K: $0.30/image = 30 cents
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 1), 30);
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 2), 60);
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 4), 120);
}
}
@@ -1 +0,0 @@
pub(crate) mod estimate_image_cost_fal_nano_banana_pro;
@@ -1,2 +0,0 @@
pub(crate) mod artcraft;
pub(crate) mod fal;
@@ -1,123 +0,0 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
ArtcraftImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_pro::PlanArtcraftNanaBananaPro;
use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::NanoBananaProMultiFunctionImageGenRequest;
use artcraft_client::endpoints::generate::image::multi_function::nano_banana_pro_multi_function_image_gen_image::nano_banana_pro_multi_function_image_gen;
pub async fn execute_artcraft_nano_banana_pro(
plan: &PlanArtcraftNanaBananaPro<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = NanoBananaProMultiFunctionImageGenRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_media_tokens: plan.image_inputs.map(|tokens| tokens.to_owned()),
num_images: Some(plan.num_images),
resolution: plan.resolution,
aspect_ratio: plan.aspect_ratio,
};
let response = nano_banana_pro_multi_function_image_gen(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload {
inference_job_token: response.inference_job_token,
}))
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{base_image_request, get_artcraft_client};
use tokens::tokens::media_files::MediaFileToken;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_nano_banana_pro() {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::OneK),
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_image_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_nano_banana_pro_batch_four() {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
resolution: Some(CommonResolution::OneK),
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_image_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_edit_image_nano_banana_pro() {
let client = get_artcraft_client();
// Reference images: the man to transplant, then the destination scene.
let image_tokens = vec![
MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8"),
MediaFileToken::new_from_str("m_2220d5we3masng5hvzg5zz68ywbdqd"),
];
let request = GenerateImageRequest {
prompt: Some("Add the man in the first image into the fantasy scene in the second image. Keep his hoodie and attire, but match the vibe of the fantasy scene."),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto), // edit mode: preserve source dimensions
resolution: Some(CommonResolution::OneK),
image_batch_count: Some(1),
..base_image_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
}
@@ -1 +0,0 @@
pub mod generate_image_artcraft_nano_banana_pro;
@@ -1,185 +0,0 @@
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_pro::{
FalNbpNumImages, FalNbpResolution, PlanFalNanaBananaPro,
};
use fal_client::requests::webhook::image::edit::enqueue_nano_banana_pro_edit_image_webhook::{
enqueue_nano_banana_pro_image_edit_webhook, EnqueueNanoBananaProEditImageArgs,
EnqueueNanoBananaProEditImageNumImages, EnqueueNanoBananaProEditImageResolution,
};
use fal_client::requests::webhook::image::text::enqueue_nano_banana_pro_text_to_image_webhook::{
enqueue_nano_banana_pro_text_to_image_webhook, EnqueueNanoBananaProTextToImageArgs,
EnqueueNanoBananaProTextToImageNumImages, EnqueueNanoBananaProTextToImageResolution,
};
pub async fn execute_fal_nano_banana_pro(
plan: &PlanFalNanaBananaPro<'_>,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
// Text-to-image mode
let args = EnqueueNanoBananaProTextToImageArgs {
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_pro_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
} else {
// Image-edit mode
let args = EnqueueNanoBananaProEditImageArgs {
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_pro_image_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: FalNbpNumImages) -> EnqueueNanoBananaProTextToImageNumImages {
match n {
FalNbpNumImages::One => EnqueueNanoBananaProTextToImageNumImages::One,
FalNbpNumImages::Two => EnqueueNanoBananaProTextToImageNumImages::Two,
FalNbpNumImages::Three => EnqueueNanoBananaProTextToImageNumImages::Three,
FalNbpNumImages::Four => EnqueueNanoBananaProTextToImageNumImages::Four,
}
}
fn to_edit_num_images(n: FalNbpNumImages) -> EnqueueNanoBananaProEditImageNumImages {
match n {
FalNbpNumImages::One => EnqueueNanoBananaProEditImageNumImages::One,
FalNbpNumImages::Two => EnqueueNanoBananaProEditImageNumImages::Two,
FalNbpNumImages::Three => EnqueueNanoBananaProEditImageNumImages::Three,
FalNbpNumImages::Four => EnqueueNanoBananaProEditImageNumImages::Four,
}
}
fn to_t2i_resolution(r: FalNbpResolution) -> EnqueueNanoBananaProTextToImageResolution {
match r {
FalNbpResolution::OneK => EnqueueNanoBananaProTextToImageResolution::OneK,
FalNbpResolution::TwoK => EnqueueNanoBananaProTextToImageResolution::TwoK,
FalNbpResolution::FourK => EnqueueNanoBananaProTextToImageResolution::FourK,
}
}
fn to_edit_resolution(r: FalNbpResolution) -> EnqueueNanoBananaProEditImageResolution {
match r {
FalNbpResolution::OneK => EnqueueNanoBananaProEditImageResolution::OneK,
FalNbpResolution::TwoK => EnqueueNanoBananaProEditImageResolution::TwoK,
FalNbpResolution::FourK => EnqueueNanoBananaProEditImageResolution::FourK,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{base_fal_image_request, get_fal_client};
use test_data::web::image_urls::{GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL};
#[tokio::test]
#[ignore] // manually run — fires a real Fal API request
async fn test_text_to_image_fal_nano_banana_pro() {
let client = get_fal_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::TwoK),
image_batch_count: Some(1),
prompt: Some("a cyberpunk city skyline at dusk, neon lights reflecting on rain-soaked streets"),
..base_fal_image_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_fal_payload().expect("expected Fal payload");
println!("Request ID: {:?}", payload.request_id);
println!("Gateway request ID: {:?}", payload.gateway_request_id);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real Fal API request
async fn test_text_to_image_fal_nano_banana_pro_batch_four() {
let client = get_fal_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
resolution: Some(CommonResolution::OneK),
image_batch_count: Some(4),
prompt: Some("a golden retriever surfing a wave, cinematic, 4K"),
..base_fal_image_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_fal_payload().expect("expected Fal payload");
println!("Request ID: {:?}", payload.request_id);
println!("Gateway request ID: {:?}", payload.gateway_request_id);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real Fal API request
async fn test_edit_image_fal_nano_banana_pro() {
let client = get_fal_client();
let image_urls = vec![
GHOST_IMAGE_URL.to_string(),
TREX_SKELETON_IMAGE_URL.to_string(),
];
let request = GenerateImageRequest {
prompt: Some("Add the ghost from the first image hovering above the T-Rex skeleton in the second image, make it look spooky but friendly"),
image_inputs: Some(ImageListRef::Urls(&image_urls)),
aspect_ratio: Some(CommonAspectRatio::Auto), // edit mode: preserve source dimensions
resolution: Some(CommonResolution::TwoK),
image_batch_count: Some(1),
..base_fal_image_request()
};
let plan = request.build().unwrap();
let ImageGenerationPlan::FalNanaBananaPro(ref p) = plan else {
panic!("expected FalNanaBananaPro plan");
};
println!("Plan image_urls: {:?}", p.image_urls);
println!("Plan edit_aspect_ratio: {:?}", p.edit_aspect_ratio);
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_fal_payload().expect("expected Fal payload");
println!("Request ID: {:?}", payload.request_id);
println!("Gateway request ID: {:?}", payload.gateway_request_id);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
}
@@ -1 +0,0 @@
pub mod generate_image_fal_nano_banana_pro;
@@ -1,2 +0,0 @@
pub mod artcraft;
pub mod fal;
@@ -1,65 +0,0 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
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::fal::plan_generate_image_fal_nano_banana_pro::plan_generate_image_fal_nano_banana_pro;
pub struct GenerateImageRequest<'a> {
/// Which model to use.
pub model: CommonImageModel,
/// Which provider to use.
pub provider: Provider,
/// The prompt for the image generation.
pub prompt: Option<&'a str>,
/// Input images for image editing.
/// If present, we're doing image editing (image-to-image).
/// If absent, we're doing text-to-image.
pub image_inputs: Option<ImageListRef<'a>>,
/// The resolution to use.
pub resolution: Option<CommonResolution>,
/// The aspect ratio to use.
pub aspect_ratio: Option<CommonAspectRatio>,
/// How many images to generate.
pub image_batch_count: Option<u16>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
/// Some providers support idempotency.
/// If not supplied, we'll generate one for the required providers.
pub idempotency_token: Option<&'a str>,
}
impl<'a> GenerateImageRequest<'a> {
/// Read the image generation request, construct a plan, then yield a means to execute it.
pub fn build(&self) -> Result<ImageGenerationPlan<'_>, ArtcraftRouterError> {
match self.provider {
Provider::Artcraft => match self.model {
CommonImageModel::NanaBananaPro => {
plan_generate_image_artcraft_nano_banana_pro(self).map(ImageGenerationPlan::ArtcraftNanaBananaPro)
}
},
Provider::Fal => match self.model {
CommonImageModel::NanaBananaPro => {
plan_generate_image_fal_nano_banana_pro(self).map(ImageGenerationPlan::FalNanaBananaPro)
}
},
}
}
pub fn get_or_generate_idempotency_token(&self) -> String {
self.idempotency_token.map(|t| t.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}
}
@@ -1,34 +0,0 @@
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug)]
pub struct ArtcraftImageResponsePayload {
pub inference_job_token: InferenceJobToken,
}
#[derive(Clone, Debug)]
pub struct FalImageResponsePayload {
pub request_id: Option<String>,
pub gateway_request_id: Option<String>,
}
#[derive(Clone, Debug)]
pub enum GenerateImageResponse {
Artcraft(ArtcraftImageResponsePayload),
Fal(FalImageResponsePayload),
}
impl GenerateImageResponse {
pub fn get_artcraft_payload(&self) -> Option<ArtcraftImageResponsePayload> {
match self {
Self::Artcraft(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_fal_payload(&self) -> Option<FalImageResponsePayload> {
match self {
Self::Fal(p) => Some(p.clone()),
_ => None,
}
}
}
@@ -1,9 +0,0 @@
/// The result of estimating the cost of an image generation plan.
pub struct ImageGenerationCostEstimate {
pub cost_in_credits: Option<u64>,
pub cost_in_usd_cents: Option<u64>,
pub is_free: bool,
pub is_unlimited: bool,
pub is_rate_limited: bool,
pub has_watermark: bool,
}
@@ -1,45 +0,0 @@
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_nano_banana_pro::estimate_image_cost_artcraft_nano_banana_pro;
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_nano_banana_pro::execute_artcraft_nano_banana_pro;
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;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_pro::PlanArtcraftNanaBananaPro;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_pro::PlanFalNanaBananaPro;
#[derive(Debug)]
pub enum ImageGenerationPlan<'a> {
ArtcraftNanaBananaPro(PlanArtcraftNanaBananaPro<'a>),
FalNanaBananaPro(PlanFalNanaBananaPro<'a>),
}
impl<'a> ImageGenerationPlan<'a> {
pub async fn generate_image(
&self,
client: &RouterClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
match self {
ImageGenerationPlan::ArtcraftNanaBananaPro(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_nano_banana_pro(plan, artcraft_client).await
}
ImageGenerationPlan::FalNanaBananaPro(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_nano_banana_pro(plan, fal_client).await
}
}
}
pub fn estimate_costs(&self) -> ImageGenerationCostEstimate {
match self {
ImageGenerationPlan::ArtcraftNanaBananaPro(plan) => {
estimate_image_cost_artcraft_nano_banana_pro(plan)
}
ImageGenerationPlan::FalNanaBananaPro(plan) => {
estimate_image_cost_fal_nano_banana_pro(plan)
}
}
}
}
@@ -1,7 +0,0 @@
pub mod cost;
pub mod execute;
pub mod generate_image_request;
pub mod generate_image_response;
pub mod image_generation_cost_estimate;
pub mod image_generation_plan;
pub mod plan;
@@ -1 +0,0 @@
pub mod plan_generate_image_artcraft_nano_banana_pro;
@@ -1,451 +0,0 @@
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 artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::{
NanoBananaProMultiFunctionImageGenAspectRatio, NanoBananaProMultiFunctionImageGenImageResolution,
NanoBananaProMultiFunctionImageGenNumImages,
};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftNanaBananaPro<'a> {
pub prompt: Option<&'a str>,
/// Input images for image editing. None means text-to-image mode.
pub image_inputs: Option<&'a Vec<MediaFileToken>>,
pub aspect_ratio: Option<NanoBananaProMultiFunctionImageGenAspectRatio>,
pub resolution: Option<NanoBananaProMultiFunctionImageGenImageResolution>,
pub num_images: NanoBananaProMultiFunctionImageGenNumImages,
pub idempotency_token: String,
}
pub fn plan_generate_image_artcraft_nano_banana_pro<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftNanaBananaPro<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let is_edit_mode = request.image_inputs.is_some();
let image_inputs = resolve_image_list_ref(request.image_inputs)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, is_edit_mode, strategy)?;
let resolution = plan_resolution(request.resolution, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftNanaBananaPro {
prompt: request.prompt,
image_inputs,
aspect_ratio,
resolution,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}
fn resolve_image_list_ref<'a>(
image_list_ref: Option<ImageListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
// Nano Banana Pro supported aspect ratios:
// Text-to-image: OneByOne, FiveByFour, FourByThree, ThreeByTwo, SixteenByNine, TwentyOneByNine,
// FourByFive, ThreeByFour, TwoByThree, NineBySixteen
// Image editing: Auto (uses source image AR), plus all text-to-image ratios
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
is_edit_mode: bool,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<NanoBananaProMultiFunctionImageGenAspectRatio>, ArtcraftRouterError> {
use NanoBananaProMultiFunctionImageGenAspectRatio as NbpAr;
match aspect_ratio {
// No preference — let the model use its default
None => Ok(None),
// Auto: only valid in edit mode (use source image dimensions)
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => {
if is_edit_mode {
Ok(Some(NbpAr::Auto))
} else {
// Auto not valid for text-to-image; fall back to square default
Ok(Some(NbpAr::OneByOne))
}
}
// Direct mappings
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(NbpAr::OneByOne)),
Some(CommonAspectRatio::WideFiveByFour) => Ok(Some(NbpAr::FiveByFour)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(NbpAr::FourByThree)),
Some(CommonAspectRatio::WideThreeByTwo) => Ok(Some(NbpAr::ThreeByTwo)),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(NbpAr::SixteenByNine)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(NbpAr::TwentyOneByNine)),
Some(CommonAspectRatio::TallFourByFive) => Ok(Some(NbpAr::FourByFive)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(NbpAr::ThreeByFour)),
Some(CommonAspectRatio::TallTwoByThree) => Ok(Some(NbpAr::TwoByThree)),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(NbpAr::NineBySixteen)),
// Mismatch — TallNineByTwentyOne has no direct equivalent
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(nearest_aspect_ratio(unsupported)))
}
},
}
}
/// Pick the nearest supported aspect ratio for unsupported inputs.
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> NanoBananaProMultiFunctionImageGenAspectRatio {
use NanoBananaProMultiFunctionImageGenAspectRatio as NbpAr;
match aspect_ratio {
// TallNineByTwentyOne (0.43) — nearest is NineBySixteen (0.5625)
CommonAspectRatio::TallNineByTwentyOne => NbpAr::NineBySixteen,
_ => NbpAr::OneByOne,
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<NanoBananaProMultiFunctionImageGenImageResolution>, ArtcraftRouterError> {
use CommonResolution as R;
use NanoBananaProMultiFunctionImageGenImageResolution as NbpRes;
match resolution {
None => Ok(None),
Some(R::OneK) => Ok(Some(NbpRes::OneK)),
Some(R::TwoK) => Ok(Some(NbpRes::TwoK)),
Some(R::FourK) => Ok(Some(NbpRes::FourK)),
Some(R::ThreeK) => {
// No 3K option; default to 2K
Ok(Some(NbpRes::TwoK))
}
}
}
// Nano Banana Pro supports 1, 2, 3, and 4 images.
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<NanoBananaProMultiFunctionImageGenNumImages, ArtcraftRouterError> {
use NanoBananaProMultiFunctionImageGenNumImages as NbpN;
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(NbpN::One),
2 => Ok(NbpN::Two),
3 => Ok(NbpN::Three),
4 => Ok(NbpN::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(NbpN::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(NbpN::Four),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_image_request;
use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::{
NanoBananaProMultiFunctionImageGenAspectRatio as NbpAr,
NanoBananaProMultiFunctionImageGenImageResolution as NbpRes,
NanoBananaProMultiFunctionImageGenNumImages as NbpN,
};
// ── Aspect ratio ────────────────────────────────────────────────────────────
#[test]
fn aspect_ratio_none_is_none() {
let request = GenerateImageRequest { aspect_ratio: None, ..base_image_request() };
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(plan.aspect_ratio.is_none());
}
#[test]
fn aspect_ratio_direct_square() {
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_image_request() };
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(matches!(plan.aspect_ratio, Some(NbpAr::OneByOne)), "expected OneByOne for {:?}", ar);
}
}
#[test]
fn aspect_ratio_direct_wide() {
let cases = [
(CommonAspectRatio::WideFiveByFour, NbpAr::FiveByFour),
(CommonAspectRatio::WideFourByThree, NbpAr::FourByThree),
(CommonAspectRatio::WideThreeByTwo, NbpAr::ThreeByTwo),
(CommonAspectRatio::WideSixteenByNine, NbpAr::SixteenByNine),
(CommonAspectRatio::Wide, NbpAr::SixteenByNine),
(CommonAspectRatio::WideTwentyOneByNine, NbpAr::TwentyOneByNine),
];
for (common, expected) in cases {
let request = GenerateImageRequest { aspect_ratio: Some(common), ..base_image_request() };
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(
matches!(plan.aspect_ratio, Some(ar) if std::mem::discriminant(&ar) == std::mem::discriminant(&expected)),
"expected {:?} for {:?}", expected, common,
);
}
}
#[test]
fn aspect_ratio_direct_tall() {
let cases = [
(CommonAspectRatio::TallFourByFive, NbpAr::FourByFive),
(CommonAspectRatio::TallThreeByFour, NbpAr::ThreeByFour),
(CommonAspectRatio::TallTwoByThree, NbpAr::TwoByThree),
(CommonAspectRatio::TallNineBySixteen, NbpAr::NineBySixteen),
(CommonAspectRatio::Tall, NbpAr::NineBySixteen),
];
for (common, expected) in cases {
let request = GenerateImageRequest { aspect_ratio: Some(common), ..base_image_request() };
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(
matches!(plan.aspect_ratio, Some(ar) if std::mem::discriminant(&ar) == std::mem::discriminant(&expected)),
"expected {:?} for {:?}", expected, common,
);
}
}
#[test]
fn aspect_ratio_auto_in_edit_mode_yields_auto() {
// Auto is only valid for edit mode (image_inputs present)
for auto_ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let tokens = vec![];
let request = GenerateImageRequest {
aspect_ratio: Some(auto_ar),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_image_request()
};
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(
matches!(plan.aspect_ratio, Some(NbpAr::Auto)),
"expected Auto in edit mode for {:?}", auto_ar,
);
}
}
#[test]
fn aspect_ratio_auto_in_text_to_image_mode_falls_back_to_square() {
for auto_ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let request = GenerateImageRequest {
aspect_ratio: Some(auto_ar),
image_inputs: None,
..base_image_request()
};
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(
matches!(plan.aspect_ratio, Some(NbpAr::OneByOne)),
"expected OneByOne fallback in text-to-image for {:?}", auto_ar,
);
}
}
#[test]
fn aspect_ratio_unsupported_error_out() {
// TallNineByTwentyOne has no direct equivalent
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::TallNineByTwentyOne),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
}
#[test]
fn aspect_ratio_unsupported_nearest_match() {
// TallNineByTwentyOne (0.43) is nearest to NineBySixteen (0.5625)
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::TallNineByTwentyOne),
request_mismatch_mitigation_strategy: strategy,
..base_image_request()
};
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(
matches!(plan.aspect_ratio, Some(NbpAr::NineBySixteen)),
"expected NineBySixteen nearest match with {:?}", strategy,
);
}
}
// ── Resolution ──────────────────────────────────────────────────────────────
#[test]
fn resolution_none_is_none() {
let request = GenerateImageRequest { resolution: None, ..base_image_request() };
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(plan.resolution.is_none());
}
#[test]
fn resolution_direct_mappings() {
let cases = [
(CommonResolution::OneK, NbpRes::OneK),
(CommonResolution::TwoK, NbpRes::TwoK),
(CommonResolution::FourK, NbpRes::FourK),
];
for (common, expected) in cases {
let request = GenerateImageRequest { resolution: Some(common), ..base_image_request() };
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(
matches!(plan.resolution, Some(r) if std::mem::discriminant(&r) == std::mem::discriminant(&expected)),
"expected {:?} for {:?}", expected, common,
);
}
}
#[test]
fn resolution_three_k_falls_back_to_two_k() {
let request = GenerateImageRequest {
resolution: Some(CommonResolution::ThreeK),
..base_image_request()
};
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(matches!(plan.resolution, Some(NbpRes::TwoK)));
}
// ── Num images ──────────────────────────────────────────────────────────────
#[test]
fn num_images_zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_image_request()
};
let result = request.build();
assert!(
matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))),
"expected UserRequestedZeroGenerations with {:?}", strategy,
);
}
}
#[test]
fn num_images_direct_mapping() {
let cases = [
(1, NbpN::One),
(2, NbpN::Two),
(3, NbpN::Three),
(4, NbpN::Four),
];
for (count, expected) in cases {
let request = GenerateImageRequest { image_batch_count: Some(count), ..base_image_request() };
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(
std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected),
"expected {:?} for count {}", expected, count,
);
}
}
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
}
#[test]
fn num_images_out_of_range_clamps_to_four() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_image_request()
};
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(
matches!(plan.num_images, NbpN::Four),
"expected Four for count 5 with {:?}", strategy,
);
}
}
// ── Image inputs ─────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
#[test]
fn no_image_inputs_is_text_to_image_mode() {
let request = GenerateImageRequest { image_inputs: None, ..base_image_request() };
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(plan.image_inputs.is_none());
}
#[test]
fn media_token_image_inputs_is_edit_mode() {
let tokens = vec![];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_image_request()
};
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBananaPro") };
assert!(plan.image_inputs.is_some());
}
}
@@ -1 +0,0 @@
pub mod plan_generate_image_fal_nano_banana_pro;
@@ -1,530 +0,0 @@
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 fal_client::requests::webhook::image::edit::enqueue_nano_banana_pro_edit_image_webhook::EnqueueNanoBananaProEditImageAspectRatio;
use fal_client::requests::webhook::image::text::enqueue_nano_banana_pro_text_to_image_webhook::EnqueueNanoBananaProTextToImageAspectRatio;
/// Intermediate resolution type shared between t2i and edit enums
/// (both have the same variants: OneK, TwoK, FourK).
#[derive(Debug, Clone, Copy)]
pub enum FalNbpResolution {
OneK,
TwoK,
FourK,
}
/// Intermediate num-images type shared between t2i and edit enums
/// (both have the same variants: One, Two, Three, Four).
#[derive(Debug, Clone, Copy)]
pub enum FalNbpNumImages {
One,
Two,
Three,
Four,
}
#[derive(Debug, Clone)]
pub struct PlanFalNanaBananaPro<'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<EnqueueNanoBananaProTextToImageAspectRatio>,
/// Pre-resolved aspect ratio for image-edit mode.
pub edit_aspect_ratio: Option<EnqueueNanoBananaProEditImageAspectRatio>,
/// Pre-resolved resolution (shared for both modes).
pub resolution: Option<FalNbpResolution>,
/// Pre-resolved number of images (1–4, shared for both modes).
pub num_images: FalNbpNumImages,
}
pub fn plan_generate_image_fal_nano_banana_pro<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanFalNanaBananaPro<'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(PlanFalNanaBananaPro {
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))
}
}
}
// Nano Banana Pro text-to-image supported aspect ratios:
// OneByOne, FiveByFour, FourByThree, ThreeByTwo, SixteenByNine, TwentyOneByNine,
// FourByFive, ThreeByFour, TwoByThree, NineBySixteen
// (no Auto — that's edit-only)
fn plan_t2i_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<EnqueueNanoBananaProTextToImageAspectRatio>, ArtcraftRouterError> {
use EnqueueNanoBananaProTextToImageAspectRatio 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)),
// Direct mappings
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)),
// Mismatch — TallNineByTwentyOne has no direct equivalent
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(nearest_t2i_aspect_ratio(unsupported)))
}
},
}
}
fn nearest_t2i_aspect_ratio(
aspect_ratio: CommonAspectRatio,
) -> EnqueueNanoBananaProTextToImageAspectRatio {
use EnqueueNanoBananaProTextToImageAspectRatio as T2iAr;
match aspect_ratio {
// TallNineByTwentyOne (0.43) — nearest is NineBySixteen (0.5625)
CommonAspectRatio::TallNineByTwentyOne => T2iAr::NineBySixteen,
_ => T2iAr::OneByOne,
}
}
// Nano Banana Pro image-edit supported aspect ratios:
// Auto (use source image dims), plus all text-to-image ratios
fn plan_edit_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
is_edit_mode: bool,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<EnqueueNanoBananaProEditImageAspectRatio>, ArtcraftRouterError> {
use EnqueueNanoBananaProEditImageAspectRatio as EditAr;
match aspect_ratio {
None => Ok(None),
// Auto: valid in edit mode (use source image dimensions)
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => {
if is_edit_mode {
Ok(Some(EditAr::Auto))
} else {
Ok(Some(EditAr::OneByOne))
}
}
// Direct mappings
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)),
// Mismatch
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(nearest_edit_aspect_ratio(unsupported)))
}
},
}
}
fn nearest_edit_aspect_ratio(
aspect_ratio: CommonAspectRatio,
) -> EnqueueNanoBananaProEditImageAspectRatio {
use EnqueueNanoBananaProEditImageAspectRatio as EditAr;
match aspect_ratio {
CommonAspectRatio::TallNineByTwentyOne => EditAr::NineBySixteen,
_ => EditAr::OneByOne,
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
) -> Result<Option<FalNbpResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(CommonResolution::OneK) => Ok(Some(FalNbpResolution::OneK)),
Some(CommonResolution::TwoK) => Ok(Some(FalNbpResolution::TwoK)),
Some(CommonResolution::FourK) => Ok(Some(FalNbpResolution::FourK)),
// No 3K option; fall back to 2K
Some(CommonResolution::ThreeK) => Ok(Some(FalNbpResolution::TwoK)),
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalNbpNumImages, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(FalNbpNumImages::One),
2 => Ok(FalNbpNumImages::Two),
3 => Ok(FalNbpNumImages::Three),
4 => Ok(FalNbpNumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(FalNbpNumImages::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(FalNbpNumImages::Four),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use fal_client::requests::webhook::image::edit::enqueue_nano_banana_pro_edit_image_webhook::EnqueueNanoBananaProEditImageAspectRatio as EditAr;
use fal_client::requests::webhook::image::text::enqueue_nano_banana_pro_text_to_image_webhook::EnqueueNanoBananaProTextToImageAspectRatio as T2iAr;
fn base_fal_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::NanaBananaPro,
provider: Provider::Fal,
prompt: Some("a cat in space"),
image_inputs: None,
resolution: None,
aspect_ratio: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
}
}
fn build_plan<'a>(
request: &'a GenerateImageRequest<'a>,
) -> PlanFalNanaBananaPro<'a> {
plan_generate_image_fal_nano_banana_pro(request).expect("plan should succeed")
}
// ── Image inputs / mode detection ────────────────────────────────────────────
#[test]
fn no_image_inputs_yields_empty_urls() {
let request = GenerateImageRequest { image_inputs: None, ..base_fal_request() };
let plan = build_plan(&request);
assert!(plan.image_urls.is_empty());
}
#[test]
fn url_image_inputs_are_extracted() {
let urls = vec![
"https://example.com/a.jpg".to_string(),
"https://example.com/b.jpg".to_string(),
];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_fal_request()
};
let plan = build_plan(&request);
assert_eq!(plan.image_urls, urls);
}
#[test]
fn media_token_inputs_return_error() {
let tokens = vec![];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_fal_request()
};
let result = plan_generate_image_fal_nano_banana_pro(&request);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
// ── Text-to-image aspect ratio ────────────────────────────────────────────────
#[test]
fn t2i_aspect_ratio_none_is_none() {
let request = GenerateImageRequest { aspect_ratio: None, ..base_fal_request() };
let plan = build_plan(&request);
assert!(plan.t2i_aspect_ratio.is_none());
}
#[test]
fn t2i_aspect_ratio_auto_falls_back_to_square() {
for auto_ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let request = GenerateImageRequest {
aspect_ratio: Some(auto_ar),
image_inputs: None,
..base_fal_request()
};
let plan = build_plan(&request);
assert!(
matches!(plan.t2i_aspect_ratio, Some(T2iAr::OneByOne)),
"expected OneByOne fallback in text-to-image for {:?}", auto_ar,
);
}
}
#[test]
fn t2i_aspect_ratio_direct_mappings() {
let cases = [
(CommonAspectRatio::Square, T2iAr::OneByOne),
(CommonAspectRatio::SquareHd, T2iAr::OneByOne),
(CommonAspectRatio::WideFiveByFour, T2iAr::FiveByFour),
(CommonAspectRatio::WideFourByThree, T2iAr::FourByThree),
(CommonAspectRatio::WideThreeByTwo, T2iAr::ThreeByTwo),
(CommonAspectRatio::WideSixteenByNine, T2iAr::SixteenByNine),
(CommonAspectRatio::Wide, T2iAr::SixteenByNine),
(CommonAspectRatio::WideTwentyOneByNine, T2iAr::TwentyOneByNine),
(CommonAspectRatio::TallFourByFive, T2iAr::FourByFive),
(CommonAspectRatio::TallThreeByFour, T2iAr::ThreeByFour),
(CommonAspectRatio::TallTwoByThree, T2iAr::TwoByThree),
(CommonAspectRatio::TallNineBySixteen, T2iAr::NineBySixteen),
(CommonAspectRatio::Tall, T2iAr::NineBySixteen),
];
for (common, expected) in cases {
let request = GenerateImageRequest { aspect_ratio: Some(common), ..base_fal_request() };
let plan = build_plan(&request);
assert!(
matches!(plan.t2i_aspect_ratio, Some(ar) if std::mem::discriminant(&ar) == std::mem::discriminant(&expected)),
"expected {:?} for {:?}", expected, common,
);
}
}
#[test]
fn t2i_aspect_ratio_unsupported_error_out() {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::TallNineByTwentyOne),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_fal_request()
};
let result = plan_generate_image_fal_nano_banana_pro(&request);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
}
#[test]
fn t2i_aspect_ratio_unsupported_nearest_match() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::TallNineByTwentyOne),
request_mismatch_mitigation_strategy: strategy,
..base_fal_request()
};
let plan = build_plan(&request);
assert!(
matches!(plan.t2i_aspect_ratio, Some(T2iAr::NineBySixteen)),
"expected NineBySixteen nearest match with {:?}", strategy,
);
}
}
// ── Edit aspect ratio ─────────────────────────────────────────────────────────
#[test]
fn edit_aspect_ratio_auto_in_edit_mode_yields_auto() {
let urls = vec!["https://example.com/img.jpg".to_string()];
for auto_ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let request = GenerateImageRequest {
aspect_ratio: Some(auto_ar),
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_fal_request()
};
let plan = build_plan(&request);
assert!(
matches!(plan.edit_aspect_ratio, Some(EditAr::Auto)),
"expected Auto in edit mode for {:?}", auto_ar,
);
}
}
#[test]
fn edit_aspect_ratio_auto_in_text_to_image_falls_back_to_square() {
for auto_ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let request = GenerateImageRequest {
aspect_ratio: Some(auto_ar),
image_inputs: None,
..base_fal_request()
};
let plan = build_plan(&request);
assert!(
matches!(plan.edit_aspect_ratio, Some(EditAr::OneByOne)),
"expected OneByOne in text-to-image for {:?}", auto_ar,
);
}
}
// ── Resolution ────────────────────────────────────────────────────────────────
#[test]
fn resolution_none_is_none() {
let request = GenerateImageRequest { resolution: None, ..base_fal_request() };
let plan = build_plan(&request);
assert!(plan.resolution.is_none());
}
#[test]
fn resolution_direct_mappings() {
let cases = [
(CommonResolution::OneK, FalNbpResolution::OneK),
(CommonResolution::TwoK, FalNbpResolution::TwoK),
(CommonResolution::FourK, FalNbpResolution::FourK),
];
for (common, expected) in cases {
let request = GenerateImageRequest { resolution: Some(common), ..base_fal_request() };
let plan = build_plan(&request);
assert!(
matches!(plan.resolution, Some(r) if std::mem::discriminant(&r) == std::mem::discriminant(&expected)),
"expected {:?} for {:?}", expected, common,
);
}
}
#[test]
fn resolution_three_k_falls_back_to_two_k() {
let request = GenerateImageRequest {
resolution: Some(CommonResolution::ThreeK),
..base_fal_request()
};
let plan = build_plan(&request);
assert!(matches!(plan.resolution, Some(FalNbpResolution::TwoK)));
}
// ── Num images ────────────────────────────────────────────────────────────────
#[test]
fn num_images_zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_fal_request()
};
let result = plan_generate_image_fal_nano_banana_pro(&request);
assert!(
matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))),
"expected UserRequestedZeroGenerations with {:?}", strategy,
);
}
}
#[test]
fn num_images_direct_mapping() {
let cases = [
(1, FalNbpNumImages::One),
(2, FalNbpNumImages::Two),
(3, FalNbpNumImages::Three),
(4, FalNbpNumImages::Four),
];
for (count, expected) in cases {
let request = GenerateImageRequest { image_batch_count: Some(count), ..base_fal_request() };
let plan = build_plan(&request);
assert!(
std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected),
"expected {:?} for count {}", expected, count,
);
}
}
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_fal_request()
};
let result = plan_generate_image_fal_nano_banana_pro(&request);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
}
#[test]
fn num_images_out_of_range_clamps_to_four() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_fal_request()
};
let plan = build_plan(&request);
assert!(
matches!(plan.num_images, FalNbpNumImages::Four),
"expected Four for count 5 with {:?}", strategy,
);
}
}
}
@@ -1,2 +0,0 @@
pub mod artcraft;
pub mod fal;

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