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
126 changed files with 5385 additions and 873 deletions
+7 -4
View File
@@ -20,10 +20,13 @@ then the story, then the fine print.
### Ordering within a file
1. **Constants** at the top, right after imports.
2. **Structs, enums, and type definitions** next. If a struct contains another struct, place
the outer/larger struct above the inner/smaller one.
3. **Type ordering for API types**: Request, then Response (success), then Error.
4. **`impl` blocks** after their type definitions.
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
@@ -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"
}
@@ -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
@@ -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,
@@ -49,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>,
@@ -69,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()
@@ -261,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,
@@ -371,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,
}
@@ -495,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,
};
@@ -524,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,
};
@@ -555,6 +564,7 @@ mod tests {
]),
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -585,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,
};
@@ -617,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,
};
@@ -671,6 +683,7 @@ mod tests {
result.public_url,
]),
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -720,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,
};
@@ -771,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,
};
@@ -829,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,
};
@@ -880,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,
};
@@ -891,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(())
}
}
@@ -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>,
@@ -1,7 +1,6 @@
use chrono::{DateTime, Utc};
use crate::notification::generate_deduplication_key::generate_deduplication_key;
use crate::notification::notification_details_builder::NotificationDetailsBuilder;
use crate::notification::notification_urgency::NotificationUrgency;
/// Details for a pager notification.
@@ -20,7 +19,7 @@ pub struct NotificationDetails {
pub(crate) event_time: DateTime<Utc>,
/// The error that triggered this notification, if any.
pub(crate) maybe_error: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
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,
@@ -1,16 +1,17 @@
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,
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<Box<dyn std::error::Error + Send + Sync + 'static>>,
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>,
@@ -24,10 +25,12 @@ pub struct NotificationDetailsBuilder {
}
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(),
@@ -43,13 +46,16 @@ impl NotificationDetailsBuilder {
}
}
/// Create a builder from a boxed error.
///
/// Sets the title to a placeholder and attaches the error object.
/// Callers should chain `.set_title()` to provide a meaningful title.
pub fn from_error(error: Box<dyn std::error::Error + Send + Sync + 'static>) -> Self {
/// 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: "Notification from Error".to_string(),
title: None,
description: None,
urgency: None,
event_time: Utc::now(),
@@ -65,8 +71,10 @@ impl NotificationDetailsBuilder {
}
}
// --- Setters ---
pub fn set_title(mut self, title: String) -> Self {
self.title = title;
self.title = Some(title);
self
}
@@ -80,7 +88,7 @@ impl NotificationDetailsBuilder {
self
}
pub fn set_error(mut self, error: Option<Box<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
pub fn set_error(mut self, error: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
self.maybe_error = error;
self
}
@@ -120,9 +128,16 @@ 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,
@@ -137,4 +152,41 @@ impl NotificationDetailsBuilder {
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(),
}
}
}
@@ -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;
@@ -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);
@@ -23,6 +23,10 @@ pub enum InferenceJobType {
#[serde(rename = "seedance2pro_queue")]
Seedance2ProQueue,
/// Seedance 2 Pro character creation. We poll for results.
#[serde(rename = "seedance2pro_character")]
Seedance2ProCharacter,
/// World Labs jobs. We poll for results.
#[serde(rename = "worldlabs_queue")]
WorldlabsQueue,
@@ -119,6 +123,7 @@ impl InferenceJobType {
match self {
Self::FalQueue => "fal_queue",
Self::Seedance2ProQueue => "seedance2pro_queue",
Self::Seedance2ProCharacter => "seedance2pro_character",
Self::WorldlabsQueue => "worldlabs_queue",
Self::VideoRender => "video_render",
Self::LivePortrait => "live_portrait",
@@ -147,6 +152,7 @@ impl InferenceJobType {
match value {
"fal_queue" => Ok(Self::FalQueue),
"seedance2pro_queue" => Ok(Self::Seedance2ProQueue),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
"worldlabs_queue" => Ok(Self::WorldlabsQueue),
"video_render" => Ok(Self::VideoRender),
"live_portrait" => Ok(Self::LivePortrait),
@@ -178,6 +184,7 @@ impl InferenceJobType {
BTreeSet::from([
Self::FalQueue,
Self::Seedance2ProQueue,
Self::Seedance2ProCharacter,
Self::WorldlabsQueue,
Self::VideoRender,
Self::LivePortrait,
@@ -220,6 +227,7 @@ mod tests {
fn test_serialization() {
assert_serialization(InferenceJobType::FalQueue, "fal_queue");
assert_serialization(InferenceJobType::Seedance2ProQueue, "seedance2pro_queue");
assert_serialization(InferenceJobType::Seedance2ProCharacter, "seedance2pro_character");
assert_serialization(InferenceJobType::WorldlabsQueue, "worldlabs_queue");
assert_serialization(InferenceJobType::VideoRender, "video_render");
assert_serialization(InferenceJobType::LivePortrait, "live_portrait");
@@ -247,6 +255,7 @@ mod tests {
fn to_str() {
assert_eq!(InferenceJobType::FalQueue.to_str(), "fal_queue");
assert_eq!(InferenceJobType::Seedance2ProQueue.to_str(), "seedance2pro_queue");
assert_eq!(InferenceJobType::Seedance2ProCharacter.to_str(), "seedance2pro_character");
assert_eq!(InferenceJobType::WorldlabsQueue.to_str(), "worldlabs_queue");
assert_eq!(InferenceJobType::VideoRender.to_str(), "video_render");
assert_eq!(InferenceJobType::LivePortrait.to_str(), "live_portrait");
@@ -274,6 +283,7 @@ mod tests {
fn from_str() {
assert_eq!(InferenceJobType::from_str("fal_queue").unwrap(), InferenceJobType::FalQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_queue").unwrap(), InferenceJobType::Seedance2ProQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_character").unwrap(), InferenceJobType::Seedance2ProCharacter);
assert_eq!(InferenceJobType::from_str("worldlabs_queue").unwrap(), InferenceJobType::WorldlabsQueue);
assert_eq!(InferenceJobType::from_str("video_render").unwrap(), InferenceJobType::VideoRender);
assert_eq!(InferenceJobType::from_str("live_portrait").unwrap(), InferenceJobType::LivePortrait);
@@ -300,7 +310,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 23;
const EXPECTED_COUNT : usize = 24;
assert_eq!(InferenceJobType::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobType::iter().len(), EXPECTED_COUNT);
@@ -31,7 +31,11 @@ pub enum InferenceResultType {
ZeroShotVoiceEmbedding,
#[serde(rename = "upload_model")]
UploadModel
UploadModel,
/// Result is a character record.
#[serde(rename = "character")]
Character,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -47,6 +51,7 @@ impl InferenceResultType {
Self::VoiceConversion => "voice_conversion",
Self::ZeroShotVoiceEmbedding => "zs_voice_embedding",
Self::UploadModel => "upload_model",
Self::Character => "character",
}
}
@@ -57,6 +62,7 @@ impl InferenceResultType {
"voice_conversion" => Ok(Self::VoiceConversion),
"zs_voice_embedding" => Ok(Self::ZeroShotVoiceEmbedding),
"upload_model" => Ok(Self::UploadModel),
"character" => Ok(Self::Character),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -70,6 +76,7 @@ impl InferenceResultType {
InferenceResultType::VoiceConversion,
InferenceResultType::ZeroShotVoiceEmbedding,
InferenceResultType::UploadModel,
InferenceResultType::Character,
])
}
}
@@ -86,6 +93,7 @@ mod tests {
assert_serialization(InferenceResultType::VoiceConversion, "voice_conversion");
assert_serialization(InferenceResultType::ZeroShotVoiceEmbedding, "zs_voice_embedding");
assert_serialization(InferenceResultType::UploadModel, "upload_model");
assert_serialization(InferenceResultType::Character, "character");
}
#[test]
@@ -95,6 +103,7 @@ mod tests {
assert_eq!(InferenceResultType::VoiceConversion.to_str(), "voice_conversion");
assert_eq!(InferenceResultType::ZeroShotVoiceEmbedding.to_str(), "zs_voice_embedding");
assert_eq!(InferenceResultType::UploadModel.to_str(), "upload_model");
assert_eq!(InferenceResultType::Character.to_str(), "character");
}
#[test]
@@ -104,18 +113,21 @@ mod tests {
assert_eq!(InferenceResultType::from_str("voice_conversion").unwrap(), InferenceResultType::VoiceConversion);
assert_eq!(InferenceResultType::from_str("zs_voice_embedding").unwrap(), InferenceResultType::ZeroShotVoiceEmbedding);
assert_eq!(InferenceResultType::from_str("upload_model").unwrap(), InferenceResultType::UploadModel);
assert_eq!(InferenceResultType::from_str("character").unwrap(), InferenceResultType::Character);
assert_eq!(InferenceResultType::from_str("invalid").is_err(), true);
}
#[test]
fn all_variants() {
// Static check
let mut variants = InferenceResultType::all_variants();
assert_eq!(variants.len(), 5);
assert_eq!(variants.len(), 6);
assert_eq!(variants.pop_first(), Some(InferenceResultType::MediaFile));
assert_eq!(variants.pop_first(), Some(InferenceResultType::TextToSpeech));
assert_eq!(variants.pop_first(), Some(InferenceResultType::VoiceConversion));
assert_eq!(variants.pop_first(), Some(InferenceResultType::ZeroShotVoiceEmbedding));
assert_eq!(variants.pop_first(), Some(InferenceResultType::UploadModel));
assert_eq!(variants.pop_first(), Some(InferenceResultType::Character));
assert_eq!(variants.pop_first(), None);
// Generated check
@@ -1,6 +1,7 @@
// ===== MySql =====
pub mod audit_logs;
pub mod batch_generations;
pub mod characters;
pub mod beta_keys;
pub mod comments;
pub mod email_sender_jobs;
@@ -105,6 +105,10 @@ pub enum PublicInferenceJobType {
#[serde(rename = "seedance2pro_queue")]
Seedance2ProQueue,
/// Seedance 2 Pro character generation jobs. We poll for results.
#[serde(rename = "seedance2pro_character")]
Seedance2ProCharacter,
/// World Labs splat generation jobs. We poll for results.
#[serde(rename = "worldlabs_queue")]
WorldlabsQueue,
@@ -139,6 +143,7 @@ impl PublicInferenceJobType {
InferenceJobType::RerenderAVideo => Self::RerenderAVideo,
InferenceJobType::ImageGenApi => Self::ImageGenApi,
InferenceJobType::Seedance2ProQueue => Self::Seedance2ProQueue,
InferenceJobType::Seedance2ProCharacter => Self::Seedance2ProCharacter,
InferenceJobType::WorldlabsQueue => Self::WorldlabsQueue,
}
}
@@ -164,6 +169,7 @@ impl PublicInferenceJobType {
Self::StableDiffusion => InferenceJobType::StableDiffusion,
Self::ImageGenApi => InferenceJobType::ImageGenApi,
Self::Seedance2ProQueue => InferenceJobType::Seedance2ProQueue,
Self::Seedance2ProCharacter => InferenceJobType::Seedance2ProCharacter,
Self::WorldlabsQueue => InferenceJobType::WorldlabsQueue,
Self::StyleTTS2 => InferenceJobType::StyleTTS2,
Self::Tacotron2 => InferenceJobType::Tacotron2,
@@ -58,7 +58,12 @@ pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server> {
.route(web::head().to(|| HttpResponse::Ok())),
)
})
.bind(bind_address)?
.bind(&bind_address)
.unwrap_or_else(|err| {
eprintln!("FATAL: Failed to bind to address '{}': {}", bind_address, err);
eprintln!("The address is likely already in use by another process.");
std::process::exit(1);
})
.workers(num_workers)
.run();
@@ -6,6 +6,7 @@ use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use server_environment::ServerEnvironment;
use sqlx::MySqlPool;
#[derive(Clone)]
pub struct JobDependencies {
pub mysql_pool: MySqlPool,
@@ -0,0 +1,110 @@
use std::collections::HashMap;
use std::time::{Duration, Instant};
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_character_jobs::list_pending_seedance2pro_character_jobs;
use seedance2pro_client::requests::poll_characters::poll_characters::{
poll_characters, CharacterCreationStatus, CharacterStatus, PollCharactersArgs,
};
use crate::job_dependencies::JobDependencies;
use super::process_failed_character::process_failed_character;
use super::process_successful_character::process_successful_character;
const POLL_ALERT_THRESHOLD: Duration = Duration::from_secs(600);
pub async fn character_polling_main_loop(deps: JobDependencies) {
while !deps.application_shutdown.get() {
let start = Instant::now();
let result = run_poll_iteration(&deps).await;
let elapsed = start.elapsed();
if let Err(err) = result {
error!("Error in character poll iteration: {:?}", err);
let _ = deps.job_stats.increment_failure_count();
}
if elapsed > POLL_ALERT_THRESHOLD {
warn!("Character poll iteration took {:.1}s (threshold: {}s)", elapsed.as_secs_f64(), POLL_ALERT_THRESHOLD.as_secs());
}
tokio::time::sleep(Duration::from_millis(deps.poll_interval_millis)).await;
}
warn!("Character polling main loop is shut down.");
}
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
// 1. Query all pending character creation jobs from DB.
let pending_jobs = list_pending_seedance2pro_character_jobs(&deps.mysql_pool).await?;
if pending_jobs.is_empty() {
return Ok(());
}
info!("Found {} pending character job(s).", pending_jobs.len());
// Build a lookup: kinovi_character_id -> job
let job_by_character_id: HashMap<String, _> = pending_jobs
.into_iter()
.map(|job| (job.kinovi_character_id.clone(), job))
.collect();
// 2. Poll characters from Kinovi API.
let response = poll_characters(PollCharactersArgs {
session: &deps.seedance2pro_session,
cursor: None,
limit: None,
host_override: None,
})
.await
.map_err(|err| anyhow::anyhow!("Error polling characters from Kinovi: {:?}", err))?;
info!("Polled {} character(s) from Kinovi.", response.characters.len());
// 3. Match polled characters against pending jobs.
for character in &response.characters {
if deps.application_shutdown.get() {
break;
}
let job = match job_by_character_id.get(&character.character_id) {
Some(j) => j,
None => continue, // Not one of our pending jobs.
};
match &character.status {
CharacterCreationStatus::Success => {
info!(
"Character {} completed successfully, processing job {}",
character.character_id, job.job_token,
);
match process_successful_character(deps, job, character).await {
Ok(()) => {
let _ = deps.job_stats.increment_success_count();
}
Err(err) => {
warn!(
"Error processing completed character {}: {:?}",
character.character_id, err,
);
let _ = deps.job_stats.increment_failure_count();
}
}
}
CharacterCreationStatus::Failed => {
info!(
"Character {} failed, processing job {}",
character.character_id, job.job_token,
);
process_failed_character(deps, job, character).await;
}
CharacterCreationStatus::Pending => {
// Still in progress — check again next poll.
}
}
}
Ok(())
}
@@ -0,0 +1,3 @@
pub mod character_polling_main_loop;
mod process_successful_character;
mod process_failed_character;
@@ -0,0 +1,45 @@
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_character_jobs::PendingSeedance2ProCharacterJob;
use seedance2pro_client::requests::poll_characters::poll_characters::CharacterStatus;
use crate::job_dependencies::JobDependencies;
pub async fn process_failed_character(
deps: &JobDependencies,
job: &PendingSeedance2ProCharacterJob,
character: &CharacterStatus,
) {
let reason = character.fail_reason
.as_deref()
.unwrap_or("unknown failure reason");
warn!(
"Character {} (job {}) failed: {}",
character.character_id, job.job_token.as_str(), reason,
);
let internal_reason = format!(
"Kinovi character creation failed. character_id={}, raw_task_status={}, raw_asset_status={:?}, fail_reason={:?}",
character.character_id,
character.raw_task_status,
character.raw_asset_status,
character.fail_reason,
);
if let Err(err) = mark_job_failed_by_token(MarkJobFailedByTokenArgs {
pool: &deps.mysql_pool,
job_token: &job.job_token,
maybe_public_failure_reason: Some(reason),
internal_debugging_failure_reason: &internal_reason,
maybe_frontend_failure_category: None,
}).await {
error!(
"Error marking character job {} as failed: {:?}",
job.job_token.as_str(), err,
);
} else {
info!("Marked character job {} as failed.", job.job_token.as_str());
}
}
@@ -0,0 +1,171 @@
use anyhow::anyhow;
use log::{error, info, warn};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::characters::activate_character_with_media::activate_character_with_media;
use mysql_queries::queries::characters::get_character_token_by_kinovi_id::get_character_token_by_kinovi_id;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_character_jobs::PendingSeedance2ProCharacterJob;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use seedance2pro_client::requests::poll_characters::poll_characters::CharacterStatus;
use crate::job_dependencies::JobDependencies;
const PREFIX: &str = "artcraft_";
const SUFFIX_PNG: &str = ".png";
const SUFFIX_JPG: &str = ".jpg";
/// Process a successfully completed character creation.
///
/// 1. Download result images and upload them as media files.
/// 2. Activate the character with the new media tokens.
/// 3. Mark the inference job as completed.
pub async fn process_successful_character(
deps: &JobDependencies,
job: &PendingSeedance2ProCharacterJob,
character: &CharacterStatus,
) -> AnyhowResult<()> {
info!(
"Processing successful character {} for job {}",
character.character_id, job.job_token.as_str(),
);
// --- Look up our character record ---
let character_token = get_character_token_by_kinovi_id(&character.character_id, &deps.mysql_pool)
.await
.map_err(|err| anyhow!("Error looking up character by kinovi_id: {:?}", err))?
.ok_or_else(|| anyhow!("Character not found for kinovi_id: {}", character.character_id))?;
// --- Download and upload result images ---
// The first result image with type "avatar" is the avatar; otherwise use the first image.
// The first result image with type "full" or the second image is the full image.
let mut maybe_avatar_media_token = None;
let mut maybe_full_image_media_token = None;
for (i, result_image) in character.result_images.iter().enumerate() {
let is_avatar = result_image.image_type.as_deref() == Some("avatar") || (i == 0 && maybe_avatar_media_token.is_none());
let is_full = result_image.image_type.as_deref() == Some("full") || (i == 1 && maybe_full_image_media_token.is_none());
if !is_avatar && !is_full {
continue;
}
let media_token = match download_and_create_media_file(deps, job, &result_image.url).await {
Ok(token) => token,
Err(err) => {
warn!("Error processing result image {}: {:?}", result_image.url, err);
continue;
}
};
if is_avatar && maybe_avatar_media_token.is_none() {
maybe_avatar_media_token = Some(media_token);
} else if is_full && maybe_full_image_media_token.is_none() {
maybe_full_image_media_token = Some(media_token);
}
}
// --- Activate character ---
activate_character_with_media(
&character_token,
maybe_avatar_media_token.as_ref(),
maybe_full_image_media_token.as_ref(),
character.asset_id.as_deref(),
&deps.mysql_pool,
)
.await
.map_err(|err| anyhow!("Error activating character: {:?}", err))?;
info!("Activated character {} (token={})", character.character_id, character_token);
// --- Mark job as completed ---
mark_generic_inference_job_successfully_done_by_token(
&deps.mysql_pool,
&job.job_token,
Some(InferenceResultType::Character),
Some(character_token.as_str()),
None,
None,
)
.await
.map_err(|err| anyhow!("Error marking job as done: {:?}", err))?;
info!("Marked job {} as completed.", job.job_token.as_str());
Ok(())
}
// =============== Private helpers ===============
/// Download an image from a URL, upload to bucket, and create a media file record.
async fn download_and_create_media_file(
deps: &JobDependencies,
job: &PendingSeedance2ProCharacterJob,
image_url: &str,
) -> AnyhowResult<tokens::tokens::media_files::MediaFileToken> {
info!("Downloading character image: {}", image_url);
let image_bytes: Vec<u8> = reqwest::get(image_url)
.await
.map_err(|err| anyhow!("reqwest error downloading image: {:?}", err))?
.bytes()
.await
.map_err(|err| anyhow!("error reading image bytes: {:?}", err))?
.to_vec();
info!("Downloaded {} bytes from {}", image_bytes.len(), image_url);
let checksum = sha256_hash_bytes(&image_bytes)
.map_err(|err| anyhow!("error hashing image: {:?}", err))?;
// Determine extension from URL or default to png.
let suffix = if image_url.contains(".jpg") || image_url.contains(".jpeg") {
SUFFIX_JPG
} else {
SUFFIX_PNG
};
let media_type = if suffix == SUFFIX_JPG { MediaFileType::Jpg } else { MediaFileType::Png };
let mime_type = if suffix == SUFFIX_JPG { "image/jpeg" } else { "image/png" };
let bucket_path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(suffix));
let object_path = bucket_path.get_full_object_path_str();
info!("Uploading character image to bucket: {}", object_path);
deps.public_bucket_client
.upload_file_with_content_type_process(object_path, &image_bytes, mime_type)
.await
.map_err(|err| anyhow!("error uploading image to bucket: {:?}", err))?;
let media_file_token = MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.media_file_class(MediaFileClass::Image)
.media_file_type(media_type)
.is_intermediate_system_file(true)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::VideoGeneration)
.mime_type(mime_type)
.file_size_bytes(image_bytes.len() as u64)
.checksum_sha2(&checksum)
.public_bucket_directory_hash(&bucket_path)
.insert_pool(&deps.mysql_pool)
.await
.map_err(|err| anyhow!("error inserting media file record: {:?}", err))?;
info!("Created media file {} for character image", media_file_token.as_str());
Ok(media_file_token)
}
@@ -1,4 +1,2 @@
pub mod alert_on_error;
pub mod main_loop;
pub mod process_job;
pub mod process_page_batch;
pub mod character_polling_job;
pub mod video_polling_job;
@@ -1,5 +1,5 @@
use log::error;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
use pager::client::pager::Pager;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
@@ -13,7 +13,7 @@ pub fn alert_pager_and_return_err<T>(
) -> anyhow::Result<T> {
let err_message = format!("{:#}", err);
let mut builder = NotificationDetailsBuilder::from_error(err.into())
let mut builder = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title(title.to_string())
.set_urgency(Some(NotificationUrgency::Medium));
@@ -0,0 +1,4 @@
pub mod alert_on_error;
pub mod video_polling_main_loop;
pub mod process_job;
pub mod process_page_batch;
@@ -2,7 +2,7 @@ use log::{error, info, warn};
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::wallets::refund::try_to_refund_ledger_entry::{try_to_refund_ledger_entry, WalletRefundOutcome};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
@@ -85,7 +85,7 @@ pub async fn process_failed_job(
err,
);
let notification = NotificationDetailsBuilder::from_error(err.into())
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Seedance2Pro refund failed".to_string())
.set_inference_job_token(Some(job.job_token.to_string()))
.set_third_party_id(Some(job.order_id.to_string()))
@@ -140,7 +140,7 @@ pub async fn process_failed_job(
err
);
let notification = NotificationDetailsBuilder::from_error(err.into())
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Seedance2Pro mark job failed error".to_string())
.set_urgency(Some(NotificationUrgency::Medium))
.build();
@@ -9,12 +9,12 @@ use enums::by_table::media_files::media_file_origin_product_category::MediaFileO
use enums::by_table::media_files::media_file_type::MediaFileType;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
use seedance2pro_client::requests::poll_orders::poll_orders::OrderStatus;
use crate::jobs::alert_on_error::alert_pager_and_return_err;
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
use crate::job_dependencies::JobDependencies;
const PREFIX: &str = "artcraft_";
@@ -1,12 +1,12 @@
use std::collections::HashMap;
use log::{info, warn};
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, TaskStatus};
use crate::job_dependencies::JobDependencies;
use crate::jobs::process_job::process_failed_job::process_failed_job;
use crate::jobs::process_job::process_successful_job::process_successful_job;
use crate::jobs::video_polling_job::process_job::process_failed_job::process_failed_job;
use crate::jobs::video_polling_job::process_job::process_successful_job::process_successful_job;
/// Process a batch of polled orders against the pending jobs map.
///
@@ -4,16 +4,16 @@ use std::time::{Duration, Instant};
use log::{error, info, warn};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::list_pending_seedance2pro_jobs;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::list_pending_seedance2pro_video_jobs;
use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, TaskStatus};
use crate::jobs::alert_on_error::alert_pager_and_return_err;
use crate::jobs::process_page_batch::process_page_batch;
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
use crate::jobs::video_polling_job::process_page_batch::process_page_batch;
use crate::job_dependencies::JobDependencies;
const POLL_ALERT_THRESHOLD: Duration = Duration::from_secs(600);
pub async fn main_loop(job_dependencies: JobDependencies) {
pub async fn video_polling_main_loop(job_dependencies: JobDependencies) {
while !job_dependencies.application_shutdown.get() {
let start = Instant::now();
let result = run_poll_iteration(&job_dependencies).await;
@@ -50,7 +50,7 @@ pub async fn main_loop(job_dependencies: JobDependencies) {
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
// 1. Query all non-terminal Seedance2Pro jobs from DB.
let pending_jobs = match list_pending_seedance2pro_jobs(&deps.mysql_pool).await {
let pending_jobs = match list_pending_seedance2pro_video_jobs(&deps.mysql_pool).await {
Ok(jobs) => jobs,
Err(err) => {
error!("Failed to list pending seedance2pro jobs: {:?}", err);
@@ -26,7 +26,8 @@ use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use server_environment::ServerEnvironment;
use crate::http_server::run_http_server::{launch_http_server, CreateServerArgs};
use crate::jobs::main_loop::main_loop;
use crate::jobs::character_polling_job::character_polling_main_loop::character_polling_main_loop;
use crate::jobs::video_polling_job::video_polling_main_loop::video_polling_main_loop;
use crate::job_dependencies::JobDependencies;
use crate::startup::build_pager::build_pager;
@@ -176,7 +177,20 @@ async fn main() -> AnyhowResult<()> {
}
});
main_loop(job_dependencies).await;
// Spawn both polling loops as concurrent tasks.
let video_deps = job_dependencies.clone();
let character_deps = job_dependencies;
let video_handle = tokio::spawn(async move {
video_polling_main_loop(video_deps).await;
});
let character_handle = tokio::spawn(async move {
character_polling_main_loop(character_deps).await;
});
// Wait for both to finish (they exit when application_shutdown is set).
let _ = tokio::join!(video_handle, character_handle);
info!("Shutting down pager worker...");
pager_for_shutdown.shutdown_worker();
@@ -58,7 +58,12 @@ pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server> {
.route(web::head().to(|| HttpResponse::Ok())),
)
})
.bind(bind_address)?
.bind(&bind_address)
.unwrap_or_else(|err| {
eprintln!("FATAL: Failed to bind to address '{}': {}", bind_address, err);
eprintln!("The address is likely already in use by another process.");
std::process::exit(1);
})
.workers(num_workers)
.run();
@@ -11,7 +11,7 @@ pub fn alert_pager_and_return_err<T>(
) -> anyhow::Result<T> {
let err_message = format!("{:#}", err);
let notification = NotificationDetailsBuilder::from_error(err.into())
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title(title.to_string())
.set_urgency(Some(NotificationUrgency::Medium))
.build();
@@ -48,6 +48,11 @@ async fn main() -> Result<(), impl Error> {
.url("/api-docs/openapi.json", ApiDoc::openapi()),
)
})
.bind(("localhost", 8989)).unwrap()
.bind(("localhost", 8989))
.unwrap_or_else(|err| {
eprintln!("FATAL: Failed to bind docs server to localhost:8989: {}", err);
eprintln!("The address is likely already in use by another process.");
std::process::exit(1);
})
.run().await
}
@@ -0,0 +1,398 @@
use std::fmt::{Display, Formatter};
use std::sync::Arc;
use actix_http::StatusCode;
use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError};
use anyhow::anyhow;
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
/// An error type for actix-web handlers that wraps causal errors for debugging
/// and paging while presenting safe, generic HTTP responses to users.
///
/// ## Usage
///
/// ```ignore
/// pub async fn my_handler(...) -> Result<Json<MyResponse>, AdvancedCommonWebError> {
/// // sqlx errors, anyhow errors, session errors — all convert via ?
/// let user = require_user_session_using_connection(&req, ...)?;
/// let data = some_db_query(&pool).await?;
///
/// if data.is_none() {
/// return Err(AdvancedCommonWebError::NotFound);
/// }
///
/// Ok(Json(MyResponse { data }))
/// }
/// ```
///
/// Errors converted via `From<T>` become `UncaughtServerError` (always 500).
/// The wrapped cause is never shown to users but is available to the error
/// alerting middleware for paging and logging.
#[derive(Clone)]
pub enum AdvancedCommonWebError {
/// 400 Bad Request with a user-facing message.
BadInputWithSimpleMessage(String),
/// 401 Unauthorized.
NotAuthorized,
/// 404 Not Found.
NotFound,
/// 402 Payment Required.
PaymentRequired,
/// Uncaught errors are always 500 Internal Server Error.
/// The user will never see the error cause or message, but our
/// middleware will handle alerting, logging, etc.
///
/// Stored in `Arc` so the error alerting middleware can clone it for paging
/// without consuming the original.
UncaughtServerError(Arc<dyn std::error::Error + Send + Sync + 'static>),
}
// =============== Public accessors ===============
impl AdvancedCommonWebError {
/// Wrap any error as an `UncaughtServerError`.
pub fn from_error(error: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::UncaughtServerError(Arc::new(error))
}
/// Wrap an `anyhow::Error` as an `UncaughtServerError`.
/// (`anyhow::Error` doesn't implement `std::error::Error`, so `from_error` can't accept it.)
pub fn from_anyhow_error(error: anyhow::Error) -> Self {
let boxed: Box<dyn std::error::Error + Send + Sync> = error.into();
Self::UncaughtServerError(Arc::from(boxed))
}
pub fn server_error_with_message(msg: &str) -> Self {
Self::from_anyhow_error(anyhow!("ServerErrorWithMessage: {:?}", msg))
}
/// Extract the wrapped causal error (only present for `UncaughtServerError`).
pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
match self {
Self::UncaughtServerError(err) => Some(err.as_ref()),
_ => None,
}
}
/// Clone the wrapped causal error as an `Arc` (only present for `UncaughtServerError`).
/// Useful for passing the error to the pager system without consuming the original.
pub fn clone_cause_arc(&self) -> Option<Arc<dyn std::error::Error + Send + Sync + 'static>> {
match self {
Self::UncaughtServerError(err) => Some(Arc::clone(err)),
_ => None,
}
}
/// Whether this is a server error (500).
pub fn is_server_error(&self) -> bool {
matches!(self, Self::UncaughtServerError(_))
}
}
// =============== Display / Debug / Error ===============
impl Display for AdvancedCommonWebError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadInputWithSimpleMessage(msg) => write!(f, "Bad input: {}", msg),
Self::NotAuthorized => write!(f, "Not authorized"),
Self::NotFound => write!(f, "Not found"),
Self::PaymentRequired => write!(f, "Payment required"),
Self::UncaughtServerError(err) => write!(f, "Server error: {}", err),
}
}
}
impl std::fmt::Debug for AdvancedCommonWebError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadInputWithSimpleMessage(msg) => write!(f, "BadInputWithSimpleMessage({:?})", msg),
Self::NotAuthorized => write!(f, "NotAuthorized"),
Self::NotFound => write!(f, "NotFound"),
Self::PaymentRequired => write!(f, "PaymentRequired"),
Self::UncaughtServerError(err) => write!(f, "UncaughtServerError({:?})", err),
}
}
}
impl std::error::Error for AdvancedCommonWebError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::UncaughtServerError(err) => Some(err.as_ref()),
_ => None,
}
}
}
// =============== Actix ResponseError ===============
impl ResponseError for AdvancedCommonWebError {
fn status_code(&self) -> StatusCode {
match self {
Self::BadInputWithSimpleMessage(_) => StatusCode::BAD_REQUEST,
Self::NotAuthorized => StatusCode::UNAUTHORIZED,
Self::NotFound => StatusCode::NOT_FOUND,
Self::PaymentRequired => StatusCode::PAYMENT_REQUIRED,
Self::UncaughtServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
let status = self.status_code();
match self {
Self::BadInputWithSimpleMessage(msg) => {
HttpResponse::BadRequest()
.json(JsonErrorWithMessage {
success: false,
error_code: status.as_u16(),
error_code_str: status.canonical_reason(),
message: msg,
})
}
_ => {
HttpResponseBuilder::new(status)
.json(JsonErrorWithoutMessage {
success: false,
error_code: status.as_u16(),
error_code_str: status.canonical_reason(),
})
}
}
}
}
// =============== From impls (automatic ? conversion) ===============
impl From<sqlx::Error> for AdvancedCommonWebError {
fn from(err: sqlx::Error) -> Self {
Self::from_error(err)
}
}
impl From<anyhow::Error> for AdvancedCommonWebError {
fn from(err: anyhow::Error) -> Self {
// anyhow::Error doesn't impl std::error::Error, so we go through Box -> Arc.
let boxed: Box<dyn std::error::Error + Send + Sync> = err.into();
Self::UncaughtServerError(Arc::from(boxed))
}
}
impl From<serde_json::Error> for AdvancedCommonWebError {
fn from(err: serde_json::Error) -> Self {
Self::from_error(err)
}
}
impl From<RequireUserSessionError> for AdvancedCommonWebError {
fn from(value: RequireUserSessionError) -> Self {
match value {
RequireUserSessionError::NotAuthorized => Self::NotAuthorized,
RequireUserSessionError::ServerError => Self::from_error(value),
}
}
}
impl From<crate::http_server::common_responses::common_web_error::CommonWebError> for AdvancedCommonWebError {
fn from(value: crate::http_server::common_responses::common_web_error::CommonWebError) -> Self {
use crate::http_server::common_responses::common_web_error::CommonWebError;
match value {
CommonWebError::BadInputWithSimpleMessage(msg) => Self::BadInputWithSimpleMessage(msg),
CommonWebError::NotAuthorized => Self::NotAuthorized,
CommonWebError::NotFound => Self::NotFound,
CommonWebError::PaymentRequired => Self::PaymentRequired,
CommonWebError::ServerError => Self::from_error(value),
}
}
}
// =============== Serialization helpers (private) ===============
#[derive(Debug, Serialize)]
struct JsonErrorWithoutMessage {
success: bool,
error_code: u16,
error_code_str: Option<&'static str>,
}
#[derive(Debug, Serialize)]
struct JsonErrorWithMessage<'a> {
success: bool,
error_code: u16,
error_code_str: Option<&'static str>,
message: &'a str,
}
// =============== OpenAPI schema ===============
impl utoipa::PartialSchema for AdvancedCommonWebError {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::Schema> {
utoipa::openapi::ObjectBuilder::new()
.property(
"success",
utoipa::openapi::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::Boolean),
)
.required("success")
.property(
"error_code",
utoipa::openapi::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::Integer)
.format(Some(utoipa::openapi::SchemaFormat::KnownFormat(
utoipa::openapi::KnownFormat::Int32,
))),
)
.required("error_code")
.property(
"error_code_str",
utoipa::openapi::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::String),
)
.property(
"message",
utoipa::openapi::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::String)
.description(Some("User-facing error message (present only for bad input errors)")),
)
.into()
}
}
impl utoipa::ToSchema for AdvancedCommonWebError {
fn name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("AdvancedCommonWebError")
}
}
// =============== Tests ===============
#[cfg(test)]
mod tests {
use super::*;
use actix_http::body::MessageBody;
#[test]
fn bad_input_returns_400_with_message() {
let error = AdvancedCommonWebError::BadInputWithSimpleMessage("name is required".to_string());
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert!(!error.is_server_error());
assert!(error.cause().is_none());
let response = error.error_response();
let bytes = response.into_body().try_into_bytes().unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(body.contains("\"message\":\"name is required\""));
assert!(body.contains("\"error_code\":400"));
}
#[test]
fn not_found_returns_404() {
let error = AdvancedCommonWebError::NotFound;
assert_eq!(error.status_code(), StatusCode::NOT_FOUND);
assert!(!error.is_server_error());
}
#[test]
fn not_authorized_returns_401() {
let error = AdvancedCommonWebError::NotAuthorized;
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn payment_required_returns_402() {
let error = AdvancedCommonWebError::PaymentRequired;
assert_eq!(error.status_code(), StatusCode::PAYMENT_REQUIRED);
}
#[test]
fn uncaught_io_error_returns_500_and_hides_cause() {
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk exploded");
let error = AdvancedCommonWebError::from_error(io_err);
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(error.is_server_error());
// Cause is accessible for middleware
let cause = error.cause().unwrap();
assert_eq!(format!("{}", cause), "disk exploded");
// But never shown to the user in the HTTP response
let response = error.error_response();
let bytes = response.into_body().try_into_bytes().unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(!body.contains("disk exploded"));
assert!(body.contains("\"error_code\":500"));
}
#[test]
fn anyhow_error_converts_to_500_via_from() {
let error: AdvancedCommonWebError = anyhow::anyhow!("something broke in the pipeline").into();
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(error.is_server_error());
let cause = error.cause().unwrap();
assert!(format!("{}", cause).contains("something broke in the pipeline"));
}
#[test]
fn nested_anyhow_error_preserves_context_chain() {
let inner = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused");
let with_context = anyhow::Error::new(inner).context("failed to connect to database");
let error: AdvancedCommonWebError = with_context.into();
assert!(error.is_server_error());
let cause = error.cause().unwrap();
let display = format!("{}", cause);
assert!(display.contains("failed to connect to database"));
// The original io error should be in the source chain
let source = cause.source();
assert!(source.is_some());
}
#[test]
fn require_user_session_not_authorized_maps_to_401() {
let error: AdvancedCommonWebError = RequireUserSessionError::NotAuthorized.into();
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
assert!(!error.is_server_error());
}
#[test]
fn require_user_session_server_error_wraps_cause() {
let error: AdvancedCommonWebError = RequireUserSessionError::ServerError.into();
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(error.is_server_error());
assert!(error.cause().is_some());
assert!(format!("{}", error.cause().unwrap()).contains("ServerError"));
}
#[test]
fn serde_json_error_converts_to_500() {
let bad_json = "not json at all{{{";
let serde_err: serde_json::Error = serde_json::from_str::<serde_json::Value>(bad_json).unwrap_err();
let error: AdvancedCommonWebError = serde_err.into();
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(error.is_server_error());
let cause = error.cause().unwrap();
assert!(format!("{}", cause).contains("expected"));
}
#[test]
fn error_source_returns_wrapped_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "root cause");
let error = AdvancedCommonWebError::from_error(io_err);
let source = std::error::Error::source(&error);
assert!(source.is_some());
assert_eq!(format!("{}", source.unwrap()), "root cause");
}
#[test]
fn non_server_errors_have_no_source() {
assert!(std::error::Error::source(&AdvancedCommonWebError::NotFound).is_none());
assert!(std::error::Error::source(&AdvancedCommonWebError::NotAuthorized).is_none());
assert!(std::error::Error::source(&AdvancedCommonWebError::PaymentRequired).is_none());
}
}
@@ -1,3 +1,4 @@
pub mod advanced_common_web_error;
pub mod common_web_error;
pub mod media;
pub mod media_file_origin_details;
@@ -1,12 +1,36 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::Acquire;
use artcraft_api_defs::characters::create_character::{CreateCharacterRequest, CreateCharacterResponse};
use enums::by_table::characters::character_type::CharacterType;
use enums::common::visibility::Visibility;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::characters::create_pending_character::{create_pending_character, CreatePendingCharacterArgs};
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_character_with_apriori_job_token::{
insert_generic_inference_job_for_seedance2pro_character_with_apriori_job_token,
InsertGenericInferenceForSeedance2ProCharacterWithAprioriJobTokenArgs,
};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_character::generate_character::{generate_character, GenerateCharacterArgs};
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url, ExtractExtensions};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use crate::util::lookup::lookup_media_file_urls_as_map::lookup_media_file_urls_as_map;
const MAX_NAME_LENGTH: usize = 255;
const MAX_DESCRIPTION_LENGTH: usize = 512;
/// Create a new character.
#[utoipa::path(
@@ -18,14 +42,227 @@ use crate::state::server_state::ServerState;
(status = 200, description = "Success", body = CreateCharacterResponse),
(status = 400, description = "Bad input"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Payment required"),
(status = 500, description = "Server error"),
),
)]
pub async fn create_character_handler(
_http_request: HttpRequest,
_request: Json<CreateCharacterRequest>,
_server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<CreateCharacterResponse>, CommonWebError> {
todo!()
http_request: HttpRequest,
request: Json<CreateCharacterRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<CreateCharacterResponse>, AdvancedCommonWebError> {
// --- Auth ---
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let user_session = match maybe_user_session {
Some(session) => session,
None => return Err(AdvancedCommonWebError::NotAuthorized),
};
let user_token = &user_session.user_token;
let ip_address = get_request_ip(&http_request);
// --- Validate input ---
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(reason));
}
// For now we use the model name as the character name and description.
let character_name = request.character_name.clone();
let character_name = truncate_string(&character_name, MAX_NAME_LENGTH);
let character_description = request.character_description.as_deref()
.map(|desc| desc.trim());
// --- Look up the image media file ---
let file_urls_by_token = lookup_media_file_urls_as_map(
&http_request,
&mut mysql_connection,
server_state.server_environment,
&[request.image_media_token.clone()],
).await?;
let image_cdn_url = file_urls_by_token
.get(&request.image_media_token)
.ok_or_else(|| {
warn!("Image media token not found: {:?}", request.image_media_token);
AdvancedCommonWebError::BadInputWithSimpleMessage("Image media file not found.".to_string())
})?;
// --- Insert idempotency token ---
insert_idempotency_token(&request.uuid_idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
AdvancedCommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
// --- Upload image to Kinovi CDN ---
let seedance_image_url = upload_to_kinovi(
&server_state.seedance2pro.cookies,
image_cdn_url,
).await?;
info!("Uploaded image to Kinovi: {}", seedance_image_url);
// --- Call Kinovi generate_character ---
let session = Seedance2ProSession::from_cookies_string(
server_state.seedance2pro.cookies.clone()
);
let gen_result = generate_character(GenerateCharacterArgs {
session: &session,
name: character_name.clone(),
description: character_description
.map(|d| d.to_string())
.unwrap_or_else(|| "".to_string()),
reference_image_url: seedance_image_url,
is_public: false,
host_override: None,
})
.await
.map_err(|err| {
error!("Error calling Kinovi generate_character: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!(
"Kinovi character created: id={}, character_id={}, name={}",
gen_result.id, gen_result.character_id, gen_result.name,
);
// --- Generate apriori job token ---
let apriori_job_token = InferenceJobToken::generate();
// --- Insert records in a transaction ---
let mut transaction = mysql_connection
.begin()
.await
.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
let character_token = create_pending_character(CreatePendingCharacterArgs {
character_type: CharacterType::KinoviSeedance,
character_name: &character_name,
maybe_description: character_description,
maybe_original_upload_media_token: Some(&request.image_media_token),
maybe_creator_user_token: Some(user_token),
creator_ip_address: &ip_address,
kinovi_character_id: &gen_result.character_id,
kinovi_character_name: &gen_result.name,
maybe_generic_inference_job_token: Some(&apriori_job_token),
mysql_executor: &mut *transaction,
phantom: PhantomData,
})
.await?;
info!("Created pending character: {}", character_token);
insert_generic_inference_job_for_seedance2pro_character_with_apriori_job_token(
InsertGenericInferenceForSeedance2ProCharacterWithAprioriJobTokenArgs {
uuid_idempotency_token: &request.uuid_idempotency_token,
apriori_job_token: &apriori_job_token,
kinovi_character_id: &gen_result.character_id,
maybe_creator_user_token: Some(user_token),
maybe_avt_token: None,
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Hidden,
mysql_executor: &mut *transaction,
phantom: PhantomData,
}
)
.await
.map_err(|err| {
error!("Error inserting inference job: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!("Created inference job: {}", apriori_job_token);
transaction
.commit()
.await
.map_err(|err| {
error!("Error committing transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
Ok(Json(CreateCharacterResponse {
success: true,
inference_job_token: apriori_job_token,
}))
}
// =============== Private helpers ===============
/// Upload an image from our CDN to Kinovi's CDN via prepare + upload.
async fn upload_to_kinovi(
cookies: &str,
our_cdn_url: &url::Url,
) -> Result<String, AdvancedCommonWebError> {
let extension = extract_extension_from_url(our_cdn_url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
.unwrap_or_else(|| "png".to_string());
let session = Seedance2ProSession::from_cookies_string(cookies.to_string());
let file_bytes = http_download_url_to_bytes(our_cdn_url.as_str())
.await
.map_err(|err| {
warn!("Error downloading media file from CDN: {:?}", err);
AdvancedCommonWebError::from_anyhow_error(err)
})?
.to_vec();
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session: &session,
extension,
host_override: None,
})
.await
.map_err(|err| {
warn!("Error preparing Kinovi file upload: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes,
host_override: None,
})
.await
.map_err(|err| {
warn!("Error uploading file to Kinovi: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
Ok(upload_result.public_url)
}
fn truncate_string(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
s[..max_len].to_string()
}
}
@@ -2,11 +2,15 @@ use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::{info, warn};
use artcraft_api_defs::characters::delete_character::{DeleteCharacterPathInfo, DeleteCharacterResponse};
use tokens::tokens::characters::CharacterToken;
use mysql_queries::queries::characters::delete_character::delete_character;
use mysql_queries::queries::characters::get_character_by_token_including_deleted::get_character_by_token_including_deleted;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
/// Delete a character.
@@ -25,9 +29,61 @@ use crate::state::server_state::ServerState;
)
)]
pub async fn delete_character_handler(
_http_request: HttpRequest,
_path: Path<DeleteCharacterPathInfo>,
_server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<DeleteCharacterResponse>, CommonWebError> {
todo!()
http_request: HttpRequest,
path: Path<DeleteCharacterPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<DeleteCharacterResponse>, AdvancedCommonWebError> {
let character_token = &path.character_token;
// --- Auth ---
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let user_session = require_user_session_using_connection(
&http_request,
&server_state.session_checker,
&mut mysql_connection,
).await?;
let user_token = &user_session.user_token;
let is_mod = user_session.is_mod();
// --- Look up the character (including deleted) ---
let character = get_character_by_token_including_deleted(character_token, &mut mysql_connection)
.await?
.ok_or_else(|| {
warn!("Character not found: {}", character_token);
AdvancedCommonWebError::NotFound
})?;
// Already deleted — return success idempotently.
if character.is_deleted {
info!("Character {} is already deleted", character_token);
return Ok(Json(DeleteCharacterResponse { success: true }));
}
// --- Ownership check ---
let is_owner = character.maybe_creator_user_token
.as_ref()
.map(|owner| owner == user_token)
.unwrap_or(false);
if !is_owner && !is_mod {
warn!(
"User {} attempted to delete character {} owned by {:?}",
user_token, character_token, character.maybe_creator_user_token,
);
return Err(AdvancedCommonWebError::NotAuthorized);
}
// --- Soft delete ---
delete_character(character_token, &mut mysql_connection).await?;
info!("Character {} deleted by user {}", character_token, user_token);
Ok(Json(DeleteCharacterResponse { success: true }))
}
@@ -2,10 +2,16 @@ use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use artcraft_api_defs::characters::edit_character::{EditCharacterRequest, EditCharacterResponse};
use mysql_queries::queries::characters::get_character_by_token::get_character_by_token;
use mysql_queries::queries::characters::update_character_name_and_description::update_character_name_and_description;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::update_character::update_character::{update_character, UpdateCharacterArgs};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
/// Edit a character's name or description.
@@ -23,9 +29,151 @@ use crate::state::server_state::ServerState;
),
)]
pub async fn edit_character_handler(
_http_request: HttpRequest,
_request: Json<EditCharacterRequest>,
_server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<EditCharacterResponse>, CommonWebError> {
todo!()
http_request: HttpRequest,
request: Json<EditCharacterRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<EditCharacterResponse>, AdvancedCommonWebError> {
// --- Auth ---
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let user_session = require_user_session_using_connection(
&http_request,
&server_state.session_checker,
&mut mysql_connection,
).await?;
let user_token = &user_session.user_token;
let is_mod = user_session.is_mod();
// --- Look up character ---
let character = get_character_by_token(&request.token, &mut mysql_connection)
.await?
.ok_or_else(|| {
warn!("Character not found: {}", request.token);
AdvancedCommonWebError::NotFound
})?;
// --- Ownership check ---
let is_owner = character.maybe_creator_user_token
.as_ref()
.map(|owner| owner == user_token)
.unwrap_or(false);
if !is_owner && !is_mod {
warn!("User {} tried to edit character {} they don't own", user_token, request.token);
return Err(AdvancedCommonWebError::NotFound);
}
// --- Determine what to update ---
let new_name = resolve_name_update(&request.updated_name);
let description_update = resolve_description_update(&request.updated_description);
let has_name_change = new_name.is_some();
let has_description_change = !matches!(description_update, DescriptionUpdateType::NoUpdate);
if !has_name_change && !has_description_change {
return Ok(Json(EditCharacterResponse { success: true }));
}
// --- Compute final values ---
let final_name = new_name.unwrap_or_else(|| character.character_name.clone().unwrap_or_default());
let final_kinovi_name = if has_name_change { final_name.clone() } else {
character.kinovi_character_name.clone()
.or_else(|| character.character_name.clone())
.unwrap_or_default()
};
let final_description = match &description_update {
DescriptionUpdateType::NoUpdate => character.maybe_description.clone(),
DescriptionUpdateType::Nullify => None,
DescriptionUpdateType::Update(text) => Some(text.clone()),
};
// --- If renaming, update Kinovi first ---
if has_name_change {
if let Some(ref kinovi_id) = character.kinovi_character_id {
let session = Seedance2ProSession::from_cookies_string(
server_state.seedance2pro.cookies.clone()
);
update_character(UpdateCharacterArgs {
session: &session,
character_id: kinovi_id.clone(),
name: final_name.clone(),
description: final_description.clone().unwrap_or_default(),
host_override: None,
})
.await
.map_err(|err| {
error!("Error updating character on Kinovi: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!("Updated character {} on Kinovi (name='{}')", kinovi_id, final_name);
}
}
// --- Update database ---
update_character_name_and_description(
&request.token,
&final_name,
&final_kinovi_name,
final_description.as_deref(),
&mut mysql_connection,
).await?;
info!("Updated character {} in database", request.token);
Ok(Json(EditCharacterResponse { success: true }))
}
// =============== Private helpers ===============
const MAX_NAME_LENGTH: usize = 50;
const MAX_DESCRIPTION_LENGTH: usize = 500;
enum DescriptionUpdateType {
NoUpdate,
Nullify,
Update(String),
}
/// Determine the new name, if any.
/// Returns None if the name should not be updated.
fn resolve_name_update(updated_name: &Option<String>) -> Option<String> {
let name = updated_name.as_ref()?;
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(truncate(trimmed, MAX_NAME_LENGTH))
}
}
/// Determine the description update type.
fn resolve_description_update(updated_description: &Option<String>) -> DescriptionUpdateType {
let desc = match updated_description.as_ref() {
None => return DescriptionUpdateType::NoUpdate,
Some(d) => d,
};
let trimmed = desc.trim();
if trimmed.is_empty() {
DescriptionUpdateType::Nullify
} else {
DescriptionUpdateType::Update(truncate(trimmed, MAX_DESCRIPTION_LENGTH))
}
}
fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len { s.to_string() } else { s[..max_len].to_string() }
}
@@ -5,14 +5,17 @@ use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::characters::get_character::{GetCharacterDetails, GetCharacterPathInfo, GetCharacterResponse};
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::common::generation::common_model_type::CommonModelType;
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
use mysql_queries::queries::characters::get_character_by_token::get_character_by_token;
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens_with_connection, MediaFilesByTokensRecord};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::state::server_state::ServerState;
@@ -34,42 +37,67 @@ pub async fn get_character_handler(
http_request: HttpRequest,
path: Path<GetCharacterPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<GetCharacterResponse>, CommonWebError> {
// TODO: Replace with real character query. This is test data using a real media file (Ernest).
) -> Result<Json<GetCharacterResponse>, AdvancedCommonWebError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let character = get_character_by_token(&path.character_token, &mut mysql_connection)
.await?
.ok_or_else(|| {
warn!("Character not found: {}", path.character_token);
AdvancedCommonWebError::NotFound
})?;
// --- Resolve media links ---
let media_domain = get_media_domain(&http_request);
let ernest_media_token = MediaFileToken::new_from_str("m_1997908e4tcbaj34wa0s5bvr4y4szt");
let maybe_links = match get_media_file(&ernest_media_token, false, &server_state.mysql_pool).await {
Ok(Some(record)) => {
let path = MediaFileBucketPath::from_object_hash(
&record.public_bucket_directory_hash,
record.maybe_public_bucket_prefix.as_deref(),
record.maybe_public_bucket_extension.as_deref(),
);
Some(MediaLinksBuilder::from_media_path_and_env(media_domain, server_state.server_environment, &path))
}
Ok(None) => {
warn!("Ernest media file not found in database");
None
}
Err(err) => {
warn!("Failed to look up Ernest media file: {:?}", err);
None
}
let mut media_tokens_to_lookup: Vec<MediaFileToken> = Vec::new();
if let Some(ref t) = character.maybe_avatar_media_token { media_tokens_to_lookup.push(t.clone()); }
if let Some(ref t) = character.maybe_full_image_media_token { media_tokens_to_lookup.push(t.clone()); }
if let Some(ref t) = character.maybe_original_upload_media_token { media_tokens_to_lookup.push(t.clone()); }
let media_records = if media_tokens_to_lookup.is_empty() {
vec![]
} else {
batch_get_media_files_by_tokens_with_connection(&mut mysql_connection, &media_tokens_to_lookup, true)
.await
.unwrap_or_else(|err| {
warn!("Failed to look up media files for character: {:?}", err);
vec![]
})
};
let character = GetCharacterDetails {
token: path.character_token.clone(),
models: vec![CommonModelType::Seedance2p0],
name: "Ernest".to_string(),
maybe_description: Some("Ernest P. Worrell".to_string()),
maybe_avatar: maybe_links.clone(),
maybe_full_image: maybe_links,
};
let maybe_avatar = resolve_media_links(&media_records, &character.maybe_avatar_media_token, media_domain, server_state.server_environment);
let maybe_full_image = resolve_media_links(&media_records, &character.maybe_full_image_media_token, media_domain, server_state.server_environment);
Ok(Json(GetCharacterResponse {
success: true,
character,
character: GetCharacterDetails {
token: character.token,
models: vec![CommonModelType::Seedance2p0],
name: character.character_name.unwrap_or_default(),
maybe_description: character.maybe_description,
maybe_avatar,
maybe_full_image,
},
}))
}
// =============== Private helpers ===============
fn resolve_media_links(
media_records: &[MediaFilesByTokensRecord],
maybe_token: &Option<MediaFileToken>,
media_domain: MediaDomain,
server_environment: server_environment::ServerEnvironment,
) -> Option<MediaLinks> {
let token = maybe_token.as_ref()?;
let record = media_records.iter().find(|r| &r.token == token)?;
let path = MediaFileBucketPath::from_object_hash(
&record.public_bucket_directory_hash,
record.maybe_public_bucket_prefix.as_deref(),
record.maybe_public_bucket_extension.as_deref(),
);
Some(MediaLinksBuilder::from_media_path_and_env(media_domain, server_environment, &path))
}
@@ -5,15 +5,18 @@ use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::characters::list_characters::{ListCharactersEntry, ListCharactersQuery, ListCharactersResponse};
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::common::generation::common_model_type::CommonModelType;
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::batch_get_media_files_by_tokens;
use tokens::tokens::characters::CharacterToken;
use mysql_queries::queries::characters::list_active_characters_for_user::list_active_characters_for_user;
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens_with_connection, MediaFilesByTokensRecord};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
/// List characters for the current session.
@@ -30,62 +33,92 @@ use crate::state::server_state::ServerState;
)]
pub async fn list_characters_handler(
http_request: HttpRequest,
_query: Query<ListCharactersQuery>,
query: Query<ListCharactersQuery>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListCharactersResponse>, CommonWebError> {
// TODO: Replace with real character queries. This is test data using real media files.
) -> Result<Json<ListCharactersResponse>, AdvancedCommonWebError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let user_session = require_user_session_using_connection(
&http_request,
&server_state.session_checker,
&mut mysql_connection,
).await?;
let user_token = &user_session.user_token;
// --- Query characters ---
let result = list_active_characters_for_user(user_token, query.cursor, &mut mysql_connection)
.await?;
if result.characters.is_empty() {
return Ok(Json(ListCharactersResponse {
success: true,
characters: vec![],
next_cursor: None,
}));
}
// --- Resolve media links ---
let media_domain = get_media_domain(&http_request);
struct TestCharacter {
token: &'static str,
name: &'static str,
description: &'static str,
media_token: &'static str,
let mut media_tokens_to_lookup: Vec<MediaFileToken> = Vec::new();
for character in &result.characters {
if let Some(ref t) = character.maybe_avatar_media_token { media_tokens_to_lookup.push(t.clone()); }
if let Some(ref t) = character.maybe_full_image_media_token { media_tokens_to_lookup.push(t.clone()); }
if let Some(ref t) = character.maybe_original_upload_media_token { media_tokens_to_lookup.push(t.clone()); }
}
let test_characters = vec![
TestCharacter { token: "character_mock_ernest_001", name: "Ernest", description: "Ernest P. Worrell", media_token: "m_1997908e4tcbaj34wa0s5bvr4y4szt" },
TestCharacter { token: "character_mock_juno_002", name: "Juno", description: "Juno the corgi", media_token: "m_m1bz02z1kkzanxy6rb4vk1kvq9de9g" },
TestCharacter { token: "character_mock_mochi_003", name: "Mochi", description: "Mochi with glasses", media_token: "m_5y81rcnpnrbey55da6cc2s9z4d2f45" },
];
let media_records = if media_tokens_to_lookup.is_empty() {
vec![]
} else {
batch_get_media_files_by_tokens_with_connection(&mut mysql_connection, &media_tokens_to_lookup, false)
.await
.unwrap_or_else(|err| {
warn!("Failed to look up media files for characters: {:?}", err);
vec![]
})
};
let media_tokens: Vec<MediaFileToken> = test_characters.iter()
.map(|c| MediaFileToken::new_from_str(c.media_token))
.collect();
// --- Build response ---
let media_records = batch_get_media_files_by_tokens(&server_state.mysql_pool, &media_tokens, false)
.await
.unwrap_or_else(|err| {
warn!("Failed to look up media files for test characters: {:?}", err);
vec![]
});
let characters = test_characters.iter().map(|tc| {
let maybe_links = media_records.iter()
.find(|r| r.token.as_str() == tc.media_token)
.map(|r| {
let path = MediaFileBucketPath::from_object_hash(
&r.public_bucket_directory_hash,
r.maybe_public_bucket_prefix.as_deref(),
r.maybe_public_bucket_extension.as_deref(),
);
MediaLinksBuilder::from_media_path_and_env(media_domain, server_state.server_environment, &path)
});
let characters = result.characters.iter().map(|c| {
let maybe_avatar = resolve_media_links(&media_records, &c.maybe_avatar_media_token, media_domain, server_state.server_environment);
let maybe_full_image = resolve_media_links(&media_records, &c.maybe_full_image_media_token, media_domain, server_state.server_environment);
ListCharactersEntry {
token: CharacterToken::new_from_str(tc.token),
token: c.token.clone(),
models: vec![CommonModelType::Seedance2p0],
name: tc.name.to_string(),
maybe_description: Some(tc.description.to_string()),
maybe_avatar: maybe_links.clone(),
maybe_full_image: maybe_links,
name: c.character_name.clone().unwrap_or_default(),
maybe_description: c.maybe_description.clone(),
maybe_avatar,
maybe_full_image,
}
}).collect();
Ok(Json(ListCharactersResponse {
success: true,
characters,
next_cursor: None,
next_cursor: result.next_cursor,
}))
}
// =============== Private helpers ===============
fn resolve_media_links(
media_records: &[MediaFilesByTokensRecord],
maybe_token: &Option<MediaFileToken>,
media_domain: MediaDomain,
server_environment: server_environment::ServerEnvironment,
) -> Option<MediaLinks> {
let token = maybe_token.as_ref()?;
let record = media_records.iter().find(|r| &r.token == token)?;
let path = MediaFileBucketPath::from_object_hash(
&record.public_bucket_directory_hash,
record.maybe_public_bucket_prefix.as_deref(),
record.maybe_public_bucket_extension.as_deref(),
);
Some(MediaLinksBuilder::from_media_path_and_env(media_domain, server_environment, &path))
}
@@ -48,6 +48,7 @@ pub async fn estimate_video_cost_handler(
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: router_resolution,
aspect_ratio: router_aspect_ratio,
duration_seconds: request.duration_seconds,
@@ -2,9 +2,10 @@ use std::collections::HashMap;
use std::sync::Arc;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
@@ -17,13 +18,14 @@ use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_funct
};
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::visibility::Visibility;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::characters::batch_lookup_characters_by_token_for_prompting::batch_lookup_characters_by_token_for_prompting;
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs,
@@ -33,6 +35,8 @@ use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_it
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, BatchCount, GenerateVideoArgs, GenerateVideoResponse, ModelType, Resolution,
@@ -42,13 +46,12 @@ use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use sqlx::Acquire;
use sqlx::MySql;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url, ExtractExtensions};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
// ======================== Result of a successful generation ========================
@@ -76,7 +79,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
http_request: HttpRequest,
request: Json<Seedance2p0MultiFunctionVideoGenRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<Seedance2p0MultiFunctionVideoGenResponse>, CommonWebError> {
) -> Result<Json<Seedance2p0MultiFunctionVideoGenResponse>, AdvancedCommonWebError> {
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
@@ -94,20 +97,20 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
CommonWebError::ServerError
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let user_session = match maybe_user_session {
Some(session) => session,
None => {
return Err(CommonWebError::NotAuthorized);
return Err(AdvancedCommonWebError::NotAuthorized);
}
};
let user_token = &user_session.user_token;
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(reason));
}
// --- Collect all media tokens to look up ---
@@ -142,13 +145,20 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
).await?
};
// --- Look up character tokens (if any) ---
let kinovi_character_ids = resolve_kinovi_character_ids(
request.reference_character_tokens.as_deref(),
&mut mysql_connection,
).await?;
// --- Insert idempotency token ---
insert_idempotency_token(&request.uuid_idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
AdvancedCommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
// --- Calculate cost and charge wallet upfront (before uploads) ---
@@ -191,6 +201,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
resolution,
batch_count,
duration_seconds,
kinovi_character_ids.clone(),
).await;
match result {
@@ -212,6 +223,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
resolution,
batch_count,
duration_seconds,
kinovi_character_ids.clone(),
).await;
match result {
@@ -221,16 +233,15 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
}
Err(err) => {
warn!("Regular session fallback also failed for user {:?}: {:?}", user_token, err);
if let Err(page_err) = server_state.pager.enqueue_page(
NotificationDetailsBuilder::from_error(err.into())
.set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build()
) {
let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into())
.set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build();
if let Err(page_err) = server_state.pager.enqueue_page(notification) {
warn!("Failed to enqueue pager alert: {:?}", page_err);
}
refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?;
return Err(CommonWebError::ServerError);
return Err(err);
}
}
}
@@ -249,22 +260,22 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
resolution,
batch_count,
duration_seconds,
kinovi_character_ids,
).await;
match result {
Ok(result) => result,
Err(err) => {
warn!("Error calling seedance2pro generate_video: {:?}", err);
if let Err(page_err) = server_state.pager.enqueue_page(
NotificationDetailsBuilder::from_error(err.into())
.set_title("Seedance 2.0 generation failed".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build()
) {
let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into())
.set_title("Seedance 2.0 generation failed".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build();
if let Err(page_err) = server_state.pager.enqueue_page(notification) {
warn!("Failed to enqueue pager alert: {:?}", page_err);
}
refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?;
return Err(CommonWebError::ServerError);
return Err(err);
}
}
};
@@ -286,7 +297,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
.await
.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
CommonWebError::ServerError
AdvancedCommonWebError::from_error(err)
})?;
// NB: Don't fail the job if the prompt insert fails.
@@ -419,7 +430,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
Err(err) => {
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
if i == 0 {
return Err(CommonWebError::ServerError);
return Err(AdvancedCommonWebError::from_error(err));
}
}
}
@@ -427,7 +438,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
let first_job_token = all_job_tokens.first().cloned().ok_or_else(|| {
error!("No inference job token was created");
CommonWebError::ServerError
AdvancedCommonWebError::server_error_with_message("No inference job token was created")
})?;
transaction
@@ -435,7 +446,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
.await
.map_err(|err| {
error!("Error committing MySQL transaction: {:?}", err);
CommonWebError::ServerError
AdvancedCommonWebError::from_error(err)
})?;
Ok(Json(Seedance2p0MultiFunctionVideoGenResponse {
@@ -482,6 +493,7 @@ fn estimate_cost_upfront(resolution: Resolution, batch_count: BatchCount, durati
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
@@ -497,14 +509,15 @@ async fn upload_and_generate(
resolution: Resolution,
batch_count: BatchCount,
duration_seconds: u8,
) -> Result<SeedanceGenerationResult, CommonWebError> {
kinovi_character_ids: Option<Vec<String>>,
) -> Result<SeedanceGenerationResult, AdvancedCommonWebError> {
// --- Upload files to seedance2pro CDN ---
let start_frame_url = match request.start_frame_media_token.as_ref() {
None => None,
Some(token) => match file_urls_by_token.get(token) {
None => return Err(CommonWebError::BadInputWithSimpleMessage("Start frame media not found.".to_string())),
None => return Err(AdvancedCommonWebError::BadInputWithSimpleMessage("Start frame media not found.".to_string())),
Some(url) => Some(upload_to_seedance2pro(session, url).await?),
}
};
@@ -512,7 +525,7 @@ async fn upload_and_generate(
let end_frame_url = match request.end_frame_media_token.as_ref() {
None => None,
Some(token) => match file_urls_by_token.get(token) {
None => return Err(CommonWebError::BadInputWithSimpleMessage("End frame media not found.".to_string())),
None => return Err(AdvancedCommonWebError::BadInputWithSimpleMessage("End frame media not found.".to_string())),
Some(url) => Some(upload_to_seedance2pro(session, url).await?),
}
};
@@ -571,6 +584,7 @@ async fn upload_and_generate(
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: kinovi_character_ids,
use_face_blur_hack: None,
host_override: None,
};
@@ -578,7 +592,7 @@ async fn upload_and_generate(
let gen_response = generate_video(video_gen_args).await
.map_err(|err| {
warn!("Error calling seedance2pro generate_video: {:?}", err);
CommonWebError::ServerError
AdvancedCommonWebError::from_error(err)
})?;
Ok(SeedanceGenerationResult {
@@ -587,6 +601,44 @@ async fn upload_and_generate(
})
}
/// Resolve character tokens to Kinovi character IDs for prompting.
///
/// Looks up the characters, filters to active ones with kinovi IDs, and warns about
/// any that are missing or inactive (but doesn't fail the request).
async fn resolve_kinovi_character_ids(
maybe_tokens: Option<&[CharacterToken]>,
connection: &mut sqlx::pool::PoolConnection<MySql>,
) -> Result<Option<Vec<String>>, AdvancedCommonWebError> {
let tokens = match maybe_tokens {
None => return Ok(None),
Some(tokens) if tokens.is_empty() => return Ok(None),
Some(tokens) => tokens,
};
let characters = batch_lookup_characters_by_token_for_prompting(tokens, connection)
.await?;
if characters.len() != tokens.len() {
warn!(
"Not all character tokens were found: requested {}, found {}",
tokens.len(), characters.len(),
);
}
for character in &characters {
if !character.is_active {
warn!("Character {} is not yet active, skipping", character.token);
}
}
let ids: Vec<String> = characters.iter()
.filter(|c| c.is_active)
.filter_map(|c| c.kinovi_character_id.clone())
.collect();
if ids.is_empty() { Ok(None) } else { Ok(Some(ids)) }
}
/// Uploads a list of reference media tokens to seedance2pro, returning the resulting URLs.
/// Returns `None` if the token list is absent or empty.
async fn upload_reference_tokens_to_seedance2pro(
@@ -594,7 +646,7 @@ async fn upload_reference_tokens_to_seedance2pro(
file_urls_by_token: &HashMap<MediaFileToken, Url>,
maybe_tokens: Option<&[MediaFileToken]>,
label: &str,
) -> Result<Option<Vec<String>>, CommonWebError> {
) -> Result<Option<Vec<String>>, AdvancedCommonWebError> {
let tokens = match maybe_tokens {
None => return Ok(None),
Some(tokens) if tokens.is_empty() => return Ok(None),
@@ -605,7 +657,7 @@ async fn upload_reference_tokens_to_seedance2pro(
for token in tokens {
match file_urls_by_token.get(token) {
None => return Err(CommonWebError::BadInputWithSimpleMessage(
None => return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
format!("{} media not found: {:?}", label, token),
)),
Some(url) => {
@@ -620,7 +672,7 @@ async fn upload_reference_tokens_to_seedance2pro(
async fn upload_to_seedance2pro(
session: &Seedance2ProSession,
our_cdn_url: &Url,
) -> Result<String, CommonWebError> {
) -> Result<String, AdvancedCommonWebError> {
let extension = extract_extension_from_url(our_cdn_url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
.unwrap_or_else(|| "png".to_string());
@@ -631,7 +683,7 @@ async fn upload_to_seedance2pro(
.await
.map_err(|err| {
warn!("Error downloading media file from CDN: {:?}", err);
CommonWebError::ServerError
AdvancedCommonWebError::from_anyhow_error(err)
})?
.to_vec();
@@ -643,7 +695,7 @@ async fn upload_to_seedance2pro(
.await
.map_err(|err| {
warn!("Error preparing seedance2pro file upload: {:?}", err);
CommonWebError::ServerError
AdvancedCommonWebError::from_error(err)
})?;
let upload_result = upload_file(UploadFileArgs {
@@ -654,7 +706,7 @@ async fn upload_to_seedance2pro(
.await
.map_err(|err| {
warn!("Error uploading file to seedance2pro: {:?}", err);
CommonWebError::ServerError
AdvancedCommonWebError::from_error(err)
})?;
Ok(upload_result.public_url)

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