Compare commits

...
Author SHA1 Message Date
Brandon Thomas d4ea026b44 dynamically select API host for development / production 2026-04-16 18:25:01 -04:00
Brandon Thomas fc3423d05e model creator + name metadata for omni endpoints (#1386) 2026-04-16 18:02:00 -04:00
bflatastic 98cd19bd40 website: video thumbnail loading spinner 2026-04-17 01:17:06 +07:00
Tanish Manku 94d9de09cc Fix video aspect ratio icon not updating on gallery recreate (#1384)
getCurrentAspectRatioIcon() was reading from aspectRatioList (useState),
which is only updated on manual picker clicks, not external store
writes. Reading aspectRatio from the store directly fixes the stale
icon.
2026-04-16 09:58:04 -07:00
Brandon Thomas 7d788bff62 omni router seedance 2.0 fast (#1382) 2026-04-16 02:59:50 -04:00
BFlat d0e2ad9269 website: credits purchasable from pricing page (#1381)
- only visible when logged in
2026-04-16 11:35:25 +07:00
BFlat 6d9401c1f1 website: promptbox resizable and expandable via chevron icon button (#1380) 2026-04-16 11:00:12 +07:00
Brandon Thomas 49e49efa3c worldlabs marble 1.1 client library (#1379) 2026-04-15 22:01:13 -04:00
BFlat d51c001835 video ref select video from library support (#1378)
## Video reference library picker

- Added "Pick from library" option to the Video Ref slot, with gallery
filtered to videos
- Added loading state to "Use selected" button in gallery modal
- Enforced seedance-2 limits (max 3 videos / 15s total) for both upload
and library flows, with a deduped video-ref-limit toast so users see a
warning on count overflow, duration overflow, or submitting when already
at max
2026-04-16 07:49:48 +07:00
BFlat f94fc5bf07 Fix @mention tagging in prompts with CJK characters (#1376)
Two bugs were preventing @image / @video / @character references from
being tagged in prompts written with Chinese, Japanese, or Korean text:

1. The mention-highlight regex was built with just the "g" flag, so a
user-typed "@image6" never matched the canonical label "@Image6" and the
reference was rendered as plain text. Make the regex case-insensitive
and do the colorMap lookup via a lowercase-keyed table so "@image6"
still finds the color for "@Image6".

2. detectMention required the character immediately before "@" to match
/\s/, which never matches CJK characters. Typing "@" right after a
Chinese character like "从" therefore never opened the autocomplete
dropdown, leaving users to type the mention by hand (which then hit bug
1). Replace the whitespace check with a "not an ASCII identifier char"
check so CJK, quotes, and punctuation all count as valid word boundaries
before an @mention.
2026-04-16 07:05:21 +07:00
Brandon Thomas f397ad825c worldlabs fixes for nsfw 500ing (#1377) 2026-04-15 20:05:07 -04:00
Brandon Thomas 7eea797883 more logging (endpoint failing without debugging info) (#1373) 2026-04-15 17:49:21 -04:00
bflatastic 73856fca85 loading spinner to wait for video thumbnail to return 2026-04-16 01:48:29 +07:00
bflatastic 3d27c620e4 site video time progress update 2026-04-16 01:35:28 +07:00
Brandon Thomas 062b23d3a0 omni seedance 2 (#1372) 2026-04-15 13:15:20 -04:00
BFlat 129fc3d593 enable omni gen api on website (#1370) 2026-04-15 01:58:56 -04:00
Brandon Thomas ad40798a12 chrome launch script (#1371) 2026-04-15 01:44:39 -04:00
bflatastic 3db8e66204 gallery to clear cache and reset after logout/login with new account 2026-04-14 23:35:52 +07:00
Brandon Thomas 963521d87b temporary disable video models (omni / website gens only - app unaffected) (#1369) 2026-04-14 11:53:11 -04:00
Brandon Thomas 4323743fe7 bump artcraft 0.27 -> 0.28 (#1368) 2026-04-14 11:49:12 -04:00
Brandon Thomas 752fad12b3 fix build issue 2026-04-14 11:47:16 -04:00
BFlat 99b831c3b0 duration slider for video gen website (#1367) 2026-04-14 21:31:45 +07:00
BFlat 4a1d1acddd Omni API UI updates, styling updates, mobile reponsiveness (website) (#1340)
- **Characters for Seedance 2.0** - Character creation/selection modal,
@-mention support with colored inline highlights via contentEditable
MentionTextarea, character tokens sent in video generation API requests
- **Mobile responsive navbar** - Hamburger menu with slide-down panel,
all nav items accessible, credits/library/sign out included
- **Mobile responsive PromptBox** - Tighter padding, stacked toolbar on
small screens, flex-wrap for option buttons
- **Mobile responsive library** - Proper sticky header offset, icon-only
filter tabs on mobile
- **Task queue overhaul** - Prompt text with marquee hover, copy button,
model creator icons, detailed failure reasons on all card types
(matching desktop app)
- **Gallery improvements** - 512px thumbnails (was 250px), 12px grid
gap, model + media type badges on hover, model icon + prompt on
in-progress cards
- **Quality selector** - QualityPicker for image models
(High/Medium/Low), wired to cost estimate and generation API
- **Video batch enqueue** - Multiple videos enqueue as separate jobs so
each completes independently
- **Form consistency** - All auth pages use shared `<Input>` component,
`<form onSubmit>` for Enter key, consistent styling
- **Bug fixes** - Lightbox type error, library carousel disabled,
popover icon alignment, end frame library picker, image ref max default
(1 → 6), task queue mobile overflow, clear session button removed
- **Mobile model selector** - Model picker now appears in the PromptBox
toolbar on mobile/tablet (`lg:hidden`), since the floating bottom-left
selector is desktop-only
- **Responsive image prompt row** - Start frame and end frame sections
stack vertically on mobile with a horizontal divider, instead of
side-by-side
- **Responsive lightbox** - Stacks vertically on mobile (media top, info
below), nav arrows always visible (not hover-only), sidebar goes
full-width, darker backdrop (`bg-black/80`)
- **Safari video inline playback** - Lightbox videos use
`disablePictureInPicture`, `controlsList` restrictions, and
vendor-prefixed attributes to prevent fullscreen/PiP pop-out on Safari
2026-04-14 20:51:06 +07:00
bflatastic 47d6ac6841 text to image to properly use the shared upload media api + delete double toast fix 2026-04-14 20:24:24 +07:00
BFlat 689bda8bf3 image ref uploads to show in gallery, media delete not disappearing fix (#1366) 2026-04-14 20:02:47 +07:00
Brandon Thomas 7e36149d13 more omni gen video models, client bindings, tests (#1365) 2026-04-14 04:48:59 -04:00
Brandon Thomas 2bbeb3de78 more support audit logs while we're at it (#1364) 2026-04-14 03:44:48 -04:00
Brandon Thomas eeacbf6719 ban users handler update / modernization (#1363) 2026-04-14 03:19:59 -04:00
Brandon Thomas af763669cc staff audit logs list (#1362) 2026-04-14 02:52:56 -04:00
Brandon Thomas 7ff78c6d94 audit impersonation (#1361) 2026-04-14 02:18:52 -04:00
Brandon Thomas 588478da73 cursors (#1359) 2026-04-14 01:00:01 -04:00
Brandon Thomas 5cb7b80b44 add api docs for impersonation (#1358) 2026-04-13 22:58:09 -04:00
Brandon Thomas 50b1a7ed06 user impersonation for support inquiries (#1357)
- only done with user consent 
- only available to support staff
- thoroughly audited
2026-04-13 22:49:14 -04:00
Tanish Manku 44cadf84cc Surface gallery load errors with message and copy-to-clipboard (#1356)
When listUserMediaFiles fails (HTTP error, network error, null
reference, or any other exception), the gallery now shows the error
message with a "Copy error" button instead of silently falling through
to "No items yet".

This is a diagnostic aid for a macOS-only blank gallery bug where the
root cause is unknown (CORS, session cookie, or runtime exception) and
there is no access to production logs.
2026-04-13 16:23:49 -07:00
Brandon Thomas b7c8151d28 error utils (reusable) (#1355) 2026-04-13 17:47:06 -04:00
262 changed files with 14225 additions and 2157 deletions
+11 -1
View File
@@ -6,7 +6,11 @@
- 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};`
- 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
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code; new database queries should use `Result<T, sqlx::Error>` instead
- SQLx queries: prefer `sqlx::query!` and `sqlx::query_as!` compile-time checked macros over
runtime `sqlx::query()` / `sqlx::query_as()`. The macros catch SQL typos, column mismatches,
and type errors at build time. Use runtime queries only when assembling complicated
dynamic/branching queries that cannot be expressed as a compile-time macro.
- 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`
- When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)`
@@ -56,8 +60,14 @@ then the story, then the fine print.
`use artcraft_router::...::CommonResolution as CommonResolutionRouter;`
This avoids long fully-qualified paths in function signatures and bodies.
## MySQL
- MySQL tables: primary key `id` columns must be `BIGINT(20) UNSIGNED`. Do not use signed IDs.
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
## TypeScript / Frontend
- TypeScript with React, Vite, Zustand, Three.js
- Nx monorepo under `crates/frontend/`
- Two spaces for indentation
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n uir.id,\n uir.impersonator_user_token as `impersonator_user_token: UserToken`,\n impersonator.username as impersonator_username,\n impersonator.display_name as impersonator_display_name,\n uir.impersonated_user_token as `impersonated_user_token: UserToken`,\n impersonated.username as impersonated_username,\n impersonated.display_name as impersonated_display_name,\n uir.is_redeemed as `is_redeemed: bool`,\n (uir.expires_at < NOW()) as `is_expired: bool`,\n uir.expires_at,\n uir.created_at,\n uir.updated_at\nFROM user_impersonation_requests uir\nJOIN users impersonator ON impersonator.token = uir.impersonator_user_token\nJOIN users impersonated ON impersonated.token = uir.impersonated_user_token\nWHERE uir.impersonated_user_token = ?\n AND uir.id < ?\nORDER BY uir.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "impersonator_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "impersonator_username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "impersonator_display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 4,
"name": "impersonated_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "impersonated_username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 6,
"name": "impersonated_display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 7,
"name": "is_redeemed: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 8,
"name": "is_expired: bool",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 9,
"name": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 11,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "27cebaa36f0ec873f86cdf8fa994d499db1a5cc84d2c6fceb3587145eb2f9bec"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n uir.id,\n uir.impersonator_user_token as `impersonator_user_token: UserToken`,\n impersonator.username as impersonator_username,\n impersonator.display_name as impersonator_display_name,\n uir.impersonated_user_token as `impersonated_user_token: UserToken`,\n impersonated.username as impersonated_username,\n impersonated.display_name as impersonated_display_name,\n uir.is_redeemed as `is_redeemed: bool`,\n (uir.expires_at < NOW()) as `is_expired: bool`,\n uir.expires_at,\n uir.created_at,\n uir.updated_at\nFROM user_impersonation_requests uir\nJOIN users impersonator ON impersonator.token = uir.impersonator_user_token\nJOIN users impersonated ON impersonated.token = uir.impersonated_user_token\nWHERE uir.impersonated_user_token = ?\nORDER BY uir.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "impersonator_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "impersonator_username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "impersonator_display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 4,
"name": "impersonated_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "impersonated_username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 6,
"name": "impersonated_display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 7,
"name": "is_redeemed: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 8,
"name": "is_expired: bool",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 9,
"name": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 11,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "381e13ab37a2f4ce39bd6db9d90202c301091376108f645a9bb5264a9cd113d7"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n sal.id,\n sal.token as `token: StaffAuditLogToken`,\n sal.audit_action,\n sal.maybe_entity_type,\n sal.maybe_entity_token,\n target_user.username as maybe_target_username,\n target_user.display_name as maybe_target_display_name,\n sal.staff_user_token as `staff_user_token: UserToken`,\n staff_user.username as staff_username,\n staff_user.display_name as staff_display_name,\n sal.staff_ip_address,\n sal.created_at\nFROM staff_audit_logs sal\nLEFT JOIN users target_user ON target_user.token = sal.maybe_entity_token\nLEFT JOIN users staff_user ON staff_user.token = sal.staff_user_token\nORDER BY sal.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: StaffAuditLogToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "audit_action",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_entity_type",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_entity_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_target_username",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 6,
"name": "maybe_target_display_name",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 7,
"name": "staff_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "staff_username",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 9,
"name": "staff_display_name",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 10,
"name": "staff_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "3846628765bd34ea2ce01e15a0db732121ce473c5c542d0e971ad29b2b421193"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n is_banned = ?,\n maybe_mod_comments = ?,\n maybe_mod_user_token = ?,\n version = version + 1\n\nWHERE users.token = ?\nLIMIT 1\n ",
"query": "\nUPDATE users\nSET\n is_banned = ?,\n maybe_mod_comments = ?,\n maybe_mod_user_token = ?,\n version = version + 1\n\nWHERE users.token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "eab69a8d679b2dcfb06a9ccc79851f6c693d0c372142ff0e7ded89c95e4bcf44"
"hash": "3d746dcb479005f1f4ae5e157a5ace7626eda4849d44c6c2d7079fd346eb4edc"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n uir.id,\n uir.impersonator_user_token as `impersonator_user_token: UserToken`,\n impersonator.username as impersonator_username,\n impersonator.display_name as impersonator_display_name,\n uir.impersonated_user_token as `impersonated_user_token: UserToken`,\n impersonated.username as impersonated_username,\n impersonated.display_name as impersonated_display_name,\n uir.is_redeemed as `is_redeemed: bool`,\n (uir.expires_at < NOW()) as `is_expired: bool`,\n uir.expires_at,\n uir.created_at,\n uir.updated_at\nFROM user_impersonation_requests uir\nJOIN users impersonator ON impersonator.token = uir.impersonator_user_token\nJOIN users impersonated ON impersonated.token = uir.impersonated_user_token\nWHERE uir.id < ?\nORDER BY uir.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "impersonator_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "impersonator_username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "impersonator_display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 4,
"name": "impersonated_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "impersonated_username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 6,
"name": "impersonated_display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 7,
"name": "is_redeemed: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 8,
"name": "is_expired: bool",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 9,
"name": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 11,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "5d45897a5d4c326c8d17f105cae81db7f5878c197b55e8ea61c225e739d11234"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n sal.id,\n sal.token as `token: StaffAuditLogToken`,\n sal.audit_action,\n sal.maybe_entity_type,\n sal.maybe_entity_token,\n target_user.username as maybe_target_username,\n target_user.display_name as maybe_target_display_name,\n sal.staff_user_token as `staff_user_token: UserToken`,\n staff_user.username as staff_username,\n staff_user.display_name as staff_display_name,\n sal.staff_ip_address,\n sal.created_at\nFROM staff_audit_logs sal\nLEFT JOIN users target_user ON target_user.token = sal.maybe_entity_token\nLEFT JOIN users staff_user ON staff_user.token = sal.staff_user_token\nWHERE sal.id < ?\nORDER BY sal.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: StaffAuditLogToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "audit_action",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_entity_type",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_entity_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_target_username",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 6,
"name": "maybe_target_display_name",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 7,
"name": "staff_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "staff_username",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 9,
"name": "staff_display_name",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 10,
"name": "staff_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "9132f832752ea40736bedf4729c76af7fe019cc69ed5cc280fce87e68c3f4846"
}
@@ -0,0 +1,80 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token,\n impersonated_user_token as `impersonated_user_token: UserToken`,\n impersonator_user_token as `impersonator_user_token: UserToken`,\n user_impersonation_token,\n is_redeemed as `is_redeemed: bool`,\n expires_at\nFROM user_impersonation_requests\nWHERE user_impersonation_token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "impersonated_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "impersonator_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "user_impersonation_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "is_redeemed: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 5,
"name": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "9dc83df0b7f1cf2716352e947b6b7fcb6c455fb0c12d72e584612f35366831f4"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n maybe_impersonation_user_token,\n ip_address_creation,\n expires_at\n)\nVALUES (?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "ba5b0a542d4b95b781077b8895529d222dd682eb28c9d00031a820ec6f37e095"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO staff_audit_logs\nSET\n token = ?,\n audit_action = ?,\n maybe_entity_type = ?,\n maybe_entity_token = ?,\n staff_user_token = ?,\n staff_ip_address = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "c39800423a5fe0aa324e117e624015f85d039c3ca6833cf6865471345ab0768e"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO user_impersonation_requests\nSET\n token = ?,\n impersonated_user_token = ?,\n impersonator_user_token = ?,\n user_impersonation_token = ?,\n ip_address_creation = ?,\n expires_at = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "cc782c807dadcaaaad08f469935f61dc2e9f3217aa01532d6602b8e37cedf92d"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE user_impersonation_requests\nSET\n is_redeemed = TRUE,\n ip_address_redemption = ?\nWHERE user_impersonation_token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "cf4be26ce3a48c7108640faa6c9fc68a48136327a9d374c52c16848c006f0984"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n uir.id,\n uir.impersonator_user_token as `impersonator_user_token: UserToken`,\n impersonator.username as impersonator_username,\n impersonator.display_name as impersonator_display_name,\n uir.impersonated_user_token as `impersonated_user_token: UserToken`,\n impersonated.username as impersonated_username,\n impersonated.display_name as impersonated_display_name,\n uir.is_redeemed as `is_redeemed: bool`,\n (uir.expires_at < NOW()) as `is_expired: bool`,\n uir.expires_at,\n uir.created_at,\n uir.updated_at\nFROM user_impersonation_requests uir\nJOIN users impersonator ON impersonator.token = uir.impersonator_user_token\nJOIN users impersonated ON impersonated.token = uir.impersonated_user_token\nORDER BY uir.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "impersonator_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "impersonator_username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "impersonator_display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 4,
"name": "impersonated_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "impersonated_username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 6,
"name": "impersonated_display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 7,
"name": "is_redeemed: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 8,
"name": "is_expired: bool",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 9,
"name": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 11,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "e72b3dfdd9df4ee10a696faa4dfc3fb8addc9d2b88017f503c339fb616bef6f1"
}
+1 -1
View File
@@ -58,7 +58,7 @@ artcraft/
- Rust with no minimum supported version
- Actix-web for HTTP services
- SQLx for MySQL and SQLite
- SQLx for MySQL and SQLite; prefer `sqlx::query!` / `sqlx::query_as!` compile-time checked macros over runtime `sqlx::query()` whenever possible
- 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;`
Generated
+22
View File
@@ -770,6 +770,7 @@ dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_client",
"enums",
"fal_client",
"log",
"muapi_client",
@@ -3876,6 +3877,13 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "error_utils"
version = "0.0.1"
dependencies = [
"regex",
]
[[package]]
name = "errors"
version = "0.0.1"
@@ -7743,6 +7751,17 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "opaque_cursors"
version = "0.0.1"
dependencies = [
"base64 0.22.1",
"magic-crypt",
"serde",
"serde_derive",
"serde_json",
]
[[package]]
name = "open"
version = "5.3.3"
@@ -11234,6 +11253,7 @@ dependencies = [
"elasticsearch",
"elasticsearch_schema",
"enums",
"error_utils",
"errors",
"fal_client",
"ffmpeg_utils",
@@ -11261,6 +11281,7 @@ dependencies = [
"mimetypes",
"mysql_queries",
"once_cell",
"opaque_cursors",
"pager",
"password",
"primitives",
@@ -14772,6 +14793,7 @@ dependencies = [
"serde",
"serde_json",
"strum",
"test_data",
"tokio",
"uuid 1.22.0",
"workspace-build-acceleration",
+4
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",
@@ -72,6 +73,7 @@ members = [
"crates/lib/http_headers",
"crates/lib/jobs_common",
"crates/lib/jwt_light",
"crates/lib/opaque_cursors",
"crates/lib/jwt_signer",
"crates/lib/logging",
"crates/lib/markdown",
@@ -236,6 +238,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" }
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" }
@@ -250,6 +253,7 @@ images = { path = "crates/lib/files/images" }
jobs_common = { path = "crates/lib/jobs_common" }
jwt_light = { path = "crates/lib/jwt_light" }
jwt_signer = { path = "crates/lib/jwt_signer" }
opaque_cursors = { path = "crates/lib/opaque_cursors" }
memory_caching = { path = "crates/lib/caching/memory_caching" }
memory_store = { path = "crates/lib/caching/memory_store" }
midjourney_client = { path = "crates/api_clients/midjourney_client" }
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS staff_audit_logs;
@@ -0,0 +1,39 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE staff_audit_logs (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Effective "primary key" of the audit log item (in case we add a UI or notes)
token VARCHAR(32) NOT NULL,
-- The type of action taken
audit_action VARCHAR(32) NOT NULL,
-- The type of entity acted upon (if any)
maybe_entity_type VARCHAR(16) DEFAULT NULL,
-- The token of the entity mutated (if any)
maybe_entity_token VARCHAR(32) NOT NULL,
-- The staff user who performed the action.
staff_user_token VARCHAR(32) DEFAULT NULL,
-- For abuse tracking.
-- Wide enough for IPv4/6
staff_ip_address VARCHAR(40) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY index_maybe_entity_type (maybe_entity_type),
KEY index_maybe_entity_token (maybe_entity_token),
KEY index_staff_user_token (staff_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE user_sessions
DROP COLUMN maybe_impersonation_user_token;
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE user_sessions
ADD COLUMN maybe_impersonation_user_token VARCHAR(32) DEFAULT NULL
AFTER user_token;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_impersonation_requests;
@@ -0,0 +1,44 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_impersonation_requests (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Internal "primary key"
token VARCHAR(32) NOT NULL,
-- The user being impersonated.
impersonated_user_token VARCHAR(32) NOT NULL,
-- The staff user who initiated the impersonation.
impersonator_user_token VARCHAR(32) NOT NULL,
-- The actual secret that must be presented to redeem the impersonation.
user_impersonation_token VARCHAR(32) NOT NULL,
-- Whether this request has been redeemed (used to create a session).
is_redeemed BOOLEAN NOT NULL DEFAULT FALSE,
-- IP address of the staff user who created the request.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address from which the request was redeemed, if redeemed.
ip_address_redemption VARCHAR(40) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- The request expires after this time if not redeemed.
expires_at TIMESTAMP NOT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (user_impersonation_token),
KEY index_impersonated_user_token (impersonated_user_token),
KEY index_impersonator_user_token (impersonator_user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,35 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Recreate with the original signed BIGINT id.
CREATE TABLE user_impersonation_requests (
id BIGINT(20) NOT NULL AUTO_INCREMENT,
token VARCHAR(32) NOT NULL,
impersonated_user_token VARCHAR(32) NOT NULL,
impersonator_user_token VARCHAR(32) NOT NULL,
user_impersonation_token VARCHAR(32) NOT NULL,
is_redeemed BOOLEAN NOT NULL DEFAULT FALSE,
ip_address_creation VARCHAR(40) NOT NULL,
ip_address_redemption VARCHAR(40) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (user_impersonation_token),
KEY index_impersonated_user_token (impersonated_user_token),
KEY index_impersonator_user_token (impersonator_user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_impersonation_requests;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_impersonation_requests;
@@ -0,0 +1,44 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_impersonation_requests (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Internal "primary key"
token VARCHAR(32) NOT NULL,
-- The user being impersonated.
impersonated_user_token VARCHAR(32) NOT NULL,
-- The staff user who initiated the impersonation.
impersonator_user_token VARCHAR(32) NOT NULL,
-- The actual secret that must be presented to redeem the impersonation.
user_impersonation_token VARCHAR(32) NOT NULL,
-- Whether this request has been redeemed (used to create a session).
is_redeemed BOOLEAN NOT NULL DEFAULT FALSE,
-- IP address of the staff user who created the request.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address from which the request was redeemed, if redeemed.
ip_address_redemption VARCHAR(40) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- The request expires after this time if not redeemed.
expires_at TIMESTAMP NOT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (user_impersonation_token),
KEY index_impersonated_user_token (impersonated_user_token),
KEY index_impersonator_user_token (impersonator_user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,31 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Recreate with the original signed BIGINT id.
CREATE TABLE staff_audit_logs (
id BIGINT(20) NOT NULL AUTO_INCREMENT,
token VARCHAR(32) NOT NULL,
audit_action VARCHAR(32) NOT NULL,
maybe_entity_type VARCHAR(16) DEFAULT NULL,
maybe_entity_token VARCHAR(32) NOT NULL,
staff_user_token VARCHAR(32) DEFAULT NULL,
staff_ip_address VARCHAR(40) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY index_maybe_entity_type (maybe_entity_type),
KEY index_maybe_entity_token (maybe_entity_token),
KEY index_staff_user_token (staff_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS staff_audit_logs;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS staff_audit_logs;
@@ -0,0 +1,39 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE staff_audit_logs (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" of the audit log item (in case we add a UI or notes)
token VARCHAR(32) NOT NULL,
-- The type of action taken
audit_action VARCHAR(32) NOT NULL,
-- The type of entity acted upon (if any)
maybe_entity_type VARCHAR(16) DEFAULT NULL,
-- The token of the entity mutated (if any)
maybe_entity_token VARCHAR(32) NOT NULL,
-- The staff user who performed the action.
staff_user_token VARCHAR(32) DEFAULT NULL,
-- For abuse tracking.
-- Wide enough for IPv4/6
staff_ip_address VARCHAR(40) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY index_maybe_entity_type (maybe_entity_type),
KEY index_maybe_entity_token (maybe_entity_token),
KEY index_staff_user_token (staff_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+38
View File
@@ -0,0 +1,38 @@
# SQL Migrations
Incremental schema changes applied in order by timestamp prefix.
## Naming Convention
Migration directories follow the format:
```
YYYY-MM-DD-HHMMSS-NNNN_description_goes_here/
up.sql
down.sql
```
These are created with the `diesel` tool, and in particular:
> `diesel migration generate description_goes_here`
- `NNNN` is a zero-padded sequence number for ordering multiple migrations on the same timestamp.
- `up.sql` applies the migration forward.
- `down.sql` reverses it.
## Table Conventions
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
- All SQL files should start with the standard IntelliJ noinspection comments:
```sql
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
```
## Rules
- Every `up.sql` must have a corresponding `down.sql` that cleanly reverses the migration.
- For `CREATE TABLE` migrations, the `down.sql` is typically `DROP TABLE IF EXISTS table_name;`.
- For `ALTER TABLE` migrations, the `down.sql` reverses the alteration (e.g. `DROP COLUMN`).
@@ -0,0 +1,26 @@
# Squashed / Materialized Schemas
Fully materialized table definitions representing the current state of each table
after all migrations have been applied. These are not executed — they exist as
human-readable documentation of the current schema.
## When to Update
Update the corresponding squashed file whenever a migration changes a table's schema
(adding columns, changing types, dropping and recreating, etc.).
## Table Conventions
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
- All SQL files should start with the standard IntelliJ noinspection comments:
```sql
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
```
## File Organization
Files are named after their table (e.g. `users.sql`, `media_files.sql`).
Some tables are grouped into subdirectories (e.g. `user_tables/`).
@@ -0,0 +1,39 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE staff_audit_logs (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" of the audit log item (in case we add a UI or notes)
token VARCHAR(32) NOT NULL,
-- The type of action taken
audit_action VARCHAR(32) NOT NULL,
-- The type of entity acted upon (if any)
maybe_entity_type VARCHAR(16) DEFAULT NULL,
-- The token of the entity mutated (if any)
maybe_entity_token VARCHAR(32) NOT NULL,
-- The staff user who performed the action.
staff_user_token VARCHAR(32) DEFAULT NULL,
-- For abuse tracking.
-- Wide enough for IPv4/6
staff_ip_address VARCHAR(40) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY index_maybe_entity_type (maybe_entity_type),
KEY index_maybe_entity_token (maybe_entity_token),
KEY index_staff_user_token (staff_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,44 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_impersonation_requests (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Internal "primary key"
token VARCHAR(32) NOT NULL,
-- The user being impersonated.
impersonated_user_token VARCHAR(32) NOT NULL,
-- The staff user who initiated the impersonation.
impersonator_user_token VARCHAR(32) NOT NULL,
-- The actual secret that must be presented to redeem the impersonation.
user_impersonation_token VARCHAR(32) NOT NULL,
-- Whether this request has been redeemed (used to create a session).
is_redeemed BOOLEAN NOT NULL DEFAULT FALSE,
-- IP address of the staff user who created the request.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address from which the request was redeemed, if redeemed.
ip_address_redemption VARCHAR(40) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- The request expires after this time if not redeemed.
expires_at TIMESTAMP NOT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (user_impersonation_token),
KEY index_impersonated_user_token (impersonated_user_token),
KEY index_impersonator_user_token (impersonator_user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,50 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_sessions (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Session primary key
token VARCHAR(32) NOT NULL,
-- The user that the session belongs to.
user_token VARCHAR(32) NOT NULL,
-- NEVER IMPERSONATE A USER'S ACCOUNT WITHOUT THEIR CONSENT!
--
-- For support requests and inquiries, we can have staff log into user accounts
-- to verify and debug possible broken states. The user is always told about
-- this if we are to use this for support cases.
--
-- Every action performed by impersonated sessions is logged for auditing.
--
-- If this session is impersonating another user, this is the token of
-- the staff user who initiated the impersonation.
maybe_impersonation_user_token VARCHAR(32) DEFAULT NULL,
-- Track each session's creation IP.
-- Wide enough for IPv4/6
ip_address_creation VARCHAR(40) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Session termination time.
-- This must be set by the server code, or the session is invalid
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- deletion = session termination
-- Typically these are destroyed by users, but if in the future we allow mods to
-- delete them, it doesn't really matter who did the deletion: sessions are not
-- designed to be recoverable.
deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY fk_user_token (user_token),
KEY index_ip_address_creation (ip_address_creation)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -15,7 +15,7 @@ body:
type: json
content: |
{
"user_token": "user_jg7ng4kdt2x5y", // newuser946
"user_token": "user_rtdhen9kz73kx", //
"payments_namespace": "artcraft"
}
order: 4100053395280358
@@ -0,0 +1,11 @@
$kind: http-request
name: Impersonate User Request
url: "{{api_host}}/v1/moderation/user_sessions/impersonate"
method: POST
body:
type: json
content: |-
{
"username": "newuser3"
}
order: 4318829221021218
@@ -16,7 +16,8 @@ pub struct GenerateVeo2ImageToVideoRequest {
/// Optional text prompt.
pub prompt: Option<String>,
/// **NB(bt): ASPECT RATIO IS ACTUALLY NOT USED IN VEO 2 IMAGE-TO-VIDEO**
/// Optional: aspect ratio of the generated video.
pub aspect_ratio: Option<GenerateVeo2AspectRatio>,
@@ -21,6 +21,9 @@ pub struct GenerateVeo3FastImageToVideoRequest {
pub resolution: Option<GenerateVeo3FastResolution>,
/// Optional: aspect ratio of the generated video.
pub aspect_ratio: Option<GenerateVeo3FastAspectRatio>,
/// Optional: duration of the generated video.
pub duration: Option<GenerateVeo3FastDuration>,
/// Optional: Generate an audio track along with the video
@@ -34,6 +37,14 @@ pub enum GenerateVeo3FastResolution {
TenEightyP, // 1080p
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo3FastAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo3FastDuration {
@@ -42,6 +42,10 @@ pub struct GenerateVeo3ImageToVideoRequest {
pub enum GenerateVeo3AspectRatio {
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
/// NB: DO NOT REMOVE. This is an unsupported resolution for Veo 3, but we can't
/// impact existing callers by removing it. The handler will convert this to a
/// supported resolution or 400 the request instead.
Square, // 1:1
}
@@ -2,6 +2,7 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
@@ -56,6 +57,9 @@ pub struct OmniGenImageProviderModelDetails {
pub struct OmniGenImageModelDetails {
pub model: CommonImageModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
@@ -120,12 +124,16 @@ pub struct OmniGenImageModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub batch_size_default: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenImageModelDetails {
fn default() -> Self {
Self {
model: CommonImageModel::NanoBananaPro,
model_creator: None,
full_name: None,
text_prompt_supported: None,
text_prompt_max_length: None,
@@ -145,6 +153,7 @@ impl Default for OmniGenImageModelDetails {
batch_size_max: None,
batch_size_options: None,
batch_size_default: None,
is_disabled: None,
}
}
}
@@ -2,6 +2,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 enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
@@ -65,6 +66,9 @@ pub struct OmniGenVideoModelDetails {
pub model: CommonVideoModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
@@ -163,12 +167,16 @@ pub struct OmniGenVideoModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub batch_size_default: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenVideoModelDetails {
fn default() -> Self {
Self {
model: DEFAULT_VIDEO_MODEL,
model_creator: None,
full_name: None,
text_prompt_supported: None,
text_prompt_max_length: None,
@@ -202,6 +210,7 @@ impl Default for OmniGenVideoModelDetails {
batch_size_max: None,
batch_size_options: None,
batch_size_default: None,
is_disabled: None,
}
}
}
@@ -2,6 +2,7 @@ pub mod analytics;
pub mod credits;
pub mod generate;
pub mod jobs;
pub mod omni_gen;
pub mod media_files;
pub mod prompts;
pub mod stripe_artcraft;
@@ -0,0 +1 @@
pub mod video;
@@ -0,0 +1 @@
pub mod omni_gen_video;
@@ -0,0 +1,21 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
pub const OMNI_GEN_VIDEO_GENERATE_PATH: &str = "/v1/omni_gen/generate/video";
pub async fn omni_gen_video_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenVideoCostAndGenerateRequest,
) -> Result<OmniGenVideoGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_VIDEO_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1 @@
pub mod generate;
@@ -17,6 +17,7 @@ workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs.workspace = true
enums.workspace = true
artcraft_client.workspace = true
fal_client.workspace = true
muapi_client.workspace = true
@@ -38,6 +38,9 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "sora_2")]
Sora2,
@@ -0,0 +1,164 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{BatchCount, GenerateVideoArgs, ModelType, Resolution};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub(crate) fn estimate_video_cost_artcraft_seedance2p0_fast(
plan: &PlanArtcraftSeedance2p0<'_>,
) -> VideoGenerationCostEstimate {
let duration_seconds = plan.duration_seconds.unwrap_or(5).clamp(4, 15);
let batch_count = match plan.batch_count {
Seedance2p0BatchCount::One => BatchCount::One,
Seedance2p0BatchCount::Two => BatchCount::Two,
Seedance2p0BatchCount::Four => BatchCount::Four,
};
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: ModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: String::new(),
resolution: Resolution::Square1x1,
duration_seconds,
batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
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_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
fn estimate_pro_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[test]
fn cost_is_present() {
assert!(estimate_usd_cents(5, 1) > 0);
}
#[test]
fn cost_batch_1() {
assert_eq!(estimate_usd_cents(4, 1), 51);
assert_eq!(estimate_usd_cents(5, 1), 64);
assert_eq!(estimate_usd_cents(10, 1), 127);
assert_eq!(estimate_usd_cents(15, 1), 191);
}
#[test]
fn cost_batch_2() {
assert_eq!(estimate_usd_cents(5, 2), 127);
}
#[test]
fn cost_batch_4() {
assert_eq!(estimate_usd_cents(5, 4), 255);
}
#[test]
fn fast_is_cheaper_than_pro() {
for duration in [4, 5, 10, 15] {
let fast = estimate_usd_cents(duration, 1);
let pro = estimate_pro_usd_cents(duration, 1);
assert!(
fast < pro,
"Fast ({}) should be cheaper than Pro ({}) at {}s",
fast, pro, duration,
);
}
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1);
let c10 = estimate_usd_cents(10, 1);
let c15 = estimate_usd_cents(15, 1);
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1);
let b2 = estimate_usd_cents(5, 2);
let b4 = estimate_usd_cents(5, 4);
assert!(b1 < b2);
assert!(b2 < b4);
}
}
@@ -8,6 +8,7 @@ pub mod estimate_video_cost_artcraft_kling3p0_standard;
pub mod estimate_video_cost_artcraft_seedance_1_0_lite;
pub mod estimate_video_cost_artcraft_seedance1p5_pro;
pub mod estimate_video_cost_artcraft_seedance2p0;
pub mod estimate_video_cost_artcraft_seedance2p0_fast;
pub mod estimate_video_cost_artcraft_sora_2;
pub mod estimate_video_cost_artcraft_sora_2_pro;
pub mod estimate_video_cost_artcraft_veo_2;
@@ -0,0 +1,129 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoArgs, ModelType, Resolution};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0_fast(
plan: &PlanSeedance2proSeedance2p0Fast,
) -> VideoGenerationCostEstimate {
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: ModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: String::new(),
resolution: Resolution::Square1x1, // Resolution does not affect cost
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_credits = args.estimate_credits();
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
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_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[test]
fn cost_is_present() {
let cents = estimate_usd_cents(5, 1);
assert!(cents > 0);
}
#[test]
fn cost_batch_1() {
// Seedance2Fast: 28 credits/sec, 220 credits/$1 (with rounding)
assert_eq!(estimate_usd_cents(4, 1), 51);
assert_eq!(estimate_usd_cents(5, 1), 64);
assert_eq!(estimate_usd_cents(10, 1), 127);
assert_eq!(estimate_usd_cents(15, 1), 191);
}
#[test]
fn cost_batch_2() {
assert_eq!(estimate_usd_cents(5, 2), 127);
}
#[test]
fn cost_batch_4() {
assert_eq!(estimate_usd_cents(5, 4), 255);
}
#[test]
fn fast_is_cheaper_than_pro() {
let fast = estimate_usd_cents(5, 1);
let pro_request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Seedance2Pro,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
};
let pro = pro_request.build().unwrap().estimate_costs().cost_in_usd_cents.unwrap();
assert!(fast < pro, "Fast ({}) should be cheaper than Pro ({})", fast, pro);
}
}
@@ -1 +1,2 @@
pub mod estimate_video_cost_seedance2pro_seedance2p0;
pub mod estimate_video_cost_seedance2pro_seedance2p0_fast;
@@ -0,0 +1,65 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_video_model::CommonVideoModel;
/// Execute Seedance 2.0 Fast via the Artcraft omni-gen video endpoint.
///
/// Unlike Seedance 2.0 Pro (which uses the legacy dedicated endpoint),
/// Seedance 2.0 Fast routes through the omni-gen unified video endpoint.
pub async fn execute_artcraft_seedance2p0_fast(
plan: &PlanArtcraftSeedance2p0<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let aspect_ratio = plan.aspect_ratio.map(|ar| match ar {
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Landscape16x9 => CommonAspectRatio::WideSixteenByNine,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Portrait9x16 => CommonAspectRatio::TallNineBySixteen,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Square1x1 => CommonAspectRatio::Square,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Standard4x3 => CommonAspectRatio::WideFourByThree,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Portrait3x4 => CommonAspectRatio::TallThreeByFour,
});
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonVideoModel::Seedance2p0Fast),
prompt: plan.prompt.map(|p| p.to_string()),
negative_prompt: None,
start_frame_image_media_token: plan.start_frame.cloned(),
end_frame_image_media_token: plan.end_frame.cloned(),
reference_image_media_tokens: plan.reference_images.cloned(),
reference_video_media_tokens: plan.reference_videos.cloned(),
reference_audio_media_tokens: plan.reference_audio.cloned(),
reference_character_tokens: None,
resolution: None,
aspect_ratio,
quality: None,
duration_seconds: plan.duration_seconds.map(|d| d as u16),
video_batch_count: Some(match plan.batch_count {
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::One => 1,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::Two => 2,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::Four => 4,
}),
generate_audio: None,
};
let response = omni_gen_video_generate(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
let all_tokens = vec![response.inference_job_token.clone()];
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token: response.inference_job_token,
all_inference_job_tokens: all_tokens,
}))
}
@@ -6,31 +6,72 @@ use crate::generate::generate_video::generate_video_response::{
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_2::PlanArtcraftVeo2;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::generate::video::generate_veo_2_image_to_video::generate_veo_2_image_to_video;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use enums::common::generation::common_video_model::CommonVideoModel;
pub async fn execute_artcraft_veo_2(
plan: &PlanArtcraftVeo2<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let request = GenerateVeo2ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(plan.start_frame.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio.clone(),
duration: plan.duration.clone(),
let inference_job_token = match plan.start_frame {
Some(media_file_token) => {
// Image-to-video: use the legacy dedicated endpoint.
let request = GenerateVeo2ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(media_file_token.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio.clone(),
duration: plan.duration.clone(),
};
let response = generate_veo_2_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
None => {
// Text-to-video: use the omni-gen unified endpoint.
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonVideoModel::Veo2),
prompt: plan.prompt.map(|p| p.to_string()),
negative_prompt: None,
start_frame_image_media_token: None,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
};
let response = omni_gen_video_generate(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
};
let response = generate_veo_2_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
let all_tokens = vec![response.inference_job_token.clone()];
let all_tokens = vec![inference_job_token.clone()];
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token: response.inference_job_token,
inference_job_token,
all_inference_job_tokens: all_tokens,
}))
}
@@ -6,33 +6,74 @@ use crate::generate::generate_video::generate_video_response::{
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_3::PlanArtcraftVeo3;
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::GenerateVeo3ImageToVideoRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::generate::video::generate_veo_3_image_to_video::generate_veo_3_image_to_video;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use enums::common::generation::common_video_model::CommonVideoModel;
pub async fn execute_artcraft_veo_3(
plan: &PlanArtcraftVeo3<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let request = GenerateVeo3ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(plan.start_frame.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
duration: plan.duration,
generate_audio: plan.generate_audio,
let inference_job_token = match plan.start_frame {
Some(media_file_token) => {
// Image-to-video: use the legacy dedicated endpoint.
let request = GenerateVeo3ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(media_file_token.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
duration: plan.duration,
generate_audio: plan.generate_audio,
};
let response = generate_veo_3_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
None => {
// Text-to-video: use the omni-gen unified endpoint.
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonVideoModel::Veo3),
prompt: plan.prompt.map(|p| p.to_string()),
negative_prompt: None,
start_frame_image_media_token: None,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: plan.generate_audio,
};
let response = omni_gen_video_generate(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
};
let response = generate_veo_3_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
let all_tokens = vec![response.inference_job_token.clone()];
let all_tokens = vec![inference_job_token.clone()];
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token: response.inference_job_token,
inference_job_token,
all_inference_job_tokens: all_tokens,
}))
}
@@ -14,8 +14,9 @@ pub async fn execute_artcraft_veo_3_fast(
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let request = GenerateVeo3FastImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(plan.start_frame.to_owned()),
media_file_token: plan.start_frame.map(|t| t.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
duration: plan.duration,
generate_audio: plan.generate_audio,
@@ -8,6 +8,7 @@ pub mod generate_video_artcraft_kling3p0_standard;
pub mod generate_video_artcraft_seedance_1_0_lite;
pub mod generate_video_artcraft_seedance1p5_pro;
pub mod generate_video_artcraft_seedance2p0;
pub mod generate_video_artcraft_seedance2p0_fast;
pub mod generate_video_artcraft_sora_2;
pub mod generate_video_artcraft_sora_2_pro;
pub mod generate_video_artcraft_veo_2;
@@ -4,28 +4,50 @@ use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::PlanFalVeo2;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{
FalVeo2Mode, PlanFalVeo2,
};
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
enqueue_veo_2_image_to_video_webhook, Veo2Args,
enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2AspectRatio,
};
use fal_client::requests::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs,
};
pub async fn execute_fal_veo_2(
plan: &PlanFalVeo2,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let args = Veo2Args {
image_url: plan.start_frame_url.as_str(),
prompt: plan.prompt.as_str(),
api_key: &fal_client.api_key,
duration: plan.duration,
aspect_ratio: plan.aspect_ratio,
webhook_url: fal_client.webhook_url.as_str(),
let webhook_response = match &plan.mode {
FalVeo2Mode::TextToVideo => {
let args = Veo2TextToVideoArgs {
prompt: plan.prompt.as_str(),
negative_prompt: plan.negative_prompt.as_deref(),
api_key: &fal_client.api_key,
duration: plan.duration,
aspect_ratio: plan.aspect_ratio.unwrap_or(Veo2AspectRatio::Auto),
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_2_text_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
FalVeo2Mode::ImageToVideo { image_url } => {
// Image-to-video does not support aspect_ratio — the output
// inherits the source image's aspect ratio.
let args = Veo2Args {
image_url: image_url.as_str(),
prompt: plan.prompt.as_str(),
api_key: &fal_client.api_key,
duration: plan.duration,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_2_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
};
let webhook_response = enqueue_veo_2_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
@@ -4,30 +4,106 @@ use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::PlanFalVeo3;
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
enqueue_veo_3_image_to_video_webhook, Veo3Args,
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{
FalVeo3Duration, FalVeo3I2vAspectRatio, FalVeo3Mode, FalVeo3Resolution,
FalVeo3T2vAspectRatio, PlanFalVeo3,
};
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration,
Veo3I2vResolution,
};
use fal_client::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
enqueue_veo_3_text_to_video_webhook, Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution,
Veo3TextToVideoArgs,
};
fn to_i2v_duration(d: FalVeo3Duration) -> Veo3I2vDuration {
match d {
FalVeo3Duration::Default => Veo3I2vDuration::Default,
FalVeo3Duration::FourSeconds => Veo3I2vDuration::FourSeconds,
FalVeo3Duration::SixSeconds => Veo3I2vDuration::SixSeconds,
FalVeo3Duration::EightSeconds => Veo3I2vDuration::EightSeconds,
}
}
fn to_t2v_duration(d: FalVeo3Duration) -> Veo3T2vDuration {
match d {
FalVeo3Duration::Default => Veo3T2vDuration::Default,
FalVeo3Duration::FourSeconds => Veo3T2vDuration::FourSeconds,
FalVeo3Duration::SixSeconds => Veo3T2vDuration::SixSeconds,
FalVeo3Duration::EightSeconds => Veo3T2vDuration::EightSeconds,
}
}
fn to_i2v_resolution(r: FalVeo3Resolution) -> Veo3I2vResolution {
match r {
FalVeo3Resolution::Default => Veo3I2vResolution::Default,
FalVeo3Resolution::SevenTwentyP => Veo3I2vResolution::SevenTwentyP,
FalVeo3Resolution::TenEightyP => Veo3I2vResolution::TenEightyP,
}
}
fn to_t2v_resolution(r: FalVeo3Resolution) -> Veo3T2vResolution {
match r {
FalVeo3Resolution::Default => Veo3T2vResolution::Default,
FalVeo3Resolution::SevenTwentyP => Veo3T2vResolution::SevenTwentyP,
FalVeo3Resolution::TenEightyP => Veo3T2vResolution::TenEightyP,
}
}
fn to_t2v_aspect_ratio(ar: FalVeo3T2vAspectRatio) -> Veo3T2vAspectRatio {
match ar {
FalVeo3T2vAspectRatio::Default => Veo3T2vAspectRatio::Default,
FalVeo3T2vAspectRatio::WideSixteenNine => Veo3T2vAspectRatio::WideSixteenNine,
FalVeo3T2vAspectRatio::TallNineSixteen => Veo3T2vAspectRatio::TallNineSixteen,
}
}
fn to_i2v_aspect_ratio(ar: FalVeo3I2vAspectRatio) -> Veo3I2vAspectRatio {
match ar {
FalVeo3I2vAspectRatio::Auto => Veo3I2vAspectRatio::Auto,
FalVeo3I2vAspectRatio::WideSixteenNine => Veo3I2vAspectRatio::WideSixteenNine,
FalVeo3I2vAspectRatio::TallNineSixteen => Veo3I2vAspectRatio::TallNineSixteen,
}
}
pub async fn execute_fal_veo_3(
plan: &PlanFalVeo3,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let args = Veo3Args {
image_url: plan.start_frame_url.as_str(),
prompt: plan.prompt.as_str(),
duration: plan.duration,
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
generate_audio: plan.generate_audio,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
let webhook_response = match &plan.mode {
FalVeo3Mode::TextToVideo => {
let args = Veo3TextToVideoArgs {
prompt: plan.prompt.as_str(),
negative_prompt: plan.negative_prompt.as_deref(),
api_key: &fal_client.api_key,
duration: to_t2v_duration(plan.duration),
aspect_ratio: plan.t2v_aspect_ratio.map(to_t2v_aspect_ratio).unwrap_or(Veo3T2vAspectRatio::Default),
resolution: to_t2v_resolution(plan.resolution),
generate_audio: plan.generate_audio,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_3_text_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
FalVeo3Mode::ImageToVideo { image_url } => {
let args = Veo3Args {
image_url: image_url.as_str(),
prompt: plan.prompt.as_str(),
duration: to_i2v_duration(plan.duration),
aspect_ratio: plan.i2v_aspect_ratio.map(to_i2v_aspect_ratio).unwrap_or(Veo3I2vAspectRatio::Auto),
resolution: to_i2v_resolution(plan.resolution),
generate_audio: plan.generate_audio,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_3_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
};
let webhook_response = enqueue_veo_3_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
@@ -16,6 +16,7 @@ pub async fn execute_fal_veo_3_fast(
let args = Veo3FastArgs {
prompt: plan.prompt.as_str(),
image_url: plan.start_frame_url.as_str(),
aspect_ratio: plan.aspect_ratio,
duration: plan.duration,
api_key: &fal_client.api_key,
resolution: plan.resolution,
@@ -60,7 +60,7 @@ pub async fn execute_seedance2pro_seedance2p0(
}
/// Downloads a file from a source URL and re-uploads it to seedance2pro CDN.
async fn upload_to_seedance2pro(
pub(crate) async fn upload_to_seedance2pro(
session: &seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession,
source_url: &str,
) -> Result<String, ArtcraftRouterError> {
@@ -89,7 +89,7 @@ async fn upload_to_seedance2pro(
Ok(upload_response.public_url)
}
async fn upload_optional_url(
pub(crate) async fn upload_optional_url(
session: &seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession,
url: Option<&str>,
) -> Result<Option<String>, ArtcraftRouterError> {
@@ -99,7 +99,7 @@ async fn upload_optional_url(
}
}
async fn upload_optional_url_list(
pub(crate) async fn upload_optional_url_list(
session: &seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession,
urls: Option<&[String]>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
@@ -0,0 +1,57 @@
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, Seedance2proVideoResponsePayload,
};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0::{
upload_optional_url, upload_optional_url_list,
};
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, GenerateVideoArgs, ModelType,
};
/// Execute Seedance 2.0 Fast via the Seedance2Pro/Kinovi provider.
///
/// Identical to the Seedance 2.0 Pro executor except it uses
/// `ModelType::Seedance2Fast` instead of `ModelType::Seedance2Pro`.
pub async fn execute_seedance2pro_seedance2p0_fast(
plan: &PlanSeedance2proSeedance2p0Fast,
seedance2pro_client: &RouterSeedance2ProClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &seedance2pro_client.session;
// Upload media files to seedance2pro CDN
let start_frame_url = upload_optional_url(session, plan.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.reference_audio_urls.as_deref()).await?;
let args = GenerateVideoArgs {
session,
model_type: ModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: plan.prompt.clone().unwrap_or_default(),
resolution: plan.resolution,
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let response = generate_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
}
@@ -1 +1,2 @@
pub mod execute_seedance2pro_seedance2p0;
pub mod execute_seedance2pro_seedance2p0_fast;
@@ -19,6 +19,7 @@ use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraf
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance_1_0_lite::plan_generate_video_artcraft_seedance_1_0_lite;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance1p5_pro::plan_generate_video_artcraft_seedance1p5_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::plan_generate_video_artcraft_seedance2p0;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0_fast::plan_generate_video_artcraft_seedance2p0_fast;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_sora_2::plan_generate_video_artcraft_sora_2;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_sora_2_pro::plan_generate_video_artcraft_sora_2_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_2::plan_generate_video_artcraft_veo_2;
@@ -44,6 +45,7 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3p1:
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3p1_fast::plan_generate_video_fal_veo_3p1_fast;
use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seedance2p0::plan_generate_video_muapi_seedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::plan_generate_video_seedance2pro_seedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::plan_generate_video_seedance2pro_seedance2p0_fast;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
/// Plan to either (1) generate a video or (2) determine how much it costs to generate that video.
@@ -131,6 +133,7 @@ impl<'a> GenerateVideoRequest<'a> {
CommonVideoModel::Seedance10Lite => plan_generate_video_artcraft_seedance_1_0_lite(self),
CommonVideoModel::Seedance1p5Pro => plan_generate_video_artcraft_seedance1p5_pro(self),
CommonVideoModel::Seedance2p0 => plan_generate_video_artcraft_seedance2p0(self),
CommonVideoModel::Seedance2p0Fast => plan_generate_video_artcraft_seedance2p0_fast(self),
CommonVideoModel::Sora2 => plan_generate_video_artcraft_sora_2(self),
CommonVideoModel::Sora2Pro => plan_generate_video_artcraft_sora_2_pro(self),
CommonVideoModel::Veo2 => plan_generate_video_artcraft_veo_2(self),
@@ -174,6 +177,7 @@ impl<'a> GenerateVideoRequest<'a> {
fn build_seedance2pro(&self) -> Result<VideoGenerationPlan<'_>, ArtcraftRouterError> {
match self.model {
CommonVideoModel::Seedance2p0 => plan_generate_video_seedance2pro_seedance2p0(self),
CommonVideoModel::Seedance2p0Fast => plan_generate_video_seedance2pro_seedance2p0_fast(self),
_ => Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", self.model))),
}
}
@@ -8,6 +8,7 @@ pub mod plan_generate_video_artcraft_kling3p0_standard;
pub mod plan_generate_video_artcraft_seedance_1_0_lite;
pub mod plan_generate_video_artcraft_seedance1p5_pro;
pub mod plan_generate_video_artcraft_seedance2p0;
pub mod plan_generate_video_artcraft_seedance2p0_fast;
pub mod plan_generate_video_artcraft_sora_2;
pub mod plan_generate_video_artcraft_sora_2_pro;
pub mod plan_generate_video_artcraft_veo_2;
@@ -0,0 +1,339 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
pub fn plan_generate_video_artcraft_seedance2p0_fast<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = resolve_image_ref(request.start_frame)?;
let end_frame = resolve_image_ref(request.end_frame)?;
let reference_images = resolve_image_list_ref(request.reference_images)?;
let reference_videos = resolve_video_list_ref(request.reference_videos)?;
let reference_audio = resolve_audio_list_ref(request.reference_audio)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::ArtcraftSeedance2p0Fast(PlanArtcraftSeedance2p0 {
prompt: request.prompt,
start_frame,
end_frame,
reference_images,
reference_videos,
reference_audio,
reference_characters: resolve_character_list_ref(request.reference_character_tokens),
aspect_ratio,
duration_seconds,
batch_count,
idempotency_token: request.get_or_generate_idempotency_token(),
}))
}
fn resolve_character_list_ref<'a>(
character_list_ref: Option<crate::api::character_list_ref::CharacterListRef<'a>>,
) -> Option<&'a Vec<CharacterToken>> {
match character_list_ref {
None => None,
Some(crate::api::character_list_ref::CharacterListRef::CharacterTokens(tokens)) => Some(tokens),
}
}
fn resolve_image_ref<'a>(
image_ref: Option<ImageRef<'a>>,
) -> Result<Option<&'a MediaFileToken>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
Some(ImageRef::Url(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_image_list_ref<'a>(
image_list_ref: Option<ImageListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_video_list_ref<'a>(
video_list_ref: Option<VideoListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match video_list_ref {
None => Ok(None),
Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(VideoListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_audio_list_ref<'a>(
audio_list_ref: Option<AudioListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match audio_list_ref {
None => Ok(None),
Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(AudioListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance2p0AspectRatio>, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Some(Seedance2p0AspectRatio::Landscape16x9))
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Some(Seedance2p0AspectRatio::Portrait9x16))
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Some(Seedance2p0AspectRatio::Square1x1))
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance2p0AspectRatio::Standard4x3)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance2p0AspectRatio::Portrait3x4)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(nearest_aspect_ratio(unsupported)))
}
},
}
}
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => Seedance2p0AspectRatio::Standard4x3,
CommonAspectRatio::WideThreeByTwo => Seedance2p0AspectRatio::Standard4x3,
CommonAspectRatio::WideTwentyOneByNine => Seedance2p0AspectRatio::Landscape16x9,
CommonAspectRatio::TallFourByFive => Seedance2p0AspectRatio::Portrait3x4,
CommonAspectRatio::TallTwoByThree => Seedance2p0AspectRatio::Portrait3x4,
CommonAspectRatio::TallNineByTwentyOne => Seedance2p0AspectRatio::Portrait9x16,
_ => Seedance2p0AspectRatio::Square1x1,
}
}
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Seedance2p0BatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(Seedance2p0BatchCount::One),
2 => Ok(Seedance2p0BatchCount::Two),
4 => Ok(Seedance2p0BatchCount::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { Seedance2p0BatchCount::Four } else { Seedance2p0BatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { Seedance2p0BatchCount::Two } else { Seedance2p0BatchCount::Four })
}
},
}
}
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
match duration_seconds {
None => Ok(None),
Some(d) if d >= MIN && d <= MAX => Ok(Some(d as u8)),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(Some(d.clamp(MIN, MAX) as u8)),
},
}
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
fn base_request() -> GenerateVideoRequest<'static> {
GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: Some("a cat in space"),
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
}
}
#[test]
fn produces_fast_variant() {
let req = base_request();
let plan = req.build().unwrap();
assert!(matches!(plan, VideoGenerationPlan::ArtcraftSeedance2p0Fast(_)));
}
#[test]
fn prompt_is_passed_through() {
let req = base_request();
let plan = req.build().unwrap();
if let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan {
assert_eq!(p.prompt, Some("a cat in space"));
} else { panic!("wrong variant"); }
}
#[test]
fn aspect_ratio_16x9() {
let req = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_request()
};
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.aspect_ratio, Some(Seedance2p0AspectRatio::Landscape16x9)));
}
#[test]
fn aspect_ratio_9x16() {
let req = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_request()
};
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
}
#[test]
fn aspect_ratio_square() {
let req = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_request()
};
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.aspect_ratio, Some(Seedance2p0AspectRatio::Square1x1)));
}
#[test]
fn batch_count_defaults_to_one() {
let req = base_request();
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.batch_count, Seedance2p0BatchCount::One));
}
#[test]
fn batch_count_2() {
let req = GenerateVideoRequest { video_batch_count: Some(2), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.batch_count, Seedance2p0BatchCount::Two));
}
#[test]
fn batch_count_4() {
let req = GenerateVideoRequest { video_batch_count: Some(4), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.batch_count, Seedance2p0BatchCount::Four));
}
#[test]
fn duration_in_range() {
let req = GenerateVideoRequest { duration_seconds: Some(10), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert_eq!(p.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = GenerateVideoRequest { duration_seconds: Some(99), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert_eq!(p.duration_seconds, Some(15));
}
#[test]
fn url_image_ref_returns_error() {
let req = GenerateVideoRequest {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg")),
..base_request()
};
assert!(matches!(
req.build(),
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
}
@@ -13,7 +13,10 @@ use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftVeo2<'a> {
pub prompt: Option<&'a str>,
pub start_frame: &'a MediaFileToken,
/// Required for execution. May be None on the cost path when the omni-gen
/// distillation hydrated the media token to a URL (which the Artcraft
/// provider can't use but cost estimation doesn't need).
pub start_frame: Option<&'a MediaFileToken>,
pub aspect_ratio: Option<GenerateVeo2AspectRatio>,
pub duration: Option<GenerateVeo2Duration>,
pub idempotency_token: String,
@@ -25,16 +28,13 @@ pub fn plan_generate_video_artcraft_veo_2<'a>(
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = match request.start_frame {
Some(ImageRef::MediaFileToken(t)) => t,
Some(ImageRef::Url(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
None => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Veo 2 requires a starting frame".to_string(),
}))
}
Some(ImageRef::MediaFileToken(t)) => Some(t),
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on duration, so URL-form refs are
// accepted and dropped.
Some(ImageRef::Url(_)) => None,
// No start_frame = text-to-video mode.
None => None,
};
if request.end_frame.is_some() {
@@ -44,7 +44,14 @@ pub fn plan_generate_video_artcraft_veo_2<'a>(
}));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
// Aspect ratio only applies to text-to-video; image-to-video inherits
// the source frame's aspect ratio.
let is_image_mode = request.start_frame.is_some();
let aspect_ratio = if is_image_mode {
None
} else {
plan_aspect_ratio(request.aspect_ratio, strategy)?
};
let duration = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::ArtcraftVeo2(PlanArtcraftVeo2 {
@@ -14,10 +14,16 @@ use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftVeo3<'a> {
pub prompt: Option<&'a str>,
pub start_frame: &'a MediaFileToken,
/// Required for image-to-video. None for text-to-video or when the
/// omni-gen distillation hydrated the token to a URL.
pub start_frame: Option<&'a MediaFileToken>,
pub aspect_ratio: Option<GenerateVeo3AspectRatio>,
pub resolution: Option<GenerateVeo3Resolution>,
pub duration: Option<GenerateVeo3Duration>,
/// The raw requested duration in seconds, preserved for cost estimation.
/// The `duration` field above maps everything to `EightSeconds` for the
/// legacy endpoint, but cost should reflect the actual requested value.
pub duration_seconds_raw: Option<u16>,
pub generate_audio: Option<bool>,
pub idempotency_token: String,
}
@@ -28,16 +34,13 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = match request.start_frame {
Some(ImageRef::MediaFileToken(t)) => t,
Some(ImageRef::Url(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
None => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Veo 3 requires a starting frame".to_string(),
}))
}
Some(ImageRef::MediaFileToken(t)) => Some(t),
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on duration/audio, so URL-form
// refs are accepted and dropped.
Some(ImageRef::Url(_)) => None,
// No start_frame = text-to-video mode.
None => None,
};
if request.end_frame.is_some() {
@@ -47,7 +50,14 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
}));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
// Aspect ratio only applies to text-to-video; image-to-video inherits
// the source frame's aspect ratio.
let is_image_mode = request.start_frame.is_some();
let aspect_ratio = if is_image_mode {
None
} else {
plan_aspect_ratio(request.aspect_ratio, strategy)?
};
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
@@ -57,6 +67,7 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
aspect_ratio,
resolution,
duration,
duration_seconds_raw: request.duration_seconds,
generate_audio: request.generate_audio,
idempotency_token: request.get_or_generate_idempotency_token(),
}))
@@ -112,14 +123,19 @@ fn plan_resolution(
}
}
// Legacy storyteller endpoint for Veo 3 only exposes EightSeconds.
// The legacy storyteller endpoint for Veo 3 only exposes EightSeconds, but
// the artcraft plan stores the raw duration_seconds from the omni request so
// that cost estimation works for all supported durations (4/6/8).
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<GenerateVeo3Duration>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
Some(8) => Ok(Some(GenerateVeo3Duration::EightSeconds)),
// The api_defs enum only has EightSeconds; all durations map to it for
// the legacy endpoint. The actual seconds are tracked via
// `duration_seconds_raw` below for cost estimation.
Some(4) | Some(6) | Some(8) => Ok(Some(GenerateVeo3Duration::EightSeconds)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -127,18 +143,25 @@ fn plan_duration(
value: format!("{}", other),
}))
}
_ => Ok(Some(GenerateVeo3Duration::EightSeconds)),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(GenerateVeo3Duration::EightSeconds)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(GenerateVeo3Duration::EightSeconds)),
},
}
}
impl PlanArtcraftVeo3<'_> {
/// Mirrors the Fal client default (8s) used by the legacy generate handler.
/// Duration in seconds for cost estimation. Uses the raw requested
/// duration, clamped to {4, 6, 8}, defaulting to 8s.
pub fn duration_seconds_for_cost(&self) -> u64 {
8
match self.duration_seconds_raw {
Some(s) if s <= 4 => 4,
Some(s) if s <= 6 => 6,
_ => 8, // None, 7, 8, or above → 8s
}
}
/// Legacy Veo 3 generate handler defaults `generate_audio` to false.
/// The omni path defaults to true (matching the fal plan builder).
pub fn generate_audio_for_cost(&self) -> bool {
self.generate_audio.unwrap_or(false)
}
@@ -1,3 +1,4 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
@@ -6,14 +7,15 @@ use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{
GenerateVeo3FastDuration, GenerateVeo3FastResolution,
GenerateVeo3FastAspectRatio, GenerateVeo3FastDuration, GenerateVeo3FastResolution,
};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftVeo3Fast<'a> {
pub prompt: Option<&'a str>,
pub start_frame: &'a MediaFileToken,
pub start_frame: Option<&'a MediaFileToken>,
pub aspect_ratio: Option<GenerateVeo3FastAspectRatio>,
pub resolution: Option<GenerateVeo3FastResolution>,
pub duration: Option<GenerateVeo3FastDuration>,
pub generate_audio: Option<bool>,
@@ -26,16 +28,9 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = match request.start_frame {
Some(ImageRef::MediaFileToken(t)) => t,
Some(ImageRef::Url(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
None => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Veo 3 Fast requires a starting frame".to_string(),
}))
}
Some(ImageRef::MediaFileToken(t)) => Some(t),
Some(ImageRef::Url(_)) => None,
None => None,
};
if request.end_frame.is_some() {
@@ -45,12 +40,14 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
}));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::ArtcraftVeo3Fast(PlanArtcraftVeo3Fast {
prompt: request.prompt,
start_frame,
aspect_ratio,
resolution,
duration,
generate_audio: request.generate_audio,
@@ -58,6 +55,33 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
}))
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<GenerateVeo3FastAspectRatio>, ArtcraftRouterError> {
use GenerateVeo3FastAspectRatio as Ar;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Some(Ar::Auto)),
},
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
@@ -9,11 +9,22 @@ use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_we
Veo2AspectRatio, Veo2Duration,
};
#[derive(Debug, Clone)]
pub enum FalVeo2Mode {
TextToVideo,
ImageToVideo {
image_url: String,
},
}
#[derive(Debug, Clone)]
pub struct PlanFalVeo2 {
pub prompt: String,
pub start_frame_url: String,
pub aspect_ratio: Veo2AspectRatio,
pub negative_prompt: Option<String>,
pub mode: FalVeo2Mode,
/// Only set for text-to-video. Image-to-video inherits the source frame's
/// aspect ratio and doesn't accept this parameter.
pub aspect_ratio: Option<Veo2AspectRatio>,
pub duration: Veo2Duration,
}
@@ -22,35 +33,37 @@ pub fn plan_generate_video_fal_veo_2<'a>(
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame_url = resolve_required_image_url(request.start_frame, "start_frame")?;
if request.end_frame.is_some() {
return Err(unsupported("end_frame", "Veo 2 does not support an ending frame"));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let mode = match request.start_frame {
Some(ImageRef::Url(url)) => FalVeo2Mode::ImageToVideo {
image_url: url.to_string(),
},
Some(ImageRef::MediaFileToken(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
None => FalVeo2Mode::TextToVideo,
};
// Aspect ratio only applies to text-to-video; image-to-video inherits
// the source frame's aspect ratio.
let aspect_ratio = match &mode {
FalVeo2Mode::TextToVideo => Some(plan_aspect_ratio(request.aspect_ratio, strategy)?),
FalVeo2Mode::ImageToVideo { .. } => None,
};
let duration = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::FalVeo2(PlanFalVeo2 {
prompt: request.prompt.unwrap_or("").to_string(),
start_frame_url,
negative_prompt: request.negative_prompt.map(|s| s.to_string()),
mode,
aspect_ratio,
duration,
}))
}
fn resolve_required_image_url(
image_ref: Option<ImageRef<'_>>,
field: &'static str,
) -> Result<String, ArtcraftRouterError> {
match image_ref {
Some(ImageRef::Url(url)) => Ok(url.to_string()),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
None => Err(unsupported(field, "Veo 2 requires a starting frame")),
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
@@ -60,6 +73,7 @@ fn plan_aspect_ratio(
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
@@ -7,16 +7,65 @@ use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
Veo3AspectRatio, Veo3Duration, Veo3Resolution,
Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution,
};
use fal_client::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution,
};
#[derive(Debug, Clone)]
pub enum FalVeo3Mode {
TextToVideo,
ImageToVideo {
image_url: String,
},
}
/// Router-level duration shared between both modes.
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3Duration {
Default,
FourSeconds,
SixSeconds,
EightSeconds,
}
/// Router-level resolution shared between both modes.
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3Resolution {
Default,
SevenTwentyP,
TenEightyP,
}
/// Router-level aspect ratio for text-to-video only (no Auto, no Square).
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3T2vAspectRatio {
Default,
WideSixteenNine,
TallNineSixteen,
}
/// Router-level aspect ratio for image-to-video only (has Auto, no Square).
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3I2vAspectRatio {
Auto,
WideSixteenNine,
TallNineSixteen,
}
#[derive(Debug, Clone)]
pub struct PlanFalVeo3 {
pub prompt: String,
pub start_frame_url: String,
pub aspect_ratio: Veo3AspectRatio,
pub resolution: Veo3Resolution,
pub duration: Veo3Duration,
pub negative_prompt: Option<String>,
pub mode: FalVeo3Mode,
/// Only set for text-to-video. Image-to-video inherits the source frame's
/// aspect ratio and uses `i2v_aspect_ratio` instead.
pub t2v_aspect_ratio: Option<FalVeo3T2vAspectRatio>,
/// Only set for image-to-video.
pub i2v_aspect_ratio: Option<FalVeo3I2vAspectRatio>,
pub resolution: FalVeo3Resolution,
pub duration: FalVeo3Duration,
pub generate_audio: bool,
}
@@ -25,58 +74,88 @@ pub fn plan_generate_video_fal_veo_3<'a>(
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame_url = resolve_required_image_url(request.start_frame)?;
if request.end_frame.is_some() {
return Err(unsupported("end_frame", "Veo 3 does not support an ending frame"));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let mode = match request.start_frame {
Some(ImageRef::Url(url)) => FalVeo3Mode::ImageToVideo {
image_url: url.to_string(),
},
Some(ImageRef::MediaFileToken(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
None => FalVeo3Mode::TextToVideo,
};
let (t2v_aspect_ratio, i2v_aspect_ratio) = match &mode {
FalVeo3Mode::TextToVideo => {
(Some(plan_t2v_aspect_ratio(request.aspect_ratio, strategy)?), None)
}
FalVeo3Mode::ImageToVideo { .. } => {
(None, Some(plan_i2v_aspect_ratio(request.aspect_ratio, strategy)?))
}
};
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
let generate_audio = request.generate_audio.unwrap_or(true);
Ok(VideoGenerationPlan::FalVeo3(PlanFalVeo3 {
prompt: request.prompt.unwrap_or("").to_string(),
start_frame_url,
aspect_ratio,
negative_prompt: request.negative_prompt.map(|s| s.to_string()),
mode,
t2v_aspect_ratio,
i2v_aspect_ratio,
resolution,
duration,
generate_audio,
}))
}
fn resolve_required_image_url(
image_ref: Option<ImageRef<'_>>,
) -> Result<String, ArtcraftRouterError> {
match image_ref {
Some(ImageRef::Url(url)) => Ok(url.to_string()),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
None => Err(unsupported("start_frame", "Veo 3 requires a starting frame")),
/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square).
fn plan_t2v_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalVeo3T2vAspectRatio, ArtcraftRouterError> {
use FalVeo3T2vAspectRatio as Ar;
match aspect_ratio {
None => Ok(Ar::Default),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
// Everything else (Auto*, Square, unsupported) → Default (16:9).
Some(_) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", aspect_ratio.unwrap())))
}
_ => Ok(Ar::Default),
},
}
}
fn plan_aspect_ratio(
/// Image-to-video: Auto, 16:9, 9:16 (no Square).
fn plan_i2v_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3AspectRatio, ArtcraftRouterError> {
use Veo3AspectRatio as Ar;
) -> Result<FalVeo3I2vAspectRatio, ArtcraftRouterError> {
use FalVeo3I2vAspectRatio as Ar;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Default),
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto),
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Ar::Square),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
Some(unsupported_ar) => match strategy {
// Everything else (Square, unsupported) → Auto.
Some(_) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
Err(unsupported("aspect_ratio", &format!("{:?}", aspect_ratio.unwrap())))
}
_ => Ok(Ar::Default),
_ => Ok(Ar::Auto),
},
}
}
@@ -84,8 +163,8 @@ fn plan_aspect_ratio(
fn plan_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3Resolution, ArtcraftRouterError> {
use Veo3Resolution as R;
) -> Result<FalVeo3Resolution, ArtcraftRouterError> {
use FalVeo3Resolution as R;
match resolution {
None => Ok(R::Default),
Some(CommonResolution::SevenTwentyP) => Ok(R::SevenTwentyP),
@@ -103,18 +182,18 @@ fn plan_resolution(
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3Duration, ArtcraftRouterError> {
) -> Result<FalVeo3Duration, ArtcraftRouterError> {
match duration_seconds {
None => Ok(Veo3Duration::Default),
Some(4) => Ok(Veo3Duration::FourSeconds),
Some(6) => Ok(Veo3Duration::SixSeconds),
Some(8) => Ok(Veo3Duration::EightSeconds),
None => Ok(FalVeo3Duration::Default),
Some(4) => Ok(FalVeo3Duration::FourSeconds),
Some(6) => Ok(FalVeo3Duration::SixSeconds),
Some(8) => Ok(FalVeo3Duration::EightSeconds),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Veo3Duration::EightSeconds),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Veo3Duration::FourSeconds),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(FalVeo3Duration::EightSeconds),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(FalVeo3Duration::FourSeconds),
},
}
}
@@ -129,9 +208,9 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
impl PlanFalVeo3 {
pub fn duration_seconds_for_cost(&self) -> u64 {
match self.duration {
Veo3Duration::Default | Veo3Duration::EightSeconds => 8,
Veo3Duration::SixSeconds => 6,
Veo3Duration::FourSeconds => 4,
FalVeo3Duration::Default | FalVeo3Duration::EightSeconds => 8,
FalVeo3Duration::SixSeconds => 6,
FalVeo3Duration::FourSeconds => 4,
}
}
}
@@ -5,14 +5,16 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{
Veo3FastDuration, Veo3FastResolution,
Veo3FastAspectRatio, Veo3FastDuration, Veo3FastResolution,
};
#[derive(Debug, Clone)]
pub struct PlanFalVeo3Fast {
pub prompt: String,
pub start_frame_url: String,
pub aspect_ratio: Veo3FastAspectRatio,
pub resolution: Veo3FastResolution,
pub duration: Veo3FastDuration,
pub generate_audio: bool,
@@ -28,6 +30,7 @@ pub fn plan_generate_video_fal_veo_3_fast<'a>(
return Err(unsupported("end_frame", "Veo 3 Fast does not support an ending frame"));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
let generate_audio = request.generate_audio.unwrap_or(true);
@@ -35,6 +38,7 @@ pub fn plan_generate_video_fal_veo_3_fast<'a>(
Ok(VideoGenerationPlan::FalVeo3Fast(PlanFalVeo3Fast {
prompt: request.prompt.unwrap_or("").to_string(),
start_frame_url,
aspect_ratio,
resolution,
duration,
generate_audio,
@@ -53,6 +57,30 @@ fn resolve_required_image_url(
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3FastAspectRatio, ArtcraftRouterError> {
use Veo3FastAspectRatio as Ar;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
Some(unsupported_ar) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
}
_ => Ok(Ar::Auto),
},
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
@@ -1 +1,2 @@
pub mod plan_generate_video_seedance2pro_seedance2p0;
pub mod plan_generate_video_seedance2pro_seedance2p0_fast;
@@ -0,0 +1,198 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use seedance2pro_client::requests::generate_video::generate_video::{BatchCount, Resolution};
#[derive(Debug, Clone)]
pub struct PlanSeedance2proSeedance2p0Fast {
pub prompt: Option<String>,
pub start_frame_url: Option<String>,
pub end_frame_url: Option<String>,
pub reference_image_urls: Option<Vec<String>>,
pub reference_video_urls: Option<Vec<String>>,
pub reference_audio_urls: Option<Vec<String>>,
pub resolution: Resolution,
pub duration_seconds: u8,
pub batch_count: BatchCount,
}
pub fn plan_generate_video_seedance2pro_seedance2p0_fast<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame_url = resolve_image_ref_url(request.start_frame)?;
let end_frame_url = resolve_image_ref_url(request.end_frame)?;
let reference_image_urls = resolve_image_list_ref_urls(request.reference_images)?;
let reference_video_urls = resolve_video_list_ref_urls(request.reference_videos)?;
let reference_audio_urls = resolve_audio_list_ref_urls(request.reference_audio)?;
let resolution = plan_resolution(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::Seedance2proSeedance2p0Fast(PlanSeedance2proSeedance2p0Fast {
prompt: request.prompt.map(|p| p.to_string()),
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
resolution,
duration_seconds,
batch_count,
}))
}
fn resolve_image_ref_url(
image_ref: Option<ImageRef<'_>>,
) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url.to_string())),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
fn resolve_image_list_ref_urls(
image_list_ref: Option<ImageListRef<'_>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => Ok(Some(urls.clone())),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
fn resolve_video_list_ref_urls(
video_list_ref: Option<VideoListRef<'_>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match video_list_ref {
None => Ok(None),
Some(VideoListRef::Urls(urls)) => Ok(Some(urls.clone())),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
fn resolve_audio_list_ref_urls(
audio_list_ref: Option<AudioListRef<'_>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match audio_list_ref {
None => Ok(None),
Some(AudioListRef::Urls(urls)) => Ok(Some(urls.clone())),
Some(AudioListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
fn plan_resolution(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Resolution, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(Resolution::Landscape16x9),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Resolution::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Resolution::Portrait9x16)
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Resolution::Square1x1)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Resolution::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Resolution::Portrait3x4),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(nearest_resolution(unsupported))
}
},
}
}
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> Resolution {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => Resolution::Standard4x3,
CommonAspectRatio::WideThreeByTwo => Resolution::Standard4x3,
CommonAspectRatio::WideTwentyOneByNine => Resolution::Landscape16x9,
CommonAspectRatio::TallFourByFive => Resolution::Portrait3x4,
CommonAspectRatio::TallTwoByThree => Resolution::Portrait3x4,
CommonAspectRatio::TallNineByTwentyOne => Resolution::Portrait9x16,
_ => Resolution::Square1x1,
}
}
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<BatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(BatchCount::One),
2 => Ok(BatchCount::Two),
4 => Ok(BatchCount::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { BatchCount::Four } else { BatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { BatchCount::Two } else { BatchCount::Four })
}
},
}
}
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u8, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
const DEFAULT: u8 = 5;
match duration_seconds {
None => Ok(DEFAULT),
Some(d) if d >= MIN && d <= MAX => Ok(d as u8),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(d.clamp(MIN, MAX) as u8),
},
}
}
@@ -10,6 +10,7 @@ use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraf
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance_1_0_lite::estimate_video_cost_artcraft_seedance_1_0_lite;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance1p5_pro::estimate_video_cost_artcraft_seedance1p5_pro;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0::estimate_video_cost_artcraft_seedance2p0;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0_fast::estimate_video_cost_artcraft_seedance2p0_fast;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_sora_2::estimate_video_cost_artcraft_sora_2;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_sora_2_pro::estimate_video_cost_artcraft_sora_2_pro;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_veo_2::estimate_video_cost_artcraft_veo_2;
@@ -35,6 +36,7 @@ use crate::generate::generate_video::cost::fal::estimate_video_cost_fal_veo_3p1:
use crate::generate::generate_video::cost::fal::estimate_video_cost_fal_veo_3p1_fast::estimate_video_cost_fal_veo_3p1_fast;
use crate::generate::generate_video::cost::muapi::estimate_video_cost_muapi_seedance2p0::estimate_video_cost_muapi_seedance2p0;
use crate::generate::generate_video::cost::seedance2pro::estimate_video_cost_seedance2pro_seedance2p0::estimate_video_cost_seedance2pro_seedance2p0;
use crate::generate::generate_video::cost::seedance2pro::estimate_video_cost_seedance2pro_seedance2p0_fast::estimate_video_cost_seedance2pro_seedance2p0_fast;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_kling_1_6_pro::execute_artcraft_kling_1_6_pro;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_kling_2_1_master::execute_artcraft_kling_2_1_master;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_kling_2_1_pro::execute_artcraft_kling_2_1_pro;
@@ -45,6 +47,7 @@ use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance_1_0_lite::execute_artcraft_seedance_1_0_lite;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance1p5_pro::execute_artcraft_seedance1p5_pro;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance2p0::execute_artcraft_seedance2p0;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance2p0_fast::execute_artcraft_seedance2p0_fast;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_sora_2::execute_artcraft_sora_2;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_sora_2_pro::execute_artcraft_sora_2_pro;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_veo_2::execute_artcraft_veo_2;
@@ -70,6 +73,7 @@ use crate::generate::generate_video::execute::fal::generate_video_fal_veo_3p1::e
use crate::generate::generate_video::execute::fal::generate_video_fal_veo_3p1_fast::execute_fal_veo_3p1_fast;
use crate::generate::generate_video::execute::muapi::execute_muapi_seedance2p0::execute_muapi_seedance2p0;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0::execute_seedance2pro_seedance2p0;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0_fast::execute_seedance2pro_seedance2p0_fast;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_1_6_pro::PlanArtcraftKling16Pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_2_1_master::PlanArtcraftKling21Master;
@@ -106,6 +110,7 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3p1:
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3p1_fast::PlanFalVeo3p1Fast;
use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seedance2p0::PlanMuapiSeedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Debug)]
@@ -120,6 +125,7 @@ pub enum VideoGenerationPlan<'a> {
ArtcraftSeedance10Lite(PlanArtcraftSeedance10Lite<'a>),
ArtcraftSeedance1p5Pro(PlanArtcraftSeedance1p5Pro<'a>),
ArtcraftSeedance2p0(PlanArtcraftSeedance2p0<'a>),
ArtcraftSeedance2p0Fast(PlanArtcraftSeedance2p0<'a>),
ArtcraftSora2(PlanArtcraftSora2<'a>),
ArtcraftSora2Pro(PlanArtcraftSora2Pro<'a>),
ArtcraftVeo2(PlanArtcraftVeo2<'a>),
@@ -129,6 +135,7 @@ pub enum VideoGenerationPlan<'a> {
ArtcraftVeo3p1Fast(PlanArtcraftVeo3p1Fast<'a>),
MuapiSeedance2p0(PlanMuapiSeedance2p0),
Seedance2proSeedance2p0(PlanSeedance2proSeedance2p0),
Seedance2proSeedance2p0Fast(PlanSeedance2proSeedance2p0Fast),
FalVeo2(PlanFalVeo2),
FalVeo3(PlanFalVeo3),
FalVeo3Fast(PlanFalVeo3Fast),
@@ -193,6 +200,10 @@ impl<'a> VideoGenerationPlan<'a> {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_seedance2p0(plan, artcraft_client).await
}
VideoGenerationPlan::ArtcraftSeedance2p0Fast(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_seedance2p0_fast(plan, artcraft_client).await
}
VideoGenerationPlan::ArtcraftSora2(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_sora_2(plan, artcraft_client).await
@@ -229,6 +240,10 @@ impl<'a> VideoGenerationPlan<'a> {
let seedance2pro_client = client.get_seedance2pro_client_ref()?;
execute_seedance2pro_seedance2p0(plan, seedance2pro_client).await
}
VideoGenerationPlan::Seedance2proSeedance2p0Fast(plan) => {
let seedance2pro_client = client.get_seedance2pro_client_ref()?;
execute_seedance2pro_seedance2p0_fast(plan, seedance2pro_client).await
}
VideoGenerationPlan::FalVeo2(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_veo_2(plan, fal_client).await
@@ -318,6 +333,9 @@ impl<'a> VideoGenerationPlan<'a> {
VideoGenerationPlan::ArtcraftSeedance2p0(plan) => {
estimate_video_cost_artcraft_seedance2p0(plan)
}
VideoGenerationPlan::ArtcraftSeedance2p0Fast(plan) => {
estimate_video_cost_artcraft_seedance2p0_fast(plan)
}
VideoGenerationPlan::ArtcraftSora2(plan) => {
estimate_video_cost_artcraft_sora_2(plan)
}
@@ -335,6 +353,9 @@ impl<'a> VideoGenerationPlan<'a> {
VideoGenerationPlan::Seedance2proSeedance2p0(plan) => {
estimate_video_cost_seedance2pro_seedance2p0(plan)
}
VideoGenerationPlan::Seedance2proSeedance2p0Fast(plan) => {
estimate_video_cost_seedance2pro_seedance2p0_fast(plan)
}
VideoGenerationPlan::FalVeo2(plan) => estimate_video_cost_fal_veo_2(plan),
VideoGenerationPlan::FalVeo3(plan) => estimate_video_cost_fal_veo_3(plan),
VideoGenerationPlan::FalVeo3Fast(plan) => estimate_video_cost_fal_veo_3_fast(plan),
@@ -7,10 +7,6 @@ pub struct Veo2ImageToVideoInput {
pub prompt: String,
/// Options: "auto", "16:9", "9:16"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "4s", "6s", "8s"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
@@ -7,6 +7,10 @@ pub struct Veo3FastImageToVideoInput {
pub prompt: String,
/// Options: "auto", "16:9", "9:16"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "720p", "1080p"
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
@@ -0,0 +1,34 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo2TextToVideoInput {
pub prompt: String,
/// Options: "16:9", "9:16", "1:1"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "5s", "6s", "7s", "8s"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2TextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2TextToVideoOutput {
pub video: Veo2TextToVideoVideoFile,
}
pub fn veo_2_text_to_video(
params: Veo2TextToVideoInput,
) -> FalRequest<Veo2TextToVideoInput, Veo2TextToVideoOutput> {
FalRequest::new("fal-ai/veo2", params)
}
@@ -0,0 +1,41 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3TextToVideoInput {
pub prompt: String,
/// Options: "16:9", "9:16"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "720p", "1080p"
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Options: "4s", "6s", "8s"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3TextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3TextToVideoOutput {
pub video: Veo3TextToVideoVideoFile,
}
pub fn veo_3_text_to_video(
params: Veo3TextToVideoInput,
) -> FalRequest<Veo3TextToVideoInput, Veo3TextToVideoOutput> {
FalRequest::new("fal-ai/veo3", params)
}
@@ -7,5 +7,7 @@ pub mod http_pixverse_v5_text_to_video;
pub mod http_seedance_1p5_pro_text_to_video;
pub mod http_sora_2_pro_text_to_video;
pub mod http_sora_2_text_to_video;
pub mod http_veo_2_text_to_video;
pub mod http_veo_3_text_to_video;
pub mod http_veo_3p1_fast_text_to_video;
pub mod http_veo_3p1_text_to_video;
@@ -1,21 +1,22 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::http::video::image::http_veo_2_image_to_video::{veo_2_image_to_video, Veo2ImageToVideoInput};
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::video::image::http_veo_2_image_to_video::{veo_2_image_to_video, Veo2ImageToVideoInput};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use reqwest::IntoUrl;
/// Args for Veo 2 image-to-video. Note: image-to-video does NOT support
/// aspect_ratio — the output inherits the source image's aspect ratio.
pub struct Veo2Args<'a, U: IntoUrl, V: IntoUrl> {
pub image_url: U,
pub webhook_url: V,
pub prompt: &'a str,
pub api_key: &'a FalApiKey,
pub duration: Veo2Duration,
pub aspect_ratio: Veo2AspectRatio,
}
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Veo2Duration {
Default, // NB: Default is 5 seconds
FiveSeconds,
@@ -24,6 +25,9 @@ pub enum Veo2Duration {
EightSeconds,
}
/// Aspect ratio enum — used by the text-to-video endpoint only.
/// Kept here because it's the canonical location that the text-to-video
/// webhook and the router plan both re-export from.
#[derive(Copy, Clone, Debug)]
pub enum Veo2AspectRatio {
Auto,
@@ -59,22 +63,13 @@ pub async fn enqueue_veo_2_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
Veo2Duration::SevenSeconds => Some("7s".to_string()),
Veo2Duration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo2AspectRatio::Auto => Some("auto".to_string()),
Veo2AspectRatio::AutoPreferPortrait => Some("auto_prefer_portrait".to_string()),
Veo2AspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo2AspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let image_url = args.image_url.as_str().to_string();
let request = Veo2ImageToVideoInput {
image_url,
prompt: args.prompt.to_string(),
aspect_ratio,
duration,
// Maybe expose these later
};
let result = veo_2_image_to_video(request)
@@ -89,26 +84,24 @@ pub async fn enqueue_veo_2_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2AspectRatio, Veo2Duration};
use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2Duration};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL;
#[tokio::test]
#[ignore]
async fn test_kling21_pro_video() -> AnyhowResult<()> {
let image_url = "https://cdn-2.fakeyou.com/media/3/4/h/f/s/34hfsmt8e38rvne6mwa4pwbxr6292sgy/image_34hfsmt8e38rvne6mwa4pwbxr6292sgy.png";
async fn test_veo_2_image_to_video() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo2Args {
image_url: image_url,
image_url: MOUNTAIN_TREE_IMAGE_URL,
prompt: "a shot of the mountains as the sun sets and reveals the moon and stars",
api_key: &api_key,
duration: Veo2Duration::Default,
aspect_ratio: Veo2AspectRatio::WideSixteenNine,
webhook_url: "https://example.com/webhook",
};
@@ -9,6 +9,7 @@ use reqwest::IntoUrl;
pub struct Veo3FastArgs<'a, U: IntoUrl, V: IntoUrl> {
pub prompt: &'a str,
pub image_url: U,
pub aspect_ratio: Veo3FastAspectRatio,
pub duration: Veo3FastDuration,
pub api_key: &'a FalApiKey,
pub resolution: Veo3FastResolution,
@@ -24,6 +25,13 @@ pub enum Veo3FastDuration {
EightSeconds,
}
#[derive(Copy, Clone, Debug)]
pub enum Veo3FastAspectRatio {
Auto,
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
}
#[derive(Copy, Clone, Debug)]
pub enum Veo3FastResolution {
Default,
@@ -62,7 +70,13 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
Veo3FastDuration::EightSeconds => Some("8s".to_string()),
};
let resolution= match args.resolution {
let aspect_ratio = match args.aspect_ratio {
Veo3FastAspectRatio::Auto => Some("auto".to_string()),
Veo3FastAspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3FastAspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let resolution = match args.resolution {
Veo3FastResolution::Default => None,
Veo3FastResolution::SevenTwentyP => Some("720p".to_string()),
Veo3FastResolution::TenEightyP => Some("1080p".to_string()),
@@ -73,6 +87,7 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
let request = Veo3FastImageToVideoInput {
image_url,
prompt: args.prompt.to_string(),
aspect_ratio,
resolution,
duration,
generate_audio: Some(args.generate_audio),
@@ -90,7 +105,7 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastDuration, Veo3FastResolution};
use crate::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastAspectRatio, Veo3FastDuration, Veo3FastResolution};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::ERNEST_GHOST_TREX_IMAGE_URL;
@@ -109,6 +124,7 @@ mod tests {
image_url: image_url,
prompt: "man is standing next to a ghost and t-rex, they begin to chase him as the camera pulls back to show the wider scene",
api_key: &api_key,
aspect_ratio: Veo3FastAspectRatio::WideSixteenNine,
duration: Veo3FastDuration::EightSeconds,
generate_audio: true,
resolution: Veo3FastResolution::TenEightyP,
@@ -9,32 +9,35 @@ use reqwest::IntoUrl;
pub struct Veo3Args<'a, U: IntoUrl, V: IntoUrl> {
pub image_url: U,
pub prompt: &'a str,
pub duration: Veo3Duration,
pub aspect_ratio: Veo3AspectRatio,
pub resolution: Veo3Resolution,
pub duration: Veo3I2vDuration,
pub aspect_ratio: Veo3I2vAspectRatio,
pub resolution: Veo3I2vResolution,
pub generate_audio: bool,
pub api_key: &'a FalApiKey,
pub webhook_url: V,
}
/// Duration for Veo 3 image-to-video. Default is 8 seconds.
#[derive(Copy, Clone, Debug)]
pub enum Veo3Duration {
pub enum Veo3I2vDuration {
Default, // Default is 8 seconds
FourSeconds,
SixSeconds,
EightSeconds,
}
/// Aspect ratio for Veo 3 image-to-video.
/// Supports Auto (inherit source), 16:9, and 9:16. No Square.
#[derive(Copy, Clone, Debug)]
pub enum Veo3AspectRatio {
Default,
WideSixteenNine,
TallNineSixteen,
Square,
pub enum Veo3I2vAspectRatio {
Auto,
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
}
/// Resolution for Veo 3 image-to-video.
#[derive(Copy, Clone, Debug)]
pub enum Veo3Resolution {
pub enum Veo3I2vResolution {
Default,
SevenTwentyP,
TenEightyP,
@@ -48,14 +51,14 @@ impl <U: IntoUrl, V: IntoUrl> FalRequestCostCalculator for Veo3Args<'_, U, V> {
// For example, a 5s video with audio on will cost $2."
match (self.duration, self.generate_audio) {
(Veo3Duration::FourSeconds, false) => 80,
(Veo3Duration::SixSeconds, false) => 120,
(Veo3Duration::EightSeconds, false) => 160,
(Veo3Duration::Default, false) => 160, // NB: Default is 8 seconds
(Veo3Duration::FourSeconds, true) => 160,
(Veo3Duration::SixSeconds, true) => 240,
(Veo3Duration::EightSeconds, true) => 320,
(Veo3Duration::Default, true) => 320, // NB: Default is 8 seconds320
(Veo3I2vDuration::FourSeconds, false) => 80,
(Veo3I2vDuration::SixSeconds, false) => 120,
(Veo3I2vDuration::EightSeconds, false) => 160,
(Veo3I2vDuration::Default, false) => 160,
(Veo3I2vDuration::FourSeconds, true) => 160,
(Veo3I2vDuration::SixSeconds, true) => 240,
(Veo3I2vDuration::EightSeconds, true) => 320,
(Veo3I2vDuration::Default, true) => 320,
}
}
}
@@ -67,23 +70,22 @@ pub async fn enqueue_veo_3_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
args: Veo3Args<'_, U, V>
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Veo3Duration::Default => None,
Veo3Duration::FourSeconds => Some("4s".to_string()),
Veo3Duration::SixSeconds => Some("6s".to_string()),
Veo3Duration::EightSeconds => Some("8s".to_string()),
Veo3I2vDuration::Default => None,
Veo3I2vDuration::FourSeconds => Some("4s".to_string()),
Veo3I2vDuration::SixSeconds => Some("6s".to_string()),
Veo3I2vDuration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo3AspectRatio::Default => None,
Veo3AspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3AspectRatio::TallNineSixteen => Some("9:16".to_string()),
Veo3AspectRatio::Square => Some("1:1".to_string()),
Veo3I2vAspectRatio::Auto => Some("auto".to_string()),
Veo3I2vAspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3I2vAspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let resolution= match args.resolution {
Veo3Resolution::Default => None,
Veo3Resolution::SevenTwentyP => Some("720p".to_string()),
Veo3Resolution::TenEightyP => Some("1080p".to_string()),
let resolution = match args.resolution {
Veo3I2vResolution::Default => None,
Veo3I2vResolution::SevenTwentyP => Some("720p".to_string()),
Veo3I2vResolution::TenEightyP => Some("1080p".to_string()),
};
let image_url = args.image_url.as_str().to_string();
@@ -109,7 +111,10 @@ pub async fn enqueue_veo_3_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3AspectRatio, Veo3Duration, Veo3Resolution};
use crate::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration,
Veo3I2vResolution,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL;
@@ -117,24 +122,21 @@ mod tests {
#[tokio::test]
#[ignore]
async fn test() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo3Args {
image_url: TREX_SKELETON_IMAGE_URL,
prompt: "the t-rex skeleton starts walking towards the camera and roars",
api_key: &api_key,
duration: Veo3Duration::EightSeconds,
aspect_ratio: Veo3AspectRatio::WideSixteenNine,
resolution: Veo3Resolution::TenEightyP,
duration: Veo3I2vDuration::EightSeconds,
aspect_ratio: Veo3I2vAspectRatio::WideSixteenNine,
resolution: Veo3I2vResolution::TenEightyP,
generate_audio: true,
webhook_url: "https://example.com/webhook",
};
let result = enqueue_veo_3_image_to_video_webhook(args).await?;
let _result = enqueue_veo_3_image_to_video_webhook(args).await?;
Ok(())
}
}
@@ -0,0 +1,103 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::video::text::http_veo_2_text_to_video::{
veo_2_text_to_video, Veo2TextToVideoInput,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{
FalRequestCostCalculator, UsdCents,
};
// Re-use the same duration/aspect-ratio enums as image-to-video.
pub use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
Veo2AspectRatio, Veo2Duration,
};
use reqwest::IntoUrl;
pub struct Veo2TextToVideoArgs<'a, V: IntoUrl> {
pub prompt: &'a str,
pub negative_prompt: Option<&'a str>,
pub duration: Veo2Duration,
pub aspect_ratio: Veo2AspectRatio,
pub webhook_url: V,
pub api_key: &'a FalApiKey,
}
impl<V: IntoUrl> FalRequestCostCalculator for Veo2TextToVideoArgs<'_, V> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Same pricing as image-to-video:
// "For 5s video your request will cost $2.50.
// For every additional second you will be charged $0.50."
match self.duration {
Veo2Duration::Default => 250,
Veo2Duration::FiveSeconds => 250,
Veo2Duration::SixSeconds => 300,
Veo2Duration::SevenSeconds => 350,
Veo2Duration::EightSeconds => 400,
}
}
}
/// Veo 2 Text-to-Video
/// https://fal.ai/models/fal-ai/veo2
pub async fn enqueue_veo_2_text_to_video_webhook<V: IntoUrl>(
args: Veo2TextToVideoArgs<'_, V>,
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Veo2Duration::Default => None,
Veo2Duration::FiveSeconds => Some("5s".to_string()),
Veo2Duration::SixSeconds => Some("6s".to_string()),
Veo2Duration::SevenSeconds => Some("7s".to_string()),
Veo2Duration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo2AspectRatio::Auto => None, // Let the API default (16:9)
Veo2AspectRatio::AutoPreferPortrait => Some("9:16".to_string()),
Veo2AspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo2AspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let request = Veo2TextToVideoInput {
prompt: args.prompt.to_string(),
aspect_ratio,
duration,
negative_prompt: args.negative_prompt.map(|s| s.to_string()),
};
let result = veo_2_text_to_video(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs, Veo2AspectRatio, Veo2Duration,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore]
async fn test_veo_2_text_to_video() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo2TextToVideoArgs {
prompt: "a drone shot of a coastal sunset with waves crashing on rocks",
negative_prompt: None,
api_key: &api_key,
duration: Veo2Duration::FiveSeconds,
aspect_ratio: Veo2AspectRatio::WideSixteenNine,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_veo_2_text_to_video_webhook(args).await?;
Ok(())
}
}
@@ -0,0 +1,137 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::video::text::http_veo_3_text_to_video::{
veo_3_text_to_video, Veo3TextToVideoInput,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{
FalRequestCostCalculator, UsdCents,
};
use reqwest::IntoUrl;
pub struct Veo3TextToVideoArgs<'a, V: IntoUrl> {
pub prompt: &'a str,
pub negative_prompt: Option<&'a str>,
pub duration: Veo3T2vDuration,
pub aspect_ratio: Veo3T2vAspectRatio,
pub resolution: Veo3T2vResolution,
pub generate_audio: bool,
pub webhook_url: V,
pub api_key: &'a FalApiKey,
}
/// Duration for Veo 3 text-to-video. Default is 8 seconds.
#[derive(Copy, Clone, Debug)]
pub enum Veo3T2vDuration {
Default, // Default is 8 seconds
FourSeconds,
SixSeconds,
EightSeconds,
}
/// Aspect ratio for Veo 3 text-to-video.
/// Only 16:9 and 9:16 are supported. No Auto, no Square.
#[derive(Copy, Clone, Debug)]
pub enum Veo3T2vAspectRatio {
Default, // Default is 16:9
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
}
/// Resolution for Veo 3 text-to-video.
#[derive(Copy, Clone, Debug)]
pub enum Veo3T2vResolution {
Default,
SevenTwentyP,
TenEightyP,
}
impl<V: IntoUrl> FalRequestCostCalculator for Veo3TextToVideoArgs<'_, V> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// $0.20/sec audio off, $0.40/sec audio on.
match (self.duration, self.generate_audio) {
(Veo3T2vDuration::FourSeconds, false) => 80,
(Veo3T2vDuration::SixSeconds, false) => 120,
(Veo3T2vDuration::EightSeconds, false) => 160,
(Veo3T2vDuration::Default, false) => 160,
(Veo3T2vDuration::FourSeconds, true) => 160,
(Veo3T2vDuration::SixSeconds, true) => 240,
(Veo3T2vDuration::EightSeconds, true) => 320,
(Veo3T2vDuration::Default, true) => 320,
}
}
}
/// Veo 3 Text-to-Video
/// https://fal.ai/models/fal-ai/veo3
pub async fn enqueue_veo_3_text_to_video_webhook<V: IntoUrl>(
args: Veo3TextToVideoArgs<'_, V>,
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Veo3T2vDuration::Default => None,
Veo3T2vDuration::FourSeconds => Some("4s".to_string()),
Veo3T2vDuration::SixSeconds => Some("6s".to_string()),
Veo3T2vDuration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo3T2vAspectRatio::Default => None,
Veo3T2vAspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3T2vAspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let resolution = match args.resolution {
Veo3T2vResolution::Default => None,
Veo3T2vResolution::SevenTwentyP => Some("720p".to_string()),
Veo3T2vResolution::TenEightyP => Some("1080p".to_string()),
};
let request = Veo3TextToVideoInput {
prompt: args.prompt.to_string(),
aspect_ratio,
resolution,
duration,
generate_audio: Some(args.generate_audio),
negative_prompt: args.negative_prompt.map(|s| s.to_string()),
};
let result = veo_3_text_to_video(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
enqueue_veo_3_text_to_video_webhook, Veo3TextToVideoArgs, Veo3T2vAspectRatio,
Veo3T2vDuration, Veo3T2vResolution,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore]
async fn test_veo_3_text_to_video() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo3TextToVideoArgs {
prompt: "a drone shot of a coastal sunset with waves crashing on rocks",
negative_prompt: None,
api_key: &api_key,
duration: Veo3T2vDuration::EightSeconds,
aspect_ratio: Veo3T2vAspectRatio::WideSixteenNine,
resolution: Veo3T2vResolution::TenEightyP,
generate_audio: true,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_veo_3_text_to_video_webhook(args).await?;
Ok(())
}
}
@@ -7,5 +7,7 @@ pub mod enqueue_pixverse_v5_text_to_video_webhook;
pub mod enqueue_seedance_1p5_pro_text_to_video_webhook;
pub mod enqueue_sora_2_pro_text_to_video_webhook;
pub mod enqueue_sora_2_text_to_video_webhook;
pub mod enqueue_veo_2_text_to_video_webhook;
pub mod enqueue_veo_3_text_to_video_webhook;
pub mod enqueue_veo_3p1_fast_text_to_video_webhook;
pub mod enqueue_veo_3p1_text_to_video_webhook;
@@ -277,6 +277,8 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
info!("Seedance2pro request : {:?}", request_body);
println!("\n\nSeedance2pro request : \n\n {:?}\n\n", request_body);
let cookie = args.session.cookies.as_str();
let client = Client::builder()
@@ -34,3 +34,4 @@ env_logger.workspace = true
env.workspace = true
chrono.workspace = true
strum.workspace = true
test_data.workspace = true
@@ -4,12 +4,26 @@ pub enum WorldLabsModel {
/// `Marble 0.1-mini` is good for quick drafts
/// Generation time: 30-45 seconds
/// Cost: 150-330 credits
#[deprecated(note="Marble 0.1-mini is deprecated. Use `marble-1.0-draft` instead.")]
Marble0p1Mini,
/// `Marble 0.1-plus` is best for final renders
/// Generation time: ~5 minutes,
/// Cost: 1500-1600 credits
#[deprecated(note="Marble 0.1-plus is deprecated. Use `marble-1.0` instead.")]
Marble0p1Plus,
/// marble-1.0
Marble1p0,
/// marble-1.0-draft
Marble1p0Draft,
/// marble-1.1
Marble1p1,
/// marble-1.1-plus
Marble1p1Plus,
}
impl WorldLabsModel {
@@ -18,6 +32,159 @@ impl WorldLabsModel {
match self {
Self::Marble0p1Mini => "Marble 0.1-mini",
Self::Marble0p1Plus => "Marble 0.1-plus",
Self::Marble1p0 => "marble-1.0",
Self::Marble1p0Draft => "marble-1.0-draft",
Self::Marble1p1 => "marble-1.1",
Self::Marble1p1Plus => "marble-1.1-plus",
}
}
pub fn is_deprecated(&self) -> bool {
match self {
Self::Marble0p1Mini | Self::Marble0p1Plus => true,
_ => false,
}
}
pub fn to_new_value(self) -> Self {
match self {
Self::Marble0p1Mini => Self::Marble1p0Draft,
Self::Marble0p1Plus => Self::Marble1p0,
_ => self,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
mod api_name {
use super::*;
#[allow(deprecated)]
#[test]
fn marble_0p1_mini() {
assert_eq!(WorldLabsModel::Marble0p1Mini.get_model_api_name_str(), "Marble 0.1-mini");
}
#[allow(deprecated)]
#[test]
fn marble_0p1_plus() {
assert_eq!(WorldLabsModel::Marble0p1Plus.get_model_api_name_str(), "Marble 0.1-plus");
}
#[test]
fn marble_1p0() {
assert_eq!(WorldLabsModel::Marble1p0.get_model_api_name_str(), "marble-1.0");
}
#[test]
fn marble_1p0_draft() {
assert_eq!(WorldLabsModel::Marble1p0Draft.get_model_api_name_str(), "marble-1.0-draft");
}
#[test]
fn marble_1p1() {
assert_eq!(WorldLabsModel::Marble1p1.get_model_api_name_str(), "marble-1.1");
}
#[test]
fn marble_1p1_plus() {
assert_eq!(WorldLabsModel::Marble1p1Plus.get_model_api_name_str(), "marble-1.1-plus");
}
}
mod is_deprecated {
use super::*;
#[allow(deprecated)]
#[test]
fn marble_0p1_mini_is_deprecated() {
assert!(WorldLabsModel::Marble0p1Mini.is_deprecated());
}
#[allow(deprecated)]
#[test]
fn marble_0p1_plus_is_deprecated() {
assert!(WorldLabsModel::Marble0p1Plus.is_deprecated());
}
#[test]
fn marble_1p0_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p0.is_deprecated());
}
#[test]
fn marble_1p0_draft_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p0Draft.is_deprecated());
}
#[test]
fn marble_1p1_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p1.is_deprecated());
}
#[test]
fn marble_1p1_plus_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p1Plus.is_deprecated());
}
}
mod to_new_value {
use super::*;
#[allow(deprecated)]
#[test]
fn marble_0p1_mini_maps_to_1p0_draft() {
let new = WorldLabsModel::Marble0p1Mini.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0-draft");
assert!(!new.is_deprecated());
}
#[allow(deprecated)]
#[test]
fn marble_0p1_plus_maps_to_1p0() {
let new = WorldLabsModel::Marble0p1Plus.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0");
assert!(!new.is_deprecated());
}
#[test]
fn marble_1p0_stays_as_1p0() {
let new = WorldLabsModel::Marble1p0.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0");
}
#[test]
fn marble_1p0_draft_stays_as_1p0_draft() {
let new = WorldLabsModel::Marble1p0Draft.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0-draft");
}
#[test]
fn marble_1p1_stays_as_1p1() {
let new = WorldLabsModel::Marble1p1.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.1");
}
#[test]
fn marble_1p1_plus_stays_as_1p1_plus() {
let new = WorldLabsModel::Marble1p1Plus.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.1-plus");
}
#[test]
fn new_models_are_not_deprecated_after_conversion() {
for model in [
WorldLabsModel::Marble1p0,
WorldLabsModel::Marble1p0Draft,
WorldLabsModel::Marble1p1,
WorldLabsModel::Marble1p1Plus,
] {
let new = model.to_new_value();
assert!(!new.is_deprecated(), "{:?} should not be deprecated after to_new_value", model);
}
}
}
}
@@ -33,10 +33,15 @@ pub struct GenerateWorldResponse {
pub async fn generate_world(args: GenerateWorldArgs<'_>) -> Result<GenerateWorldResponse, WorldLabsError> {
let client = Client::new();
let model_name = args.model
.to_new_value()
.get_model_api_name_str()
.to_string();
let payload = RawRequest {
world_prompt: args.world_prompt,
display_name: args.display_name,
model: Some(args.model.get_model_api_name_str().to_string()),
model: Some(model_name),
seed: args.seed,
tags: args.tags,
permission: args.permission,
@@ -90,8 +95,11 @@ pub async fn generate_world(args: GenerateWorldArgs<'_>) -> Result<GenerateWorld
mod tests {
use super::*;
use crate::api::requests::generate_world::http_request::{ContentReference, WorldPrompt};
use crate::api::requests::prepare_upload::prepare_upload::{prepare_upload, PrepareUploadArgs, MediaAssetKind};
use crate::api::requests::upload_to_signed_url::upload_to_signed_url::{upload_to_signed_url, UploadToSignedUrlArgs};
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use filesys::file_read_bytes::file_read_bytes;
use log::LevelFilter;
#[tokio::test]
@@ -151,4 +159,291 @@ mod tests {
assert_eq!(1, 2);
}
/// End-to-end: upload a safe image step by step (prepare → upload →
/// generate_world), printing each step's result for debugging.
///
/// Uses a synthetic gradient PNG from test_data/.
#[tokio::test]
#[ignore]
async fn test_generate_world_for_safe_image() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
// --- Read file ---
let file_bytes = file_read_bytes("/Users/bt/dev/storyteller/artcraft/test_data/image/mochi.jpg")
.expect("failed to read safe test image");
println!("Read {} bytes from safe test image", file_bytes.len());
// --- Step 1: prepare_upload ---
println!("\n=== Step 1: prepare_upload ===");
let prepare_result = prepare_upload(PrepareUploadArgs {
creds: &creds,
file_name: "mochi.jpg",
kind: MediaAssetKind::Image,
request_timeout: None,
}).await;
println!("prepare_upload result: {:?}", prepare_result.as_ref().map(|r| &r.media_asset_id));
let prepare_response = match prepare_result {
Ok(r) => {
println!(" Media Asset ID: {}", r.media_asset_id.as_str());
println!(" Upload URL: {}", r.upload_url);
println!(" Required headers: {:?}", r.required_headers);
r
}
Err(err) => {
println!(" ERROR at prepare_upload: {:?}", err);
panic!("prepare_upload failed: {:?}", err);
}
};
// --- Step 2: upload_to_signed_url ---
println!("\n=== Step 2: upload_to_signed_url ===");
let upload_result = upload_to_signed_url(UploadToSignedUrlArgs {
upload_url: &prepare_response.upload_url,
file_bytes,
required_headers: &prepare_response.required_headers,
content_type: "image/jpeg",
request_timeout: None,
}).await;
match &upload_result {
Ok(()) => println!(" Upload succeeded."),
Err(err) => {
println!(" ERROR at upload_to_signed_url: {:?}", err);
panic!("upload_to_signed_url failed: {:?}", err);
}
}
// --- Step 3: generate_world ---
println!("\n=== Step 3: generate_world ===");
let generate_result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::MediaAsset {
media_asset_id: prepare_response.media_asset_id.as_str().to_string(),
},
text_prompt: Some("A colorful gradient landscape".to_string()),
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble0p1Mini,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &generate_result {
Ok(r) => {
println!(" SUCCESS — safe image");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!(" ERROR at generate_world: {:?}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
/// End-to-end: upload an NSFW image step by step (prepare → upload →
/// generate_world), printing each step's result for debugging.
///
/// We expect the NSFW rejection to happen at one of these steps.
/// This test helps us identify *which* step returns the moderation error
/// and what the error response body looks like.
///
/// NB: Place the NSFW test image at `test_data/images/nsfw_test_image.png`.
/// This file is NOT checked into git.
#[tokio::test]
#[ignore]
async fn test_generate_world_for_nsfw_image() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
// --- Read file ---
let file_bytes = file_read_bytes("/Users/bt/Pictures/nsfw_test/nsfw_test_1.jpg")
.expect("failed to read NSFW test image");
println!("Read {} bytes from NSFW test image", file_bytes.len());
// --- Step 1: prepare_upload ---
println!("\n=== Step 1: prepare_upload ===");
let prepare_result = prepare_upload(PrepareUploadArgs {
creds: &creds,
file_name: "nsfw_test_1.jpg",
kind: MediaAssetKind::Image,
request_timeout: None,
}).await;
let prepare_response = match prepare_result {
Ok(r) => {
println!(" OK — Media Asset ID: {}", r.media_asset_id.as_str());
println!(" Upload URL: {}", r.upload_url);
r
}
Err(err) => {
println!(" NSFW CAUGHT AT STEP 1 (prepare_upload)!");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
assert_eq!(1, 2, "NSFW caught at prepare_upload — inspect output above");
unreachable!();
}
};
// --- Step 2: upload_to_signed_url ---
println!("\n=== Step 2: upload_to_signed_url ===");
let upload_result = upload_to_signed_url(UploadToSignedUrlArgs {
upload_url: &prepare_response.upload_url,
file_bytes,
required_headers: &prepare_response.required_headers,
content_type: "image/jpeg",
request_timeout: None,
}).await;
match &upload_result {
Ok(()) => println!(" OK — upload succeeded."),
Err(err) => {
println!(" NSFW CAUGHT AT STEP 2 (upload_to_signed_url)!");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
assert_eq!(1, 2, "NSFW caught at upload — inspect output above");
}
}
// --- Step 3: generate_world ---
println!("\n=== Step 3: generate_world ===");
let generate_result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::MediaAsset {
media_asset_id: prepare_response.media_asset_id.as_str().to_string(),
},
text_prompt: None,
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble0p1Mini,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &generate_result {
Ok(r) => {
println!(" UNEXPECTED SUCCESS — NSFW image was NOT rejected at generate_world!");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!(" NSFW CAUGHT AT STEP 3 (generate_world)!");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
/// Marble 1.1: generate world from a mountain/tree image via URI.
#[tokio::test]
#[ignore]
async fn test_generate_world_marble_1p1_image_uri() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
let image_url = test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL;
println!("=== generate_world with Marble 1.1 ===");
println!("Image URL: {}", image_url);
let result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::Uri {
uri: image_url.to_string(),
},
text_prompt: Some("Mountain landscape with a tree".to_string()),
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble1p1,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &result {
Ok(r) => {
println!("SUCCESS — Marble 1.1");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!("ERROR — Marble 1.1");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
/// Marble 1.1-plus: generate world from a wide fall mountains image via URI.
#[tokio::test]
#[ignore]
async fn test_generate_world_marble_1p1_plus_image_uri() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
let image_url = test_data::web::image_urls::SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL;
println!("=== generate_world with Marble 1.1-plus ===");
println!("Image URL: {}", image_url);
let result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::Uri {
uri: image_url.to_string(),
},
text_prompt: Some("Autumn mountain panorama".to_string()),
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble1p1Plus,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &result {
Ok(r) => {
println!("SUCCESS — Marble 1.1-plus");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!("ERROR — Marble 1.1-plus");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
}
@@ -195,7 +195,8 @@ mod tests {
let creds = get_test_api_key().unwrap();
let operation_id = OperationId("1fab3bf1-05a1-4929-907e-c6df07c539e2".to_string());
//let operation_id = OperationId("1fab3bf1-05a1-4929-907e-c6df07c539e2".to_string());
let operation_id = OperationId("a0894fa6-594d-4680-8f3a-5109f22664b1".to_string());
let response = get_operation(GetOperationArgs {
creds: &creds,
@@ -16,6 +16,11 @@ pub fn filter_world_labs_http_error(status_code: StatusCode, maybe_body: Option<
_ => {},
}
// Check for NSFW content policy rejection (403 with nsfw/sexuality keywords).
if let Some(nsfw_error) = check_nsfw_content_rejection(status_code, maybe_body) {
return Err(nsfw_error.into());
}
if let Some(body) = maybe_body {
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
@@ -28,3 +33,163 @@ pub fn filter_world_labs_http_error(status_code: StatusCode, maybe_body: Option<
body: String::new(),
}.into())
}
/// Check if a 403 response is an NSFW content policy rejection.
/// Returns `Some(WorldLabsSpecificApiError)` if it matches, `None` otherwise.
fn check_nsfw_content_rejection(
status_code: StatusCode,
maybe_body: Option<&str>,
) -> Option<WorldLabsSpecificApiError> {
if status_code != StatusCode::FORBIDDEN {
return None;
}
let body = maybe_body?;
let body_lower = body.to_lowercase();
if body_lower.contains("nsfw") || body_lower.contains("sexuality") {
Some(WorldLabsSpecificApiError::NsfwContentPolicyRejected {
message: Some(body.to_string()),
})
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn success_200_returns_ok() {
let result = filter_world_labs_http_error(StatusCode::OK, None);
assert!(result.is_ok());
}
#[test]
fn success_200_with_body_returns_ok() {
let result = filter_world_labs_http_error(StatusCode::OK, Some("{}"));
assert!(result.is_ok());
}
// --- NSFW 403 ---
#[test]
fn nsfw_403_real_response() {
let body = r#"{"detail":"NSFW Content is not permitted by policy. Sexuality"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
let err = result.unwrap_err();
assert!(err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. }
)));
}
#[test]
fn nsfw_403_preserves_message() {
let body = r#"{"detail":"NSFW Content is not permitted by policy. Sexuality"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
let err = result.unwrap_err();
if let WorldLabsError::ApiSpecific(WorldLabsSpecificApiError::NsfwContentPolicyRejected { message }) = err {
assert_eq!(message.as_deref(), Some(body));
} else {
panic!("expected NsfwContentPolicyRejected, got: {:?}", err);
}
}
#[test]
fn nsfw_403_case_insensitive_nsfw() {
let body = r#"{"detail":"nsfw content detected"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. }
)));
}
#[test]
fn nsfw_403_case_insensitive_sexuality() {
let body = r#"{"detail":"Rejected due to Sexuality content"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. }
)));
}
// --- Non-NSFW 403 ---
#[test]
fn non_nsfw_403_is_generic_error() {
let body = r#"{"detail":"Forbidden: invalid API key"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
let err = result.unwrap_err();
assert!(err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn non_nsfw_403_no_body_is_generic_error() {
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, None);
let err = result.unwrap_err();
assert!(err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
// --- 402 Payment Required ---
#[test]
fn payment_required_402() {
let result = filter_world_labs_http_error(StatusCode::PAYMENT_REQUIRED, Some("{}"));
let err = result.unwrap_err();
assert!(matches!(err, WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::InsufficientCredits
)));
assert!(!err.is_403_forbidden());
}
// --- Other errors ---
#[test]
fn server_error_500_is_generic() {
let body = r#"{"detail":"Internal server error"}"#;
let result = filter_world_labs_http_error(StatusCode::INTERNAL_SERVER_ERROR, Some(body));
let err = result.unwrap_err();
assert!(!err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn bad_request_400_is_generic() {
let body = r#"{"detail":"Bad request"}"#;
let result = filter_world_labs_http_error(StatusCode::BAD_REQUEST, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn nsfw_keyword_in_non_403_is_not_nsfw_error() {
// Even if the body mentions "nsfw", a 400 should NOT be classified as NSFW rejection.
let body = r#"{"detail":"nsfw check failed"}"#;
let result = filter_world_labs_http_error(StatusCode::BAD_REQUEST, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn error_500_no_body() {
let result = filter_world_labs_http_error(StatusCode::INTERNAL_SERVER_ERROR, None);
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { body, .. }
) if body.is_empty()));
}
}
@@ -10,6 +10,16 @@ pub enum WorldLabsError {
ApiGeneric(WorldLabsGenericApiError),
}
impl WorldLabsError {
pub fn is_403_forbidden(&self) -> bool {
match self {
Self::ApiSpecific(e) => e.is_403_forbidden(),
Self::ApiGeneric(e) => e.is_403_forbidden(),
Self::Client(_) => false,
}
}
}
impl Error for WorldLabsError {}
impl std::fmt::Display for WorldLabsError {
@@ -24,6 +24,18 @@ pub enum WorldLabsGenericApiError {
WreqError(wreq::Error),
}
impl WorldLabsGenericApiError {
pub fn is_403_forbidden(&self) -> bool {
match self {
Self::UncategorizedBadResponseWithStatusAndBody { status_code, .. }
| Self::GoogleUploadFailed { status_code, .. } => {
*status_code == StatusCode::FORBIDDEN
}
_ => false,
}
}
}
impl Error for WorldLabsGenericApiError {}
impl Display for WorldLabsGenericApiError {
@@ -5,6 +5,20 @@ use std::fmt::{Display, Formatter};
pub enum WorldLabsSpecificApiError {
/// There aren't sufficient funds for generation.
InsufficientCredits,
/// The content was rejected by WorldLabs' NSFW / content policy filter.
NsfwContentPolicyRejected {
message: Option<String>,
},
}
impl WorldLabsSpecificApiError {
pub fn is_403_forbidden(&self) -> bool {
match self {
Self::NsfwContentPolicyRejected { .. } => true,
_ => false,
}
}
}
impl Error for WorldLabsSpecificApiError {}
@@ -13,6 +27,13 @@ impl Display for WorldLabsSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InsufficientCredits => write!(f, "Insufficient credits"),
Self::NsfwContentPolicyRejected { message } => {
write!(f, "NSFW content policy rejection")?;
if let Some(msg) = message {
write!(f, ": {}", msg)?;
}
Ok(())
}
}
}
}
@@ -42,6 +42,38 @@ fn credits_for(model: WorldLabsModel, input_type: InputType) -> u32 {
(WorldLabsModel::Marble0p1Plus, InputType::ImageNonPanorama) => 1580,
(WorldLabsModel::Marble0p1Plus, InputType::MultiImage) => 1600,
(WorldLabsModel::Marble0p1Plus, InputType::Video) => 1600,
// Marble 1.0 (successor to 0.1-plus)
(WorldLabsModel::Marble1p0, InputType::ImagePanorama) => 1500,
(WorldLabsModel::Marble1p0, InputType::Text) => 1580,
(WorldLabsModel::Marble1p0, InputType::ImageNonPanorama) => 1580,
(WorldLabsModel::Marble1p0, InputType::MultiImage) => 1600,
(WorldLabsModel::Marble1p0, InputType::Video) => 1600,
// Marble 1.0-draft (successor to 0.1-mini)
(WorldLabsModel::Marble1p0Draft, InputType::ImagePanorama) => 150,
(WorldLabsModel::Marble1p0Draft, InputType::Text) => 230,
(WorldLabsModel::Marble1p0Draft, InputType::ImageNonPanorama) => 230,
(WorldLabsModel::Marble1p0Draft, InputType::MultiImage) => 250,
(WorldLabsModel::Marble1p0Draft, InputType::Video) => 250,
// Marble 1.1
(WorldLabsModel::Marble1p1, InputType::ImagePanorama) => 1500,
(WorldLabsModel::Marble1p1, InputType::Text) => 1580,
(WorldLabsModel::Marble1p1, InputType::ImageNonPanorama) => 1580,
(WorldLabsModel::Marble1p1, InputType::MultiImage) => 1600,
(WorldLabsModel::Marble1p1, InputType::Video) => 1600,
// TODO: Looks like there's no way for us to know what the price will be
// for us to bill this accurately, we'll have to bill this after the fact.
// also - they don't have an API for us to ascertain this??! Wat.
// Marble 1.1-plus (range pricing — we use the max to charge enough upfront)
// Actual range: 1500–3000 / 1580–3080 / 1600–3100
(WorldLabsModel::Marble1p1Plus, InputType::ImagePanorama) => 3000,
(WorldLabsModel::Marble1p1Plus, InputType::Text) => 3080,
(WorldLabsModel::Marble1p1Plus, InputType::ImageNonPanorama) => 3080,
(WorldLabsModel::Marble1p1Plus, InputType::MultiImage) => 3100,
(WorldLabsModel::Marble1p1Plus, InputType::Video) => 3100,
}
}
@@ -64,6 +96,10 @@ mod tests {
let models = [
WorldLabsModel::Marble0p1Mini,
WorldLabsModel::Marble0p1Plus,
WorldLabsModel::Marble1p0,
WorldLabsModel::Marble1p0Draft,
WorldLabsModel::Marble1p1,
WorldLabsModel::Marble1p1Plus,
];
println!("\n{:<20} {:<20} {:>10} {:>12}", "Model", "Input Type", "Credits", "USD Cents");
@@ -101,6 +137,59 @@ mod tests {
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::Video).worldlabs_credits, 1600);
}
#[test]
fn test_marble_1p0_draft_credits() {
// Same as Marble 0.1-mini (its successor)
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::Text).worldlabs_credits, 230);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::ImageNonPanorama).worldlabs_credits, 230);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::ImagePanorama).worldlabs_credits, 150);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::MultiImage).worldlabs_credits, 250);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::Video).worldlabs_credits, 250);
}
#[test]
fn test_marble_1p0_credits() {
// Standard model, same pricing as Marble 0.1-plus
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::Text).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::ImageNonPanorama).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::ImagePanorama).worldlabs_credits, 1500);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::MultiImage).worldlabs_credits, 1600);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::Video).worldlabs_credits, 1600);
}
#[test]
fn test_marble_1p1_credits() {
// Standard model, same pricing tier as 1.0
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::Text).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::ImageNonPanorama).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::ImagePanorama).worldlabs_credits, 1500);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::MultiImage).worldlabs_credits, 1600);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::Video).worldlabs_credits, 1600);
}
#[test]
fn test_marble_1p1_plus_credits() {
// Plus model uses max of the range pricing (1500–3000, 1580–3080, 1600–3100)
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::Text).worldlabs_credits, 3080);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::ImageNonPanorama).worldlabs_credits, 3080);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::ImagePanorama).worldlabs_credits, 3000);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::MultiImage).worldlabs_credits, 3100);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::Video).worldlabs_credits, 3100);
}
#[test]
fn test_marble_1p1_plus_is_more_expensive_than_standard() {
for input_type in InputType::iter() {
let standard = calculate_cost(WorldLabsModel::Marble1p1, input_type);
let plus = calculate_cost(WorldLabsModel::Marble1p1Plus, input_type);
assert!(
plus.worldlabs_credits > standard.worldlabs_credits,
"1.1-plus should cost more than 1.1 for {:?}: {} vs {}",
input_type, plus.worldlabs_credits, standard.worldlabs_credits,
);
}
}
#[test]
fn test_dollar_conversion() {
// 1250 credits = $1.00 = 100 cents
@@ -120,6 +209,10 @@ mod tests {
let models = [
WorldLabsModel::Marble0p1Mini,
WorldLabsModel::Marble0p1Plus,
WorldLabsModel::Marble1p0,
WorldLabsModel::Marble1p0Draft,
WorldLabsModel::Marble1p1,
WorldLabsModel::Marble1p1Plus,
];
for model in &models {
@@ -41,6 +41,7 @@ pub (super) async fn handle_artcraft_veo3_fast(
generate_audio: request.generate_audio,
duration: None, // TODO: Parameterize
resolution: None, // TODO: Parameterize
aspect_ratio: None, // TODO: Parameterize
};
let result = generate_veo_3_fast_image_to_video(
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.27.0";
pub const ARTCRAFT_VERSION: &str = "0.28.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.27.0",
"version": "0.28.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",

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