Compare commits

..
Author SHA1 Message Date
Brandon Thomas f9d5a9bf92 error utils (reusable) 2026-04-13 17:46:29 -04:00
Brandon Thomas 640e392d6c observability into more unknown pages (#1354) 2026-04-13 17:35:54 -04:00
Brandon Thomas 45651103f0 kinovi credits check + alerting (#1352) 2026-04-12 22:33:31 -04:00
Brandon Thomas cba8e2c074 kinovi jobs - better logging (#1351) 2026-04-12 21:24:36 -04:00
Brandon Thomas 07d9d537dd page on image batch failures (#1350) 2026-04-12 19:11:24 -04:00
Brandon Thomas 5770534fc3 error messages on failure to download (#1349) 2026-04-12 18:40:00 -04:00
Brandon Thomas 6fe0909879 kinovi order polling threshold in hours (#1348) 2026-04-12 17:18:17 -04:00
Brandon Thomas 834f0b48a9 fal webhook fixes 2 (#1347) 2026-04-12 05:32:16 -04:00
Brandon Thomas d64944b1c3 artcraft 0.26 -> 0.27 (#1346) 2026-04-12 03:10:01 -04:00
Brandon Thomas 03edd5faf6 tauri cors fix (#1344) 2026-04-12 03:03:07 -04:00
Brandon Thomas 0ef57ff97e fal webhook error handling (#1343)
extractors for fal webhook data with tests
2026-04-12 02:04:22 -04:00
Brandon Thomas adda7483e8 fix billing pages (#1342) 2026-04-11 19:45:58 -04:00
Tanish Manku e77b946d57 fix brief No items yet flash after username fetch resolves (#1336)
setInitialLoading(true) and setUsername(name) are batched together so
React never produces a frame where username is set but initialLoading is
still false, which would fall through to the No items yet branch before
refreshGallery has a chance to set initialLoading(true) itself.
2026-04-10 08:11:59 -07:00
Brandon Thomas 3a6fad1812 bump artcraft 0.25 -> 0.26 (#1335) 2026-04-10 10:45:31 -04:00
Brandon Thomas 9e04990224 character tokens in omni api (#1333) 2026-04-10 10:25:31 -04:00
Brandon Thomas 885b68630a bump artcraft 0.24 -> 0.25 (#1332) 2026-04-10 10:15:04 -04:00
Brandon Thomas 9fee0b1c99 rename omni image params before widespread use (#1331) 2026-04-10 09:08:31 -04:00
Brandon Thomas 631b98f605 omni video handling + tests for seedream 1.5 (rest to follow) (#1330)
adjusts handling, and begins to add unit tests to ensure this works
2026-04-10 09:04:26 -04:00
Brandon Thomas 3334cf7b81 omni image tests (#1329)
This is a substantial number of tests to ensure we don't screw up parameters when migrating over to "omni image generation" from the distinct one-off, model-by-model endpoints.
2026-04-10 01:18:40 -04:00
Tanish Manku 50d8abcfb1 Fix gallery showing blank when session fetch fails or is slow (#1328)
- Remove useless forceFilterRef and its stale useEffect; use forceFilter
prop directly in JSX
- Show skeleton immediately while username is being fetched (!username
condition) instead of flashing "No items yet"
- Track username fetch failure with usernameError state and surface an
error message with a working Retry button
- Fix retry: setUsername("") was a no-op (same value → React bails out);
use usernameRetryCount in deps to actually re-trigger the fetch
- Guard error UI with allItems.length === 0 so stale items stay visible
if a retry fails mid-session
2026-04-09 19:11:02 -07:00
Tanish Mankuandbflatastic bce8d166e0 Feature: Multifile upload (#1294)
Notable behavior:

- Mixing supported and/or unsupported file types will only keep files of
file type that match the first supported file type in the list received
from browser (e.g. PNG, OBJ, EXE, JPG, OBJ order selection of files will
only show PNG, JPG for image upload)
- Removing files until only one is left will result in the sidebar
vanishing and only a single preview showing. More files can be added by
selecting multiple files again through the file selection button
- Failure to upload has been handled but not tested. It is expected any
failed uploads could be retried with a "retry failed" button. Any
individual fail can also be retried via a retry button upon hovering on
the exclamation mark by the failed file's name
- All multi-file uploads are queued async and likely wouldn't be ordered
as shown in the preview list (first-come-first-serve)
- Potentially deadlocks if a task never responds. Likely handled by
backend timeouts. This deadlock state is only partial and not lossy as
successfully uploaded files will appear in gallery even if the task is
cancelled. Cancellation can be attempted at any point during the upload.
Uploads shown in progress may be successful after cancellation depending
on the internal upload progress.
- TopBar upload images button now opens the image modal instead for
consistency and same-component use.

---------

Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-04-09 09:07:21 -07:00
frankywashereandClaude Opus 4.6 5fc27a5153 Fix blank screen crash and text selection loss in video prompt box (#1315)
## Summary
- **Fix blank screen crash** during video recreate flow: typing text,
adjusting time slider, typing again, then clicking the text box caused
an uncaught DOM exception in MentionTextarea's contentEditable caret
operations, which unmounted the entire React tree (no error boundaries
existed)
- **Fix text selection disappearing** on mouse release in
MentionTextarea: click handler and sync useEffect were destroying active
selections by rewriting innerHTML and restoring only a collapsed caret
- **Add React Error Boundary** around PromptBoxVideo as a safety net

## Changes
### MentionTextarea.tsx
- Wrap `getCaretOffset`, `setCaretOffset`, `scrollCaretIntoView`,
`handleInput`, sync `useEffect`, and `getOffsetRect` in try/catch to
prevent uncaught DOMExceptions
- Add null guard for `childNodes` iteration and `parentNode` access
- Skip `detectMention` in `handleClick` when user has active text
selection
- Skip innerHTML rewrite in sync useEffect when editor has selected text

### PromptBoxVideo.tsx
- Stabilize `characterNames` with `useMemo` to prevent `mentionColorMap`
from rebuilding every render
- Move empty characters array to module-level constant for stable
reference
- Add cleanup `useEffect` for `durationTimerRef` to prevent stale timer
after unmount

### New: PromptBoxErrorBoundary.tsx
- React Error Boundary with "Try again" recovery button

### ImageToVideo.tsx
- Wrap `<PromptBoxVideo>` with `<PromptBoxErrorBoundary>`

## Reproduction steps (original bug)
1. Click "Recreate" on a video
2. Type text into the prompt box
3. Adjust the time slider
4. Type text again
5. Adjust the time slider and click the text box → **UI goes blank**

## Test plan
- [x] Reproduced blank screen bug on Mac before fix
- [x] Verified fix: repeated recreation flow no longer causes blank
screen
- [x] Verified text selection persists after mouse release
- [x] Video generation with @mentions, reference images, and duration
changes works correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:03:59 -07:00
Tanish Manku 51e5db6b12 Text2Image carousel: cross-set navigation at arrow key boundaries (#1322)
Instead of looping within the image set, pressing Right on the last
image advances to the next set and Left on the first image goes to the
previous set, consistent with Up/Down behavior.
2026-04-08 23:46:19 -07:00
Brandon Thomas ff1ce82f66 transcode quicktime (#1318) 2026-04-08 10:27:14 -04:00
bflatastic ccea741443 fix safari mobile api calls/login not working since its third party 2026-04-08 14:45:29 +07:00
Brandon Thomas 2b9882d2d7 fix tests 2026-04-08 00:03:47 -04:00
bflatastic 55cab30dec comment out web generation access for now 2026-04-08 10:06:22 +07:00
Brandon Thomas a2f204b1be additional context for pages (#1317) 2026-04-07 19:07:49 -04:00
Brandon Thomas 12e4ff7a29 Omni Gen Video Models (#1316) 2026-04-07 17:05:52 -04:00
Brandon Thomas 8b73569238 omni image models (#1314) 2026-04-07 03:38:23 -04:00
Brandon Thomas badabe57eb fix inference-job 2026-04-07 00:49:18 -04:00
Brandon Thomas 2e62eef85b Bt cleanup web (#1313) 2026-04-07 00:43:21 -04:00
Brandon Thomas fd9f002615 avt cleanup (fix paging) (#1312) 2026-04-07 00:29:18 -04:00
Brandon Thomas a0e50067bb stop the session and session jobs alert pages by fixing error handling (#1310)
- was still getting dozens of pages from these two endpoints
- fixed the error chain so this is semantically correct
- improved code quality of legacy code/endpoints
2026-04-06 20:56:28 -04:00
Brandon Thomas 2b97694ebf omni gen for a few image models (#1308) 2026-04-06 04:10:14 -04:00
Brandon Thomas 0f6998cb6b bump artcraft 0.23 -> 0.24 (#1307) 2026-04-05 17:57:42 -04:00
Brandon Thomas 8076d1b830 fix builds (#1306) 2026-04-05 02:55:08 -04:00
Brandon Thomas 9842734ce7 more cleanup of deprecated or orphaned code (#1305) 2026-04-05 02:35:55 -04:00
Brandon Thomas 72761e11f3 delete public enums crate - old tech debt / cruft (#1304)
Ages ago I tried to hide what we were doing from "Uberduck". That was
stupid. As if it mattered.

Never again.
2026-04-04 23:04:32 -04:00
bflatastic 60c97ff312 characters modal bug overflowing on very small screens 2026-04-05 06:40:55 +07:00
BFlat 03ed457cb5 remove character limits on seedance 2 and nano banana models (#1303) 2026-04-05 06:24:30 +07:00
Brandon Thomas e89229896f character descriptions max length (#1302) 2026-04-04 16:50:44 -04:00
Brandon Thomas 56c1b57cce bump website version to 0.23 2026-04-03 19:16:02 -04:00
bflatastic 122c1d6688 mention text area small styling fix 2026-04-04 05:40:52 +07:00
Brandon Thomas 6768208af3 bump artcraft 0.22 -> 0.23 2026-04-03 18:31:21 -04:00
Brandon Thomas 99b5a9774a Frontend failure text updates (#1300) 2026-04-03 18:20:06 -04:00
BFlat f55a4c722e mentions text area (#1299)
- content editable div instead of textarea when mentions is needed
- no more overlayed text areas and misaligned cursors
2026-04-04 05:16:14 +07:00
Tanish Manku f337d630e3 Fix: Allow asset.localhost in CSP for Windows drag-drop (#1298)
On Windows production builds, convertFileSrc() generates
https://asset.localhost/... URLs. The connect-src CSP only had "asset:"
(covering asset:// on macOS/Linux) but not https://asset.localhost,
causing the fetch() in GlobalFileDropHandler to be silently blocked —
overlay appeared but no modal opened on drop.
2026-04-03 14:46:32 -07:00
Brandon Thomas b4e1e3ea57 marketing tracking (#1297) 2026-04-03 15:06:38 -04:00
bflatastic 71fd694368 maybe_landing_url signup params 2026-04-03 23:51:11 +07:00
bflatastic f2c386dcb9 Merge branch 'main' of https://github.com/storytold/storyteller-rust 2026-04-03 23:39:36 +07:00
bflatastic ffb8cbe433 landing videos updates 2026-04-03 23:39:09 +07:00
Brandon Thomas 5f062c4512 new optional signup params 2026-04-03 12:37:33 -04:00
Brandon Thomas 2442cda22f bump ArtCraft 0.21 -> 0.22 (#1296) 2026-04-03 11:36:17 -04:00
Brandon Thomas 0a534921ce fix mac build 2026-04-03 11:34:46 -04:00
Brandon Thomas 1fecc4405c Bump ArtCraft 0.20 -> 0.21 (#1295) 2026-04-03 11:05:47 -04:00
bflatastic cbca7e3cb4 confirm before deleting character alert dialog 2026-04-03 15:28:40 +07:00
BFlat d107b78498 characters (#1280)
- Create, list, delete characters for use as reference with seedance 2.0
- Characters are mentionable in the prompt box by using the @ sign
2026-04-03 15:19:41 +07:00
Brandon Thomas 702bdc8777 tauri characters via artcraft_router (#1293) 2026-04-03 03:37:04 -04:00
Brandon Thomas 4fe36781de characters job (#1292) 2026-04-03 02:50:04 -04:00
bflatastic 029375052d fix lightbox carousel not loading on second open 2026-04-03 12:06:21 +07:00
Brandon Thomas 72d680411f characters API and schema (#1290) 2026-04-03 00:17:03 -04:00
bflatastic 79a7443baa fixed library arrow keys navigation 2026-04-03 09:06:29 +07:00
Brandon Thomas 004a5c157a rich paging on both fal and seedance webhook handlers (#1289) 2026-04-02 03:51:46 -04:00
BFlat 63d3d63709 promptbox expand overflow bug fixed (#1288) 2026-04-02 12:45:22 +07:00
Brandon Thomas d6e23838ce advanced http errors (#1287) 2026-04-02 01:31:03 -04:00
BFlat 6785db1d59 task queue: group fail messages in tooltip, prompt message on every card type (#1286)
- grouped fail message with fail reason under one tooltip for cleaner ui
- prompt / copy prompt for every type of task card
- larger thumbnails, wider task queue dropdown
- removed prompt hover on card thumbnails
- marquee css updated
- other styling cleanups
2026-04-02 10:13:52 +07:00
675 changed files with 36911 additions and 6557 deletions
+5 -1
View File
@@ -5,7 +5,7 @@
- Two spaces for indentation (not four)
- No minimum supported Rust version; use latest stable features freely
- `#[macro_use] extern crate serde_derive;` is used in binary crates; library crates use `use serde_derive::{Serialize, Deserialize};`
- Prefer `log` crate macros (`info!`, `warn!`, `error!`) over `println!`
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros (`info!`, `warn!`, `error!`, `debug!`, `trace!`) instead
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code
- Enum Display/Debug: use `impl_enum_display_and_debug_using_to_str!` macro, not `derive_more::Display` (CLion doesn't understand it)
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token`
@@ -51,6 +51,10 @@ then the story, then the fine print.
- Group imports: std, external crates, internal workspace crates, `crate::` imports
- Use fully qualified paths for one-off references; `use` for repeated references
- Prefer specific imports over wildcards, except in `api_doc.rs` and test modules
- When two crates export a type with the same name, alias with a suffix describing the source crate:
`use enums::...::CommonResolution as CommonResolutionEnum;`
`use artcraft_router::...::CommonResolution as CommonResolutionRouter;`
This avoids long fully-qualified paths in function signatures and bodies.
## TypeScript / Frontend
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO characters\nSET\n token = ?,\n character_type = ?,\n is_active = false,\n character_name = ?,\n maybe_description = ?,\n maybe_avatar_media_token = NULL,\n maybe_full_image_media_token = NULL,\n maybe_original_upload_media_token = ?,\n maybe_creator_user_token = ?,\n creator_ip_address = ?,\n kinovi_character_id = ?,\n kinovi_character_name = ?,\n maybe_kinovi_asset_id = NULL,\n maybe_generic_inference_job_token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "2a19cc533815aa3325b940eafe56b9f014fedb3b360b954935d6ec13ff14302d"
}
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO users\nSET\n token = ?,\n \n is_temporary = ?,\n \n username = ?,\n display_name = ?,\n\n username_is_generated = ?,\n username_is_not_customized = ?,\n\n email_address = ?,\n email_gravatar_hash = ?,\n\n email_confirmed = FALSE,\n email_confirmed_by_google = ?,\n email_is_synthetic = ?,\n \n was_eagerly_provisioned = ?,\n\n profile_markdown = ?,\n profile_rendered_html = ?,\n user_role_slug = ?,\n\n password_hash = ?,\n is_without_password = ?,\n\n maybe_feature_flags = ?,\n\n ip_address_creation = ?,\n ip_address_last_login = ?,\n ip_address_last_update = ?,\n\n maybe_source = ?,\n\n maybe_signup_method = ?,\n\n maybe_referral_url = ?\n ",
"query": "\nINSERT INTO users\nSET\n token = ?,\n \n is_temporary = ?,\n \n username = ?,\n display_name = ?,\n\n username_is_generated = ?,\n username_is_not_customized = ?,\n\n email_address = ?,\n email_gravatar_hash = ?,\n\n email_confirmed = FALSE,\n email_confirmed_by_google = ?,\n email_is_synthetic = ?,\n \n was_eagerly_provisioned = ?,\n\n profile_markdown = ?,\n profile_rendered_html = ?,\n user_role_slug = ?,\n\n password_hash = ?,\n is_without_password = ?,\n\n maybe_feature_flags = ?,\n\n ip_address_creation = ?,\n ip_address_last_login = ?,\n ip_address_last_update = ?,\n\n maybe_source = ?,\n\n maybe_signup_method = ?,\n\n maybe_referral_url = ?,\n maybe_landing_url = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 23
"Right": 24
},
"nullable": []
},
"hash": "08996721482fe897c2721298c04cf0eba5fcf1d04d57397b8afab3dd59feaa8c"
"hash": "3aacc0d270a9073ee5a38595cc32ab7793e0e8b9ce3ad81f554e7198fd0fd509"
}
@@ -0,0 +1,157 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n token as `token: tokens::tokens::characters::CharacterToken`,\n character_type as `character_type: enums::by_table::characters::character_type::CharacterType`,\n is_active as `is_active: bool`,\n character_name,\n maybe_description,\n maybe_avatar_media_token as `maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_full_image_media_token as `maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_original_upload_media_token as `maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n kinovi_character_id,\n kinovi_character_name,\n maybe_kinovi_asset_id\nFROM characters\nWHERE maybe_creator_user_token = ?\n AND is_active = true\n AND deleted_at IS NULL\n AND id < ?\nORDER BY id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: tokens::tokens::characters::CharacterToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "character_type: enums::by_table::characters::character_type::CharacterType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "is_active: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 4,
"name": "character_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 6,
"name": "maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 10,
"name": "kinovi_character_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 11,
"name": "kinovi_character_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 12,
"name": "maybe_kinovi_asset_id",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 256
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "8c130ebd0d192355844863b3ebd44952aa8715eebe96375fb28f9f12baf7f9b1"
}
@@ -0,0 +1,47 @@
{
"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 `kinovi_character_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.job_type = ?\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": "kinovi_character_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
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
true,
true
]
},
"hash": "9bd472515a1ec55a4b3eed9b37baea3a9740484172086b1797889a5f88b5750f"
}
@@ -1,6 +1,6 @@
{
"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 ",
"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.job_type = ?\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": [
{
@@ -85,7 +85,7 @@
}
],
"parameters": {
"Right": 1
"Right": 2
},
"nullable": [
false,
@@ -98,5 +98,5 @@
true
]
},
"hash": "cb33fd483f0fef7ec2a1f5950218f312a19e38c7971b88db949ccaa05bf7b7c4"
"hash": "a0002183d4d077b07bb0cd062ca95bf113d1564ce3195f0e62e531ce4297dd76"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE characters\nSET\n is_active = true,\n maybe_avatar_media_token = ?,\n maybe_full_image_media_token = ?,\n maybe_kinovi_asset_id = ?\nWHERE token = ?\n AND deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "abbd24f51fae54ba3cd54ec7e3caf1ed7121b244796331d56b2d7f20eb7d4b9b"
}
@@ -0,0 +1,12 @@
{
"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 = NULL,\n\n maybe_wallet_ledger_entry_token = NULL,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = NULL,\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": 12
},
"nullable": []
},
"hash": "b0ba0fe63908df3d66b4820ca47cbb773786e480464c84a278fe63b1fa8913b4"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: tokens::tokens::characters::CharacterToken`,\n character_type as `character_type: enums::by_table::characters::character_type::CharacterType`,\n is_active as `is_active: bool`,\n character_name,\n maybe_description,\n maybe_avatar_media_token as `maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_full_image_media_token as `maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_original_upload_media_token as `maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n kinovi_character_id,\n kinovi_character_name,\n maybe_kinovi_asset_id\nFROM characters\nWHERE token = ?\n AND deleted_at IS NULL\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::characters::CharacterToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "character_type: enums::by_table::characters::character_type::CharacterType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 2,
"name": "is_active: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 3,
"name": "character_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 5,
"name": "maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "kinovi_character_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 10,
"name": "kinovi_character_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 11,
"name": "maybe_kinovi_asset_id",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 256
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "e9078c593087df3da20dbd193cdad102d6bb3ae83ee81c8c4ad75fe184e03c92"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE characters\nSET\n character_name = ?,\n kinovi_character_name = ?,\n maybe_description = ?\nWHERE token = ?\n AND deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "f54e22efa17f3b8d36387dfd724e1fd0c88634fc8defa420ddd2de7e9103518c"
}
+2
View File
@@ -60,6 +60,8 @@ artcraft/
- Actix-web for HTTP services
- SQLx for MySQL and SQLite
- A mix of wreq and reqwest for Rust HTTP clients
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
- When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;`
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
Generated
+25 -39
View File
@@ -508,7 +508,6 @@ version = "0.0.1"
dependencies = [
"anyhow",
"chrono",
"config",
"datetimes",
"dotenv",
"easyenv",
@@ -523,6 +522,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"tokens",
"tokio",
@@ -727,7 +727,6 @@ dependencies = [
"anyhow",
"chrono",
"enums",
"enums_public",
"serde",
"serde_derive",
"serde_json",
@@ -748,7 +747,6 @@ dependencies = [
"cloudflare_errors",
"cookie 0.18.1",
"enums",
"enums_public",
"errors",
"log",
"mimetypes",
@@ -3133,7 +3131,6 @@ dependencies = [
"bucket_paths",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
"elasticsearch",
"enums",
@@ -3144,6 +3141,7 @@ dependencies = [
"mimetypes",
"mysql_queries",
"rand 0.9.2",
"shared_env_var_config",
"sqlx",
"storyteller_root",
"tokens",
@@ -3159,7 +3157,6 @@ dependencies = [
"bucket_paths",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
"enums",
"errors",
@@ -3167,6 +3164,7 @@ dependencies = [
"log",
"mimetypes",
"mysql_queries",
"shared_env_var_config",
"sqlx",
"tokens",
"tokio",
@@ -3438,6 +3436,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"storyteller_root",
"subprocess",
@@ -3445,6 +3444,7 @@ dependencies = [
"tempdir",
"tokens",
"tokio",
"url",
"workspace-build-acceleration",
"zip 0.6.6",
]
@@ -3610,7 +3610,6 @@ version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.60",
"config",
"easyenv",
"elasticsearch",
"elasticsearch_schema",
@@ -3622,6 +3621,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"storyteller_root",
"strum",
@@ -3701,6 +3701,7 @@ dependencies = [
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"sqlx",
"workspace-build-acceleration",
]
@@ -3794,20 +3795,6 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "enums_public"
version = "0.0.1"
dependencies = [
"enums",
"serde",
"serde_derive",
"serde_json",
"strum",
"toml 1.0.7+spec-1.1.0",
"utoipa",
"workspace-build-acceleration",
]
[[package]]
name = "env"
version = "1.0.1"
@@ -3889,6 +3876,13 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "error_utils"
version = "0.0.1"
dependencies = [
"regex",
]
[[package]]
name = "errors"
version = "0.0.1"
@@ -3904,7 +3898,6 @@ dependencies = [
"anyhow",
"bootstrap",
"chrono",
"config",
"dotenv",
"easyenv",
"elasticsearch",
@@ -3923,6 +3916,7 @@ dependencies = [
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"sqlx",
"storyteller_root",
"tokens",
@@ -5764,6 +5758,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"subprocess",
"subprocess_common",
@@ -6101,7 +6096,6 @@ dependencies = [
name = "jwt_signer"
version = "0.0.1"
dependencies = [
"errors",
"hmac 0.10.1",
"jwt",
"sha2 0.9.9",
@@ -6146,13 +6140,13 @@ dependencies = [
name = "kubectl-pod-cleanup"
version = "0.0.1"
dependencies = [
"config",
"easyenv",
"errors",
"log",
"once_cell",
"rand 0.8.5",
"regex",
"shared_env_var_config",
"workspace-build-acceleration",
]
@@ -6856,7 +6850,6 @@ dependencies = [
"bucket_paths",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
"enums",
"errors",
@@ -6867,6 +6860,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"storyteller_root",
"strum",
@@ -8657,16 +8651,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "prompt_engineering"
version = "0.1.0"
dependencies = [
"once_cell",
"regex",
"speculoos",
"workspace-build-acceleration",
]
[[package]]
name = "prost"
version = "0.12.6"
@@ -10195,9 +10179,9 @@ dependencies = [
"anyhow",
"bootstrap",
"bucket_paths",
"chrono",
"cloud_storage",
"concurrency",
"config",
"easyenv",
"enums",
"errors",
@@ -11257,7 +11241,7 @@ dependencies = [
"elasticsearch",
"elasticsearch_schema",
"enums",
"enums_public",
"error_utils",
"errors",
"fal_client",
"ffmpeg_utils",
@@ -11288,7 +11272,6 @@ dependencies = [
"pager",
"password",
"primitives",
"prompt_engineering",
"r2d2",
"rand 0.9.2",
"redis 1.0.2",
@@ -12888,6 +12871,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"subprocess",
"tempdir",
@@ -12921,6 +12905,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"tempdir",
"tokio",
@@ -13377,7 +13362,6 @@ dependencies = [
"bucket_paths",
"cloud_storage",
"concurrency",
"config",
"easyenv",
"errors",
"ffmpeg_utils",
@@ -13459,6 +13443,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"subprocess",
"tempdir",
@@ -13489,6 +13474,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_env_var_config",
"sqlx",
"subprocess",
"tempdir",
@@ -14760,7 +14746,6 @@ dependencies = [
"bucket_paths",
"cloud_storage",
"concurrency",
"config",
"easyenv",
"enums",
"errors",
@@ -14773,6 +14758,7 @@ dependencies = [
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"sqlx",
"tokens",
"tokio",
+2 -3
View File
@@ -59,6 +59,7 @@ members = [
"crates/lib/data/primitives",
"crates/lib/data/web_base64",
"crates/lib/easyenv", # NB: Vendored version of https://github.com/echelon/easyenv.rs that we'll improve.
"crates/lib/error_utils",
"crates/lib/errors",
"crates/lib/ffmpeg_utils",
"crates/lib/files/file_to_base64_url",
@@ -94,7 +95,6 @@ members = [
"crates/lib/deprecated/http_server_common", # TODO(bt, 2023-03-20): Move useful pieces to actix_helpers
"crates/lib/deprecated/image_algorithms",
"crates/lib/deprecated/machine_learning/ml_weights_registry",
"crates/lib/deprecated/machine_learning/prompt_engineering",
"crates/lib/deprecated/subprocess_common",
"crates/lib/deprecated/tts_common",
@@ -117,7 +117,6 @@ members = [
# Schema (public): common strongly-typed tokens, ids, payloads, etc.
"crates/schema/public/composite_identifiers",
"crates/schema/public/enums",
"crates/schema/public/enums_public",
"crates/schema/public/reusable_types", # TODO(bt, 2022-12-21): Deprecate
"crates/schema/public/tokens",
@@ -238,7 +237,7 @@ cookie_store = { path = "crates/lib/cookie_store" }
crockford = { path = "crates/lib/data/crockford" }
easyenv = { path = "crates/lib/easyenv" }
enums = { path = "crates/schema/public/enums" }
enums_public = { path = "crates/schema/public/enums_public" }
error_utils = { path = "crates/lib/error_utils" }
errors = { path = "crates/lib/errors" }
fal_client = { path = "crates/api_clients/fal_client" }
ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS characters;
@@ -0,0 +1,101 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Characters are reusable entities that can be referenced in video/image generations.
-- A character is created from one or more reference images and registered with third-party
-- systems (e.g. Kinovi/Seedance) that support character-based generation.
CREATE TABLE characters (
-- Not used for anything except replication (private, not even used in queries)
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Public-facing "primary key" (PUBLIC)
token VARCHAR(32) NOT NULL,
-- The type or "features" of the character.
character_type VARCHAR(24) NOT NULL,
-- Whether the character has been fully created and is available for use.
-- A character may exist in the database before its creation job completes.
is_active BOOLEAN NOT NULL DEFAULT false,
-- ========== CHARACTER DETAILS ==========
-- User's display name for the character.
character_name VARCHAR(255) DEFAULT NULL,
-- Optional user description of the character.
maybe_description VARCHAR(512) DEFAULT NULL,
-- ========== MEDIA ==========
-- Avatar image (cropped to face, ideally).
maybe_avatar_media_token VARCHAR(32) DEFAULT NULL,
-- Full-size reference image.
maybe_full_image_media_token VARCHAR(32) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- WE'LL NEED TO BREAK THIS INTO ANOTHER TABLE IF WE WANT TO SUPPORT MULTIPLE IMAGES.
-- Image the character was originally created with.
maybe_original_upload_media_token VARCHAR(32) DEFAULT NULL,
-- ========== CREATOR ==========
-- The user who created this character.
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- For abuse tracking.
creator_ip_address VARCHAR(40) NOT NULL,
-- ========== KINOVI (TEMPORARY) ==========
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- The identifier in the third-party system.
-- e.g. Kinovi's "char_1774752056469_2wlxoq"
kinovi_character_id VARCHAR(64) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- The name used by the third party.
-- Important for Kinovi since characters are referenced by name in prompts.
kinovi_character_name VARCHAR(255) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- This might be the upstream third party of kinovi.
-- e.g. Kinovi's assetId "asset-20260329104101-kk5kl"
maybe_kinovi_asset_id VARCHAR(64) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- Our job system token (if the character was created via a job).
maybe_generic_inference_job_token VARCHAR(32) DEFAULT NULL,
-- Maybe this doesn't exist
-- -- TEMPORARY FOR GOING FAST :
-- -- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- -- Third party's job system token (if any).
-- maybe_kinovi_job_id VARCHAR(64) DEFAULT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP DEFAULT NULL,
-- ========== INDICES ==========
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY index_character_type (character_type),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_maybe_creator_user_token_active (maybe_creator_user_token, is_active),
KEY index_is_active (is_active),
KEY index_kinovi_character_id (kinovi_character_id),
KEY index_maybe_generic_inference_job_token (maybe_generic_inference_job_token),
KEY index_created_at (created_at),
KEY index_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
DROP COLUMN maybe_landing_url;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
ADD COLUMN maybe_landing_url VARCHAR(255) DEFAULT NULL;
@@ -15,6 +15,9 @@ CREATE TABLE characters (
-- Effective "primary key" (PUBLIC)
token VARCHAR(32) NOT NULL,
-- The type or "features" of the character.
character_type VARCHAR(24) NOT NULL,
-- Whether the character has been fully created and is available for use.
-- A character may exist in the database before its creation job completes.
is_active BOOLEAN NOT NULL DEFAULT false,
@@ -22,7 +25,7 @@ CREATE TABLE characters (
-- ========== CHARACTER DETAILS ==========
-- Display name for the character.
name VARCHAR(255) NOT NULL,
character_name VARCHAR(255) DEFAULT NULL,
-- Optional description of the character.
maybe_description VARCHAR(512) DEFAULT NULL,
@@ -35,6 +38,11 @@ CREATE TABLE characters (
-- Full-size reference image.
maybe_full_image_media_token VARCHAR(32) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- WE'LL NEED TO BREAK THIS INTO ANOTHER TABLE IF WE WANT TO SUPPORT MULTIPLE IMAGES.
-- Image the character was originally created with.
maybe_original_upload_media_token VARCHAR(32) DEFAULT NULL,
-- ========== CREATOR ==========
-- The user who created this character.
@@ -43,6 +51,37 @@ CREATE TABLE characters (
-- For abuse tracking.
creator_ip_address VARCHAR(40) NOT NULL,
-- ========== KINOVI (TEMPORARY) ==========
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- The identifier in the third-party system.
-- e.g. Kinovi's "char_1774752056469_2wlxoq"
kinovi_character_id VARCHAR(64) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- The name used by the third party.
-- Important for Kinovi since characters are referenced by name in prompts.
kinovi_character_name VARCHAR(255) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- This might be the upstream third party of kinovi.
-- e.g. Kinovi's assetId "asset-20260329104101-kk5kl"
maybe_kinovi_asset_id VARCHAR(64) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- Our job system token (if the character was created via a job).
maybe_generic_inference_job_token VARCHAR(32) DEFAULT NULL,
-- Maybe this doesn't exist
-- -- TEMPORARY FOR GOING FAST :
-- -- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- -- Third party's job system token (if any).
-- maybe_kinovi_job_id VARCHAR(64) DEFAULT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -53,8 +92,13 @@ CREATE TABLE characters (
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY index_character_type (character_type),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_maybe_creator_user_token_active (maybe_creator_user_token, is_active),
KEY index_is_active (is_active),
KEY index_created_at (created_at)
KEY index_kinovi_character_id (kinovi_character_id),
KEY index_maybe_generic_inference_job_token (maybe_generic_inference_job_token),
KEY index_created_at (created_at),
KEY index_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -222,8 +222,13 @@ CREATE TABLE users (
was_eagerly_provisioned BOOLEAN NOT NULL DEFAULT false,
-- The referral URL the user arrived from when signing up.
-- This helps us know what marketing is effective.
maybe_referral_url VARCHAR(255) DEFAULT NULL,
-- The actual URL the user first arrived at when signing up, including query params.
-- This helps us know what marketing is effective.
maybe_landing_url VARCHAR(255) DEFAULT NULL,
-- ========== MODERATION DETAILS ==========
-- Different than deleted.
@@ -0,0 +1,3 @@
$kind: collection
name: characters
order: 4767
@@ -0,0 +1,16 @@
$kind: http-request
name: Create Character
url: "{{api_host}}/v1/character/create"
method: POST
body:
type: json
content: >-
{
"uuid_idempotency_token": "{{$randomUUID}}",
"model": "seedance_2p0",
//"image_media_token": "m_v2hjh936k72knmggmqqa2xfs9sge8k", // steampunk clown
//"image_media_token": "m_13m13fm33bw3cxvfrncyc6873z0dyy", // mochi on couch
"image_media_token": "m_cp20gcxsattt6j7699brs4ndqf803z", // t-rex
"character_name": "T-rex"
}
order: 1775175681198
@@ -0,0 +1,5 @@
$kind: http-request
name: Delete Character
url: "{{api_host}}/v1/character/character_c40dzeq9pa1qtfy19q9f1w"
method: DELETE
order: 76446840865701
@@ -0,0 +1,18 @@
$kind: http-request
name: Edit Character
url: "{{api_host}}/v1/character/edit"
method: POST
body:
type: json
content: |-
{
/// The character to edit.
"token": "character_4aj66rcqsbjmv3jp5ek4fc",
/// Updated name (if changing).
"updated_name": "Mochi",
/// Updated description (if changing).
"updated_description": "New Description"
}
order: 487436956121943
@@ -0,0 +1,23 @@
$kind: http-request
name: Seedance 2.0 (character ref to video)
url: "{{api_host}}/v1/generate/video/multi_function/seedance_2p0"
method: POST
headers:
- key: Accept
value: application/json
- key: Session
value: ""
disabled: true
- key: Session
value: ""
disabled: true
body:
type: json
content: >
{
"uuid_idempotency_token": "{{$randomUUID}}",
"prompt": "@Mochi is riding on the shoulder of @Steampunk Clown while they're walking through a crowded shopping mall in the sci-fi future. Retro 80's aesthetics while simultaneously futuristic and steam punk. @Mochi licks her lips and barks.",
"duration_seconds": 5,
"reference_character_tokens": ["character_beph3xd1z2m4kkbedra24f", "character_4aj66rcqsbjmv3jp5ek4fc"]
}
order: 3802
@@ -0,0 +1,22 @@
$kind: http-request
name: Add Wallet Banked Balance
url: "{{api_host}}/v1/moderation/wallet/wallet_2pz2h3m2bdfvna7m3b92ccwwr/add_ba\
nked_balance"
method: POST
headers:
- key: Accept
value: application/json
- key: Session
value: ""
disabled: true
- key: Session
value: ""
disabled: true
body:
type: json
content: |
{
"title": "test alert from thread 2",
"description": "testing testing test"
}
order: 3365370184335612
@@ -0,0 +1,21 @@
$kind: http-request
name: Create Wallet for User
url: "{{api_host}}/v1/moderation/wallet/create_for_user"
method: POST
headers:
- key: Accept
value: application/json
- key: Session
value: ""
disabled: true
- key: Session
value: ""
disabled: true
body:
type: json
content: |
{
"user_token": "user_jg7ng4kdt2x5y", // newuser946
"payments_namespace": "artcraft"
}
order: 4100053395280358
@@ -8,6 +8,6 @@ body:
type: json
content: |-
{
"google_credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImIyN...[redacted]"
"google_credential": "...[redacted]"
}
order: 4000
@@ -21,7 +21,6 @@ workspace-build-acceleration.workspace = true
# Internal
enums.workspace = true
enums_public.workspace = true
tokens.workspace = true
# External
@@ -16,6 +16,12 @@ pub struct CreateCharacterRequest {
/// The reference image media file token.
pub image_media_token: MediaFileToken,
/// Name of the character.
pub character_name: String,
/// Description of the character.
pub character_description: Option<String>,
}
/// Response body for creating a character.
@@ -89,7 +89,7 @@ mod tests {
#[test]
fn test_serialization_text_to_image() {
let request = EstimateImageCostRequest {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::TextToImage,
aspect_ratio: None,
@@ -103,7 +103,7 @@ mod tests {
#[test]
fn test_serialization_image_edit() {
let request = EstimateImageCostRequest {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::ImageEdit { count: 2 },
aspect_ratio: None,
@@ -28,7 +28,7 @@ pub struct GenerateKling16ProImageToVideoRequest {
pub duration: Option<GenerateKling16ProDuration>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateKling16ProAspectRatio {
/// 16:9 aspect ratio
@@ -39,7 +39,7 @@ pub enum GenerateKling16ProAspectRatio {
Square,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateKling16ProDuration {
FiveSeconds,
@@ -24,7 +24,7 @@ pub struct GenerateKling21MasterImageToVideoRequest {
pub duration: Option<GenerateKling21MasterDuration>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateKling21MasterAspectRatio {
/// 16:9 aspect ratio
@@ -35,7 +35,7 @@ pub enum GenerateKling21MasterAspectRatio {
Square,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateKling21MasterDuration {
FiveSeconds,
@@ -28,7 +28,7 @@ pub struct GenerateKling21ProImageToVideoRequest {
pub duration: Option<GenerateKling21ProDuration>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateKling21ProAspectRatio {
/// 16:9 aspect ratio
@@ -39,7 +39,7 @@ pub enum GenerateKling21ProAspectRatio {
Square,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateKling21ProDuration {
FiveSeconds,
@@ -31,14 +31,14 @@ pub struct GenerateSeedance10LiteImageToVideoRequest {
pub aspect_ratio: Option<GenerateSeedance10LiteAspectRatio>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateSeedance10LiteResolution {
FourEightyP,
SevenTwentyP,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateSeedance10LiteDuration {
FiveSeconds,
@@ -46,7 +46,7 @@ pub enum GenerateSeedance10LiteDuration {
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateSeedance10LiteAspectRatio {
Auto,
@@ -24,7 +24,7 @@ pub struct GenerateVeo2ImageToVideoRequest {
pub duration: Option<GenerateVeo2Duration>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo2AspectRatio {
Auto,
@@ -33,7 +33,7 @@ pub enum GenerateVeo2AspectRatio {
TallNineSixteen, // 9:16
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo2Duration {
FiveSeconds,
@@ -27,14 +27,14 @@ pub struct GenerateVeo3FastImageToVideoRequest {
pub generate_audio: Option<bool>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo3FastResolution {
SevenTwentyP, // 720p
TenEightyP, // 1080p
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo3FastDuration {
EightSeconds,
@@ -37,7 +37,7 @@ pub struct GenerateVeo3ImageToVideoRequest {
// pub auto_fix: Option<bool>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo3AspectRatio {
WideSixteenNine, // 16:9
@@ -45,14 +45,14 @@ pub enum GenerateVeo3AspectRatio {
Square, // 1:1
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo3Resolution {
SevenTwentyP, // 720p
TenEightyP, // 1080p
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo3Duration {
EightSeconds,
@@ -41,14 +41,14 @@ pub struct Kling2p5TurboProMultiFunctionVideoGenRequest {
pub aspect_ratio: Option<Kling2p5TurboProMultiFunctionVideoGenAspectRatio>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Kling2p5TurboProMultiFunctionVideoGenDuration {
FiveSeconds,
TenSeconds,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Kling2p5TurboProMultiFunctionVideoGenAspectRatio {
Square,
@@ -45,14 +45,14 @@ pub struct Kling2p6ProMultiFunctionVideoGenRequest {
pub aspect_ratio: Option<Kling2p6ProMultiFunctionVideoGenAspectRatio>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Kling2p6ProMultiFunctionVideoGenDuration {
FiveSeconds,
TenSeconds,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Kling2p6ProMultiFunctionVideoGenAspectRatio {
Square,
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
@@ -29,6 +30,10 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Optional reference audio files.
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional character tokens to reference in the prompt.
/// Characters are referenced in prompts as @CharacterName.
pub reference_character_tokens: Option<Vec<CharacterToken>>,
/// Video aspect ratio / resolution.
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
@@ -35,14 +35,14 @@ pub struct Sora2MultiFunctionVideoGenRequest {
pub aspect_ratio: Option<Sora2MultiFunctionVideoGenAspectRatio>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Sora2MultiFunctionVideoGenResolution {
Auto,
SevenTwentyP,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Sora2MultiFunctionVideoGenDuration {
FourSeconds,
@@ -50,7 +50,7 @@ pub enum Sora2MultiFunctionVideoGenDuration {
TwelveSeconds,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Sora2MultiFunctionVideoGenAspectRatio {
Auto,
@@ -35,7 +35,7 @@ pub struct Sora2ProMultiFunctionVideoGenRequest {
pub aspect_ratio: Option<Sora2ProMultiFunctionVideoGenAspectRatio>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Sora2ProMultiFunctionVideoGenResolution {
Auto,
@@ -43,7 +43,7 @@ pub enum Sora2ProMultiFunctionVideoGenResolution {
TenEightyP,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Sora2ProMultiFunctionVideoGenDuration {
FourSeconds,
@@ -51,7 +51,7 @@ pub enum Sora2ProMultiFunctionVideoGenDuration {
TwelveSeconds,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Sora2ProMultiFunctionVideoGenAspectRatio {
Auto,
@@ -60,7 +60,7 @@ pub struct Veo3p1FastMultiFunctionVideoGenRequest {
pub auto_fix: Option<bool>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Veo3p1FastMultiFunctionVideoGenDuration {
FourSeconds,
@@ -68,7 +68,7 @@ pub enum Veo3p1FastMultiFunctionVideoGenDuration {
EightSeconds,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Veo3p1FastMultiFunctionVideoGenAspectRatio {
Auto,
@@ -76,7 +76,7 @@ pub enum Veo3p1FastMultiFunctionVideoGenAspectRatio {
NineBySixteen,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Veo3p1FastMultiFunctionVideoGenResolution {
SevenTwentyP,
@@ -60,7 +60,7 @@ pub struct Veo3p1MultiFunctionVideoGenRequest {
pub auto_fix: Option<bool>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Veo3p1MultiFunctionVideoGenDuration {
FourSeconds,
@@ -68,7 +68,7 @@ pub enum Veo3p1MultiFunctionVideoGenDuration {
EightSeconds,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Veo3p1MultiFunctionVideoGenAspectRatio {
Auto,
@@ -76,7 +76,7 @@ pub enum Veo3p1MultiFunctionVideoGenAspectRatio {
NineBySixteen,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Veo3p1MultiFunctionVideoGenResolution {
SevenTwentyP,
@@ -40,11 +40,11 @@ pub struct OmniGenImageCostAndGenerateRequest {
pub image_batch_count: Option<u16>,
/// Only for angle manipulation models.
pub horizontal_angle: Option<f64>,
pub adjust_horizontal_angle: Option<f64>,
/// Only for angle manipulation models.
pub vertical_angle: Option<f64>,
pub adjust_vertical_angle: Option<f64>,
/// Only for angle manipulation models.
pub zoom: Option<f64>,
pub adjust_zoom: Option<f64>,
}
@@ -5,6 +5,7 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
/// Shared request body for both the video cost estimate and video generation endpoints.
@@ -39,6 +40,10 @@ pub struct OmniGenVideoCostAndGenerateRequest {
/// Reference audio (optional).
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional character tokens to reference in the prompt.
/// Characters are referenced in prompts as @CharacterName.
pub reference_character_tokens: Option<Vec<CharacterToken>>,
/// The resolution to use.
pub resolution: Option<CommonResolution>,
@@ -103,6 +103,7 @@ pub struct OmniGenImageModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution_default: Option<CommonResolution>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_options: Option<Vec<CommonQuality>>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -124,7 +125,7 @@ pub struct OmniGenImageModelDetails {
impl Default for OmniGenImageModelDetails {
fn default() -> Self {
Self {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
full_name: None,
text_prompt_supported: None,
text_prompt_max_length: None,
@@ -1,10 +1,13 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
/// Video model to default to if none is specified
const DEFAULT_VIDEO_MODEL : CommonVideoModel = CommonVideoModel::Seedance2p0;
/// Query string parameters for the video models endpoint.
#[derive(Deserialize, IntoParams, ToSchema)]
@@ -110,6 +113,12 @@ pub struct OmniGenVideoModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_references_max_total_duration_seconds: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub character_references_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub character_references_max: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_generate_with_sound_toggle: Option<bool>,
@@ -159,7 +168,7 @@ pub struct OmniGenVideoModelDetails {
impl Default for OmniGenVideoModelDetails {
fn default() -> Self {
Self {
model: CommonVideoModel::Seedance2p0,
model: DEFAULT_VIDEO_MODEL,
full_name: None,
text_prompt_supported: None,
text_prompt_max_length: None,
@@ -176,6 +185,8 @@ impl Default for OmniGenVideoModelDetails {
audio_references_supported: None,
audio_references_max: None,
audio_references_max_total_duration_seconds: None,
character_references_supported: None,
character_references_max: None,
show_generate_with_sound_toggle: None,
aspect_ratio_options: None,
aspect_ratio_default: None,
@@ -12,10 +12,14 @@ pub struct StripeArtcraftCreateSubscriptionCheckoutWithUserSignupRequest {
pub cadence: Option<PlanBillingCadence>,
/// Optional: The referral URL the user arrived from when signing up.
/// Optional: The referral URL the user arrived from when first hitting the site, prior to navigation and signing up.
/// The browser can send `document.referrer` to the backend so we know how people are finding us.
/// If the browser doesn't send this parameter, we'll try the `referer` header.
pub maybe_referral_url: Option<String>,
/// Optional: The URL where the user landed when they first arrived, prior to navigation and signing up.
/// The browser can send `window.location.href` to the backend so we know how people are finding us.
pub maybe_landing_url: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema, Debug, Copy, Clone)]
@@ -26,7 +26,6 @@ workspace-build-acceleration.workspace = true
artcraft_api_defs.workspace = true
cloudflare_errors.workspace = true
enums.workspace = true
enums_public.workspace = true
errors.workspace = true
uuid_utils.workspace = true
mimetypes.workspace = true
@@ -15,7 +15,7 @@ use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use enums::by_table::model_weights::weights_types::WeightsType;
use log::debug;
use serde_derive::Deserialize;
use tokens::tokens::batch_generations::BatchGenerationToken;
@@ -193,7 +193,7 @@ pub struct GetMediaFileModeratorFields {
#[derive(Deserialize, Debug)]
pub struct GetMediaFileModelInfo {
pub weight_token: ModelWeightToken,
pub weight_type: PublicWeightsType,
pub weight_type: WeightsType,
pub weight_category: WeightsCategory,
pub title: String,
@@ -8,7 +8,6 @@ use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums::common::generation_provider::GenerationProvider;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
@@ -7,7 +7,6 @@ use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
@@ -8,7 +8,6 @@ use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums::common::generation_provider::GenerationProvider;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
@@ -5,7 +5,6 @@ use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
@@ -5,7 +5,6 @@ use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
@@ -1,6 +1,6 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub enum AudioListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
@@ -0,0 +1,9 @@
use tokens::tokens::characters::CharacterToken;
/// Forward-compatible character reference list.
#[derive(Copy, Clone, Debug)]
pub enum CharacterListRef<'a> {
CharacterTokens(&'a Vec<CharacterToken>),
// In the future, we may have other identifiers for characters.
}
@@ -36,7 +36,11 @@ pub enum CommonAspectRatio {
// Auto values that bake in resolution
// These are from the Seedream models
#[serde(rename = "auto_2k")]
Auto2k,
#[serde(rename = "auto_3k")]
Auto3k,
#[serde(rename = "auto_4k")]
Auto4k,
// Defined aspect ratios that bake in resolution
@@ -13,14 +13,16 @@ pub enum CommonImageModel {
FluxPro11,
#[serde(rename = "flux_pro_1p1_ultra")]
FluxPro11Ultra,
#[serde(rename = "gpt_image_1")]
GptImage1,
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "nano_banana")]
NanaBanana,
NanoBanana,
#[serde(rename = "nano_banana_2")]
NanaBanana2,
NanoBanana2,
#[serde(rename = "nano_banana_pro")]
NanaBananaPro,
NanoBananaPro,
#[serde(rename = "seedream_4")]
Seedream4,
#[serde(rename = "seedream_4p5")]
@@ -0,0 +1,11 @@
use serde_derive::{Deserialize, Serialize};
/// Common quality levels you can specify when enqueuing a generation.
/// Not every model will use this; models that don't simply ignore it.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CommonQuality {
High,
Medium,
Low,
}
@@ -1,6 +1,6 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub enum ImageListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
@@ -1,6 +1,6 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub enum ImageRef<'a> {
MediaFileToken(&'a MediaFileToken),
Url(&'a str),
@@ -1,6 +1,8 @@
pub mod audio_list_ref;
pub mod character_list_ref;
pub mod common_aspect_ratio;
pub mod common_image_model;
pub mod common_quality;
pub mod common_resolution;
pub mod common_splat_model;
pub mod common_video_model;
@@ -1,6 +1,6 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub enum VideoListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -31,6 +31,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -46,6 +46,7 @@ mod tests {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
@@ -6,8 +6,11 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
pub(crate) fn estimate_image_cost_artcraft_flux_pro_1p1(
plan: &PlanArtcraftFluxPro11<'_>,
) -> ImageGenerationCostEstimate {
// NB(bt): Slightly more than 3 cents, weird megapixel maths. We can eat that cost for now.
let cost_per_image: u64 = 3;
// Fal pricing: $0.04 per megapixel, billed by rounding up to the nearest
// megapixel. The default image_size values are ~1MP, so 4 cents per image.
// (The legacy `generate_flux_pro_11_text_to_image_handler` charges this same
// amount via `FluxPro11Args::calculate_cost_in_cents`.)
let cost_per_image: u64 = 4;
let num_images: u64 = match plan.num_images {
GenerateFluxPro11TextToImageNumImages::One => 1,
@@ -43,6 +46,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -60,10 +64,10 @@ mod tests {
#[test]
fn test_estimate_cost_usd_cents() {
// ~$0.03/image = 3 cents each (slightly more than 3¢ actual, rounded up)
assert_eq!(estimate_usd_cents(1), 3);
assert_eq!(estimate_usd_cents(2), 6);
assert_eq!(estimate_usd_cents(3), 9);
assert_eq!(estimate_usd_cents(4), 12);
// $0.04/image = 4 cents each (Fal's $0.04/MP at the default ~1MP).
assert_eq!(estimate_usd_cents(1), 4);
assert_eq!(estimate_usd_cents(2), 8);
assert_eq!(estimate_usd_cents(3), 12);
assert_eq!(estimate_usd_cents(4), 16);
}
}
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -0,0 +1,239 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::{
ArtcraftGptImage1Quality, ArtcraftGptImage1Size, PlanArtcraftGptImage1,
};
pub(crate) fn estimate_image_cost_artcraft_gpt_image_1(
plan: &PlanArtcraftGptImage1<'_>,
) -> ImageGenerationCostEstimate {
// Pricing per the fal-ai GPT Image 1 docs, adapted for our artcraft billing:
//
// Output image cost (per output image):
// Low – $0.011 (1024×1024) / $0.016 (other) → 2¢ / 2¢
// Medium – $0.042 (1024×1024) / $0.063 (other) → 5¢ / 7¢
// High – $0.167 (1024×1024) / $0.250 (other) → 17¢ / 25¢
//
// Input image cost (edit mode only, per input image, high-fidelity assumed):
// 3,050 tokens × $0.005/1K tokens ≈ $0.01525 → 2¢ per input image
//
// Input text tokens: free (we don't charge for these).
//
let is_square = matches!(
plan.image_size,
None | Some(ArtcraftGptImage1Size::Square)
);
let output_cost_per_image: u64 = match (plan.quality, is_square) {
(ArtcraftGptImage1Quality::Auto, _) => 17, // Auto treated as High square
(ArtcraftGptImage1Quality::Low, true) => 2,
(ArtcraftGptImage1Quality::Low, false) => 2,
(ArtcraftGptImage1Quality::Medium, true) => 5,
(ArtcraftGptImage1Quality::Medium, false) => 7,
(ArtcraftGptImage1Quality::High, true) => 17,
(ArtcraftGptImage1Quality::High, false) => 25,
};
let num_output_images = plan.num_images.as_u64();
let output_cost = output_cost_per_image * num_output_images;
// Input image token cost: 2¢ per input image (high-fidelity estimate).
let input_image_cost: u64 = 2 * plan.num_input_images;
let cost_in_usd_cents = output_cost + input_image_cost;
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_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use tokens::tokens::media_files::MediaFileToken;
fn base() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::GptImage1,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
fn estimate_text(
quality: Option<CommonQuality>,
aspect_ratio: Option<CommonAspectRatio>,
batch: u16,
) -> u64 {
let request = GenerateImageRequest {
quality,
aspect_ratio,
image_batch_count: Some(batch),
..base()
};
request.build().unwrap().estimate_costs()
.cost_in_usd_cents.unwrap()
}
fn estimate_edit(
quality: Option<CommonQuality>,
aspect_ratio: Option<CommonAspectRatio>,
batch: u16,
num_input_images: usize,
) -> u64 {
let tokens: Vec<MediaFileToken> = (0..num_input_images)
.map(|i| MediaFileToken::new_from_str(&format!("mf_test{:028}", i)))
.collect();
let request = GenerateImageRequest {
quality,
aspect_ratio,
image_batch_count: Some(batch),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base()
};
request.build().unwrap().estimate_costs()
.cost_in_usd_cents.unwrap()
}
// ── Default quality (None → High) ───────────────────────────────────────
#[test]
fn default_quality_square_one_image_costs_17_cents() {
assert_eq!(estimate_text(None, None, 1), 17);
}
#[test]
fn default_quality_square_four_images_costs_68_cents() {
assert_eq!(estimate_text(None, None, 4), 68);
}
// ── Low quality (2¢/image regardless of size) ───────────────────────────
#[test]
fn low_square_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Low), Some(CommonAspectRatio::Square), 1), 2);
}
#[test]
fn low_wide_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), 1), 2);
}
#[test]
fn low_tall_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Low), Some(CommonAspectRatio::TallNineBySixteen), 1), 2);
}
#[test]
fn low_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::Low), None, 4), 8);
}
// ── Medium quality (5¢ square, 7¢ wide/tall) ───────────────────────────
#[test]
fn medium_square_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 1), 5);
}
#[test]
fn medium_wide_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 1), 7);
}
#[test]
fn medium_tall_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), 1), 7);
}
#[test]
fn medium_square_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 4), 20);
}
#[test]
fn medium_wide_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 4), 28);
}
// ── High quality (17¢ square, 25¢ wide/tall) ───────────────────────────
#[test]
fn high_square_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1), 17);
}
#[test]
fn high_wide_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 1), 25);
}
#[test]
fn high_tall_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), 1), 25);
}
#[test]
fn high_square_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 4), 68);
}
#[test]
fn high_wide_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 4), 100);
}
// ── Edit mode: adds 2¢ per input image ─────────────────────────────────
#[test]
fn edit_one_input_adds_2_cents() {
// High square 1 output = 17¢, plus 1 input × 2¢ = 19¢
assert_eq!(estimate_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1, 1), 19);
}
#[test]
fn edit_three_inputs_adds_6_cents() {
// High square 1 output = 17¢, plus 3 inputs × 2¢ = 23¢
assert_eq!(estimate_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1, 3), 23);
}
#[test]
fn edit_five_inputs_adds_10_cents() {
// Medium wide 2 outputs = 7×2 = 14¢, plus 5 inputs × 2¢ = 24¢
assert_eq!(estimate_edit(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 2, 5), 24);
}
#[test]
fn edit_low_quality_with_inputs() {
// Low square 1 output = 2¢, plus 2 inputs × 2¢ = 6¢
assert_eq!(estimate_edit(Some(CommonQuality::Low), None, 1, 2), 6);
}
// ── Text mode (no input images) has no input cost ──────────────────────
#[test]
fn text_mode_no_input_cost() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1), 17);
}
}
@@ -9,42 +9,33 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
pub(crate) fn estimate_image_cost_artcraft_gpt_image_1p5(
plan: &PlanArtcraftGptImage1p5<'_>,
) -> ImageGenerationCostEstimate {
// Pricing varies by quality and image size. 1 credit = 1 USD cent.
// Low: $0.01/image (any size)
// Medium: $0.03/image (square/unset), $0.05/image (wide or tall)
// High: $0.13/image (square/unset), $0.20/image (wide or tall)
// Per fal docs (fal-ai/gpt-image-1.5 and fal-ai/gpt-image-1.5/edit):
//
// From Fal (text to image) -
// Output image cost (per output image), rounded up to whole cents:
// Low: $0.009 (1024×1024) / $0.013 (other) → 1¢ / 2¢
// Medium: $0.034 (1024×1024) / $0.050 (1536×1024) / $0.051 (1024×1536) → 4¢ / 5¢ / 6¢
// High: $0.133 (1024×1024) / $0.199 (1536×1024) / $0.200 (1024×1536) → 14¢ / 20¢ / 20¢
//
// Your request will cost different amounts based on the number of images, quality, and size.
// Input text tokens: free (we don't charge for these).
//
// You will be charged $0.005 per 1,000 input text tokens. One word is roughly 4 tokens.
// You will be charged $0.010 per 1,000 output text tokens. The model will consume tokens reasoning about your prompt based on it's complexity.
// For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image.
// For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image.
// For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image.
// Default quality: High (when request.quality is None).
//
// From Fal (image to image) -
//
// Your request will cost different amounts based on the number of images, quality, and size.
//
// You will be charged $0.005 per 1,000 input text tokens. One word is roughly 4 tokens.
// You will be charged $0.008 per 1,000 input image tokens. One 1024x1024 image is roughly 135 tokens in low fidelity mode, or 3,050 tokens in high fidelity mode.
// You will be charged $0.010 per 1,000 output text tokens. The model will consume tokens reasoning about your prompt based on it's complexity.
// For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image.
// For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image.
// For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image.
//
let cost_per_image: u64 = match plan.quality {
GptImage1p5MultiFunctionImageGenQuality::Low => 1,
GptImage1p5MultiFunctionImageGenQuality::Medium => match plan.image_size {
Some(GptImage1p5MultiFunctionImageGenSize::Square) | None => 3,
Some(GptImage1p5MultiFunctionImageGenSize::Wide) | Some(GptImage1p5MultiFunctionImageGenSize::Tall) => 5,
},
GptImage1p5MultiFunctionImageGenQuality::High => match plan.image_size {
Some(GptImage1p5MultiFunctionImageGenSize::Square) | None => 13,
Some(GptImage1p5MultiFunctionImageGenSize::Wide) | Some(GptImage1p5MultiFunctionImageGenSize::Tall) => 20,
},
use GptImage1p5MultiFunctionImageGenQuality as Q;
use GptImage1p5MultiFunctionImageGenSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(Q::Low, None | Some(S::Square)) => 1,
(Q::Low, Some(S::Wide) | Some(S::Tall)) => 2,
// Medium quality
(Q::Medium, None | Some(S::Square)) => 4,
(Q::Medium, Some(S::Wide)) => 5,
(Q::Medium, Some(S::Tall)) => 6,
// High quality
(Q::High, None | Some(S::Square)) => 14,
(Q::High, Some(S::Wide) | Some(S::Tall)) => 20,
};
let num_images: u64 = match plan.num_images {
@@ -70,11 +61,16 @@ pub(crate) fn estimate_image_cost_artcraft_gpt_image_1p5(
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_quality::CommonQuality;
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(image_batch_count: u16, aspect_ratio: Option<CommonAspectRatio>) -> u64 {
fn estimate(
quality: Option<CommonQuality>,
aspect_ratio: Option<CommonAspectRatio>,
batch: u16,
) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::GptImage1p5,
provider: Provider::Artcraft,
@@ -82,7 +78,8 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio,
image_batch_count: Some(image_batch_count),
quality,
image_batch_count: Some(batch),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
@@ -90,34 +87,140 @@ mod tests {
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
request.build().unwrap().estimate_costs()
.cost_in_usd_cents.unwrap()
}
// ── Default quality (None → High) ─────────────────────────────────────
#[test]
fn default_quality_square_one_image() {
assert_eq!(estimate(None, Some(CommonAspectRatio::Square), 1), 14);
}
#[test]
fn test_estimate_cost_medium_square() {
// Default quality (Medium) + square/unset size = 3 cents each
assert_eq!(estimate_usd_cents(1, None), 3);
assert_eq!(estimate_usd_cents(1, Some(CommonAspectRatio::Square)), 3);
assert_eq!(estimate_usd_cents(2, Some(CommonAspectRatio::Square)), 6);
assert_eq!(estimate_usd_cents(3, Some(CommonAspectRatio::Square)), 9);
assert_eq!(estimate_usd_cents(4, Some(CommonAspectRatio::Square)), 12);
fn default_quality_unset_one_image() {
assert_eq!(estimate(None, None, 1), 14);
}
#[test]
fn test_estimate_cost_medium_wide() {
// Medium + wide = 5 cents each
assert_eq!(estimate_usd_cents(1, Some(CommonAspectRatio::WideSixteenByNine)), 5);
assert_eq!(estimate_usd_cents(4, Some(CommonAspectRatio::WideSixteenByNine)), 20);
fn default_quality_wide_one_image() {
assert_eq!(estimate(None, Some(CommonAspectRatio::WideSixteenByNine), 1), 20);
}
#[test]
fn test_estimate_cost_medium_tall() {
// Medium + tall = 5 cents each
assert_eq!(estimate_usd_cents(1, Some(CommonAspectRatio::TallNineBySixteen)), 5);
assert_eq!(estimate_usd_cents(4, Some(CommonAspectRatio::TallNineBySixteen)), 20);
fn default_quality_tall_one_image() {
assert_eq!(estimate(None, Some(CommonAspectRatio::TallNineBySixteen), 1), 20);
}
#[test]
fn default_quality_square_four_images() {
assert_eq!(estimate(None, Some(CommonAspectRatio::Square), 4), 56);
}
// ── Low quality (1¢ square, 2¢ wide/tall) ─────────────────────────────
#[test]
fn low_square_one() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::Square), 1), 1);
}
#[test]
fn low_unset_one() {
assert_eq!(estimate(Some(CommonQuality::Low), None, 1), 1);
}
#[test]
fn low_wide_one() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), 1), 2);
}
#[test]
fn low_tall_one() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::TallNineBySixteen), 1), 2);
}
#[test]
fn low_square_four() {
assert_eq!(estimate(Some(CommonQuality::Low), None, 4), 4);
}
#[test]
fn low_wide_four() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), 4), 8);
}
// ── Medium quality (4¢ square, 5¢ wide, 6¢ tall) ─────────────────────
#[test]
fn medium_square_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 1), 4);
}
#[test]
fn medium_unset_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), None, 1), 4);
}
#[test]
fn medium_wide_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 1), 5);
}
#[test]
fn medium_tall_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), 1), 6);
}
#[test]
fn medium_square_four() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 4), 16);
}
#[test]
fn medium_wide_four() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 4), 20);
}
#[test]
fn medium_tall_four() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), 4), 24);
}
// ── High quality (14¢ square, 20¢ wide/tall) ─────────────────────────
#[test]
fn high_square_one() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1), 14);
}
#[test]
fn high_unset_one() {
assert_eq!(estimate(Some(CommonQuality::High), None, 1), 14);
}
#[test]
fn high_wide_one() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 1), 20);
}
#[test]
fn high_tall_one() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), 1), 20);
}
#[test]
fn high_square_four() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 4), 56);
}
#[test]
fn high_wide_four() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 4), 80);
}
#[test]
fn high_tall_four() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), 4), 80);
}
}
@@ -37,12 +37,13 @@ mod tests {
fn estimate_usd_cents(image_batch_count: u16) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::NanaBanana,
model: CommonImageModel::NanoBanana,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -8,12 +8,17 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
pub(crate) fn estimate_image_cost_artcraft_nano_banana_2(
plan: &PlanArtcraftNanaBanana2<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.08/image at 0.5K, $0.15/image at 1K or 2K, $0.30/image at 4K.
// (Stored as USD cents.)
// Pricing (USD cents per image), scaled off the 1K base cost of $0.08:
// 0.5K → $0.06 (0.75×)
// 1K → $0.08 (1×, default)
// 2K → $0.12 (1.5×)
// 4K → $0.16 (2×)
let cost_per_image: u64 = match plan.resolution {
Some(NanaBanana2MultiFunctionImageGenImageResolution::HalfK) => 8,
Some(NanaBanana2MultiFunctionImageGenImageResolution::FourK) => 30,
_ => 15, // OneK, TwoK, or None (default 1K)
Some(NanaBanana2MultiFunctionImageGenImageResolution::HalfK) => 6,
Some(NanaBanana2MultiFunctionImageGenImageResolution::OneK) => 8,
Some(NanaBanana2MultiFunctionImageGenImageResolution::TwoK) => 12,
Some(NanaBanana2MultiFunctionImageGenImageResolution::FourK) => 16,
None => 8, // default 1K
};
let num_images: u64 = match plan.num_images {
@@ -45,12 +50,13 @@ mod tests {
fn estimate_usd_cents(resolution: Option<CommonResolution>, image_batch_count: u16) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::NanaBanana2,
model: CommonImageModel::NanoBanana2,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -66,17 +72,125 @@ mod tests {
.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), 4), 60);
// ── Default (None → 1K → 8¢/image) ─────────────────────────────────────────
// 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);
#[test]
fn default_resolution_one_image_costs_8_cents() {
assert_eq!(estimate_usd_cents(None, 1), 8);
}
#[test]
fn default_resolution_two_images_costs_16_cents() {
assert_eq!(estimate_usd_cents(None, 2), 16);
}
#[test]
fn default_resolution_three_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(None, 3), 24);
}
#[test]
fn default_resolution_four_images_costs_32_cents() {
assert_eq!(estimate_usd_cents(None, 4), 32);
}
// ── 0.5K → 6¢/image (0.75× base) ───────────────────────────────────────────
#[test]
fn half_k_one_image_costs_6_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 1), 6);
}
#[test]
fn half_k_two_images_costs_12_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 2), 12);
}
#[test]
fn half_k_three_images_costs_18_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 3), 18);
}
#[test]
fn half_k_four_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 4), 24);
}
// ── 1K → 8¢/image (base) ───────────────────────────────────────────────────
#[test]
fn one_k_one_image_costs_8_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 1), 8);
}
#[test]
fn one_k_two_images_costs_16_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 2), 16);
}
#[test]
fn one_k_three_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 3), 24);
}
#[test]
fn one_k_four_images_costs_32_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 4), 32);
}
// ── 2K → 12¢/image (1.5× base) ─────────────────────────────────────────────
#[test]
fn two_k_one_image_costs_12_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 1), 12);
}
#[test]
fn two_k_two_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 2), 24);
}
#[test]
fn two_k_three_images_costs_36_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 3), 36);
}
#[test]
fn two_k_four_images_costs_48_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 4), 48);
}
// ── 4K → 16¢/image (2× base) ───────────────────────────────────────────────
#[test]
fn four_k_one_image_costs_16_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 1), 16);
}
#[test]
fn four_k_two_images_costs_32_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 2), 32);
}
#[test]
fn four_k_three_images_costs_48_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 3), 48);
}
#[test]
fn four_k_four_images_costs_64_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 4), 64);
}
// ── 3K (unsupported) falls back to 2K pricing ──────────────────────────────
#[test]
fn three_k_one_image_falls_back_to_two_k_pricing() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::ThreeK), 1), 12);
}
#[test]
fn three_k_four_images_falls_back_to_two_k_pricing() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::ThreeK), 4), 48);
}
}
@@ -44,12 +44,13 @@ mod tests {
fn estimate_usd_cents(resolution: Option<CommonResolution>, image_batch_count: u16) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -46,6 +46,7 @@ mod tests {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -2,6 +2,7 @@ pub mod estimate_image_cost_artcraft_flux_1_dev;
pub mod estimate_image_cost_artcraft_flux_1_schnell;
pub mod estimate_image_cost_artcraft_flux_pro_1p1;
pub mod estimate_image_cost_artcraft_flux_pro_1p1_ultra;
pub mod estimate_image_cost_artcraft_gpt_image_1;
pub mod estimate_image_cost_artcraft_gpt_image_1p5;
pub mod estimate_image_cost_artcraft_nano_banana;
pub mod estimate_image_cost_artcraft_nano_banana_2;
@@ -0,0 +1,26 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::PlanFalFlux1Dev;
pub(crate) fn estimate_image_cost_fal_flux_1_dev(
plan: &PlanFalFlux1Dev<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.025 per megapixel, billed by rounding up to nearest megapixel.
// Default image sizes are ~1MP (1024x1024), so ~$0.025/image = 3 cents rounded up.
//
// Fal docs:
// Your request will cost $0.025 per megapixel, with images billed by rounding up
// to the nearest megapixel.
//
let cost_per_image: u64 = 3;
let num_images = plan.num_images.as_u64();
let cost_in_usd_cents = cost_per_image * num_images;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
@@ -0,0 +1,26 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::PlanFalFlux1Schnell;
pub(crate) fn estimate_image_cost_fal_flux_1_schnell(
plan: &PlanFalFlux1Schnell<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.003 per megapixel, billed by rounding up to nearest megapixel.
// Default image sizes are ~1MP (1024x1024), so ~$0.003/image = 1 cent rounded up.
//
// Fal docs:
// Your request will cost $0.003 per megapixel, with images billed by rounding up
// to the nearest megapixel.
//
let cost_per_image: u64 = 1;
let num_images = plan.num_images.as_u64();
let cost_in_usd_cents = cost_per_image * num_images;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
@@ -0,0 +1,21 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1::PlanFalFluxPro11;
pub(crate) fn estimate_image_cost_fal_flux_pro_1p1(
plan: &PlanFalFluxPro11<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04 per megapixel, billed by rounding up to nearest megapixel.
// Default image sizes are ~1MP (1024x1024), so ~$0.04/image = 4 cents.
let cost_per_image: u64 = 4;
let num_images = plan.num_images.as_u64();
let cost_in_usd_cents = cost_per_image * num_images;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
@@ -0,0 +1,20 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::PlanFalFluxPro11Ultra;
pub(crate) fn estimate_image_cost_fal_flux_pro_1p1_ultra(
plan: &PlanFalFluxPro11Ultra<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.06 per image.
let cost_per_image: u64 = 6;
let num_images = plan.num_images.as_u64();
let cost_in_usd_cents = cost_per_image * num_images;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
@@ -0,0 +1,245 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::{
FalGptImage1ImageSize, FalGptImage1Quality, PlanFalGptImage1,
};
pub(crate) fn estimate_image_cost_fal_gpt_image_1(
plan: &PlanFalGptImage1<'_>,
) -> ImageGenerationCostEstimate {
// Per fal docs (fal-ai/gpt-image-1/{text-to-image,edit-image}):
//
// Output image cost (per output image):
// Low: $0.011 (1024×1024) / $0.016 (other) → 2¢ / 2¢
// Medium: $0.042 (1024×1024) / $0.063 (other) → 5¢ / 7¢
// High: $0.167 (1024×1024) / $0.250 (other) → 17¢ / 25¢
//
// Input image tokens (edit mode, per input image, high-fidelity assumed):
// 3,050 tokens × $0.005/1K tokens ≈ $0.01525 → 2¢ per input image
//
// Input text tokens: free (we don't charge for these).
//
let is_square = matches!(plan.image_size, None | Some(FalGptImage1ImageSize::Square));
let output_cost_per_image: u64 = match (plan.quality, is_square) {
(FalGptImage1Quality::Low, true) => 2,
(FalGptImage1Quality::Low, false) => 2,
(FalGptImage1Quality::Medium, true) => 5,
(FalGptImage1Quality::Medium, false) => 7,
(FalGptImage1Quality::High, true) => 17,
(FalGptImage1Quality::High, false) => 25,
};
let num_output_images = plan.num_images.as_u64();
let output_cost = output_cost_per_image * num_output_images;
// Input image token cost: 2¢ per input image (high-fidelity estimate).
let input_image_cost: u64 = if plan.image_urls.is_empty() {
0
} else {
2 * plan.image_urls.len() as u64
};
let cost_in_usd_cents = output_cost + input_image_cost;
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 super::*;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::FalGptImage1NumImages;
fn text_plan(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
) -> PlanFalGptImage1<'static> {
PlanFalGptImage1 {
prompt: Some("p"),
image_urls: vec![],
image_size,
quality,
num_images,
}
}
fn edit_plan(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
num_input_images: usize,
) -> PlanFalGptImage1<'static> {
let image_urls = (0..num_input_images)
.map(|i| format!("https://fake.example.com/img{}.png", i))
.collect();
PlanFalGptImage1 {
prompt: Some("p"),
image_urls,
image_size,
quality,
num_images,
}
}
fn cents_text(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
) -> u64 {
estimate_image_cost_fal_gpt_image_1(&text_plan(quality, image_size, num_images))
.cost_in_usd_cents
.unwrap()
}
fn cents_edit(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
num_input_images: usize,
) -> u64 {
estimate_image_cost_fal_gpt_image_1(&edit_plan(quality, image_size, num_images, num_input_images))
.cost_in_usd_cents
.unwrap()
}
// ── Low quality text-to-image (2¢/image, both sizes) ──────────────────
#[test]
fn low_square_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 2);
}
#[test]
fn low_horizontal_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::One), 2);
}
#[test]
fn low_vertical_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::One), 2);
}
#[test]
fn low_unset_size_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, None, FalGptImage1NumImages::One), 2);
}
#[test]
fn low_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::Low, None, FalGptImage1NumImages::Four), 8);
}
// ── Medium quality text-to-image (5¢ square, 7¢ wide/tall) ────────────
#[test]
fn medium_square_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 5);
}
#[test]
fn medium_horizontal_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::One), 7);
}
#[test]
fn medium_vertical_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::One), 7);
}
#[test]
fn medium_square_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::Four), 20);
}
#[test]
fn medium_horizontal_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::Four), 28);
}
// ── High quality text-to-image (17¢ square, 25¢ wide/tall) ────────────
#[test]
fn high_square_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 17);
}
#[test]
fn high_horizontal_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::One), 25);
}
#[test]
fn high_vertical_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::One), 25);
}
#[test]
fn high_square_two_images() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::Two), 34);
}
#[test]
fn high_horizontal_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::Four), 100);
}
// ── Edit mode adds 2¢ per input image ─────────────────────────────────
#[test]
fn edit_high_square_one_output_one_input() {
// 17¢ output + 2¢ input = 19¢
assert_eq!(cents_edit(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One, 1), 19);
}
#[test]
fn edit_high_square_one_output_three_inputs() {
// 17¢ output + 3×2¢ input = 23¢
assert_eq!(cents_edit(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One, 3), 23);
}
#[test]
fn edit_medium_wide_two_outputs_five_inputs() {
// 2×7¢ output + 5×2¢ input = 24¢
assert_eq!(cents_edit(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::Two, 5), 24);
}
#[test]
fn edit_low_square_one_output_two_inputs() {
// 2¢ output + 2×2¢ input = 6¢
assert_eq!(cents_edit(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One, 2), 6);
}
#[test]
fn edit_high_vertical_four_outputs_one_input() {
// 4×25¢ output + 1×2¢ input = 102¢
assert_eq!(cents_edit(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::Four, 1), 102);
}
// ── Text mode has no input image cost ─────────────────────────────────
#[test]
fn text_mode_no_input_cost() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 17);
}
// ── Metadata flags ────────────────────────────────────────────────────
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_fal_gpt_image_1(&text_plan(
FalGptImage1Quality::Medium,
None,
FalGptImage1NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -0,0 +1,180 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::{
FalGptImage1p5ImageSize, FalGptImage1p5Quality, PlanFalGptImage1p5,
};
pub(crate) fn estimate_image_cost_fal_gpt_image_1p5(
plan: &PlanFalGptImage1p5<'_>,
) -> ImageGenerationCostEstimate {
// Per fal docs (fal-ai/gpt-image-1.5 and fal-ai/gpt-image-1.5/edit):
//
// Output image cost (per output image), rounded up to whole cents:
// Low: $0.009 (1024×1024) / $0.013 (other) → 1¢ / 2¢
// Medium: $0.034 (1024×1024) / $0.050 (1536×1024) / $0.051 (1024×1536) → 4¢ / 5¢ / 6¢
// High: $0.133 (1024×1024) / $0.199 (1536×1024) / $0.200 (1024×1536) → 14¢ / 20¢ / 20¢
//
use FalGptImage1p5ImageSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(FalGptImage1p5Quality::Low, None | Some(S::Square)) => 1,
(FalGptImage1p5Quality::Low, Some(S::Wide) | Some(S::Tall)) => 2,
// Medium quality
(FalGptImage1p5Quality::Medium, None | Some(S::Square)) => 4,
(FalGptImage1p5Quality::Medium, Some(S::Wide)) => 5,
(FalGptImage1p5Quality::Medium, Some(S::Tall)) => 6,
// High quality
(FalGptImage1p5Quality::High, None | Some(S::Square)) => 14,
(FalGptImage1p5Quality::High, Some(S::Wide) | Some(S::Tall)) => 20,
};
let cost_in_usd_cents = cost_per_image * plan.num_images.as_u64();
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 super::*;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::FalGptImage1p5NumImages;
fn plan(
quality: FalGptImage1p5Quality,
image_size: Option<FalGptImage1p5ImageSize>,
num_images: FalGptImage1p5NumImages,
) -> PlanFalGptImage1p5<'static> {
PlanFalGptImage1p5 {
prompt: Some("p"),
image_urls: vec![],
image_size,
quality,
num_images,
}
}
fn cents(
quality: FalGptImage1p5Quality,
image_size: Option<FalGptImage1p5ImageSize>,
num_images: FalGptImage1p5NumImages,
) -> u64 {
estimate_image_cost_fal_gpt_image_1p5(&plan(quality, image_size, num_images))
.cost_in_usd_cents.unwrap()
}
// ── Low quality (1¢ square, 2¢ wide/tall) ─────────────────────────────
#[test]
fn low_square() {
assert_eq!(cents(FalGptImage1p5Quality::Low, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::One), 1);
}
#[test]
fn low_unset() {
assert_eq!(cents(FalGptImage1p5Quality::Low, None, FalGptImage1p5NumImages::One), 1);
}
#[test]
fn low_wide() {
assert_eq!(cents(FalGptImage1p5Quality::Low, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::One), 2);
}
#[test]
fn low_tall() {
assert_eq!(cents(FalGptImage1p5Quality::Low, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::One), 2);
}
#[test]
fn low_four() {
assert_eq!(cents(FalGptImage1p5Quality::Low, None, FalGptImage1p5NumImages::Four), 4);
}
// ── Medium quality (4¢ square, 5¢ wide, 6¢ tall) ─────────────────────
#[test]
fn medium_square() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::One), 4);
}
#[test]
fn medium_unset() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, None, FalGptImage1p5NumImages::One), 4);
}
#[test]
fn medium_wide() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::One), 5);
}
#[test]
fn medium_tall() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::One), 6);
}
#[test]
fn medium_square_four() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::Four), 16);
}
#[test]
fn medium_wide_four() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::Four), 20);
}
#[test]
fn medium_tall_four() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::Four), 24);
}
// ── High quality (14¢ square, 20¢ wide/tall) ─────────────────────────
#[test]
fn high_square() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::One), 14);
}
#[test]
fn high_unset() {
assert_eq!(cents(FalGptImage1p5Quality::High, None, FalGptImage1p5NumImages::One), 14);
}
#[test]
fn high_wide() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::One), 20);
}
#[test]
fn high_tall() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::One), 20);
}
#[test]
fn high_square_four() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::Four), 56);
}
#[test]
fn high_wide_four() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::Four), 80);
}
// ── Metadata flags ────────────────────────────────────────────────────
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_fal_gpt_image_1p5(&plan(
FalGptImage1p5Quality::Medium, None, FalGptImage1p5NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -0,0 +1,34 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana::{
FalNbNumImages, PlanFalNanoBanana,
};
pub(crate) fn estimate_image_cost_fal_nano_banana(
plan: &PlanFalNanoBanana<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.039/image (no resolution multiplier — Gemini 25 Flash has no resolution option).
// Rounded to 4 cents per image.
//
// Fal docs:
// Your request will cost $0.039 per image. For $1.00, you can run this model 25 times.
//
let cost_per_image: u64 = 4;
let num_images: u64 = match plan.num_images {
FalNbNumImages::One => 1,
FalNbNumImages::Two => 2,
FalNbNumImages::Three => 3,
FalNbNumImages::Four => 4,
};
let cost_in_usd_cents = cost_per_image * num_images;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
@@ -0,0 +1,44 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::{
FalNb2NumImages, FalNb2Resolution, PlanFalNanaBanana2,
};
pub(crate) fn estimate_image_cost_fal_nano_banana_2(
plan: &PlanFalNanaBanana2<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.08/image base (1K). Multipliers: 0.5K = 0.75x, 2K = 1.5x, 4K = 2x.
// (Stored as USD cents.)
//
// Fal docs:
// Your request will cost $0.08 per image. For $1.00, you can run this model 12 times.
// 2K and 4K outputs will be charged at 1.5 times and 2 times the standard rate, respectively.
// 0.5K (512px) resolution outputs will be charged at 0.75 times the standard rate. If web
// search is used, an additional $0.015 will be charged. If high thinking is used,
// an additional $0.002 will be charged. Note: Pricing is subject to change.
//
let cost_per_image: u64 = match plan.resolution {
Some(FalNb2Resolution::HalfK) => 6, // $0.06 = $0.08 * 0.75
Some(FalNb2Resolution::OneK) => 8, // $0.08 base
Some(FalNb2Resolution::TwoK) => 12, // $0.12 = $0.08 * 1.5
Some(FalNb2Resolution::FourK) => 16, // $0.16 = $0.08 * 2.0
None => 8, // default = 1K base
};
let num_images: u64 = match plan.num_images {
FalNb2NumImages::One => 1,
FalNb2NumImages::Two => 2,
FalNb2NumImages::Three => 3,
FalNb2NumImages::Four => 4,
};
let cost_in_usd_cents = cost_per_image * num_images;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
@@ -42,12 +42,13 @@ mod tests {
fn estimate_usd_cents(resolution: Option<CommonResolution>, image_batch_count: u16) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::NanaBananaPro,
model: CommonImageModel::NanoBananaPro,
provider: Provider::Fal,
prompt: None,
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -0,0 +1,19 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4::PlanFalSeedream4;
pub(crate) fn estimate_image_cost_fal_seedream_4(
plan: &PlanFalSeedream4<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.03 per image.
let cost_per_image: u64 = 3;
let cost_in_usd_cents = cost_per_image * plan.num_images.as_u64();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
@@ -0,0 +1,19 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4p5::PlanFalSeedream4p5;
pub(crate) fn estimate_image_cost_fal_seedream_4p5(
plan: &PlanFalSeedream4p5<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04 per image.
let cost_per_image: u64 = 4;
let cost_in_usd_cents = cost_per_image * plan.num_images.as_u64();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
@@ -0,0 +1,19 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_5_lite::PlanFalSeedream5Lite;
pub(crate) fn estimate_image_cost_fal_seedream_5_lite(
plan: &PlanFalSeedream5Lite<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.035 per image — round up to 4 cents.
let cost_per_image: u64 = 4;
let cost_in_usd_cents = cost_per_image * plan.num_images.as_u64();
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,
}
}
@@ -1 +1,12 @@
pub(crate) mod estimate_image_cost_fal_flux_1_dev;
pub(crate) mod estimate_image_cost_fal_flux_1_schnell;
pub(crate) mod estimate_image_cost_fal_flux_pro_1p1;
pub(crate) mod estimate_image_cost_fal_flux_pro_1p1_ultra;
pub(crate) mod estimate_image_cost_fal_gpt_image_1;
pub(crate) mod estimate_image_cost_fal_gpt_image_1p5;
pub(crate) mod estimate_image_cost_fal_seedream_4;
pub(crate) mod estimate_image_cost_fal_seedream_4p5;
pub(crate) mod estimate_image_cost_fal_seedream_5_lite;
pub(crate) mod estimate_image_cost_fal_nano_banana;
pub(crate) mod estimate_image_cost_fal_nano_banana_2;
pub(crate) mod estimate_image_cost_fal_nano_banana_pro;
@@ -47,6 +47,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_flux_1_dev_image_request()
@@ -69,6 +70,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_1_dev_image_request()
@@ -94,6 +96,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a bear walking through an abandoned desert city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -37,7 +37,6 @@ mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{base_flux_1_schnell_image_request, get_artcraft_client};
use strum::IntoEnumIterator;
@@ -47,6 +46,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a man and woman walking through a cyberpunk city at night"),
..base_flux_1_schnell_image_request()
@@ -69,6 +69,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_1_schnell_image_request()
@@ -94,6 +95,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a man and woman walking through a futuristic city at evening"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -51,6 +51,7 @@ mod tests {
let tokens = vec![MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8")];
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::SquareHd),
quality: None,
image_batch_count: Some(1),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
horizontal_angle: Some(30.0),
@@ -47,6 +47,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_flux_pro_1p1_image_request()
@@ -69,6 +70,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_pro_1p1_image_request()
@@ -94,6 +96,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a bear walking through an abandoned desert city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -47,6 +47,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_flux_pro_1p1_ultra_image_request()
@@ -69,6 +70,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_pro_1p1_ultra_image_request()
@@ -94,6 +96,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a t-rex walking through an abandoned desert city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -0,0 +1,147 @@
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_gpt_image_1::PlanArtcraftGptImage1;
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::GptImage1EditImageRequest;
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageRequest;
use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
use artcraft_client::endpoints::generate::image::text::generate_gpt_image_1_text_to_image::generate_gpt_image_1_text_to_image;
/// GPT Image 1 has two distinct legacy storyteller-web endpoints (one for
/// text-to-image, one for image-edit), so we dispatch on whether image refs
/// are present rather than calling a single multi-function endpoint.
pub async fn execute_artcraft_gpt_image_1(
plan: &PlanArtcraftGptImage1<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let inference_job_token = match plan.image_inputs {
Some(image_inputs) => {
let request = GptImage1EditImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_media_tokens: Some(image_inputs.to_owned()),
image_size: plan.image_size.map(|s| s.to_edit()),
num_images: Some(plan.num_images.to_edit()),
image_quality: Some(plan.quality.to_edit()),
};
let response = gpt_image_1_edit_image(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
None => {
let request = GenerateGptImage1TextToImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_size: plan.image_size.map(|s| s.to_t2i()),
num_images: Some(plan.num_images.to_t2i()),
image_quality: Some(plan.quality.to_t2i()),
};
let response = generate_gpt_image_1_text_to_image(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
};
Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload {
inference_job_token,
}))
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{base_gpt_image_1_image_request, get_artcraft_client};
use tokens::tokens::media_files::MediaFileToken;
// Build-only smoke test that exercises the full text-to-image plan path
// (no network I/O — we never call generate_image).
#[test]
fn build_text_to_image_plan_smoke() {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_gpt_image_1_image_request()
};
let plan = request.build().expect("plan should build");
assert!(matches!(plan, ImageGenerationPlan::ArtcraftGptImage1(_)));
}
// Build-only smoke test for the edit path. We can't actually fire the
// request without an authenticated client, but we want to make sure the
// dispatcher recognizes media-token inputs as edit-mode.
#[test]
fn build_edit_image_plan_smoke() {
let tokens = vec![MediaFileToken::new_from_str("mf_test00000000000000000000000000")];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_gpt_image_1_image_request()
};
let plan = request.build().expect("plan should build");
let ImageGenerationPlan::ArtcraftGptImage1(plan) = plan else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_inputs.is_some(), "edit mode must carry image_inputs");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_gpt_image_1() {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
prompt: Some("a horse walking through a cyberpunk city at night"),
..base_gpt_image_1_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);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_edit_image_gpt_image_1() {
let client = get_artcraft_client();
let image_tokens = vec![
MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8"),
];
let request = GenerateImageRequest {
prompt: Some("Change the background to a desert with cacti"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_gpt_image_1_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);
}
}
@@ -50,6 +50,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_nano_banana_image_request()
@@ -79,6 +80,7 @@ mod tests {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto),
quality: None,
image_batch_count: Some(1),
..base_nano_banana_image_request()
};
@@ -103,6 +105,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a shiba walking through a cyberpunk city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -138,6 +141,7 @@ mod tests {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_nano_banana_image_request()

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