Compare commits

...
Author SHA1 Message Date
bflatastic 795bd92036 character modal content scroll fix 2026-04-03 15:18:53 +07:00
bflatastic 4009ed6ae8 styling cleanup and frontend character creation polling 2026-04-03 15:10:13 +07:00
bflatastic cbb707238e characters edit, delete and token field update 2026-04-03 14:39:03 +07:00
bflatastic 7ca1739573 adjust seedance 2.0 time to ~15 mins 2026-04-03 14:39:03 +07:00
BFlat dcdb8a1e90 remove char button tooltip and theme color awaremess 2026-04-03 14:39:03 +07:00
BFlat e4119ebe2e cancel button styling update 2026-04-03 14:39:03 +07:00
BFlat edcd21fa2d styling updates 2026-04-03 14:39:03 +07:00
BFlat c8243d77cb styling updates character modal 2026-04-03 14:39:03 +07:00
bflatastic 0e1567e3bc modal width and height update 2026-04-03 14:39:03 +07:00
BFlat 7d6e082cb3 character references field 2026-04-03 14:39:03 +07:00
BFlat c644e378fb characters update to use real api 2026-04-03 14:39:03 +07:00
BFlat 0b8aea44af characters 2026-04-03 14:39:03 +07:00
Brandon Thomas 702bdc8777 tauri characters via artcraft_router (#1293) 2026-04-03 03:37:04 -04:00
Brandon Thomas 4fe36781de characters job (#1292) 2026-04-03 02:50:04 -04:00
bflatastic 029375052d fix lightbox carousel not loading on second open 2026-04-03 12:06:21 +07:00
Brandon Thomas 72d680411f characters API and schema (#1290) 2026-04-03 00:17:03 -04:00
bflatastic 79a7443baa fixed library arrow keys navigation 2026-04-03 09:06:29 +07:00
Brandon Thomas 004a5c157a rich paging on both fal and seedance webhook handlers (#1289) 2026-04-02 03:51:46 -04:00
BFlat 63d3d63709 promptbox expand overflow bug fixed (#1288) 2026-04-02 12:45:22 +07:00
Brandon Thomas d6e23838ce advanced http errors (#1287) 2026-04-02 01:31:03 -04:00
BFlat 6785db1d59 task queue: group fail messages in tooltip, prompt message on every card type (#1286)
- grouped fail message with fail reason under one tooltip for cleaner ui
- prompt / copy prompt for every type of task card
- larger thumbnails, wider task queue dropdown
- removed prompt hover on card thumbnails
- marquee css updated
- other styling cleanups
2026-04-02 10:13:52 +07:00
Brandon Thomas c5f62daa2b categorize seedance credits error (#1285) 2026-04-01 22:51:25 -04:00
bflatastic 7122e4507f fixed issue where duration isn't passed through when clicking on recreate 2026-04-02 09:35:44 +07:00
Tanish Manku 238cb10499 Add copy prompt button and prompt preview to failed task notifications (#1284)
- Copy button on failed task cards copies the prompt to clipboard with a
3s check/green feedback
- Prompt preview row with marquee scroll on hover (only when text
overflows), tooltip showing full prompt up to 300 chars
- Fix dismiss and action button icon centering across all card types
(InProgress, Completed, Failed)
- Add marquee keyframe animation to tailwind.config.ts
2026-04-01 19:08:43 -07:00
Brandon Thomas 1ebb124f05 cleaned up pager (#1283) 2026-04-01 22:00:38 -04:00
Brandon Thomas e7bb263755 mysql backup tool 2026-04-01 13:15:27 -04:00
Tanish Manku bbfea2ae96 Code cleanup: Delete PageEdit dead code and migrate shared types to ui-pagedraw (#1281)
PageEdit was never rendered in the app. Removes all PageEdit files
(~4000 lines) and migrates the shared types (BaseSelectorImage,
ImageBundle) and HistoryStack component to @storyteller/ui-pagedraw
which already owns the canonical definitions.
2026-04-01 09:29:17 -07:00
BFlat 3bc55c761c footnote update pricing apge 2026-04-01 22:45:42 +07:00
BFlat 8348a91693 show seedance in pricing table 2026-04-01 21:55:11 +07:00
BFlat d1adc0b179 website footer reddit link 2026-04-01 21:51:53 +07:00
BFlat c0ac3cbc9c pricing plan description updated 2026-04-01 20:03:59 +07:00
Brandon Thomas 0a13d36070 no more "free user downgrade" since we're not giving away nano banana… (#1279)
… pro anymore
2026-04-01 03:00:00 -04:00
Brandon Thomas 8662e79824 use tested typesafe fal bindings in webhook (#1278)
wip - still needs tested extractors for image, video, etc.
2026-04-01 02:46:21 -04:00
Brandon Thomas dc30cf9669 fal webhooks - tested cases for error extraction (#1277) 2026-04-01 02:21:44 -04:00
Tanish Manku b09a0fd854 Drag-N-Drop assets into Artcraft to upload them (#1231) 2026-03-31 21:05:38 -04:00
Brandon Thomas 400bccc155 more claude config files (#1276) 2026-03-31 19:22:45 -04:00
Tanish Manku 11011dacaf 2D: Add blank canvas option with preset sizes (#1275) 2026-03-31 15:28:22 -07:00
Tanish Manku 2f73e861d1 2D: Make PageDraw a self-contained lib (#1244)
Moves PageDraw from artcraft app to a component lib, leaving an adapter
implementation and import behind instead
2026-03-31 14:43:21 -07:00
Brandon Thomas 0151a2e28d fix password null bytes paging (#1273) 2026-03-31 14:55:10 -04:00
250 changed files with 9273 additions and 7149 deletions
+47
View File
@@ -0,0 +1,47 @@
# API Conventions
## HTTP Handlers (Actix-Web)
Handlers in `storyteller_web` follow this structure:
1. **Request/Response types** with `#[derive(Deserialize/Serialize, ToSchema)]`
2. **Error enum** implementing `ResponseError`, `Display`, `Serialize`, `ToSchema`
3. **Handler function** annotated with `#[utoipa::path(...)]` for OpenAPI docs
4. Handler returns `Result<Json<Response>, ErrorType>`. Only use `Result<HttpResponse, ErrorType>` when you need to set headers/cookies.
### Error Types
- Use `CommonWebError` for simple handlers that only need standard HTTP error codes
- Use a custom error enum when you need endpoint-specific error variants (e.g. `LoginErrorType::InvalidCredentials`)
- Custom error enums MUST implement `ResponseError` (actix), `Display`, and `Serialize`
- When adding `From<RequireUserSessionError>` or similar conversions, implement them on `CommonWebError` in its own module (orphan rule)
- Error response bodies include: `success: false`, `error_code`, `error_code_str`
### Session/Auth
- `require_user_session_using_connection()` reuses an open DB connection (preferred)
- `require_user_session()` acquires its own connection (deprecated)
- `require_moderator()` for admin-only endpoints
### API Type Definitions
- Shared request/response types go in `artcraft_api_defs` crate (under `crates/api_clients/artcraft/artcraft_api_defs/`)
- Types that need `ResponseError` impl must stay in `storyteller_web` (orphan rule)
- New types must be added to `api_doc.rs` schemas (in storyteller-web) for Swagger generation
## Enums (Database-backed)
- Located in `crates/schema/public/enums/src/by_table/{table_name}/`
- Require manual `to_str()` and `from_str()` implementations
- Use `impl_enum_display_and_debug_using_to_str!` and `impl_mysql_enum_coders!` macros
- Add comprehensive tests: serialization, round-trip, variant count
- YOU CAN ADD NEW VALUES but DO NOT CHANGE EXISTING VALUES without a migration strategy
- Note that adding new values can break older versions of the Tauri desktop app that read
responses over Rust - make sure it consumes variants with an "Unknown(String)" variant
catch-all to future proof the APIs.
## Tokens (Primary Keys)
- Located in `crates/schema/public/tokens/src/tokens/`
- Opaque `String` wrappers with Stripe-like prefixes (e.g. `user_`, `mf_`)
- Use `impl_string_token!`, `impl_mysql_token_from_row!`, `impl_crockford_generator!` macros
+59
View File
@@ -0,0 +1,59 @@
# Code Style
## Rust
- Two spaces for indentation (not four)
- No minimum supported Rust version; use latest stable features freely
- `#[macro_use] extern crate serde_derive;` is used in binary crates; library crates use `use serde_derive::{Serialize, Deserialize};`
- Prefer `log` crate macros (`info!`, `warn!`, `error!`) over `println!`
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code
- Enum Display/Debug: use `impl_enum_display_and_debug_using_to_str!` macro, not `derive_more::Display` (CLion doesn't understand it)
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token`
- When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)`
## File Layout (Top-to-Bottom Reading Order)
Organize code so a human reading top-to-bottom encounters the most important things first and
can drill down into details only when curious. Think of it like a newspaper: headline first,
then the story, then the fine print.
### Ordering within a file
1. **Constants** at the top, right after imports.
2. **The primary public type** (the main struct/enum the file is about) comes next.
A reader opening this file should immediately see what it defines.
3. **Supporting/subordinate types** (sub-structs, helper enums, serialization DTOs) go
below the primary type, not above it. Ask: "Would a reader need to know this type
exists before seeing the main type?" — if no, push it down.
4. **Type ordering for API types**: Request, then Response (success), then Error.
5. **`impl` blocks** after their type definitions.
### Ordering within an `impl` block
1. **Constructors** (`new`, `from_*`, `builder`) at the top.
2. **Public methods** next — these are the main entry points a reader cares about.
3. **Private helper methods** below public methods.
- Among helpers: place "meaty" helpers (substantial logic, direct continuations of the
public entry point) above "leaf" helpers (small formatters, simple lookups).
- A method that calls another method in the same file should appear *above* the method
it calls. This lets readers follow the call chain downward.
## Naming
- Handler functions: `{verb}_{noun}_handler` (e.g. `get_health_check_handler`, `login_handler`)
- Error enums: `{Feature}Error` (e.g. `FalWebhookError`, `HealthCheckError`)
- Request/response structs: `{Action}Request`, `{Action}Response`, `{Action}SuccessResponse`
- Path params: `{Action}PathInfo`
- Builder structs: `{Thing}Builder` with `set_` prefixed setters and a `build()` method
## Imports
- Group imports: std, external crates, internal workspace crates, `crate::` imports
- Use fully qualified paths for one-off references; `use` for repeated references
- Prefer specific imports over wildcards, except in `api_doc.rs` and test modules
## TypeScript / Frontend
- TypeScript with React, Vite, Zustand, Three.js
- Nx monorepo under `crates/frontend/`
- Two spaces for indentation
+27
View File
@@ -0,0 +1,27 @@
# Testing
## Running Tests
- Most crates: `cargo test -p {crate_name}`
- Crates using SQLx offline mode: `SQLX_OFFLINE=true cargo test -p {crate_name}`
- The `storyteller-web` and `mysql_queries` crates require `SQLX_OFFLINE=true` for compilation
- Use `cargo check -p {crate_name}` for fast compilation checks without running tests
## Test Patterns
- Tests live in `#[cfg(test)] mod tests { ... }` at the bottom of each file
- Group related tests in sub-modules: `mod explicit_checks`, `mod mechanical_checks`
- Enum tests should cover: serialization, deserialization, `to_str`/`from_str` round-trip, variant count
- Password/crypto tests: be mindful of computation cost, don't add too many bcrypt test cases
## What to Test
- New error types and their mappings
- Enum variant serialization stability (these are stored in the database)
- Hash/deduplication functions with known inputs
- Edge cases in parsers (empty input, missing fields, malformed data)
## What Not to Test
- Don't add integration tests that require a running database unless explicitly asked
- Don't add tests for simple struct construction or trivial getters
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO characters\nSET\n token = ?,\n character_type = ?,\n is_active = false,\n character_name = ?,\n maybe_description = ?,\n maybe_avatar_media_token = NULL,\n maybe_full_image_media_token = NULL,\n maybe_original_upload_media_token = ?,\n maybe_creator_user_token = ?,\n creator_ip_address = ?,\n kinovi_character_id = ?,\n kinovi_character_name = ?,\n maybe_kinovi_asset_id = NULL,\n maybe_generic_inference_job_token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "2a19cc533815aa3325b940eafe56b9f014fedb3b360b954935d6ec13ff14302d"
}
@@ -0,0 +1,157 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n token as `token: tokens::tokens::characters::CharacterToken`,\n character_type as `character_type: enums::by_table::characters::character_type::CharacterType`,\n is_active as `is_active: bool`,\n character_name,\n maybe_description,\n maybe_avatar_media_token as `maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_full_image_media_token as `maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_original_upload_media_token as `maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n kinovi_character_id,\n kinovi_character_name,\n maybe_kinovi_asset_id\nFROM characters\nWHERE maybe_creator_user_token = ?\n AND is_active = true\n AND deleted_at IS NULL\n AND id < ?\nORDER BY id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: tokens::tokens::characters::CharacterToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "character_type: enums::by_table::characters::character_type::CharacterType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "is_active: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 4,
"name": "character_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 6,
"name": "maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 10,
"name": "kinovi_character_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 11,
"name": "kinovi_character_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 12,
"name": "maybe_kinovi_asset_id",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 256
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "8c130ebd0d192355844863b3ebd44952aa8715eebe96375fb28f9f12baf7f9b1"
}
@@ -0,0 +1,47 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `kinovi_character_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.job_type = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "kinovi_character_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 2,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
true,
true
]
},
"hash": "9bd472515a1ec55a4b3eed9b37baea3a9740484172086b1797889a5f88b5750f"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.job_type = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"describe": {
"columns": [
{
@@ -85,7 +85,7 @@
}
],
"parameters": {
"Right": 1
"Right": 2
},
"nullable": [
false,
@@ -98,5 +98,5 @@
true
]
},
"hash": "cb33fd483f0fef7ec2a1f5950218f312a19e38c7971b88db949ccaa05bf7b7c4"
"hash": "a0002183d4d077b07bb0cd062ca95bf113d1564ce3195f0e62e531ce4297dd76"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE characters\nSET\n is_active = true,\n maybe_avatar_media_token = ?,\n maybe_full_image_media_token = ?,\n maybe_kinovi_asset_id = ?\nWHERE token = ?\n AND deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "abbd24f51fae54ba3cd54ec7e3caf1ed7121b244796331d56b2d7f20eb7d4b9b"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = NULL,\n\n maybe_wallet_ledger_entry_token = NULL,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = NULL,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "b0ba0fe63908df3d66b4820ca47cbb773786e480464c84a278fe63b1fa8913b4"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: tokens::tokens::characters::CharacterToken`,\n character_type as `character_type: enums::by_table::characters::character_type::CharacterType`,\n is_active as `is_active: bool`,\n character_name,\n maybe_description,\n maybe_avatar_media_token as `maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_full_image_media_token as `maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_original_upload_media_token as `maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n kinovi_character_id,\n kinovi_character_name,\n maybe_kinovi_asset_id\nFROM characters\nWHERE token = ?\n AND deleted_at IS NULL\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::characters::CharacterToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "character_type: enums::by_table::characters::character_type::CharacterType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 2,
"name": "is_active: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 3,
"name": "character_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 5,
"name": "maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "kinovi_character_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 10,
"name": "kinovi_character_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 11,
"name": "maybe_kinovi_asset_id",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 256
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "e9078c593087df3da20dbd193cdad102d6bb3ae83ee81c8c4ad75fe184e03c92"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE characters\nSET\n character_name = ?,\n kinovi_character_name = ?,\n maybe_description = ?\nWHERE token = ?\n AND deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "f54e22efa17f3b8d36387dfd724e1fd0c88634fc8defa420ddd2de7e9103518c"
}
+11
View File
@@ -62,3 +62,14 @@ artcraft/
- A mix of wreq and reqwest for Rust HTTP clients
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
### File Layout
Organize for top-to-bottom reading. Important things first, details later.
- **Constants** at the top (after imports)
- **Structs/enums** next; outer structs above inner sub-structs
- **API types** in order: Request, Response, Error
- **In impl blocks**: constructors first, then public methods, then private helpers
- Private helpers go *below* the methods that call them
- Among helpers: meatier logic above leaf-level formatters
Generated
+7
View File
@@ -5163,6 +5163,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "http-types"
version = "2.12.0"
@@ -11846,6 +11852,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http 1.3.1",
"http-range",
"jni 0.21.1",
"libc",
"log",
+1 -1
View File
@@ -327,7 +327,7 @@ sha2 = "0.11.0-rc.5"
sha3 = "0.10.8"
speculoos = "0.11.0"
strum = { version = "0.28.0", features = ["derive"] }
tauri = { version = "2.8.3", features = ["unstable", "devtools"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.3.3"
tauri-plugin-fs = "2.4.2"
tauri-plugin-log = "2.6.0"
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS characters;
@@ -0,0 +1,101 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Characters are reusable entities that can be referenced in video/image generations.
-- A character is created from one or more reference images and registered with third-party
-- systems (e.g. Kinovi/Seedance) that support character-based generation.
CREATE TABLE characters (
-- Not used for anything except replication (private, not even used in queries)
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Public-facing "primary key" (PUBLIC)
token VARCHAR(32) NOT NULL,
-- The type or "features" of the character.
character_type VARCHAR(24) NOT NULL,
-- Whether the character has been fully created and is available for use.
-- A character may exist in the database before its creation job completes.
is_active BOOLEAN NOT NULL DEFAULT false,
-- ========== CHARACTER DETAILS ==========
-- User's display name for the character.
character_name VARCHAR(255) DEFAULT NULL,
-- Optional user description of the character.
maybe_description VARCHAR(512) DEFAULT NULL,
-- ========== MEDIA ==========
-- Avatar image (cropped to face, ideally).
maybe_avatar_media_token VARCHAR(32) DEFAULT NULL,
-- Full-size reference image.
maybe_full_image_media_token VARCHAR(32) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- WE'LL NEED TO BREAK THIS INTO ANOTHER TABLE IF WE WANT TO SUPPORT MULTIPLE IMAGES.
-- Image the character was originally created with.
maybe_original_upload_media_token VARCHAR(32) DEFAULT NULL,
-- ========== CREATOR ==========
-- The user who created this character.
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- For abuse tracking.
creator_ip_address VARCHAR(40) NOT NULL,
-- ========== KINOVI (TEMPORARY) ==========
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- The identifier in the third-party system.
-- e.g. Kinovi's "char_1774752056469_2wlxoq"
kinovi_character_id VARCHAR(64) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- The name used by the third party.
-- Important for Kinovi since characters are referenced by name in prompts.
kinovi_character_name VARCHAR(255) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- This might be the upstream third party of kinovi.
-- e.g. Kinovi's assetId "asset-20260329104101-kk5kl"
maybe_kinovi_asset_id VARCHAR(64) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- Our job system token (if the character was created via a job).
maybe_generic_inference_job_token VARCHAR(32) DEFAULT NULL,
-- Maybe this doesn't exist
-- -- TEMPORARY FOR GOING FAST :
-- -- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- -- Third party's job system token (if any).
-- maybe_kinovi_job_id VARCHAR(64) DEFAULT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP DEFAULT NULL,
-- ========== INDICES ==========
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY index_character_type (character_type),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_maybe_creator_user_token_active (maybe_creator_user_token, is_active),
KEY index_is_active (is_active),
KEY index_kinovi_character_id (kinovi_character_id),
KEY index_maybe_generic_inference_job_token (maybe_generic_inference_job_token),
KEY index_created_at (created_at),
KEY index_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -15,6 +15,9 @@ CREATE TABLE characters (
-- Effective "primary key" (PUBLIC)
token VARCHAR(32) NOT NULL,
-- The type or "features" of the character.
character_type VARCHAR(24) NOT NULL,
-- Whether the character has been fully created and is available for use.
-- A character may exist in the database before its creation job completes.
is_active BOOLEAN NOT NULL DEFAULT false,
@@ -22,7 +25,7 @@ CREATE TABLE characters (
-- ========== CHARACTER DETAILS ==========
-- Display name for the character.
name VARCHAR(255) NOT NULL,
character_name VARCHAR(255) DEFAULT NULL,
-- Optional description of the character.
maybe_description VARCHAR(512) DEFAULT NULL,
@@ -35,6 +38,11 @@ CREATE TABLE characters (
-- Full-size reference image.
maybe_full_image_media_token VARCHAR(32) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- WE'LL NEED TO BREAK THIS INTO ANOTHER TABLE IF WE WANT TO SUPPORT MULTIPLE IMAGES.
-- Image the character was originally created with.
maybe_original_upload_media_token VARCHAR(32) DEFAULT NULL,
-- ========== CREATOR ==========
-- The user who created this character.
@@ -43,6 +51,37 @@ CREATE TABLE characters (
-- For abuse tracking.
creator_ip_address VARCHAR(40) NOT NULL,
-- ========== KINOVI (TEMPORARY) ==========
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- The identifier in the third-party system.
-- e.g. Kinovi's "char_1774752056469_2wlxoq"
kinovi_character_id VARCHAR(64) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- The name used by the third party.
-- Important for Kinovi since characters are referenced by name in prompts.
kinovi_character_name VARCHAR(255) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- This might be the upstream third party of kinovi.
-- e.g. Kinovi's assetId "asset-20260329104101-kk5kl"
maybe_kinovi_asset_id VARCHAR(64) DEFAULT NULL,
-- TEMPORARY FOR GOING FAST :
-- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- Our job system token (if the character was created via a job).
maybe_generic_inference_job_token VARCHAR(32) DEFAULT NULL,
-- Maybe this doesn't exist
-- -- TEMPORARY FOR GOING FAST :
-- -- IDEALLY WE MOVE THIS TO A SEPARATE TABLE.
-- -- Third party's job system token (if any).
-- maybe_kinovi_job_id VARCHAR(64) DEFAULT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -53,8 +92,13 @@ CREATE TABLE characters (
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY index_character_type (character_type),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_maybe_creator_user_token_active (maybe_creator_user_token, is_active),
KEY index_is_active (is_active),
KEY index_created_at (created_at)
KEY index_kinovi_character_id (kinovi_character_id),
KEY index_maybe_generic_inference_job_token (maybe_generic_inference_job_token),
KEY index_created_at (created_at),
KEY index_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,3 @@
$kind: collection
name: characters
order: 4767
@@ -0,0 +1,16 @@
$kind: http-request
name: Create Character
url: "{{api_host}}/v1/character/create"
method: POST
body:
type: json
content: >-
{
"uuid_idempotency_token": "{{$randomUUID}}",
"model": "seedance_2p0",
//"image_media_token": "m_v2hjh936k72knmggmqqa2xfs9sge8k", // steampunk clown
//"image_media_token": "m_13m13fm33bw3cxvfrncyc6873z0dyy", // mochi on couch
"image_media_token": "m_cp20gcxsattt6j7699brs4ndqf803z", // t-rex
"character_name": "T-rex"
}
order: 1775175681198
@@ -0,0 +1,5 @@
$kind: http-request
name: Delete Character
url: "{{api_host}}/v1/character/character_c40dzeq9pa1qtfy19q9f1w"
method: DELETE
order: 76446840865701
@@ -0,0 +1,18 @@
$kind: http-request
name: Edit Character
url: "{{api_host}}/v1/character/edit"
method: POST
body:
type: json
content: |-
{
/// The character to edit.
"token": "character_4aj66rcqsbjmv3jp5ek4fc",
/// Updated name (if changing).
"updated_name": "Mochi",
/// Updated description (if changing).
"updated_description": "New Description"
}
order: 487436956121943
@@ -0,0 +1,23 @@
$kind: http-request
name: Seedance 2.0 (character ref to video)
url: "{{api_host}}/v1/generate/video/multi_function/seedance_2p0"
method: POST
headers:
- key: Accept
value: application/json
- key: Session
value: ""
disabled: true
- key: Session
value: ""
disabled: true
body:
type: json
content: >
{
"uuid_idempotency_token": "{{$randomUUID}}",
"prompt": "@Mochi is riding on the shoulder of @Steampunk Clown while they're walking through a crowded shopping mall in the sci-fi future. Retro 80's aesthetics while simultaneously futuristic and steam punk. @Mochi licks her lips and barks.",
"duration_seconds": 5,
"reference_character_tokens": ["character_beph3xd1z2m4kkbedra24f", "character_4aj66rcqsbjmv3jp5ek4fc"]
}
order: 3802
@@ -8,6 +8,6 @@ body:
type: json
content: |-
{
"google_credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImIyN...[redacted]"
"google_credential": "...[redacted]"
}
order: 4000
@@ -0,0 +1,22 @@
# artcraft_api_defs
Shared HTTP API type definitions used by both the server (`storyteller_web`) and clients.
## Structure
Organized by feature: `users/`, `media_file/`, `jobs/`, `moderation/`, etc.
## Conventions
- Request types: `#[derive(Deserialize, ToSchema)]`
- Response types: `#[derive(Serialize, ToSchema)]`
- Path params: `#[derive(Deserialize, ToSchema)]` with `PathInfo` suffix
- Query params: `#[derive(Deserialize, IntoParams)]`
## Orphan Rule
Types that need `ResponseError` impl (actix-web) CANNOT live here because of Rust's orphan rule. Keep error response types in `storyteller_web` and only put data types here.
## Dependencies
Only `serde`, `serde_derive`, `utoipa`, `chrono`, `url`, and internal schema crates (`enums`, `tokens`). No actix-web dependency.
@@ -16,6 +16,12 @@ pub struct CreateCharacterRequest {
/// The reference image media file token.
pub image_media_token: MediaFileToken,
/// Name of the character.
pub character_name: String,
/// Description of the character.
pub character_description: Option<String>,
}
/// Response body for creating a character.
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
@@ -29,6 +30,10 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Optional reference audio files.
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional character tokens to reference in the prompt.
/// Characters are referenced in prompts as @CharacterName.
pub reference_character_tokens: Option<Vec<CharacterToken>>,
/// Video aspect ratio / resolution.
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
@@ -0,0 +1,9 @@
use tokens::tokens::characters::CharacterToken;
/// Forward-compatible character reference list.
#[derive(Copy, Clone)]
pub enum CharacterListRef<'a> {
CharacterTokens(&'a Vec<CharacterToken>),
// In the future, we may have other identifiers for characters.
}
@@ -1,4 +1,5 @@
pub mod audio_list_ref;
pub mod character_list_ref;
pub mod common_aspect_ratio;
pub mod common_image_model;
pub mod common_resolution;
@@ -71,6 +71,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -71,6 +71,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -74,6 +74,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -34,6 +34,7 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -68,6 +69,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -22,6 +22,7 @@ pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0(
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -20,6 +20,7 @@ pub async fn execute_artcraft_seedance2p0(
reference_image_media_tokens: plan.reference_images.map(|tokens| tokens.to_owned()),
reference_video_media_tokens: plan.reference_videos.map(|tokens| tokens.to_owned()),
reference_audio_media_tokens: plan.reference_audio.map(|tokens| tokens.to_owned()),
reference_character_tokens: plan.reference_characters.map(|tokens| tokens.to_owned()),
aspect_ratio: plan.aspect_ratio,
duration_seconds: plan.duration_seconds,
batch_count: Some(plan.batch_count),
@@ -44,6 +44,7 @@ pub async fn execute_seedance2pro_seedance2p0(
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -1,7 +1,9 @@
use tokens::tokens::characters::CharacterToken;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
@@ -47,6 +49,9 @@ pub struct GenerateVideoRequest<'a> {
/// Reference audio (optional).
pub reference_audio: Option<AudioListRef<'a>>,
/// Reference characters (optional).
pub reference_character_tokens: Option<CharacterListRef<'a>>,
/// The resolution to use
pub resolution: Option<CommonResolution>,
@@ -180,6 +180,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -180,6 +180,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -197,6 +197,7 @@ mod tests {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -11,6 +11,7 @@ use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
@@ -21,6 +22,7 @@ pub struct PlanArtcraftSeedance2p0<'a> {
pub reference_images: Option<&'a Vec<MediaFileToken>>,
pub reference_videos: Option<&'a Vec<MediaFileToken>>,
pub reference_audio: Option<&'a Vec<MediaFileToken>>,
pub reference_characters: Option<&'a Vec<CharacterToken>>,
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
pub duration_seconds: Option<u8>,
pub batch_count: Seedance2p0BatchCount,
@@ -49,6 +51,7 @@ pub fn plan_generate_video_artcraft_seedance2p0<'a>(
reference_images,
reference_videos,
reference_audio,
reference_characters: resolve_character_list_ref(request.reference_character_tokens),
aspect_ratio,
duration_seconds,
batch_count,
@@ -56,6 +59,15 @@ pub fn plan_generate_video_artcraft_seedance2p0<'a>(
}))
}
fn resolve_character_list_ref<'a>(
character_list_ref: Option<crate::api::character_list_ref::CharacterListRef<'a>>,
) -> Option<&'a Vec<CharacterToken>> {
match character_list_ref {
None => None,
Some(crate::api::character_list_ref::CharacterListRef::CharacterTokens(tokens)) => Some(tokens),
}
}
fn resolve_image_ref<'a>(
image_ref: Option<ImageRef<'a>>,
) -> Result<Option<&'a MediaFileToken>, ArtcraftRouterError> {
@@ -292,6 +292,7 @@ pub fn base_video_request() -> GenerateVideoRequest<'static> {
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
+1
View File
@@ -5,3 +5,4 @@ pub mod error;
pub mod model;
pub mod requests;
pub mod utils;
pub mod webhook_api;
@@ -0,0 +1,3 @@
pub mod parse_webhook_inner_payload;
pub mod parse_webhook_payload;
pub mod payload;
@@ -0,0 +1,240 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
use crate::webhook_api::payload::webhook_inner_payload::{ErrorData, PayloadErrorData, SuccessData, WebhookInnerPayload};
use crate::webhook_api::payload::webhook_payload::{WebhookPayload, WebhookStatus};
/// Parse the inner payload of a FAL webhook into one of three cases.
///
/// 1. If status is ERROR, parse out the first `detail` entry's `msg` and `type`.
/// 2. If status is OK but there's no payload and there is a `payload_error`, return PayloadError.
/// 3. Otherwise, return Success with the payload.
pub fn parse_webhook_inner_payload(webhook: &WebhookPayload) -> WebhookInnerPayload {
match webhook.status {
WebhookStatus::Error => {
let (message, error_type) = extract_error_first_detail(&webhook.payload);
WebhookInnerPayload::Error(ErrorData {
message,
error_type,
})
}
WebhookStatus::Ok => {
// Check for payload_error case: status=OK but no payload, has payload_error.
if webhook.payload.is_none() {
if let Some(ref payload_error) = webhook.payload_error {
return WebhookInnerPayload::PayloadError(PayloadErrorData {
payload_error: payload_error.clone(),
});
}
}
WebhookInnerPayload::Success(SuccessData {
payload: webhook.payload.clone().unwrap_or(Value::Null),
})
}
}
}
/// Extract the first `msg` and `type` from `payload.detail[]`.
fn extract_error_first_detail(payload: &Option<Value>) -> (Option<String>, Option<WebhookErrorType>) {
let payload = match payload {
Some(p) => p,
None => return (None, None),
};
let detail_array = match payload.get("detail").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return (None, None),
};
let first = match detail_array.first() {
Some(item) => item,
None => return (None, None),
};
let message = first.get("msg").and_then(|v| v.as_str()).map(|s| s.to_string());
let error_type = first
.get("type")
.and_then(|v| v.as_str())
.map(WebhookErrorType::from_str);
(message, error_type)
}
#[cfg(test)]
mod tests {
use super::*;
fn load_test_webhook(filename: &str) -> WebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn gpt_image_1p5_content_policy_violation() {
let webhook = load_test_webhook("gpt_image_1p5_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
);
assert_eq!(
data.message.as_deref(),
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn gpt_image_invalid_api_key() {
let webhook = load_test_webhook("gpt_image_fail_invalid_api_key.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::InvalidApiKey),
);
assert_eq!(
data.message.as_deref(),
Some("Invalid API key"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn kling_1p6_pro_file_too_large() {
let webhook = load_test_webhook("kling_1p6_pro_file_too_large_error.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::FileTooLarge),
);
assert_eq!(
data.message.as_deref(),
Some("File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn kling_3p0_pro_content_policy_violation() {
let webhook = load_test_webhook("kling_3p0_pro_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
);
assert_eq!(
data.message.as_deref(),
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn nano_banana_pro_no_media_generated() {
let webhook = load_test_webhook("nano_banana_pro_fail_no_media_generated.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::NoMediaGenerated),
);
assert!(
data.message.as_deref().unwrap().starts_with("The model did not generate"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn payload_error_case() {
let webhook = WebhookPayload {
request_id: "test-123".to_string(),
gateway_request_id: "test-123".to_string(),
status: WebhookStatus::Ok,
error: None,
payload: None,
payload_error: Some("encoding error occurred".to_string()),
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::PayloadError(data) => {
assert_eq!(data.payload_error, "encoding error occurred");
}
other => panic!("Expected WebhookInnerPayload::PayloadError, got {:?}", other),
}
}
#[test]
fn success_case() {
let webhook = WebhookPayload {
request_id: "test-456".to_string(),
gateway_request_id: "test-456".to_string(),
status: WebhookStatus::Ok,
error: None,
payload: Some(serde_json::json!({"images": [{"url": "https://example.com/img.png"}]})),
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Success(data) => {
assert!(data.payload.get("images").is_some());
}
other => panic!("Expected WebhookInnerPayload::Success, got {:?}", other),
}
}
#[test]
fn error_with_no_payload() {
let webhook = WebhookPayload {
request_id: "test-789".to_string(),
gateway_request_id: "test-789".to_string(),
status: WebhookStatus::Error,
error: Some("Internal server error".to_string()),
payload: None,
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(data.message, None);
assert_eq!(data.error_type, None);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
}
@@ -0,0 +1,5 @@
use crate::webhook_api::payload::webhook_payload::WebhookPayload;
pub fn parse_webhook_payload(json: &str) -> Result<WebhookPayload, serde_json::Error> {
serde_json::from_str(json)
}
@@ -0,0 +1,3 @@
pub mod webhook_inner_payload;
pub mod webhook_payload;
pub mod webhook_error_type;
@@ -0,0 +1,104 @@
use serde::Deserialize;
/// Machine-readable error type from FAL webhook error payloads.
///
/// These are the `type` field values from the `detail` array in FAL error responses.
/// See: https://fal.ai/docs/documentation/model-apis/errors
#[derive(Clone, Debug, Eq, PartialEq, Hash, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookErrorType {
FileTooLarge,
InternalServerError,
GenerationTimeout,
DownstreamServiceError,
DownstreamServiceUnavailable,
ContentPolicyViolation,
NoMediaGenerated,
ImageTooSmall,
ImageTooLarge,
ImageLoadError,
FileDownloadError,
FaceDetectionError,
GreaterThan,
GreaterThanEqual,
LessThan,
LessThanEqual,
MultipleOf,
SequenceTooShort,
SequenceTooLong,
OneOf,
FeatureNotSupported,
InvalidArchive,
ArchiveFileCountBelowMinimum,
ArchiveFileCountExceedsMaximum,
InvalidApiKey,
/// Catch-all for unrecognized error types from FAL.
#[serde(untagged)]
Unknown(String),
}
impl WebhookErrorType {
pub fn from_str(value: &str) -> Self {
serde_json::from_value(serde_json::Value::String(value.to_string()))
.unwrap_or_else(|_| Self::Unknown(value.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_known_types() {
let cases = vec![
("\"file_too_large\"", WebhookErrorType::FileTooLarge),
("\"internal_server_error\"", WebhookErrorType::InternalServerError),
("\"generation_timeout\"", WebhookErrorType::GenerationTimeout),
("\"downstream_service_error\"", WebhookErrorType::DownstreamServiceError),
("\"downstream_service_unavailable\"", WebhookErrorType::DownstreamServiceUnavailable),
("\"content_policy_violation\"", WebhookErrorType::ContentPolicyViolation),
("\"no_media_generated\"", WebhookErrorType::NoMediaGenerated),
("\"image_too_small\"", WebhookErrorType::ImageTooSmall),
("\"image_too_large\"", WebhookErrorType::ImageTooLarge),
("\"image_load_error\"", WebhookErrorType::ImageLoadError),
("\"file_download_error\"", WebhookErrorType::FileDownloadError),
("\"face_detection_error\"", WebhookErrorType::FaceDetectionError),
("\"greater_than\"", WebhookErrorType::GreaterThan),
("\"greater_than_equal\"", WebhookErrorType::GreaterThanEqual),
("\"less_than\"", WebhookErrorType::LessThan),
("\"less_than_equal\"", WebhookErrorType::LessThanEqual),
("\"multiple_of\"", WebhookErrorType::MultipleOf),
("\"sequence_too_short\"", WebhookErrorType::SequenceTooShort),
("\"sequence_too_long\"", WebhookErrorType::SequenceTooLong),
("\"one_of\"", WebhookErrorType::OneOf),
("\"feature_not_supported\"", WebhookErrorType::FeatureNotSupported),
("\"invalid_archive\"", WebhookErrorType::InvalidArchive),
("\"archive_file_count_below_minimum\"", WebhookErrorType::ArchiveFileCountBelowMinimum),
("\"archive_file_count_exceeds_maximum\"", WebhookErrorType::ArchiveFileCountExceedsMaximum),
("\"invalid_api_key\"", WebhookErrorType::InvalidApiKey),
];
for (json, expected) in cases {
let deserialized: WebhookErrorType = serde_json::from_str(json).unwrap();
assert_eq!(deserialized, expected, "failed for {}", json);
}
}
#[test]
fn deserialize_unknown_type() {
let deserialized: WebhookErrorType = serde_json::from_str("\"some_new_error_type\"").unwrap();
assert_eq!(deserialized, WebhookErrorType::Unknown("some_new_error_type".to_string()));
}
#[test]
fn from_str_known() {
assert_eq!(WebhookErrorType::from_str("content_policy_violation"), WebhookErrorType::ContentPolicyViolation);
assert_eq!(WebhookErrorType::from_str("face_detection_error"), WebhookErrorType::FaceDetectionError);
}
#[test]
fn from_str_unknown() {
assert_eq!(WebhookErrorType::from_str("brand_new_type"), WebhookErrorType::Unknown("brand_new_type".to_string()));
}
}
@@ -0,0 +1,32 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
/// The parsed inner payload of a FAL webhook.
#[derive(Debug)]
pub enum WebhookInnerPayload {
/// The webhook reported success and has a payload.
Success(SuccessData),
/// The webhook reported an error (status=ERROR) with optional detail info.
Error(ErrorData),
/// The webhook reported success but had no payload and instead had a payload_error.
PayloadError(PayloadErrorData),
}
#[derive(Debug)]
pub struct SuccessData {
pub payload: Value,
}
#[derive(Debug)]
pub struct ErrorData {
/// The first human-readable message from `payload.detail[].msg`, if any.
pub message: Option<String>,
/// The first machine-readable error type from `payload.detail[].type`, if any.
pub error_type: Option<WebhookErrorType>,
}
#[derive(Debug)]
pub struct PayloadErrorData {
pub payload_error: String,
}
@@ -0,0 +1,43 @@
use serde::Deserialize;
use serde_json::Value;
/// The value FAL sends to webhook endpoints.
#[derive(Deserialize, Debug, Clone)]
pub struct WebhookPayload {
/// Value originally used in the queue API
pub request_id: String,
/// Mostly the same as `request_id`, but if the request failed and was retried,
/// then gateway_request_id will have the value of the last tried request,
/// while request_id will be the value used in the queue API.
pub gateway_request_id: String,
/// Status of the request: OK or ERROR.
pub status: WebhookStatus,
/// When an error happens, the status will be ERROR.
/// The error property will contain a message and payload will contain the error details.
/// eg. ""Invalid status code: 422"
pub error: Option<String>,
/// Payload of the webhook (typically present)
/// THIS FIELD IS HIGHLY VARIABLE JSON.
/// This is present for both *success* and *failure* cases,
/// though the payload differs. Sometimes it is absent.
pub payload: Option<Value>,
/// In rare instances, if there was an error encoding the payload,
/// this may be set. This can seemingly be set on "OK" responses.
pub payload_error: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Copy)]
pub enum WebhookStatus {
/// Success case
#[serde(alias = "OK")]
Ok,
/// Failure case
#[serde(alias = "ERROR")]
Error
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4756-130b-7141-a16c-8f411b785844",
"payload": {
"detail": [
{
"input": {
"background": "auto",
"image_size": "1536x1024",
"input_fidelity": null,
"num_images": 1,
"openai_api_key": null,
"output_format": "png",
"prompt": "[redacted nsfw query]",
"quality": "medium"
},
"loc": [
"body",
"prompt"
],
"msg": "The content could not be processed because it contained material flagged by a content checker.",
"type": "content_policy_violation",
"url": "https://docs.fal.ai/errors#content_policy_violation"
}
]
},
"request_id": "019d4756-130b-7141-a16c-8f411b785844",
"status": "ERROR"
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4771-5b51-7480-afc6-67e6e74b9f22",
"payload": {
"detail": [
{
"input": {
"background": "auto",
"image_size": "1536x1024",
"input_fidelity": null,
"num_images": 1,
"openai_api_key": "REDACTED",
"output_format": "png",
"prompt": "[redacted]",
"quality": "high"
},
"loc": [
"body",
"openai_api_key"
],
"msg": "Invalid API key",
"type": "invalid_api_key",
"url": "https://docs.fal.ai/errors#invalid_api_key"
}
]
},
"request_id": "019d4771-5b51-7480-afc6-67e6e74b9f22",
"status": "ERROR"
}
@@ -0,0 +1,23 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d477e-c123-7120-81c7-30d6b58a6720",
"payload": {
"detail": [
{
"ctx": {
"max_size": 10485760
},
"input": "[redacted]",
"loc": [
"body",
"image_url"
],
"msg": "File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file.",
"type": "file_too_large",
"url": "https://docs.fal.ai/errors#file_too_large"
}
]
},
"request_id": "019d477e-c123-7120-81c7-30d6b58a6720",
"status": "ERROR"
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4761-035d-7b50-8c42-1521efdd4f34",
"payload": {
"detail": [
{
"input": {
"aspect_ratio": "16:9",
"cfg_scale": 0.5,
"duration": "3",
"generate_audio": false,
"multi_prompt": null,
"negative_prompt": "blur, distort, and low quality",
"prompt": "[redacted prompt]",
"shot_type": "customize",
"voice_ids": null
},
"loc": [
"body"
],
"msg": "The content could not be processed because it contained material flagged by a content checker.",
"type": "content_policy_violation",
"url": "https://docs.fal.ai/errors#content_policy_violation"
}
]
},
"request_id": "019d4761-035d-7b50-8c42-1521efdd4f34",
"status": "ERROR"
}
@@ -0,0 +1,30 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d475a-291a-7681-81dc-b27d2489beae",
"payload": {
"detail": [
{
"input": {
"aspect_ratio": "16:9",
"enable_web_search": false,
"limit_generations": false,
"num_images": 1,
"output_format": "png",
"prompt": "[nsfw prompt redacted]",
"resolution": "1K",
"safety_tolerance": "4",
"seed": null,
"sync_mode": false
},
"loc": [
"body"
],
"msg": "The model did not generate the expected output for this prompt. This may occur for several reasons, including unsafe content, a prompt that is incompatible with the selected media type (e.g., prompting an image model to generate audio), references to missing attachments, or other cases where the input cannot be processed as the requested output type. Please review your inputs and try again. If the issue persists, contact support.",
"type": "no_media_generated",
"url": "https://docs.fal.ai/errors#no_media_generated"
}
]
},
"request_id": "019d475a-291a-7681-81dc-b27d2489beae",
"status": "ERROR"
}
@@ -1,6 +1,8 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::StatusCode;
#[derive(Debug)]
pub enum Seedance2ProSpecificApiError {
/// The session cookies are expired or invalid.
@@ -8,6 +10,12 @@ pub enum Seedance2ProSpecificApiError {
/// The video generation request was flagged as a content violation.
VideoGenerationViolation(String),
/// The account does not have enough credits to perform the operation.
BillingError {
status_code: StatusCode,
body: String,
},
}
impl Error for Seedance2ProSpecificApiError {}
@@ -17,6 +25,7 @@ impl Display for Seedance2ProSpecificApiError {
match self {
Self::UnauthorizedSessionExpired => write!(f, "Unauthorized: session cookies expired or invalid."),
Self::VideoGenerationViolation(body) => write!(f, "Video generation violation: {}", body),
Self::BillingError { status_code, body } => write!(f, "Billing error (status {}): {}", status_code, body),
}
}
}
@@ -5,6 +5,7 @@ use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use crate::requests::generate_video::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::categorize_seedance2pro_error::categorize_seedance2pro_error;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use wreq::Client;
@@ -48,6 +49,10 @@ pub struct GenerateVideoArgs<'a> {
/// Sent in a separate `audioUrls` field (not in `uploadedUrls`).
pub reference_audio_urls: Option<Vec<String>>,
/// Optional Kinovi character IDs to reference in the prompt.
/// Characters are referenced in prompts as @CharacterName.
pub character_ids: Option<Vec<String>>,
/// Controls the `faceBlurMode` field: true sends "on", false sends "off", None omits it.
pub use_face_blur_hack: Option<bool>,
@@ -68,6 +73,7 @@ impl std::fmt::Debug for GenerateVideoArgs<'_> {
.field("reference_image_urls", &self.reference_image_urls)
.field("reference_video_urls", &self.reference_video_urls)
.field("reference_audio_urls", &self.reference_audio_urls)
.field("character_ids", &self.character_ids)
.field("use_face_blur_hack", &self.use_face_blur_hack)
.field("host_override", &self.host_override)
.finish()
@@ -260,6 +266,7 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
duration,
mode: video_input_mode,
face_blur_mode,
character_ids: args.character_ids,
uploaded_urls,
audio_urls,
batch_count,
@@ -308,10 +315,7 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
info!("Response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
return Err(categorize_seedance2pro_error(status, response_body));
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
@@ -373,6 +377,7 @@ mod tests {
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
}
@@ -497,6 +502,7 @@ mod tests {
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -526,6 +532,7 @@ mod tests {
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -557,6 +564,7 @@ mod tests {
]),
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -587,6 +595,7 @@ mod tests {
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -619,6 +628,7 @@ mod tests {
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -673,6 +683,7 @@ mod tests {
result.public_url,
]),
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -722,6 +733,7 @@ mod tests {
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -773,6 +785,7 @@ mod tests {
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -831,6 +844,7 @@ mod tests {
reference_image_urls: Some(uploaded_urls),
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -882,6 +896,7 @@ mod tests {
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: Some(vec![upload_result.public_url]),
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -893,5 +908,137 @@ mod tests {
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
mod character_tests {
use super::*;
const STEAMPUNK_CLOWN_ID: &str = "char_1775176566518_sik0te";
const MOCHI_ID: &str = "char_1775177718294_g2pitx";
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs money
async fn test_text_prompt_with_character_pro() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Pro,
prompt: "@Steampunk Clown is juggling flaming torches in a circus tent.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]),
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs money
async fn test_text_prompt_with_character_fast() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Fast,
prompt: "@Mochi the female shiba inu is eating a cheese pizza while standing on the table".to_string(),
resolution: Resolution::Portrait9x16,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: Some(vec![MOCHI_ID.to_string()]),
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs money
async fn test_character_with_image_ref_pro() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Pro,
prompt: "@Steampunk Clown is walking up to pet a dog on the couch.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260329/1774752385699-1ff44886.jpeg".to_string(),
]),
reference_video_urls: None,
reference_audio_urls: None,
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]),
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs money
async fn test_two_characters_fast() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Fast,
prompt: "@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: Some(vec![
STEAMPUNK_CLOWN_ID.to_string(),
MOCHI_ID.to_string(),
]),
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
}
}
@@ -30,6 +30,8 @@ pub (super) struct ApiParams {
pub mode: &'static str,
#[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")]
pub face_blur_mode: Option<&'static str>,
#[serde(rename = "characterIds", skip_serializing_if = "Option::is_none")]
pub character_ids: Option<Vec<String>>,
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
pub uploaded_urls: Option<Vec<String>>,
#[serde(rename = "audioUrls", skip_serializing_if = "Option::is_none")]
@@ -4,4 +4,5 @@ pub mod kinovi_host;
pub mod poll_characters;
pub mod poll_orders;
pub mod prepare_file_upload;
pub mod update_character;
pub mod upload_file;
@@ -0,0 +1,2 @@
pub mod update_character;
pub(crate) mod request_types;
@@ -0,0 +1,44 @@
use serde_derive::{Deserialize, Serialize};
// ======================== Request ========================
#[derive(Serialize, Debug)]
pub(super) struct BatchRequest {
#[serde(rename = "0")]
pub zero: BatchRequestInner,
}
#[derive(Serialize, Debug)]
pub(super) struct BatchRequestInner {
pub json: BatchRequestJson,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(super) struct BatchRequestJson {
pub character_id: String,
pub name: String,
pub description: String,
}
// ======================== Response ========================
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseItem {
pub result: BatchResponseResult,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseResult {
pub data: BatchResponseData,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseData {
pub json: UpdateCharacterResponseJson,
}
#[derive(Deserialize, Debug)]
pub(super) struct UpdateCharacterResponseJson {
pub success: bool,
}
@@ -0,0 +1,203 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::update_character::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use wreq::Client;
use wreq_util::Emulation;
// --- Public args ---
pub struct UpdateCharacterArgs<'a> {
pub session: &'a Seedance2ProSession,
/// The Kinovi character ID (e.g. "char_1775184656440_vsy3eu").
pub character_id: String,
/// The new name for the character.
pub name: String,
/// The new description (empty string clears it).
pub description: String,
/// Override the default host (kinovi.ai).
pub host_override: Option<KinoviHost>,
}
// --- Public response ---
#[derive(Debug)]
pub struct UpdateCharacterResponse {
pub success: bool,
}
// --- Implementation ---
pub async fn update_character(args: UpdateCharacterArgs<'_>) -> Result<UpdateCharacterResponse, Seedance2ProError> {
let host = resolve_host(args.host_override.as_ref());
let base_url = host.api_base_url();
let url = format!("{}/api/trpc/character.updateCharacter?batch=1", base_url);
info!("Updating character '{}' (name='{}', description='{}')", args.character_id, args.name, args.description);
let request_body = BatchRequest {
zero: BatchRequestInner {
json: BatchRequestJson {
character_id: args.character_id.clone(),
name: args.name,
description: args.description,
},
},
};
let cookie = args.session.cookies.as_str();
let referer = format!("{}/app/characters", base_url);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.post(&url)
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", &referer)
.header("Content-Type", "application/json")
.header("x-trpc-source", "client")
.header("Origin", base_url)
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.json(&request_body)
.send()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Update character response: status={}, body={}", status, response_body);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
let data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
Ok(UpdateCharacterResponse {
success: data.success,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
// Steampunk Clown character
const TEST_CHARACTER_ID: &str = "char_1775176566518_sik0te";
#[tokio::test]
#[ignore] // manually test — requires real cookies, modifies real data
async fn test_rename_character() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = update_character(UpdateCharacterArgs {
session: &session,
character_id: TEST_CHARACTER_ID.to_string(),
name: "Steampunk Clown (renamed)".to_string(),
description: "A steampunk clown character for testing".to_string(),
host_override: None,
}).await?;
println!("Success: {}", result.success);
assert!(result.success);
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, modifies real data
async fn test_rename_character_clear_description() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = update_character(UpdateCharacterArgs {
session: &session,
character_id: TEST_CHARACTER_ID.to_string(),
name: "Steampunk Clown".to_string(),
description: "".to_string(),
host_override: None,
}).await?;
println!("Success: {}", result.success);
assert!(result.success);
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, modifies real data
async fn test_rename_and_restore() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
// Rename
let result = update_character(UpdateCharacterArgs {
session: &session,
character_id: TEST_CHARACTER_ID.to_string(),
name: "Temporary Name".to_string(),
description: "Temporary description".to_string(),
host_override: None,
}).await?;
assert!(result.success);
// Restore
let result = update_character(UpdateCharacterArgs {
session: &session,
character_id: TEST_CHARACTER_ID.to_string(),
name: "Steampunk Clown".to_string(),
description: "A steampunk clown".to_string(),
host_override: None,
}).await?;
assert!(result.success);
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -0,0 +1,111 @@
use wreq::StatusCode;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
/// Known error message substrings that indicate a billing/credits error.
const BILLING_ERROR_MARKERS: &[&str] = &[
"credits not enough",
];
/// Categorize a non-success HTTP response into a specific or generic error.
///
/// Checks the response body for known patterns (e.g. billing errors) and returns
/// a specific error variant when possible, falling back to
/// `UncategorizedBadResponseWithStatusAndBody` otherwise.
pub fn categorize_seedance2pro_error(
status_code: StatusCode,
body: String,
) -> Seedance2ProError {
if is_billing_error(&body) {
return Seedance2ProSpecificApiError::BillingError {
status_code,
body,
}.into();
}
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}.into()
}
/// Check if the response body contains a known billing error marker.
fn is_billing_error(body: &str) -> bool {
let body_lower = body.to_lowercase();
BILLING_ERROR_MARKERS.iter().any(|marker| body_lower.contains(marker))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::seedance2pro_error::Seedance2ProError;
#[test]
fn real_not_enough_credits_response() {
let body = std::fs::read_to_string("test_data/responses/not_enough_credits.json")
.expect("Failed to read test data file");
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { status_code, body }
) => {
assert_eq!(status_code, StatusCode::BAD_REQUEST);
assert!(body.contains("credits not enough"));
}
other => panic!("Expected BillingError, got: {:?}", other),
}
}
#[test]
fn billing_error_case_insensitive() {
let body = r#"{"error":"Credits Not Enough"}"#.to_string();
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { .. }
) => {}
other => panic!("Expected BillingError, got: {:?}", other),
}
}
#[test]
fn unknown_error_falls_back_to_uncategorized() {
let body = r#"{"error":"something else went wrong"}"#.to_string();
let error = categorize_seedance2pro_error(
StatusCode::INTERNAL_SERVER_ERROR,
body.clone(),
);
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: b }
) => {
assert_eq!(status_code, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(b, body);
}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
#[test]
fn empty_body_falls_back_to_uncategorized() {
let error = categorize_seedance2pro_error(
StatusCode::BAD_REQUEST,
String::new(),
);
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
) => {}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
}
@@ -1 +1,2 @@
pub mod categorize_seedance2pro_error;
pub mod common_headers;
@@ -0,0 +1,25 @@
[
{
"error": {
"json": {
"message": "credits not enough",
"code": -32600,
"data": {
"code": "BAD_REQUEST",
"httpStatus": 400,
"path": "workflow.runTask",
"zodError": null
},
"stack": null
},
"meta": {
"values": {
"stack": [
"undefined"
]
},
"v": 1
}
}
}
]
@@ -10,6 +10,7 @@
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-set-focus",
"core:window:allow-toggle-maximize",
"core:window:default",
"dialog:default",
@@ -3,12 +3,14 @@ use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_comma
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::generation_events::common::GenerationModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_router::api::audio_list_ref::AudioListRef;
use artcraft_router::api::character_list_ref::CharacterListRef;
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::video_list_ref::VideoListRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::api::video_list_ref::VideoListRef;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
use artcraft_router::client::router_client::RouterClient;
@@ -16,7 +18,6 @@ use artcraft_router::generate::generate_video::generate_video_request::GenerateV
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use log::{error, info};
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
pub(super) async fn handle_artcraft_seedance_2p0(
request: &EnqueueImageToVideoRequest,
@@ -42,6 +43,7 @@ pub(super) async fn handle_artcraft_seedance_2p0(
reference_images,
reference_videos: request.reference_video_media_tokens.as_ref().map(VideoListRef::MediaFileTokens),
reference_audio: request.reference_audio_media_tokens.as_ref().map(AudioListRef::MediaFileTokens),
reference_character_tokens: request.reference_character_tokens.as_ref().map(CharacterListRef::CharacterTokens),
resolution: None,
aspect_ratio: request.aspect_ratio,
duration_seconds: request.duration_seconds,
@@ -3,6 +3,7 @@ use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_comma
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::generation_events::common::GenerationModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use artcraft_router::api::character_list_ref::CharacterListRef;
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::provider::Provider;
@@ -40,6 +41,7 @@ pub(super) async fn handle_artcraft_video_via_router(
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: request.reference_character_tokens.as_ref().map(CharacterListRef::CharacterTokens),
resolution: None,
aspect_ratio: request.aspect_ratio,
duration_seconds: request.duration_seconds,
@@ -29,6 +29,7 @@ use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
use tauri::{AppHandle, State};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
/// This is used in the Tauri command bridge.
@@ -123,6 +124,10 @@ pub struct EnqueueImageToVideoRequest {
/// Reference audio for the video generation (e.g. voices, lip sync, music, etc.)
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional character tokens to reference in the prompt.
/// Characters are referenced in prompts as @CharacterName.
pub reference_character_tokens: Option<Vec<CharacterToken>>,
/// Optional.
/// Text prompt used to direct the video.
pub prompt: Option<String>,
@@ -34,10 +34,6 @@ pub async fn setup_main_window(
let win_builder = win_builder
.decorations(false); // NB: This breaks Mac! (And breaks resize on Linux)
#[cfg(target_os = "windows")]
let win_builder = win_builder
.drag_and_drop(false); // TODO: Is this necessary on Windows?
let _window = win_builder.build()?;
Ok(())
+5
View File
@@ -14,6 +14,10 @@
"windows": [
],
"security": {
"assetProtocol": {
"enable": true,
"scope": ["**"]
},
"csp": {
"default-src": [
"'self'",
@@ -32,6 +36,7 @@
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"asset:",
"blob:",
"data:",
"http://ipc.localhost",
+40
View File
@@ -0,0 +1,40 @@
# Pager Crate
Alerting/paging system that sends notifications via Rootly.
## Key Types
- `Pager` - Main client, cloneable. Has `enqueue_page()` and `shutdown_worker()`
- `PagerBuilder` - Constructs a Pager with optional background worker
- `NotificationDetails` - Alert payload with title, description, urgency, HTTP context, entity tokens
- `NotificationDetailsBuilder` - Fluent builder for constructing notifications
- `NotificationUrgency` - High, Medium, Low
## Building Notifications
```rust
let notification = NotificationDetailsBuilder::from_title("Something happened".to_string())
.set_description(Some("Details...".to_string()))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some("POST".to_string()))
.set_http_path(Some("/v1/endpoint".to_string()))
.set_inference_job_token(Some(job_token.to_string()))
.build();
```
For errors, use `from_error()` which auto-populates title and description from the error:
```rust
NotificationDetailsBuilder::from_error(&err)
.set_title("Custom title".to_string())
.set_urgency(Some(NotificationUrgency::Medium))
.build()
```
## Deduplication
`NotificationDetails::to_deduplication_key()` generates a SHA-256 hash for dedup. Same title + HTTP context + hour = same key.
## Worker Thread
The pager worker uses `Condvar::wait()` (blocking). It MUST run on a dedicated OS thread, not a tokio task.
+49 -85
View File
@@ -66,7 +66,11 @@ pub struct PageSentResult {
pub short_id: Option<String>,
}
// =============== Implementation ===============
impl PagerClient {
// --- Constructor ---
pub fn new(
client_config: PagerClientConfig,
application_name: Option<String>,
@@ -77,6 +81,8 @@ impl PagerClient {
Self { client_config, application_name, environment, hostname, service_id }
}
// --- Public interface ---
pub fn is_noop(&self) -> bool {
matches!(self.client_config, PagerClientConfig::NoOp)
}
@@ -94,6 +100,8 @@ impl PagerClient {
}
}
// --- Private helpers (called by send_page) ---
async fn send_page_via_rootly(
&self,
notification: &NotificationDetails,
@@ -115,92 +123,13 @@ impl PagerClient {
debug!("Sending page via Rootly (source={}): {}", source, notification.title);
let mut labels: Vec<(String, String)> = Vec::new();
let labels = self.build_labels(notification);
if let Some(name) = &self.application_name {
labels.push(("application_name".to_string(), name.clone()));
}
if let Some(id) = &self.service_id {
labels.push(("service_id".to_string(), id.clone()));
}
if let Some(env) = &self.environment {
labels.push(("environment".to_string(), env.clone()));
}
if let Some(h) = &self.hostname {
labels.push(("hostname".to_string(), h.clone()));
}
if let Some(method) = &notification.http_method {
labels.push(("http_method".to_string(), method.clone()));
}
if let Some(path) = &notification.http_path {
labels.push(("http_path".to_string(), path.clone()));
}
if let Some(status_code) = notification.http_status_code {
labels.push(("http_status_code".to_string(), status_code.to_string()));
}
let labels = if labels.is_empty() { None } else { Some(labels) };
// Enrich the description with context and hostname if present.
let description = match &notification.description {
Some(desc) => {
let mut parts = vec![desc.clone()];
match (&self.application_name, &self.service_id) {
(Some(name), Some(id)) => {
parts.push(format!("Application: {} (service_id: {})", name, id));
}
(Some(name), None) => {
parts.push(format!("Application: {}", name));
}
(None, Some(id)) => {
parts.push(format!("Service ID: {}", id));
}
(None, None) => {}
}
if let Some(method) = &notification.http_method {
parts.push(format!("HTTP Method: {}", method));
}
if let Some(path) = &notification.http_path {
parts.push(format!("HTTP Path: {}", path));
}
if let Some(status_code) = notification.http_status_code {
parts.push(format!("HTTP Status Code: {}", status_code));
}
if let Some(user_token) = &notification.user_token {
parts.push(format!("User Token: {}", user_token));
}
if let Some(media_file_token) = &notification.media_file_token {
parts.push(format!("Media File Token: {}", media_file_token));
}
if let Some(inference_job_token) = &notification.inference_job_token {
parts.push(format!("Inference Job Token: {}", inference_job_token));
}
if let Some(third_party_id) = &notification.third_party_id {
parts.push(format!("Third Party ID: {}", third_party_id));
}
if let Some(h) = &self.hostname {
parts.push(format!("Hostname: {}", h));
}
Some(parts.join("\n\n"))
}
None => None,
};
let description = notification.build_enriched_description(
self.application_name.as_deref(),
self.service_id.as_deref(),
self.hostname.as_deref(),
);
// https://docs.rootly.com/api-reference/alerts/creates-an-alert
let result = create_alert(CreateAlertArgs {
@@ -241,4 +170,39 @@ impl PagerClient {
}
}
}
/// Build structured labels from the client config and notification fields.
fn build_labels(&self, notification: &NotificationDetails) -> Option<Vec<(String, String)>> {
let mut labels: Vec<(String, String)> = Vec::new();
if let Some(name) = &self.application_name {
labels.push(("application_name".to_string(), name.clone()));
}
if let Some(id) = &self.service_id {
labels.push(("service_id".to_string(), id.clone()));
}
if let Some(env) = &self.environment {
labels.push(("environment".to_string(), env.clone()));
}
if let Some(h) = &self.hostname {
labels.push(("hostname".to_string(), h.clone()));
}
if let Some(method) = &notification.http_method {
labels.push(("http_method".to_string(), method.clone()));
}
if let Some(path) = &notification.http_path {
labels.push(("http_path".to_string(), path.clone()));
}
if let Some(status_code) = notification.http_status_code {
labels.push(("http_status_code".to_string(), status_code.to_string()));
}
if labels.is_empty() { None } else { Some(labels) }
}
}
@@ -63,6 +63,7 @@ mod tests {
media_file_token: None,
inference_job_token: None,
third_party_id: None,
maybe_error: None,
}
}
@@ -222,6 +223,7 @@ mod tests {
media_file_token: None,
inference_job_token: None,
third_party_id: None,
maybe_error: None,
}
}
@@ -1,162 +1,220 @@
use chrono::{DateTime, Utc};
use std::fmt::{Debug, Display};
use crate::notification::generate_deduplication_key::generate_deduplication_key;
use crate::notification::notification_urgency::NotificationUrgency;
/// Details for a pager notification.
#[derive(Clone, Debug)]
#[derive(Debug)]
pub struct NotificationDetails {
/// Title or summary of the alert.
pub title: String,
pub(crate) title: String,
/// Full details for the alert.
pub description: Option<String>,
/// When the event occurred.
pub event_time: DateTime<Utc>,
/// HTTP method associated with the event, if any.
pub http_method: Option<String>,
/// HTTP endpoint path associated with the event, if any.
pub http_path: Option<String>,
/// HTTP status code associated with the event, if any.
pub http_status_code: Option<u16>,
/// Whether this notification originated from an error.
pub is_from_error: bool,
pub(crate) description: Option<String>,
/// Urgency level for the notification.
pub urgency: Option<NotificationUrgency>,
pub(crate) urgency: Option<NotificationUrgency>,
/// When the event occurred.
pub(crate) event_time: DateTime<Utc>,
/// The error that triggered this notification, if any.
pub(crate) maybe_error: Option<std::sync::Arc<dyn std::error::Error + Send + Sync + 'static>>,
/// Whether this notification originated from an error.
pub(crate) is_from_error: bool,
/// HTTP method associated with the event, if any.
pub(crate) http_method: Option<String>,
/// HTTP endpoint path associated with the event, if any.
pub(crate) http_path: Option<String>,
/// HTTP status code associated with the event, if any.
pub(crate) http_status_code: Option<u16>,
/// User token associated with the event, if any.
pub user_token: Option<String>,
pub(crate) user_token: Option<String>,
/// Media file token associated with the event, if any.
pub media_file_token: Option<String>,
pub(crate) media_file_token: Option<String>,
/// Inference job token associated with the event, if any.
pub inference_job_token: Option<String>,
pub(crate) inference_job_token: Option<String>,
/// Third-party identifier associated with the event, if any.
pub third_party_id: Option<String>,
pub(crate) third_party_id: Option<String>,
}
impl NotificationDetails {
/// Return a key that can group notifications for deduplication,
/// which will prevent spamming the same page over and over.
pub fn to_deduplication_key(&self) -> String {
generate_deduplication_key(self)
}
/// Create a notification with a title and description.
pub fn with_title_and_description(title: String, description: String) -> Self {
Self {
title,
description: Some(description),
event_time: Utc::now(),
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: false,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
}
// =============== Description Building ===============
/// Create a notification with just a title.
pub fn with_title(title: String) -> Self {
Self {
title,
description: None,
event_time: Utc::now(),
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: false,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
}
/// Create a notification from any error type.
/// Build the full enriched description for this notification.
///
/// Formats the error into a structured description that includes:
/// - The error message
/// - The error's source chain (if any)
/// - A backtrace (if available via `std::backtrace`)
/// - The timestamp of the event
pub fn from_error<E: Debug + Display>(error: &E) -> Self {
let title = format!("{}", error);
/// Assembles atomic sections in a consistent order:
/// 1. User-provided description
/// 2. Error chain (if a boxed error is attached)
/// 3. Event time
/// 4. HTTP context
/// 5. Service tokens
/// 6. Application/service identity and hostname
pub(crate) fn build_enriched_description(
&self,
application_name: Option<&str>,
service_id: Option<&str>,
hostname: Option<&str>,
) -> Option<String> {
let mut sections: Vec<String> = Vec::new();
// Truncate the title to a reasonable length for alert titles.
let title = if title.len() > 200 {
format!("{}...", &title[..197])
// 1. User-provided description
if let Some(desc) = &self.description {
sections.push(desc.clone());
}
// 2. Error chain
if let Some(error_section) = self.format_error_chain() {
sections.push(error_section);
}
// 3. Event time
sections.push(self.format_event_time());
// 4. HTTP context
if let Some(http_section) = self.format_http_context() {
sections.push(http_section);
}
// 5. Service tokens
if let Some(tokens_section) = self.format_service_tokens() {
sections.push(tokens_section);
}
// 6. Application/service identity and hostname
if let Some(identity_section) = Self::format_app_identity(application_name, service_id, hostname) {
sections.push(identity_section);
}
if sections.is_empty() {
None
} else {
title
};
Some(sections.join("\n\n"))
}
}
let event_time = Utc::now();
/// Format the error chain from the boxed error, walking the source chain.
fn format_error_chain(&self) -> Option<String> {
let error = self.maybe_error.as_ref()?;
let mut description_parts: Vec<String> = Vec::new();
let mut parts: Vec<String> = Vec::new();
description_parts.push(format!("Event time: {}", event_time.format("%Y-%m-%d %H:%M:%S UTC")));
description_parts.push(String::new());
description_parts.push(format!("Error: {}", error));
// Top-level error
parts.push(format!("Error: {}", error));
// Include the Debug representation if it differs from Display (often has more detail).
// Debug representation if it differs
let debug_repr = format!("{:?}", error);
let display_repr = format!("{}", error);
if debug_repr != display_repr {
description_parts.push(String::new());
description_parts.push(format!("Debug: {}", debug_repr));
parts.push(format!("Debug: {}", debug_repr));
}
// Attempt to walk the error source chain.
// NB: We use the Debug trait here since we can't require std::error::Error
// (that would require E: 'static + Error which is too restrictive for callers).
// Walk the source chain
let mut depth = 0;
let mut source = error.source();
while let Some(cause) = source {
depth += 1;
parts.push(format!("Caused by ({depth}): {cause}"));
// Try to capture a backtrace from the current call site.
#[cfg(feature = "backtrace")]
{
let bt = std::backtrace::Backtrace::capture();
if bt.status() == std::backtrace::BacktraceStatus::Captured {
description_parts.push(String::new());
description_parts.push(format!("Backtrace:\n{}", bt));
let cause_debug = format!("{:?}", cause);
let cause_display = format!("{}", cause);
if cause_debug != cause_display {
parts.push(format!(" Debug ({depth}): {cause_debug}"));
}
source = cause.source();
}
let description = description_parts.join("\n");
Self {
title,
description: Some(description),
event_time,
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: true,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
Some(parts.join("\n"))
}
/// Create a notification from an error, with a custom title prefix.
pub fn from_error_with_context<E: Debug + Display>(context: &str, error: &E) -> Self {
let mut notification = Self::from_error(error);
notification.title = format!("{}: {}", context, notification.title);
if notification.title.len() > 200 {
notification.title = format!("{}...", &notification.title[..197]);
/// Format the event time.
fn format_event_time(&self) -> String {
format!("Event time: {}", self.event_time.format("%Y-%m-%d %H:%M:%S UTC"))
}
/// Format HTTP context (method, path, status code) if any fields are present.
fn format_http_context(&self) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(method) = &self.http_method {
parts.push(format!("HTTP Method: {}", method));
}
notification
if let Some(path) = &self.http_path {
parts.push(format!("HTTP Path: {}", path));
}
if let Some(status_code) = self.http_status_code {
parts.push(format!("HTTP Status Code: {}", status_code));
}
if parts.is_empty() { None } else { Some(parts.join("\n")) }
}
/// Format service tokens (user, media file, inference job, third party) if any are present.
fn format_service_tokens(&self) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(user_token) = &self.user_token {
parts.push(format!("User Token: {}", user_token));
}
if let Some(media_file_token) = &self.media_file_token {
parts.push(format!("Media File Token: {}", media_file_token));
}
if let Some(inference_job_token) = &self.inference_job_token {
parts.push(format!("Inference Job Token: {}", inference_job_token));
}
if let Some(third_party_id) = &self.third_party_id {
parts.push(format!("Third Party ID: {}", third_party_id));
}
if parts.is_empty() { None } else { Some(parts.join("\n")) }
}
/// Format the application name, service ID, and hostname.
fn format_app_identity(
application_name: Option<&str>,
service_id: Option<&str>,
hostname: Option<&str>,
) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
match (application_name, service_id) {
(Some(name), Some(id)) => {
parts.push(format!("Application: {} (service_id: {})", name, id));
}
(Some(name), None) => {
parts.push(format!("Application: {}", name));
}
(None, Some(id)) => {
parts.push(format!("Service ID: {}", id));
}
(None, None) => {}
}
if let Some(h) = hostname {
parts.push(format!("Hostname: {}", h));
}
if parts.is_empty() { None } else { Some(parts.join("\n")) }
}
}
@@ -1,36 +1,44 @@
use std::sync::Arc;
use chrono::{DateTime, Utc};
use std::fmt::{Debug, Display};
use crate::notification::notification_details::NotificationDetails;
use crate::notification::notification_urgency::NotificationUrgency;
pub struct NotificationDetailsBuilder {
title: String,
description: Option<String>,
event_time: DateTime<Utc>,
http_method: Option<String>,
http_path: Option<String>,
http_status_code: Option<u16>,
is_from_error: bool,
urgency: Option<NotificationUrgency>,
user_token: Option<String>,
media_file_token: Option<String>,
inference_job_token: Option<String>,
third_party_id: Option<String>,
pub(crate) title: Option<String>,
pub(crate) description: Option<String>,
pub(crate) urgency: Option<NotificationUrgency>,
pub(crate) event_time: DateTime<Utc>,
pub(crate) maybe_error: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
pub(crate) is_from_error: bool,
pub(crate) http_method: Option<String>,
pub(crate) http_path: Option<String>,
pub(crate) http_status_code: Option<u16>,
pub(crate) user_token: Option<String>,
pub(crate) media_file_token: Option<String>,
pub(crate) inference_job_token: Option<String>,
pub(crate) third_party_id: Option<String>,
}
impl NotificationDetailsBuilder {
/// Create a builder from a summary string.
// --- Constructors ---
/// Create a builder with an explicit title.
pub fn from_title(title: String) -> Self {
Self {
title,
title: Some(title),
description: None,
urgency: None,
event_time: Utc::now(),
maybe_error: None,
is_from_error: false,
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: false,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
@@ -38,31 +46,35 @@ impl NotificationDetailsBuilder {
}
}
/// Create a builder from an error.
///
/// Sets `is_from_error` to true and derives the summary and description
/// from the error, matching the behavior of `NotificationDetails::from_error`.
pub fn from_error<E: Debug + Display>(error: &E) -> Self {
// TODO(bt,2026-03-30): Clean this up
let details = NotificationDetails::from_error(error);
/// Create a builder from a boxed error. Converts to `Arc` internally.
pub fn from_boxed_error(error: Box<dyn std::error::Error + Send + Sync + 'static>) -> Self {
Self::from_error(Arc::from(error))
}
/// Create a builder from an `Arc`'d error.
/// Title will be auto-generated from the error if not explicitly set.
pub fn from_error(error: Arc<dyn std::error::Error + Send + Sync + 'static>) -> Self {
Self {
title: details.title,
description: details.description,
event_time: details.event_time,
title: None,
description: None,
urgency: None,
event_time: Utc::now(),
maybe_error: Some(error),
is_from_error: true,
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: true,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
}
// --- Setters ---
pub fn set_title(mut self, title: String) -> Self {
self.title = title;
self.title = Some(title);
self
}
@@ -71,6 +83,16 @@ impl NotificationDetailsBuilder {
self
}
pub fn set_urgency(mut self, urgency: Option<NotificationUrgency>) -> Self {
self.urgency = urgency;
self
}
pub fn set_error(mut self, error: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
self.maybe_error = error;
self
}
pub fn set_http_method(mut self, http_method: Option<String>) -> Self {
self.http_method = http_method;
self
@@ -86,11 +108,6 @@ impl NotificationDetailsBuilder {
self
}
pub fn set_urgency(mut self, urgency: Option<NotificationUrgency>) -> Self {
self.urgency = urgency;
self
}
pub fn set_user_token(mut self, user_token: Option<String>) -> Self {
self.user_token = user_token;
self
@@ -111,20 +128,65 @@ impl NotificationDetailsBuilder {
self
}
// --- Build ---
pub fn build(self) -> NotificationDetails {
let title = match self.title {
Some(t) => t,
None => Self::generate_title(&self.maybe_error, &self.http_method, &self.http_path),
};
NotificationDetails {
title: self.title,
title,
description: self.description,
urgency: self.urgency,
event_time: self.event_time,
maybe_error: self.maybe_error,
is_from_error: self.is_from_error,
http_method: self.http_method,
http_path: self.http_path,
http_status_code: self.http_status_code,
is_from_error: self.is_from_error,
urgency: self.urgency,
user_token: self.user_token,
media_file_token: self.media_file_token,
inference_job_token: self.inference_job_token,
third_party_id: self.third_party_id,
}
}
// --- Private helpers ---
/// Generate a reasonable title from available context.
///
/// Priority:
/// 1. Error + HTTP info: "POST /v1/foo - SomeError: details"
/// 2. Error alone: "SomeError: details"
/// 3. HTTP info alone: "POST /v1/foo - Unknown Error"
/// 4. Nothing: "Unknown Error"
fn generate_title(
maybe_error: &Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
http_method: &Option<String>,
http_path: &Option<String>,
) -> String {
let http_prefix = match (http_method, http_path) {
(Some(method), Some(path)) => Some(format!("{} {}", method, path)),
(None, Some(path)) => Some(path.clone()),
_ => None,
};
let error_summary = maybe_error.as_ref().map(|err| {
let msg = format!("{}", err);
if msg.len() > 150 {
format!("{}...", &msg[..147])
} else {
msg
}
});
match (http_prefix, error_summary) {
(Some(prefix), Some(summary)) => format!("{} - {}", prefix, summary),
(None, Some(summary)) => summary,
(Some(prefix), None) => format!("{} - Unknown Error", prefix),
(None, None) => "Unknown Error".to_string(),
}
}
}
@@ -6,7 +6,9 @@ pub fn bcrypt_confirm_password(password: String, bcrypt_hash: &str) -> Result<bo
return Err(PasswordConfirmError::HashNotProvided);
}
if bcrypt_hash == PASSWORD_HASH_SENTINEL_VALUE {
// The database stores the sentinel "*" in a BINARY(60) column, which pads with null bytes.
// This reproduces the real-world value: "*" followed by 59 null bytes.
if bcrypt_hash.trim_end_matches('\0') == PASSWORD_HASH_SENTINEL_VALUE {
return Err(PasswordConfirmError::HashIsSentinelValue);
}
@@ -20,7 +22,7 @@ mod tests {
use crate::errors::password_confirm_error::PasswordConfirmError;
#[test]
fn test_confirmation() {
fn test_several_password_hash_pairs() {
// NB: This is to make sure we don't migrate the algorithm to a state where it won't work anymore.
// NB: Be mindful that these are expensive to compute and don't add too many test cases.
let hash_to_passwords = [
@@ -47,4 +49,18 @@ mod tests {
let result = bcrypt_confirm_password("password".to_string(), "*");
assert!(matches!(result, Err(PasswordConfirmError::HashIsSentinelValue)));
}
#[test]
fn test_sentinel_value_with_null_bytes_returns_bcrypt_error() {
// The database stores the sentinel "*" in a BINARY(60) column, which pads with null bytes.
// This reproduces the real-world value: "*" followed by 59 null bytes.
// It's currently paging me (until I fix it):
// [2026-03-31T16:03:00Z WARN storyteller_web::http_server::endpoints::users::login_handler] Login hash comparison error: BcryptVerifyError(InvalidHash("*\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"))
let mut hash_with_nulls = String::from("*");
for _ in 0..59 {
hash_with_nulls.push('\0');
}
let result = bcrypt_confirm_password("password".to_string(), &hash_with_nulls);
assert!(matches!(result, Err(PasswordConfirmError::HashIsSentinelValue)));
}
}
@@ -19,6 +19,18 @@ pub enum DatabaseQueryError {
AnyhowError(anyhow::Error),
}
impl std::error::Error for DatabaseQueryError {}
impl std::fmt::Display for DatabaseQueryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DatabaseQueryError::IdempotencyDuplicateKeyError => write!(f, "Idempotency duplicate key error"),
DatabaseQueryError::SqlxError(err) => write!(f, "SQLX error: {}", err),
DatabaseQueryError::AnyhowError(err) => write!(f, "Anyhow error: {}", err),
}
}
}
impl From<anyhow::Error> for DatabaseQueryError {
fn from(err: anyhow::Error) -> Self {
DatabaseQueryError::AnyhowError(err)
@@ -0,0 +1,37 @@
use sqlx::MySqlPool;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
/// Activate a character after its creation job completes successfully.
///
/// Sets `is_active = true`, attaches media tokens, and records the kinovi asset ID.
pub async fn activate_character_with_media(
character_token: &CharacterToken,
maybe_avatar_media_token: Option<&MediaFileToken>,
maybe_full_image_media_token: Option<&MediaFileToken>,
maybe_kinovi_asset_id: Option<&str>,
pool: &MySqlPool,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
UPDATE characters
SET
is_active = true,
maybe_avatar_media_token = ?,
maybe_full_image_media_token = ?,
maybe_kinovi_asset_id = ?
WHERE token = ?
AND deleted_at IS NULL
LIMIT 1
"#,
maybe_avatar_media_token.map(|t| t.as_str()),
maybe_full_image_media_token.map(|t| t.as_str()),
maybe_kinovi_asset_id,
character_token.as_str(),
)
.execute(pool)
.await?;
Ok(())
}
@@ -0,0 +1,54 @@
use sqlx::pool::PoolConnection;
use sqlx::{FromRow, MySql, QueryBuilder};
use tokens::tokens::characters::CharacterToken;
/// Character data needed for constructing prompts with character references.
#[derive(Debug, FromRow)]
pub struct CharacterPromptData {
pub token: CharacterToken,
pub is_active: bool,
pub character_name: Option<String>,
pub kinovi_character_id: Option<String>,
pub kinovi_character_name: Option<String>,
}
/// Look up multiple characters by their tokens, returning the data needed for prompting.
///
/// Characters that are not found (or soft-deleted) are silently omitted from the results.
pub async fn batch_lookup_characters_by_token_for_prompting(
tokens: &[CharacterToken],
connection: &mut PoolConnection<MySql>,
) -> Result<Vec<CharacterPromptData>, sqlx::Error> {
if tokens.is_empty() {
return Ok(Vec::new());
}
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
r#"
SELECT
token,
is_active,
character_name,
kinovi_character_id,
kinovi_character_name
FROM characters
WHERE deleted_at IS NULL
AND token IN (
"#,
);
let mut separated = query_builder.separated(", ");
for token in tokens {
separated.push_bind(token.as_str().to_string());
}
query_builder.push(")");
let results: Vec<CharacterPromptData> = query_builder
.build_query_as::<CharacterPromptData>()
.fetch_all(&mut **connection)
.await?;
Ok(results)
}
@@ -0,0 +1,94 @@
use log::info;
use sqlx::{Executor, MySql};
use std::marker::PhantomData;
use enums::by_table::characters::character_type::CharacterType;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
/// Arguments for inserting a new pending character record.
pub struct CreatePendingCharacterArgs<'e, 'c, E>
where E: 'e + Executor<'c, Database = MySql>
{
/// The type of character (e.g. KinoviSeedance).
pub character_type: CharacterType,
/// User's display name for the character (truncated to 255 chars by caller).
pub character_name: &'e str,
/// Optional user description (truncated to 512 chars by caller).
pub maybe_description: Option<&'e str>,
/// The media file token of the original uploaded image.
pub maybe_original_upload_media_token: Option<&'e MediaFileToken>,
/// The user who created this character.
pub maybe_creator_user_token: Option<&'e UserToken>,
/// IP address for abuse tracking.
pub creator_ip_address: &'e str,
/// The Kinovi character ID (e.g. "char_1774752056469_2wlxoq").
pub kinovi_character_id: &'e str,
/// The name Kinovi assigned to the character.
pub kinovi_character_name: &'e str,
/// Our inference job token for tracking the character creation.
pub maybe_generic_inference_job_token: Option<&'e InferenceJobToken>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Insert a new pending character record. Returns the generated CharacterToken.
///
/// The character is created with `is_active = false`. It will be activated
/// once the async creation job completes.
pub async fn create_pending_character<'e, 'c: 'e, E>(
args: CreatePendingCharacterArgs<'e, 'c, E>,
) -> Result<CharacterToken, sqlx::Error>
where E: 'e + Executor<'c, Database = MySql>
{
let token = CharacterToken::generate();
let query = sqlx::query!(
r#"
INSERT INTO characters
SET
token = ?,
character_type = ?,
is_active = false,
character_name = ?,
maybe_description = ?,
maybe_avatar_media_token = NULL,
maybe_full_image_media_token = NULL,
maybe_original_upload_media_token = ?,
maybe_creator_user_token = ?,
creator_ip_address = ?,
kinovi_character_id = ?,
kinovi_character_name = ?,
maybe_kinovi_asset_id = NULL,
maybe_generic_inference_job_token = ?
"#,
token.as_str(),
args.character_type.to_str(),
args.character_name,
args.maybe_description,
args.maybe_original_upload_media_token.map(|t| t.as_str()),
args.maybe_creator_user_token.map(|t| t.as_str()),
args.creator_ip_address,
args.kinovi_character_id,
args.kinovi_character_name,
args.maybe_generic_inference_job_token.map(|t| t.as_str()),
);
let result = query.execute(args.mysql_executor)
.await?;
info!("Created pending character {} (record ID {})", token, result.last_insert_id());
Ok(token)
}
@@ -0,0 +1,27 @@
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use tokens::tokens::characters::CharacterToken;
/// Soft-delete a character by setting deleted_at to NOW().
///
/// This is idempotent — calling it on an already-deleted character is a no-op.
pub async fn delete_character(
character_token: &CharacterToken,
connection: &mut PoolConnection<MySql>,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE characters
SET deleted_at = NOW()
WHERE token = ?
AND deleted_at IS NULL
LIMIT 1
"#,
)
.bind(character_token.as_str())
.execute(&mut **connection)
.await?;
Ok(())
}
@@ -0,0 +1,54 @@
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use enums::by_table::characters::character_type::CharacterType;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
/// Full character record for the GET endpoint.
pub struct CharacterRecord {
pub token: CharacterToken,
pub character_type: CharacterType,
pub is_active: bool,
pub character_name: Option<String>,
pub maybe_description: Option<String>,
pub maybe_avatar_media_token: Option<MediaFileToken>,
pub maybe_full_image_media_token: Option<MediaFileToken>,
pub maybe_original_upload_media_token: Option<MediaFileToken>,
pub maybe_creator_user_token: Option<UserToken>,
pub kinovi_character_id: Option<String>,
pub kinovi_character_name: Option<String>,
pub maybe_kinovi_asset_id: Option<String>,
}
/// Look up a character by token (excluding soft-deleted).
pub async fn get_character_by_token(
character_token: &CharacterToken,
connection: &mut PoolConnection<MySql>,
) -> Result<Option<CharacterRecord>, sqlx::Error> {
sqlx::query_as!(
CharacterRecord,
r#"
SELECT
token as `token: tokens::tokens::characters::CharacterToken`,
character_type as `character_type: enums::by_table::characters::character_type::CharacterType`,
is_active as `is_active: bool`,
character_name,
maybe_description,
maybe_avatar_media_token as `maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken`,
maybe_full_image_media_token as `maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken`,
maybe_original_upload_media_token as `maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken`,
maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
kinovi_character_id,
kinovi_character_name,
maybe_kinovi_asset_id
FROM characters
WHERE token = ?
AND deleted_at IS NULL
"#,
character_token.as_str(),
)
.fetch_optional(&mut **connection)
.await
}
@@ -0,0 +1,39 @@
use sqlx::pool::PoolConnection;
use sqlx::{FromRow, MySql};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::users::UserToken;
/// Minimal character record for ownership and deletion checks.
#[derive(Debug, FromRow)]
pub struct CharacterOwnershipRecord {
pub token: CharacterToken,
pub maybe_creator_user_token: Option<UserToken>,
pub is_active: bool,
pub is_deleted: bool,
}
/// Look up a character by token, including soft-deleted records.
///
/// Returns `None` if the character does not exist at all.
pub async fn get_character_by_token_including_deleted(
character_token: &CharacterToken,
connection: &mut PoolConnection<MySql>,
) -> Result<Option<CharacterOwnershipRecord>, sqlx::Error> {
let result = sqlx::query_as::<_, CharacterOwnershipRecord>(
r#"
SELECT
token,
maybe_creator_user_token,
is_active,
(deleted_at IS NOT NULL) as is_deleted
FROM characters
WHERE token = ?
"#,
)
.bind(character_token.as_str())
.fetch_optional(&mut **connection)
.await?;
Ok(result)
}
@@ -0,0 +1,24 @@
use sqlx::MySqlPool;
use tokens::tokens::characters::CharacterToken;
/// Look up a character token by its Kinovi character ID.
pub async fn get_character_token_by_kinovi_id(
kinovi_character_id: &str,
pool: &MySqlPool,
) -> Result<Option<CharacterToken>, sqlx::Error> {
let row = sqlx::query_as::<_, (CharacterToken,)>(
r#"
SELECT token
FROM characters
WHERE kinovi_character_id = ?
AND deleted_at IS NULL
LIMIT 1
"#,
)
.bind(kinovi_character_id)
.fetch_optional(pool)
.await?;
Ok(row.map(|(token,)| token))
}
@@ -0,0 +1,86 @@
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use enums::by_table::characters::character_type::CharacterType;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
const DEFAULT_PAGE_SIZE: u32 = 50;
/// A character row for the list endpoint.
pub struct CharacterListRow {
pub id: u64,
pub token: CharacterToken,
pub character_type: CharacterType,
pub is_active: bool,
pub character_name: Option<String>,
pub maybe_description: Option<String>,
pub maybe_avatar_media_token: Option<MediaFileToken>,
pub maybe_full_image_media_token: Option<MediaFileToken>,
pub maybe_original_upload_media_token: Option<MediaFileToken>,
pub maybe_creator_user_token: Option<UserToken>,
pub kinovi_character_id: Option<String>,
pub kinovi_character_name: Option<String>,
pub maybe_kinovi_asset_id: Option<String>,
}
pub struct ListActiveCharactersResult {
pub characters: Vec<CharacterListRow>,
pub next_cursor: Option<u64>,
}
/// List active, non-deleted characters for a user, paginated by descending id.
pub async fn list_active_characters_for_user(
user_token: &UserToken,
maybe_cursor: Option<u64>,
connection: &mut PoolConnection<MySql>,
) -> Result<ListActiveCharactersResult, sqlx::Error> {
let fetch_limit = (DEFAULT_PAGE_SIZE + 1) as i64;
let id_cursor = maybe_cursor.map(|c| c as i64).unwrap_or(i64::MAX);
let rows = sqlx::query_as!(
CharacterListRow,
r#"
SELECT
id as `id: u64`,
token as `token: tokens::tokens::characters::CharacterToken`,
character_type as `character_type: enums::by_table::characters::character_type::CharacterType`,
is_active as `is_active: bool`,
character_name,
maybe_description,
maybe_avatar_media_token as `maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken`,
maybe_full_image_media_token as `maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken`,
maybe_original_upload_media_token as `maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken`,
maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
kinovi_character_id,
kinovi_character_name,
maybe_kinovi_asset_id
FROM characters
WHERE maybe_creator_user_token = ?
AND is_active = true
AND deleted_at IS NULL
AND id < ?
ORDER BY id DESC
LIMIT ?
"#,
user_token.as_str(),
id_cursor,
fetch_limit,
)
.fetch_all(&mut **connection)
.await?;
let has_next = rows.len() as u32 >= DEFAULT_PAGE_SIZE;
let characters: Vec<CharacterListRow> = rows.into_iter().take(DEFAULT_PAGE_SIZE as usize).collect();
let next_cursor = if has_next {
characters.last().map(|c| c.id)
} else {
None
};
Ok(ListActiveCharactersResult {
characters,
next_cursor,
})
}
@@ -0,0 +1,9 @@
pub mod activate_character_with_media;
pub mod batch_lookup_characters_by_token_for_prompting;
pub mod create_pending_character;
pub mod delete_character;
pub mod get_character_by_token;
pub mod get_character_by_token_including_deleted;
pub mod get_character_token_by_kinovi_id;
pub mod list_active_characters_for_user;
pub mod update_character_name_and_description;
@@ -0,0 +1,37 @@
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use tokens::tokens::characters::CharacterToken;
/// Update a character's name, kinovi name, and/or description.
///
/// All fields are always SET in the query — the caller should pass the
/// existing values for fields that should not change.
pub async fn update_character_name_and_description(
character_token: &CharacterToken,
character_name: &str,
kinovi_character_name: &str,
maybe_description: Option<&str>,
connection: &mut PoolConnection<MySql>,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
UPDATE characters
SET
character_name = ?,
kinovi_character_name = ?,
maybe_description = ?
WHERE token = ?
AND deleted_at IS NULL
LIMIT 1
"#,
character_name,
kinovi_character_name,
maybe_description,
character_token.as_str(),
)
.execute(&mut **connection)
.await?;
Ok(())
}
@@ -0,0 +1,125 @@
use anyhow::anyhow;
use log::info;
use sqlx::{Executor, MySql};
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use enums::common::job_status_plus::JobStatusPlus;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::users::UserToken;
use crate::errors::database_query_error::DatabaseQueryError;
pub struct InsertGenericInferenceForSeedance2ProCharacterWithAprioriJobTokenArgs<'e, 'c, E>
where E: 'e + Executor<'c, Database = MySql>
{
pub uuid_idempotency_token: &'e str,
/// Pre-generated job token.
pub apriori_job_token: &'e InferenceJobToken,
/// The Kinovi character_id returned by generate_character().
pub kinovi_character_id: &'e str,
pub maybe_creator_user_token: Option<&'e UserToken>,
pub maybe_avt_token: Option<&'e AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'e str,
pub creator_set_visibility: Visibility,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn insert_generic_inference_job_for_seedance2pro_character_with_apriori_job_token<'e, 'c : 'e, E>(
args: InsertGenericInferenceForSeedance2ProCharacterWithAprioriJobTokenArgs<'e, 'c, E>
) -> Result<InferenceJobToken, DatabaseQueryError>
where E: 'e + Executor<'c, Database = MySql>
{
const JOB_TYPE: InferenceJobType = InferenceJobType::Seedance2ProCharacter;
const INFERENCE_CATEGORY: InferenceCategory = InferenceCategory::CharacterGeneration;
const PRODUCT_CATEGORY: InferenceJobProductCategory = InferenceJobProductCategory::Seedance2ProCharacter;
const EXTERNAL_THIRD_PARTY: InferenceJobExternalThirdParty = InferenceJobExternalThirdParty::Seedance2Pro;
const STATUS: JobStatusPlus = JobStatusPlus::Pending;
let query = sqlx::query!(
r#"
INSERT INTO generic_inference_jobs
SET
token = ?,
uuid_idempotency_token = ?,
job_type = ?,
maybe_external_third_party = ?,
maybe_external_third_party_id = ?,
product_category = ?,
inference_category = ?,
maybe_model_type = NULL,
maybe_model_token = NULL,
maybe_input_source_token = NULL,
maybe_input_source_token_type = NULL,
maybe_download_url = NULL,
maybe_cover_image_media_file_token = NULL,
maybe_prompt_token = NULL,
maybe_wallet_ledger_entry_token = NULL,
maybe_raw_inference_text = NULL,
maybe_inference_args = NULL,
maybe_creator_user_token = ?,
maybe_creator_anonymous_visitor_token = ?,
creator_ip_address = ?,
creator_set_visibility = ?,
priority_level = 0,
is_keepalive_required = FALSE,
max_duration_seconds = 0,
is_debug_request = FALSE,
maybe_routing_tag = NULL,
status = ?
"#,
args.apriori_job_token.as_str(),
args.uuid_idempotency_token,
JOB_TYPE.to_str(),
EXTERNAL_THIRD_PARTY.to_str(),
args.kinovi_character_id,
PRODUCT_CATEGORY.to_str(),
INFERENCE_CATEGORY.to_str(),
args.maybe_creator_user_token.map(|t| t.to_string()),
args.maybe_avt_token.map(|t| t.to_string()),
args.creator_ip_address,
args.creator_set_visibility.to_str(),
STATUS.to_str(),
);
let query_result = query.execute(args.mysql_executor)
.await;
let record_id = match query_result {
Err(err) => return Err(DatabaseQueryError::from(err)),
Ok(res) => res.last_insert_id(),
};
info!("Insert generic inference job for Seedance2Pro character: {} with record ID {}", args.apriori_job_token, record_id);
Ok(args.apriori_job_token.clone())
}
@@ -0,0 +1,72 @@
use log::warn;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::users::UserToken;
/// A pending Seedance2Pro character job waiting for a result.
#[derive(Debug)]
pub struct PendingSeedance2ProCharacterJob {
pub job_token: InferenceJobToken,
/// The Kinovi character_id (stored as the external third party ID).
pub kinovi_character_id: String,
pub maybe_creator_user_token: Option<UserToken>,
}
#[derive(Debug, Default)]
struct RawRecord {
job_token: InferenceJobToken,
kinovi_character_id: Option<String>,
maybe_creator_user_token: Option<UserToken>,
}
/// Returns all non-terminal Seedance2Pro character jobs that have an associated character ID.
pub async fn list_pending_seedance2pro_character_jobs(pool: &MySqlPool) -> Result<Vec<PendingSeedance2ProCharacterJob>, sqlx::Error> {
let records = sqlx::query_as!(
RawRecord,
r#"
SELECT
jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
jobs.maybe_external_third_party_id as `kinovi_character_id`,
jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`
FROM generic_inference_jobs as jobs
WHERE jobs.maybe_external_third_party = ?
AND jobs.job_type = ?
AND jobs.status NOT IN ('complete_success', 'complete_failure')
AND jobs.maybe_external_third_party_id IS NOT NULL
LIMIT 25000
"#,
InferenceJobExternalThirdParty::Seedance2Pro.to_str(),
InferenceJobType::Seedance2ProCharacter.to_str(),
)
.fetch_all(pool)
.await?;
let jobs = records
.into_iter()
.filter_map(|record| {
let kinovi_character_id = match record.kinovi_character_id {
Some(id) => id,
None => {
warn!("PendingSeedance2ProCharacterJob has no kinovi_character_id, skipping");
return None;
}
};
Some(PendingSeedance2ProCharacterJob {
job_token: record.job_token,
kinovi_character_id,
maybe_creator_user_token: record.maybe_creator_user_token,
})
})
.collect();
Ok(jobs)
}
@@ -1,10 +1,9 @@
use anyhow::anyhow;
use log::warn;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
@@ -41,8 +40,8 @@ struct RawRecord {
maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
}
/// Returns all non-terminal Seedance2Pro jobs that have an associated order_id.
pub async fn list_pending_seedance2pro_jobs(pool: &MySqlPool) -> AnyhowResult<Vec<PendingSeedance2ProJob>> {
/// Returns all non-terminal Seedance2Pro video jobs that have an associated order_id.
pub async fn list_pending_seedance2pro_video_jobs(pool: &MySqlPool) -> Result<Vec<PendingSeedance2ProJob>, sqlx::Error> {
let records = sqlx::query_as!(
RawRecord,
r#"
@@ -59,16 +58,17 @@ SELECT
FROM generic_inference_jobs as jobs
WHERE jobs.maybe_external_third_party = ?
AND jobs.job_type = ?
AND jobs.status NOT IN ('complete_success', 'complete_failure')
AND jobs.maybe_external_third_party_id IS NOT NULL
LIMIT 25000
"#,
InferenceJobExternalThirdParty::Seedance2Pro.to_str(),
InferenceJobType::Seedance2ProQueue.to_str(),
)
.fetch_all(pool)
.await
.map_err(|err| anyhow!("error querying pending seedance2pro jobs: {:?}", err))?;
.await?;
let jobs = records
.into_iter()
@@ -1,2 +1,4 @@
pub mod insert_generic_inference_job_for_seedance2pro_character_with_apriori_job_token;
pub mod insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token;
pub mod list_pending_seedance2pro_jobs;
pub mod list_pending_seedance2pro_character_jobs;
pub mod list_pending_seedance2pro_video_jobs;
@@ -187,6 +187,7 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
InferenceCategory::SeedVc => Some("Seed VC"),
InferenceCategory::ConvertBvhToWorkflow => Some("BVH to Workflow"),
InferenceCategory::SplatGeneration => Some("Splat Generation"),
InferenceCategory::CharacterGeneration => Some("Character Generation"),
InferenceCategory::DeprecatedField => Some("Job"), // TODO(bt,2024-07-16): Fix
};
}
@@ -210,6 +211,7 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
InferenceCategory::LivePortrait => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::ConvertBvhToWorkflow => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::SplatGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::CharacterGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::DeprecatedField => (true, None), // TODO(bt,2024-07-16): We'll need to read another field!
};
@@ -162,6 +162,7 @@ fn raw_record_to_public_result(record: RawGenericInferenceJobStatus) -> GenericI
InferenceCategory::SeedVc => Some("Seed VC"),
InferenceCategory::ConvertBvhToWorkflow => Some("BVH to Workflow"),
InferenceCategory::SplatGeneration => Some("Splat Generation"),
InferenceCategory::CharacterGeneration => Some("Character Generation"),
InferenceCategory::DeprecatedField => Some("Job"), // TODO(bt,2024-07-16): Fix
};
}
@@ -185,6 +186,7 @@ fn raw_record_to_public_result(record: RawGenericInferenceJobStatus) -> GenericI
InferenceCategory::SeedVc => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::ConvertBvhToWorkflow => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::SplatGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::CharacterGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::DeprecatedField => (true, None), // TODO(bt,2024-07-16): We'll need to read another field!
};
@@ -253,6 +253,7 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
InferenceCategory::LivePortrait => Some("Live Portrait"),
InferenceCategory::SeedVc => Some("Seed VC"),
InferenceCategory::SplatGeneration => Some("Splat Generation"),
InferenceCategory::CharacterGeneration => Some("Character Generation"),
InferenceCategory::DeprecatedField => Some("Job"), // TODO(bt,2024-07-16): Fix
};
}
@@ -276,6 +277,7 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
InferenceCategory::SeedVc => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::ConvertBvhToWorkflow => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::SplatGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::CharacterGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::DeprecatedField => (true, None), // TODO(bt,2024-07-16): We'll need to read another field!
};
@@ -6,6 +6,7 @@ pub mod batch_generations;
pub mod beta_keys;
pub mod billing;
pub mod browser_session_logs;
pub mod characters;
pub mod comments;
pub mod database_time;
pub mod email_sender_jobs;
+26
View File
@@ -0,0 +1,26 @@
# Enums Crate
Database-backed enums stored as VARCHAR fields in MySQL.
## Adding a New Enum
1. Create file: `src/by_table/{table_name}/{field_name}.rs`
2. Define the enum with required derives:
```rust
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
pub enum MyEnum { ... }
```
3. Implement `to_str()` and `from_str()` manually
4. Add macros: `impl_enum_display_and_debug_using_to_str!(MyEnum);` and `impl_mysql_enum_coders!(MyEnum);`
5. Add to parent `mod.rs`
6. Write tests: serialization, round-trip, `all_variants()` count
## Rules
- **NEVER change existing variant string values** - they are stored in the database
- New variants CAN be added freely - but be very careful about the Tauri API callers
ingesting new values. Make sure to pair the client with variants with an "Unknown(String)"
catch-all / future-proof bucket.
- All string values must be snake_case
- Use `#[serde(rename = "snake_case_value")]` on each variant
@@ -0,0 +1,108 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
/// Used in the `characters` table in `VARCHAR(24)` field `character_type`.
///
/// Identifies the type/backend used to create the character.
///
/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)]
pub enum CharacterType {
/// Character created via Kinovi / Seedance2Pro.
#[serde(rename = "kinovi_seedance")]
KinoviSeedance,
}
impl_enum_display_and_debug_using_to_str!(CharacterType);
impl_mysql_enum_coders!(CharacterType);
impl CharacterType {
pub fn to_str(&self) -> &'static str {
match self {
Self::KinoviSeedance => "kinovi_seedance",
}
}
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"kinovi_seedance" => Ok(Self::KinoviSeedance),
_ => Err(format!("invalid value: {:?}", value)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::KinoviSeedance,
])
}
}
#[cfg(test)]
mod tests {
use crate::by_table::characters::character_type::CharacterType;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
#[test]
fn test_serialization() {
assert_serialization(CharacterType::KinoviSeedance, "kinovi_seedance");
}
#[test]
fn to_str() {
assert_eq!(CharacterType::KinoviSeedance.to_str(), "kinovi_seedance");
}
#[test]
fn from_str() {
assert_eq!(CharacterType::from_str("kinovi_seedance").unwrap(), CharacterType::KinoviSeedance);
assert!(CharacterType::from_str("invalid").is_err());
}
#[test]
fn all_variants() {
const EXPECTED_COUNT: usize = 1;
assert_eq!(CharacterType::all_variants().len(), EXPECTED_COUNT);
use strum::IntoEnumIterator;
assert_eq!(CharacterType::all_variants().len(), CharacterType::iter().len());
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(CharacterType::all_variants().len(), CharacterType::iter().len());
}
#[test]
fn round_trip() {
for variant in CharacterType::all_variants() {
assert_eq!(variant, CharacterType::from_str(variant.to_str()).unwrap());
assert_eq!(variant, CharacterType::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, CharacterType::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
const MAX_LENGTH: usize = 24;
for variant in CharacterType::all_variants() {
let serialized = variant.to_str();
assert!(!serialized.is_empty(), "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long for VARCHAR({})", variant, MAX_LENGTH);
}
}
}
}
@@ -0,0 +1 @@
pub mod character_type;
@@ -52,6 +52,10 @@ pub enum InferenceCategory {
#[serde(rename = "splat_generation")]
SplatGeneration,
/// Character generation (eg. Kinovi/Seedance2Pro characters)
#[serde(rename = "character_generation")]
CharacterGeneration,
/// FAL-powered image background removal
#[serde(rename = "background_removal")]
BackgroundRemoval,
@@ -109,6 +113,7 @@ impl InferenceCategory {
Self::VideoGeneration => "video_generation",
Self::ObjectGeneration => "object_generation",
Self::SplatGeneration => "splat_generation",
Self::CharacterGeneration => "character_generation",
Self::BackgroundRemoval => "background_removal",
Self::Mocap => "mocap",
Self::Workflow => "workflow",
@@ -131,6 +136,7 @@ impl InferenceCategory {
"video_generation" => Ok(Self::VideoGeneration),
"object_generation" => Ok(Self::ObjectGeneration),
"splat_generation" => Ok(Self::SplatGeneration),
"character_generation" => Ok(Self::CharacterGeneration),
"background_removal" => Ok(Self::BackgroundRemoval),
"f5_tts" => Ok(Self::F5TTS),
"mocap" => Ok(Self::Mocap),
@@ -155,6 +161,7 @@ impl InferenceCategory {
Self::ImageGeneration,
Self::ObjectGeneration,
Self::SplatGeneration,
Self::CharacterGeneration,
Self::VideoGeneration,
Self::BackgroundRemoval,
Self::Mocap,
@@ -187,6 +194,7 @@ mod tests {
assert_serialization(InferenceCategory::VideoGeneration, "video_generation");
assert_serialization(InferenceCategory::ObjectGeneration, "object_generation");
assert_serialization(InferenceCategory::SplatGeneration, "splat_generation");
assert_serialization(InferenceCategory::CharacterGeneration, "character_generation");
assert_serialization(InferenceCategory::BackgroundRemoval, "background_removal");
assert_serialization(InferenceCategory::Mocap, "mocap");
assert_serialization(InferenceCategory::F5TTS, "f5_tts");
@@ -208,6 +216,7 @@ mod tests {
assert_eq!(InferenceCategory::VideoGeneration.to_str(), "video_generation");
assert_eq!(InferenceCategory::ObjectGeneration.to_str(), "object_generation");
assert_eq!(InferenceCategory::SplatGeneration.to_str(), "splat_generation");
assert_eq!(InferenceCategory::CharacterGeneration.to_str(), "character_generation");
assert_eq!(InferenceCategory::BackgroundRemoval.to_str(), "background_removal");
assert_eq!(InferenceCategory::F5TTS.to_str(), "f5_tts");
assert_eq!(InferenceCategory::SeedVc.to_str(), "seed_vc");
@@ -229,6 +238,7 @@ mod tests {
assert_eq!(InferenceCategory::from_str("video_generation").unwrap(), InferenceCategory::VideoGeneration);
assert_eq!(InferenceCategory::from_str("object_generation").unwrap(), InferenceCategory::ObjectGeneration);
assert_eq!(InferenceCategory::from_str("splat_generation").unwrap(), InferenceCategory::SplatGeneration);
assert_eq!(InferenceCategory::from_str("character_generation").unwrap(), InferenceCategory::CharacterGeneration);
assert_eq!(InferenceCategory::from_str("background_removal").unwrap(), InferenceCategory::BackgroundRemoval);
assert_eq!(InferenceCategory::from_str("f5_tts").unwrap(), InferenceCategory::F5TTS);
assert_eq!(InferenceCategory::from_str("seed_vc").unwrap(), InferenceCategory::SeedVc);
@@ -243,7 +253,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 17;
const EXPECTED_COUNT : usize = 18;
assert_eq!(InferenceCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceCategory::iter().len(), EXPECTED_COUNT);
@@ -35,6 +35,9 @@ pub enum InferenceJobProductCategory {
#[serde(rename = "seedance2pro_video")]
Seedance2ProVideo,
#[serde(rename = "seedance2pro_character")]
Seedance2ProCharacter,
// =============== WORLD LABS ===============
/// World Labs: Gaussian Splat Generation
@@ -137,6 +140,7 @@ impl InferenceJobProductCategory {
Self::FalObject => "fal_object",
Self::FalBgRemoval => "fal_bg_removal",
Self::Seedance2ProVideo => "seedance2pro_video",
Self::Seedance2ProCharacter => "seedance2pro_character",
Self::WorldlabsSplat => "worldlabs_splat",
Self::TtsGptSoVits => "tts_gpt_so_vits",
Self::TtsStyleTts2 => "tts_style_tts2",
@@ -171,6 +175,7 @@ impl InferenceJobProductCategory {
"fal_object" => Ok(Self::FalObject),
"fal_bg_removal" => Ok(Self::FalBgRemoval),
"seedance2pro_video" => Ok(Self::Seedance2ProVideo),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
"worldlabs_splat" => Ok(Self::WorldlabsSplat),
"tts_gpt_so_vits" => Ok(Self::TtsGptSoVits),
"tts_style_tts2" => Ok(Self::TtsStyleTts2),
@@ -208,6 +213,7 @@ impl InferenceJobProductCategory {
Self::FalObject,
Self::FalBgRemoval,
Self::Seedance2ProVideo,
Self::Seedance2ProCharacter,
Self::WorldlabsSplat,
Self::TtsGptSoVits,
Self::TtsStyleTts2,
@@ -251,6 +257,7 @@ mod tests {
assert_serialization(InferenceJobProductCategory::FalObject, "fal_object");
assert_serialization(InferenceJobProductCategory::FalBgRemoval, "fal_bg_removal");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideo, "seedance2pro_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProCharacter, "seedance2pro_character");
assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat");
assert_serialization(InferenceJobProductCategory::TtsGptSoVits, "tts_gpt_so_vits");
assert_serialization(InferenceJobProductCategory::TtsStyleTts2, "tts_style_tts2");
@@ -284,6 +291,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::FalObject.to_str(), "fal_object");
assert_eq!(InferenceJobProductCategory::FalBgRemoval.to_str(), "fal_bg_removal");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideo.to_str(), "seedance2pro_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProCharacter.to_str(), "seedance2pro_character");
assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat");
assert_eq!(InferenceJobProductCategory::TtsGptSoVits.to_str(), "tts_gpt_so_vits");
assert_eq!(InferenceJobProductCategory::TtsStyleTts2.to_str(), "tts_style_tts2");
@@ -316,6 +324,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::from_str("fal_object").unwrap(), InferenceJobProductCategory::FalObject);
assert_eq!(InferenceJobProductCategory::from_str("fal_bg_removal").unwrap(), InferenceJobProductCategory::FalBgRemoval);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video").unwrap(), InferenceJobProductCategory::Seedance2ProVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_character").unwrap(), InferenceJobProductCategory::Seedance2ProCharacter);
assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat);
assert_eq!(InferenceJobProductCategory::from_str("tts_gpt_so_vits").unwrap(), InferenceJobProductCategory::TtsGptSoVits);
assert_eq!(InferenceJobProductCategory::from_str("tts_style_tts2").unwrap(), InferenceJobProductCategory::TtsStyleTts2);
@@ -343,7 +352,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 29;
const EXPECTED_COUNT : usize = 30;
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);

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