Compare commits

..
Author SHA1 Message Date
Brandon Thomas c238a30776 fix frontend dev scripts 2026-06-10 09:27:28 -04:00
1148 changed files with 8993 additions and 76302 deletions
+3 -12
View File
@@ -19,18 +19,9 @@ Handlers in `storyteller_web` follow this structure:
### Session/Auth
Helpers live in `storyteller_web` under `http_server/web_utils/user_session/`. Each takes a
generic `mysql_executor: E` (any `sqlx::Executor`), so whether it reuses a connection or acquires
its own depends on the **argument you pass**:
- `require_user_session(http_request, session_checker, mysql_executor)` — require a logged-in user.
- `require_user_session_extended(...)` — same, with extra session detail.
- `require_moderator(http_request, session_checker, mysql_executor)` — admin-only endpoints.
PREFER passing an already-open connection (`&mut *conn`) so the helper reuses it. Passing
`&server_state.mysql_pool` makes it acquire a fresh connection — avoid that when the handler
already holds one (the double-acquire adds pool pressure; this crate has had pool-timeout
incidents). There is no `*_using_connection` variant — the reuse/acquire choice is the argument.
- `require_user_session_using_connection()` reuses an open DB connection (preferred)
- `require_user_session()` acquires its own connection (deprecated)
- `require_moderator()` for admin-only endpoints
### API Type Definitions
+4 -5
View File
@@ -7,11 +7,10 @@
- `#[macro_use] extern crate serde_derive;` is used in binary crates; library crates use `use serde_derive::{Serialize, Deserialize};`
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros (`info!`, `warn!`, `error!`, `debug!`, `trace!`) instead
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code; new database queries should use `Result<T, sqlx::Error>` instead
- SQLx: MySQL queries belong in the `mysql_queries` crate. Do NOT write `sqlx::query!` /
`sqlx::query()` in application/service crates (e.g. `storyteller-web`, which has zero direct
SQLx queries) — add or reuse a function in `mysql_queries` and call it. The compile-time
`query!` / `query_as!` macro conventions for that crate are documented in
`crates/schema/database/mysql_queries/CLAUDE.md`.
- SQLx queries: prefer `sqlx::query!` and `sqlx::query_as!` compile-time checked macros over
runtime `sqlx::query()` / `sqlx::query_as()`. The macros catch SQL typos, column mismatches,
and type errors at build time. Use runtime queries only when assembling complicated
dynamic/branching queries that cannot be expressed as a compile-time macro.
- Enum Display/Debug: use `impl_enum_display_and_debug_using_to_str!` macro, not `derive_more::Display` (CLion doesn't understand it)
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token`
- When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)`
-2
View File
@@ -38,7 +38,6 @@ script/socat_dev_database_connect.sh
venv/
# LLM Testing
/external/
/prompts/
/reports/
/requests/
@@ -103,4 +102,3 @@ windows/sqlite/sqlite3.lib
# ArtCraft Windows DLLs
crates/desktop/src-tauri/*.dll
windows_dlls/
@@ -1,124 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE owner_user_token = ?\nORDER BY id DESC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "api_key_prefix!",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 4,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
true,
false,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab"
}
@@ -1,12 +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 = ?,\n maybe_model_token = ?,\n\n maybe_input_source_token = ?,\n maybe_input_source_token_type = ?,\n\n maybe_download_url = ?,\n maybe_cover_image_media_file_token = ?,\n\n maybe_prompt_token = ?,\n maybe_wallet_ledger_entry_token = ?,\n\n maybe_raw_inference_text = ?,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n platform_type = ?,\n\n maybe_external_third_party_cost_credits = ?,\n maybe_external_third_party_cost_usd_cents = ?,\n maybe_system_cost_credits = ?,\n maybe_system_cost_usd_cents = ?,\n\n priority_level = ?,\n is_keepalive_required = ?,\n max_duration_seconds = ?,\n is_debug_request = ?,\n\n maybe_routing_tag = ?,\n\n maybe_debug_log_event_token = ?,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
"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 = ?,\n maybe_model_token = ?,\n\n maybe_input_source_token = ?,\n maybe_input_source_token_type = ?,\n\n maybe_download_url = ?,\n maybe_cover_image_media_file_token = ?,\n\n maybe_prompt_token = ?,\n maybe_wallet_ledger_entry_token = ?,\n\n maybe_raw_inference_text = ?,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n platform_type = ?,\n\n priority_level = ?,\n is_keepalive_required = ?,\n max_duration_seconds = ?,\n is_debug_request = ?,\n\n maybe_routing_tag = ?,\n\n maybe_debug_log_event_token = ?,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 35
"Right": 31
},
"nullable": []
},
"hash": "5c67c3dd7f8bb6771c6b058a8cf6ffcc66b2be2bea41bd988070713173982dab"
"hash": "108731dc51d915ebb8b6ed391c5bae4c41ae6f2c813be92c07d8a10ca401db53"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO uploaded_videos\nSET\n token = ?,\n sha1_checksum = ?,\n filesize_bytes = ?,\n maybe_filename = ?,\n maybe_width = ?,\n maybe_height = ?,\n maybe_resolution = ?,\n maybe_detected_model_family = ?,\n maybe_detected_model_type = ?,\n maybe_report = ?,\n upload_ip_address = ?,\n created_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 11
},
"nullable": []
},
"hash": "14e63e59f7d550181126151029bcd17d08278196ba0ee4d32450c2567b1da26e"
}
@@ -48,7 +48,7 @@
"name": "maybe_entity_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
@@ -132,7 +132,7 @@
false,
false,
true,
true,
false,
true,
true,
true,
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE uploaded_video_notes\nSET\n maybe_filename = ?,\n maybe_reported_model_type = ?,\n maybe_reported_model_name = ?,\n maybe_website = ?,\n maybe_other_website = ?,\n maybe_comments = ?,\n maybe_email_address = ?,\n can_share_report = ?,\n was_scammed = ?,\n maybe_comment_update_ip_address = ?,\n version = version + 1\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 11
},
"nullable": []
},
"hash": "4036dfd9d4e9e77e557776468c14b75f49170de1e55a1b9b9e83446a0c356e74"
}
@@ -1,157 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: UploadedVideoToken`,\n sha1_checksum,\n filesize_bytes as `filesize_bytes: u32`,\n maybe_width as `maybe_width: u32`,\n maybe_height as `maybe_height: u32`,\n maybe_resolution,\n maybe_detected_model_family as `maybe_detected_model_family: UploadedVideoDetectedModelFamily`,\n maybe_detected_model_type as `maybe_detected_model_type: UploadedVideoDetectedModelType`,\n maybe_report,\n upload_ip_address,\n maybe_updated_ip_address,\n created_at,\n updated_at\nFROM uploaded_videos\nWHERE sha1_checksum = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: UploadedVideoToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "sha1_checksum",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 2,
"name": "filesize_bytes: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 3,
"name": "maybe_width: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 4,
"name": "maybe_height: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 5,
"name": "maybe_resolution",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 48
}
},
{
"ordinal": 6,
"name": "maybe_detected_model_family: UploadedVideoDetectedModelFamily",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_detected_model_type: UploadedVideoDetectedModelType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_report",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 9,
"name": "upload_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 10,
"name": "maybe_updated_ip_address",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 12,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "408c893a0ced805de4c3c6cfdf0c6a4bcc93dd1eaf98382522eae46e72eb3aa5"
}
@@ -1,25 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT status as `status: enums::common::job_status_plus::JobStatusPlus`\nFROM generic_inference_jobs\nWHERE token = ?\nFOR UPDATE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "status: enums::common::job_status_plus::JobStatusPlus",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "489c6ef00507330fedbaf2b6e024ecf409a920efd85f75456829a8ed412d9bc3"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_model_type as `maybe_model_type: enums::common::generation::common_model_type::CommonModelType`,\n p.maybe_generation_provider as `maybe_generation_provider: enums::common::generation_provider::GenerationProvider`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n\n p.maybe_generation_mode as `maybe_generation_mode: enums::common::generation::common_generation_mode::CommonGenerationMode`,\n p.maybe_aspect_ratio as `maybe_aspect_ratio: enums::common::generation::common_aspect_ratio::CommonAspectRatio`,\n p.maybe_resolution as `maybe_resolution: enums::common::generation::common_resolution::CommonResolution`,\n p.maybe_bitrate as `maybe_bitrate: enums::common::generation::common_bitrate::CommonBitrate`,\n p.maybe_batch_count as `maybe_batch_count: u8`,\n p.maybe_generate_audio as `maybe_generate_audio: bool`,\n p.maybe_duration_seconds as `maybe_duration_seconds: u32`,\n\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ",
"query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_model_type as `maybe_model_type: enums::common::generation::common_model_type::CommonModelType`,\n p.maybe_generation_provider as `maybe_generation_provider: enums::common::generation_provider::GenerationProvider`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n\n p.maybe_generation_mode as `maybe_generation_mode: enums::common::generation::common_generation_mode::CommonGenerationMode`,\n p.maybe_aspect_ratio as `maybe_aspect_ratio: enums::common::generation::common_aspect_ratio::CommonAspectRatio`,\n p.maybe_resolution as `maybe_resolution: enums::common::generation::common_resolution::CommonResolution`,\n p.maybe_batch_count as `maybe_batch_count: u8`,\n p.maybe_generate_audio as `maybe_generate_audio: bool`,\n p.maybe_duration_seconds as `maybe_duration_seconds: u32`,\n\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ",
"describe": {
"columns": [
{
@@ -105,16 +105,6 @@
},
{
"ordinal": 10,
"name": "maybe_bitrate: enums::common::generation::common_bitrate::CommonBitrate",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "maybe_batch_count: u8",
"type_info": {
"type": "Tiny",
@@ -124,7 +114,7 @@
}
},
{
"ordinal": 12,
"ordinal": 11,
"name": "maybe_generate_audio: bool",
"type_info": {
"type": "Tiny",
@@ -134,7 +124,7 @@
}
},
{
"ordinal": 13,
"ordinal": 12,
"name": "maybe_duration_seconds: u32",
"type_info": {
"type": "Long",
@@ -144,7 +134,7 @@
}
},
{
"ordinal": 14,
"ordinal": 13,
"name": "maybe_other_args",
"type_info": {
"type": "Blob",
@@ -154,7 +144,7 @@
}
},
{
"ordinal": 15,
"ordinal": 14,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
@@ -164,7 +154,7 @@
}
},
{
"ordinal": 16,
"ordinal": 15,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -192,10 +182,9 @@
true,
true,
true,
true,
false,
false
]
},
"hash": "1b69f192c3bb51bd249f97d37f209ae271d498bb46c978351b2c1d8759338bab"
"hash": "4a50b7bf06061f05e769a41e547cb927bd2b3a6f67c8ceb37883b744237ba34c"
}
@@ -1,157 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: UploadedVideoToken`,\n sha1_checksum,\n filesize_bytes as `filesize_bytes: u32`,\n maybe_width as `maybe_width: u32`,\n maybe_height as `maybe_height: u32`,\n maybe_resolution,\n maybe_detected_model_family as `maybe_detected_model_family: UploadedVideoDetectedModelFamily`,\n maybe_detected_model_type as `maybe_detected_model_type: UploadedVideoDetectedModelType`,\n maybe_report,\n upload_ip_address,\n maybe_updated_ip_address,\n created_at,\n updated_at\nFROM uploaded_videos\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: UploadedVideoToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "sha1_checksum",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 2,
"name": "filesize_bytes: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 3,
"name": "maybe_width: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 4,
"name": "maybe_height: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 5,
"name": "maybe_resolution",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 48
}
},
{
"ordinal": 6,
"name": "maybe_detected_model_family: UploadedVideoDetectedModelFamily",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_detected_model_type: UploadedVideoDetectedModelType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_report",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 9,
"name": "upload_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 10,
"name": "maybe_updated_ip_address",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 12,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "73d70c5ee77f5059801d324610a80b3f52da3ddec2e89f63e8f41fc68ff0aaec"
}
@@ -1,14 +1,14 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY fmf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"name": "membership_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "8aca9bc976b980ea1a3b3052bc8a45dd4d8db55c20fd1aede2a4415cc1395cf3"
"hash": "88673127b54626dc38d28693e709dfaeced9a05300a26b3f3c52a17f28171c9a"
}
@@ -48,7 +48,7 @@
"name": "maybe_entity_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
@@ -132,7 +132,7 @@
false,
false,
true,
true,
false,
true,
true,
true,
@@ -1,25 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT status as `status: enums::common::job_status_plus::JobStatusPlus`\nFROM generic_inference_jobs\nWHERE token = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "status: enums::common::job_status_plus::JobStatusPlus",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "9312694d1651235b06d1744d904b59f64d66449b8927c1606bb08bd5fdc15c7e"
}
@@ -1,124 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE api_key = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "api_key_prefix!",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 4,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO uploaded_video_notes\nSET\n token = ?,\n uploaded_video_token = ?,\n maybe_filename = ?,\n maybe_reported_model_type = ?,\n maybe_reported_model_name = ?,\n maybe_website = ?,\n maybe_other_website = ?,\n maybe_comments = ?,\n maybe_email_address = ?,\n can_share_report = ?,\n was_scammed = ?,\n comment_create_ip_address = ?,\n created_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "b1f30a79864351a28a58cae43ee38ff12a8d64d9393b8f27590fe03b0ae3ab05"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE api_keys\nSET\n maybe_description = ?,\n ip_address_update = ?\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO api_keys\nSET\n token = ?,\n api_key = ?,\n name = ?,\n maybe_description = ?,\n owner_user_token = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4"
}
@@ -1,124 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "api_key_prefix!",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 4,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE api_keys\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE uploaded_videos\nSET\n maybe_filename = ?,\n maybe_width = ?,\n maybe_height = ?,\n maybe_resolution = ?,\n maybe_detected_model_family = ?,\n maybe_detected_model_type = ?,\n maybe_report = ?,\n maybe_updated_ip_address = ?,\n version = version + 1\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "c6bbdc963e0d49ac55973a728d04b371967c20bfb9dd4bd75e4693dc7a114c99"
}
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n \n maybe_model_type = ?,\n maybe_generation_provider = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n\n maybe_generation_mode = ?,\n maybe_aspect_ratio = ?,\n maybe_resolution = ?,\n maybe_bitrate = ?,\n maybe_batch_count = ?,\n maybe_generate_audio = ?,\n maybe_duration_seconds = ?,\n\n maybe_other_args = ?,\n\n creator_ip_address = ?\n ",
"query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n \n maybe_model_type = ?,\n maybe_generation_provider = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n\n maybe_generation_mode = ?,\n maybe_aspect_ratio = ?,\n maybe_resolution = ?,\n maybe_batch_count = ?,\n maybe_generate_audio = ?,\n maybe_duration_seconds = ?,\n\n maybe_other_args = ?,\n\n creator_ip_address = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 16
"Right": 15
},
"nullable": []
},
"hash": "7519c1dcc2946f4c31a8aad995bbb66a5519f6ee571797a6c0f597c9e57986a7"
"hash": "ccf92ebe0328d3019ac6b01be4b442df706a183362fa7e5c331f5a4bf1a74a00"
}
@@ -1,14 +1,14 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND fmf.id < ?\nORDER BY fmf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"name": "membership_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "c0ba9f9aa3184b1cb3d714173b31e530e201a6c24afd29ad534cf7f3954c7316"
"hash": "d2a406bcf08f656fd602aa6ba1124a77d04ad874b15abafca300e8b381c7b211"
}
@@ -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 jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,\n jobs.created_at\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 ",
"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 jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`\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": [
{
@@ -92,16 +92,6 @@
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
@@ -116,9 +106,8 @@
false,
true,
true,
true,
false
true
]
},
"hash": "1703bc63ce65d98cbcad81d515cbcf211a943c533e38e887d2c12713c78b687d"
"hash": "ddbbd3886c00268edd3993508f635549fc8ddc877bb059fd58f14b8c07586fc9"
}
@@ -1,47 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n api_keys.token as `api_key_token: ApiKeyToken`,\n users.token as `user_token: UserToken`,\n users.is_banned\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "api_key_token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "is_banned",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "f52c495a7ff5cafb357f79c4cdb503bbbef045fd193a5a37a66180fcac77f363"
}
+1 -1
View File
@@ -58,7 +58,7 @@ artcraft/
- Rust with no minimum supported version
- Actix-web for HTTP services
- SQLx for MySQL and SQLite. MySQL queries belong in the `mysql_queries` crate, not in application/service crates like `storyteller-web` — call those query functions instead of embedding `sqlx::query!` / `sqlx::query()` in handlers. (Query-writing conventions live in `crates/schema/database/mysql_queries/CLAUDE.md`.)
- SQLx for MySQL and SQLite; prefer `sqlx::query!` / `sqlx::query_as!` compile-time checked macros over runtime `sqlx::query()` whenever possible
- A mix of wreq and reqwest for Rust HTTP clients
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
- When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;`
Generated
+740 -691
View File
File diff suppressed because it is too large Load Diff
+13 -20
View File
@@ -7,12 +7,9 @@ members = [
# API clients for external services
# These might be custom implementations or wrappers over other client libraries.
"crates/api_clients/artcraft/artcraft_api_defs",
"crates/api_clients/artcraft/artcraft_api_keys",
"crates/api_clients/artcraft/artcraft_client",
"crates/api_clients/artcraft/artcraft_router",
"crates/api_clients/bucket_client",
"crates/api_clients/cloud_storage",
"crates/api_clients/comet_api_client",
"crates/api_clients/datadog_client",
"crates/api_clients/fal_client",
"crates/api_clients/google_drive_downloader",
@@ -88,7 +85,6 @@ members = [
"crates/lib/pager",
"crates/lib/password",
"crates/lib/server_environment",
"crates/lib/sha1_hash",
"crates/lib/sqlx_mysql_helpers",
"crates/lib/storyteller_root",
"crates/lib/url_config",
@@ -96,7 +92,6 @@ members = [
"crates/lib/user_input_common",
"crates/lib/users",
"crates/lib/uuid_utils",
"crates/lib/video_info",
#"crates/lib/api_clients/fakeyou_client",
#"crates/lib/deprecated/machine_learning/ml_models",
@@ -236,11 +231,9 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
actix_artcraft = { path = "crates/lib/actix_artcraft" }
actix_helpers = { path = "crates/lib/actix_helpers" }
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
artcraft_api_keys = { path = "crates/api_clients/artcraft/artcraft_api_keys" }
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
bootstrap = { path = "crates/lib/bootstrap" }
bucket_client = { path = "crates/api_clients/bucket_client" }
bucket_paths = { path = "crates/schema/buckets/bucket_paths" }
build_metadata = { path = "crates/lib/build_metadata" }
cloud_storage = { path = "crates/api_clients/cloud_storage" }
@@ -285,7 +278,6 @@ openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
seedance2pro_client = { path = "crates/api_clients/seedance2pro_client" }
server_environment = { path = "crates/lib/server_environment" }
sha1_hash = { path = "crates/lib/sha1_hash" }
shared_service_components = { path = "crates/lib/shared_service_components" }
sqlite_tasks = { path = "crates/schema/database/sqlite_tasks" }
subprocess_common = { path = "crates/lib/deprecated/subprocess_common" }
@@ -295,7 +287,6 @@ tokens = { path = "crates/schema/public/tokens" }
url_utils = { path = "crates/lib/url_utils" }
users = { path = "crates/lib/users" }
uuid_utils = { path = "crates/lib/uuid_utils" }
video_info = { path = "crates/lib/video_info" }
web_base64 = { path = "crates/lib/data/web_base64" }
worldlabs_api_client = { path = "crates/api_clients/worldlabs_api_client" }
worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_client" }
@@ -303,14 +294,16 @@ worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_clie
# External libs
# Centrally pinned versions so that we can uniformly upgrade/downgrade versions across the workspace
# If a crate needs to use an older/newer version, the dependency should be managed in that crate's own Cargo.toml
actix-cors = "0.7.1"
actix-http = "3.12.1"
actix-multipart = "0.7.2"
actix-rt = "2.11.0"
actix = "0.13.5"
actix-cors = "0.6.4" # TODO(bt): Carefully upgrade this. CORS broke last time.
actix-http = "3.7.0"
actix-multipart = "0.6.2"
actix-rt = "2.10.0"
actix-service = "2.0.3"
actix-utils = "3.0.1"
actix-web = "4.13.0"
actix-web-lab = "0.26.0"
actix-web = "4.7.0" # TODO(bt): We're behind and need to bump versions.
actix-web-actors = "4.3.1+deprecated"
actix-web-lab = "0.20.2"
anyhow = "1.0.102"
assertor = "0.0.3"
base64 = "0.22.1"
@@ -350,11 +343,11 @@ sha2 = "0.11.0-rc.5"
sha3 = "0.10.8"
speculoos = "0.11.0"
strum = { version = "0.28.0", features = ["derive"] }
tauri = { version = "2.11.2", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1"
tauri-plugin-log = "2.8.0"
tauri-plugin-upload = "2.4.0"
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.3.3"
tauri-plugin-fs = "2.4.2"
tauri-plugin-log = "2.6.0"
tauri-plugin-upload = "2.3.1"
tempdir = "0.3.7"
tempfile = "3.27.0"
thiserror = "2.0.18"
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN maybe_external_third_party_cost_credits;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_external_third_party_cost_credits INT(10) UNSIGNED DEFAULT NULL;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN maybe_external_third_party_cost_usd_cents;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_external_third_party_cost_usd_cents INT(10) UNSIGNED DEFAULT NULL;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN maybe_system_cost_credits;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_system_cost_credits INT(10) UNSIGNED DEFAULT NULL;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN maybe_system_cost_usd_cents;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_system_cost_usd_cents INT(10) UNSIGNED DEFAULT NULL;
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS api_keys;
@@ -1,50 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE api_keys (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- The actual API key value presented by clients to authenticate.
api_key VARCHAR(255) NOT NULL,
-- ========== KEY INFO ==========
-- User-supplied display name for the key.
name VARCHAR(255) NOT NULL,
-- Optional user-supplied description.
maybe_description VARCHAR(512) DEFAULT NULL,
-- ========== OWNER ==========
-- The user that owns this API key.
owner_user_token VARCHAR(32) NOT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the key. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
ip_address_update VARCHAR(40) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the key is live.
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (api_key),
KEY fk_owner_user_token (owner_user_token),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS uploaded_videos;
@@ -1,62 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE uploaded_videos (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- ========== FILE ==========
-- SHA-1 checksum of the uploaded bytes (40 hex chars). De-dupes uploads.
sha1_checksum VARCHAR(40) NOT NULL,
-- Size of the uploaded file in bytes.
filesize_bytes INT(10) UNSIGNED NOT NULL,
-- Pixel dimensions, when they could be determined.
maybe_width INT(10) UNSIGNED DEFAULT NULL,
maybe_height INT(10) UNSIGNED DEFAULT NULL,
-- Resolution label (e.g. "1080p", "1920x1080"), when known.
maybe_resolution VARCHAR(12) DEFAULT NULL,
-- ========== DETECTION ==========
-- Detected provenance family (e.g. "seedance", "veo", "sora", "dreamina", "kling").
maybe_detected_model_family VARCHAR(32) DEFAULT NULL,
-- Detected model type / variant (e.g. "full", "fast", "mini").
maybe_detected_model_type VARCHAR(32) DEFAULT NULL,
-- Free-form report / analysis blob for this upload.
maybe_report MEDIUMTEXT DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that uploaded the video. Wide enough for IPv4/IPv6.
upload_ip_address VARCHAR(40) NOT NULL,
-- IP address that last updated the row. Wide enough for IPv4/IPv6.
maybe_updated_ip_address VARCHAR(40) DEFAULT NULL,
-- ========== VERSION ==========
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (sha1_checksum),
KEY index_maybe_detected_model_family (maybe_detected_model_family)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS uploaded_video_notes;
@@ -1,69 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE uploaded_video_notes (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- The uploaded video this note is about (FK to uploaded_videos.token).
uploaded_video_token VARCHAR(32) NOT NULL,
-- ========== SUBMISSION ==========
-- Original filename of the submitted clip, as provided by the user.
maybe_filename VARCHAR(512) DEFAULT NULL,
-- The model type the submitter claims this video is.
maybe_reported_model_type VARCHAR(32) DEFAULT NULL,
-- Free-form custom model name when the submitter's model isn't a known type.
maybe_reported_model_name VARCHAR(64) DEFAULT NULL,
-- The website / platform the video was obtained from.
maybe_website VARCHAR(32) DEFAULT NULL,
-- Free-form website when it isn't a known option.
maybe_other_website VARCHAR(128) DEFAULT NULL,
-- Free-form comments from the submitter.
maybe_comments VARCHAR(1024) DEFAULT NULL,
-- ========== CONTACT & FLAGS ==========
-- Optional email address for follow-up.
maybe_email_address VARCHAR(255) DEFAULT NULL,
-- Whether the submitter consents to us sharing the report.
can_share_report BOOLEAN NOT NULL DEFAULT FALSE,
-- Whether the submitter believes they were scammed.
was_scammed BOOLEAN NOT NULL DEFAULT FALSE,
-- ========== IP ADDRESSES ==========
-- IP address that submitted the note. Wide enough for IPv4/IPv6.
comment_create_ip_address VARCHAR(40) NOT NULL,
-- IP address that last updated the note. Wide enough for IPv4/IPv6.
maybe_comment_update_ip_address VARCHAR(40) DEFAULT NULL,
-- ========== VERSION ==========
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY fk_uploaded_video_token (uploaded_video_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE uploaded_videos
DROP COLUMN maybe_filename;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE uploaded_videos
ADD COLUMN maybe_filename VARCHAR(512) DEFAULT NULL AFTER filesize_bytes;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
DROP COLUMN maybe_bitrate;
@@ -1,8 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Add bitrate parameter to prompts table.
-- Represented by the enum `CommonBitrate` (normal, high).
ALTER TABLE prompts
ADD COLUMN maybe_bitrate VARCHAR(16) DEFAULT NULL;
@@ -1,8 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Revert `maybe_entity_token` back to NOT NULL.
-- NB: This will fail if any rows have a null `maybe_entity_token`.
ALTER TABLE staff_audit_logs
MODIFY COLUMN maybe_entity_token VARCHAR(32) NOT NULL;
@@ -1,9 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- `maybe_entity_token` is optional (many audited actions have no entity), but it
-- was mistakenly created as NOT NULL, so inserts with a null entity token fail
-- with "Column 'maybe_entity_token' cannot be null". Make it nullable.
ALTER TABLE staff_audit_logs
MODIFY COLUMN maybe_entity_token VARCHAR(32) DEFAULT NULL;
@@ -1,50 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE api_keys (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- The actual API key value presented by clients to authenticate.
api_key VARCHAR(255) NOT NULL,
-- ========== KEY INFO ==========
-- User-supplied display name for the key.
name VARCHAR(255) NOT NULL,
-- Optional user-supplied description.
maybe_description VARCHAR(512) DEFAULT NULL,
-- ========== OWNER ==========
-- The user that owns this API key.
owner_user_token VARCHAR(32) NOT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the key. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
ip_address_update VARCHAR(40) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the key is live.
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (api_key),
KEY fk_owner_user_token (owner_user_token),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -56,19 +56,6 @@ CREATE TABLE generic_inference_jobs (
-- service uses.
maybe_external_third_party_id VARCHAR(64) DEFAULT NULL,
-- What the third-party service charges us for the job, in the service's
-- own credit denomination (if it bills in credits).
maybe_external_third_party_cost_credits INT(10) UNSIGNED DEFAULT NULL,
-- What the third-party service charges us for the job, in USD cents.
maybe_external_third_party_cost_usd_cents INT(10) UNSIGNED DEFAULT NULL,
-- What we charge the user for the job, in our own credits.
maybe_system_cost_credits INT(10) UNSIGNED DEFAULT NULL,
-- What we charge the user for the job, in USD cents.
maybe_system_cost_usd_cents INT(10) UNSIGNED DEFAULT NULL,
-- There is an index on this column.
-- NB: See notes on inference_category. This is becoming a problematic field. We're using
-- this to load the container spin-up arguments and dependencies, but the former is being
@@ -72,10 +72,6 @@ CREATE TABLE prompts (
-- The quality setting (eg. high, medium, low)
maybe_quality VARCHAR(16) DEFAULT NULL,
-- Represented by the enum `CommonBitrate`.
-- The output bitrate setting (eg. normal, high)
maybe_bitrate VARCHAR(16) DEFAULT NULL,
-- The duration in seconds
maybe_duration_seconds INT UNSIGNED DEFAULT NULL,
@@ -17,7 +17,7 @@ CREATE TABLE staff_audit_logs (
maybe_entity_type VARCHAR(16) DEFAULT NULL,
-- The token of the entity mutated (if any)
maybe_entity_token VARCHAR(32) DEFAULT NULL,
maybe_entity_token VARCHAR(32) NOT NULL,
-- The staff user who performed the action.
staff_user_token VARCHAR(32) DEFAULT NULL,
@@ -1,69 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE uploaded_video_notes (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- The uploaded video this note is about (FK to uploaded_videos.token).
uploaded_video_token VARCHAR(32) NOT NULL,
-- ========== SUBMISSION ==========
-- Original filename of the submitted clip, as provided by the user.
maybe_filename VARCHAR(512) DEFAULT NULL,
-- The model type the submitter claims this video is.
maybe_reported_model_type VARCHAR(32) DEFAULT NULL,
-- Free-form custom model name when the submitter's model isn't a known type.
maybe_reported_model_name VARCHAR(64) DEFAULT NULL,
-- The website / platform the video was obtained from.
maybe_website VARCHAR(32) DEFAULT NULL,
-- Free-form website when it isn't a known option.
maybe_other_website VARCHAR(128) DEFAULT NULL,
-- Free-form comments from the submitter.
maybe_comments VARCHAR(1024) DEFAULT NULL,
-- ========== CONTACT & FLAGS ==========
-- Optional email address for follow-up.
maybe_email_address VARCHAR(255) DEFAULT NULL,
-- Whether the submitter consents to us sharing the report.
can_share_report BOOLEAN NOT NULL DEFAULT FALSE,
-- Whether the submitter believes they were scammed.
was_scammed BOOLEAN NOT NULL DEFAULT FALSE,
-- ========== IP ADDRESSES ==========
-- IP address that submitted the note. Wide enough for IPv4/IPv6.
comment_create_ip_address VARCHAR(40) NOT NULL,
-- IP address that last updated the note. Wide enough for IPv4/IPv6.
maybe_comment_update_ip_address VARCHAR(40) DEFAULT NULL,
-- ========== VERSION ==========
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY fk_uploaded_video_token (uploaded_video_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,65 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE uploaded_videos (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- ========== FILE ==========
-- SHA-1 checksum of the uploaded bytes (40 hex chars). De-dupes uploads.
sha1_checksum VARCHAR(40) NOT NULL,
-- Size of the uploaded file in bytes.
filesize_bytes INT(10) UNSIGNED NOT NULL,
-- Original filename of the uploaded clip, as provided by the user.
maybe_filename VARCHAR(512) DEFAULT NULL,
-- Pixel dimensions, when they could be determined.
maybe_width INT(10) UNSIGNED DEFAULT NULL,
maybe_height INT(10) UNSIGNED DEFAULT NULL,
-- Resolution label (e.g. "1080p", "1920x1080"), when known.
maybe_resolution VARCHAR(12) DEFAULT NULL,
-- ========== DETECTION ==========
-- Detected provenance family (e.g. "seedance", "veo", "sora", "dreamina", "kling").
maybe_detected_model_family VARCHAR(32) DEFAULT NULL,
-- Detected model type / variant (e.g. "full", "fast", "mini").
maybe_detected_model_type VARCHAR(32) DEFAULT NULL,
-- Free-form report / analysis blob for this upload.
maybe_report MEDIUMTEXT DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that uploaded the video. Wide enough for IPv4/IPv6.
upload_ip_address VARCHAR(40) NOT NULL,
-- IP address that last updated the row. Wide enough for IPv4/IPv6.
maybe_updated_ip_address VARCHAR(40) DEFAULT NULL,
-- ========== VERSION ==========
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (sha1_checksum),
KEY index_maybe_detected_model_family (maybe_detected_model_family)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-625
View File
@@ -1,625 +0,0 @@
# ArtCraft Omni API
> # 📚 Full API reference: **<https://storyteller-docs.netlify.app/>**
>
> **Every ArtCraft API endpoint is documented there.** This guide covers only the Omni API video
> endpoints — for everything else, start at the link above.
The **Omni API** is the API-key–authenticated surface for ArtCraft generation. It mirrors the
in-app "Omni Gen" endpoints but is designed for programmatic use: you authenticate with an API key
in the `Authorization` header instead of a browser session cookie.
This guide covers **video generation from URL inputs** — passing image, video, and audio reference
URLs directly. The server downloads each URL, stores it as a media file on your account, and then
runs generation against it. You never have to upload files yourself.
> All examples send **no cookies**. The Omni API is authenticated purely by your API key. If you
> send session cookies they are ignored.
---
## Base URLs
| Environment | Base URL |
| ------------- | ------------------------------ |
| Development | `http://localhost:12345` |
| Production | `https://api.storyteller.ai` |
All paths below are relative to the base URL (e.g. `POST /v1/omni_api/generate/video`).
The examples in this guide use the **development** base URL. To run against production, swap
`http://localhost:12345` for `https://api.storyteller.ai`.
Note: Our APIs are hosted on "`api.storyteller.ai`", not "`api.getartcraft.com`". This is not a
typo. Our Cloudflare CDN URLs are `cdn-2.fakeyou.com`.
---
## Getting an API key
Create and manage API keys from the ArtCraft web app:
1. Go to **<https://app.getartcraft.com/>**
2. Click **Account → Settings → API Keys** (your account must have API keys enabled from our staff)
3. Create a key and copy it. The secret is shown **once** — store it somewhere safe.
> **Don't see the API Keys section?** API access is gated per account. Contact the Artcraft team
> to enable it.
### What an API key looks like
- Prefix: `artcraft_api_`
- Followed by 40 random lowercase Crockford-base32 characters (`0-9 a-z`, excluding `i l o u`)
- Total length: **53 characters**, e.g. `artcraft_api_3k7q9w0xv2hs5n8m4d6r1t8y0p2s4f6h8j0k2m4n6`
Treat the key like a password: never commit it to source control or paste it into logs.
---
## Authentication
Send the secret key in the `Authorization` header. Three forms are accepted:
```
Authorization: Bearer artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Authorization: Key artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Authorization: artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
`Bearer` is recommended. **Do not send cookies.**
---
## Video generation — `POST /v1/omni_api/generate/video`
Generates a video and returns an inference job token you can poll for results.
### Request body
| Field | Type | Notes |
| ---------------------------- | --------------- | ----- |
| `model` | string | Generation model, e.g. `seedance_2p0`. **Required.** |
| `prompt` | string | Text prompt. |
| `duration_seconds` | integer | Length of the output video, e.g. `6`. |
| `aspect_ratio` | string | e.g. `wide_sixteen_by_nine`, `tall_nine_by_sixteen`, `square`. |
| `idempotency_token` | string (UUID) | **Required.** A fresh UUID per request; prevents accidental duplicates. |
| `negative_prompt` | string | Optional. |
| `resolution` | string | Optional, e.g. `seven_twenty_p`, `ten_eighty_p`. |
| `generate_audio` | boolean | Optional. |
| **URL inputs** (this guide) | | |
| `start_frame_image_url` | string (URL) | Starting keyframe image. |
| `end_frame_image_url` | string (URL) | Ending keyframe image. |
| `reference_image_urls` | array of URL | Reference images. |
| `reference_video_urls` | array of URL | Reference videos (`mp4`). |
| `reference_audio_urls` | array of URL | Reference audio (`wav`, `mp3`, `ogg`, …). |
| **Media-token inputs** | | |
| `start_frame_image_media_token`, `end_frame_image_media_token`, `reference_image_media_tokens`, `reference_video_media_tokens`, `reference_audio_media_tokens` | token(s) | Use these instead if you've already uploaded media. |
#### Rules for URL inputs
- Each URL field and its media-token counterpart are **mutually exclusive**. Sending both
`reference_image_urls` and `reference_image_media_tokens`, for example, returns:
`Either reference_image_media_tokens or reference_image_urls must be set, not both`.
- Every URL must start with `http://` or `https://`, otherwise:
`URL must start with http:// or https://, bad URL: <url>`.
- Redirects are followed (up to 10), so CDN/redirecting URLs work.
- The file type is detected from the downloaded bytes. Images must be a real image
(`jpeg`/`png`/`gif`/`webp`), reference videos must be `mp4`, reference audio must be a
supported audio type (`wav`, `mp3`, `aac`, `ogg`, `flac`, …).
### Response
```json
{
"success": true,
"inference_job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx",
"all_job_tokens": ["jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"]
}
```
Use `inference_job_token` to poll for the finished video. `all_job_tokens` lists every job created
(batch requests can create more than one).
---
## Examples
The examples reuse these public sample URLs:
- Images (Wikimedia Commons): an Atlantic puffin, the Matterhorn, a toco toucan
- Video: a short sample `mp4`
- Audio: a short sample `wav`
Replace `YOUR_API_KEY` with your `artcraft_api_…` secret, and generate a fresh `idempotency_token`
(UUID) for every request.
---
### Example 1 — Starting-frame Seedance (`start_frame_image_url`)
Animate outward from a single starting image.
#### bash
```bash
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "'"$(uuidgen)"'",
"start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900"
}'
```
#### python
```python
import uuid
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
resp = requests.post(
f"{BASE_URL}/v1/omni_api/generate/video",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": str(uuid.uuid4()),
"start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
},
timeout=240,
)
resp.raise_for_status()
print(resp.json())
```
#### javascript
```javascript
import { randomUUID } from "node:crypto";
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seedance_2p0",
prompt:
"An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
duration_seconds: 6,
aspect_ratio: "wide_sixteen_by_nine",
idempotency_token: randomUUID(),
start_frame_image_url:
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
}),
});
console.log(resp.status, await resp.json());
```
---
### Example 2 — Image-reference Seedance (`reference_image_urls`)
Guide the generation with one or more reference images.
#### bash
```bash
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0",
"prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "'"$(uuidgen)"'",
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900"
]
}'
```
#### python
```python
import uuid
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
resp = requests.post(
f"{BASE_URL}/v1/omni_api/generate/video",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "seedance_2p0",
"prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": str(uuid.uuid4()),
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
},
timeout=240,
)
resp.raise_for_status()
print(resp.json())
```
#### javascript
```javascript
import { randomUUID } from "node:crypto";
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seedance_2p0",
prompt:
"A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
duration_seconds: 6,
aspect_ratio: "wide_sixteen_by_nine",
idempotency_token: randomUUID(),
reference_image_urls: [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
}),
});
console.log(resp.status, await resp.json());
```
---
### Example 3 — Image + video reference Seedance (`reference_image_urls` + `reference_video_urls`)
Combine reference images with a reference video (and, optionally, reference audio). This is the
fullest URL-driven request.
#### bash
```bash
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "'"$(uuidgen)"'",
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900"
],
"reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"],
"reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"]
}'
```
#### python
```python
import uuid
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
resp = requests.post(
f"{BASE_URL}/v1/omni_api/generate/video",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": str(uuid.uuid4()),
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
"reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"],
"reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"],
},
timeout=240,
)
resp.raise_for_status()
print(resp.json())
```
#### javascript
```javascript
import { randomUUID } from "node:crypto";
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seedance_2p0",
prompt:
"An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
duration_seconds: 6,
aspect_ratio: "wide_sixteen_by_nine",
idempotency_token: randomUUID(),
reference_image_urls: [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
reference_video_urls: ["https://download.samplelib.com/mp4/sample-5s.mp4"],
reference_audio_urls: ["https://download.samplelib.com/wav/sample-6s.wav"],
}),
});
console.log(resp.status, await resp.json());
```
---
## Checking job status
Generation is asynchronous. The generate call returns an `inference_job_token`; poll the job-status
endpoint until the job reaches a terminal state, then read the result URL.
### Endpoints
- **Single job:** `GET /v1/omni_api/job_status/job/{token}`
- **Batch (many jobs):** `GET /v1/omni_api/job_status/batch?tokens=jinf_aaa&tokens=jinf_bbb`
Both require the same `Authorization: Bearer …` header (no cookies).
### Response shape
```json
{
"success": true,
"state": {
"job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx",
"request": {
"inference_category": "video_generation",
"maybe_prompt_token": "prompt_xxxxxxxxxxxxxxxxxxxxxxxxx",
"maybe_model_type": "seedance_2p0",
"maybe_model_token": null
},
"status": {
"status": "pending",
"maybe_first_started_at": null,
"maybe_failure_category": null,
"progress_percentage": 0
},
"maybe_result": null,
"created_at": "2026-06-24T04:06:14Z",
"updated_at": "2026-06-24T04:06:14Z"
}
}
```
- `state.status.status` — the job state. Keep polling while it's `pending`, `started`, or
`attempt_failed`. **Terminal** states are `complete_success`, `complete_failure`, `dead`,
`cancelled_by_user`, and `cancelled_by_system`.
- `state.status.progress_percentage` — an integer 0–100.
- `state.maybe_result` — `null` until the job succeeds. On `complete_success` it contains the
finished media; the playable/downloadable URL is **`state.maybe_result.media_links.cdn_url`**.
For video, `media_links.maybe_video_previews` also carries still/animated preview URLs.
The batch endpoint returns `{ "success": true, "job_states": [ … ] }` where each entry is the same
`state` payload as above.
### Where the result video / image lives
When `state.status.status` is `complete_success`, the finished media is under `state.maybe_result`:
- **`state.maybe_result.media_links.cdn_url`** — the primary, playable/downloadable URL of the
finished asset. **This is the field you want.** For a video job it's the `.mp4`; for an image job
it's the image file (`.jpg` / `.png` / etc.).
- `state.maybe_result.entity_token` — the `m_…` media-file token for the result.
- `state.maybe_result.media_links.maybe_video_previews` — **video jobs only**. Contains a `still`
(poster `.jpg`) and an `animated` (`.gif`) preview URL, plus `{WIDTH}` thumbnail templates. This
is `null` for image jobs.
- `state.maybe_result.media_links.maybe_thumbnail_template` — **image jobs only**. A URL template;
replace `{WIDTH}` with a pixel width to fetch a resized thumbnail. `null` for video jobs.
**Example — completed video job:**
```json
{
"success": true,
"state": {
"job_token": "jinf_9fcn2qv4dv3vvbrrxbnmfgen06v",
"request": {
"inference_category": "video_generation",
"maybe_prompt_token": "prompt_q53tp4rh97qh8q159p21tazzt",
"maybe_model_type": "seedance_2p0",
"maybe_model_token": null
},
"status": {
"status": "complete_success",
"maybe_first_started_at": "2026-06-24T04:07:11Z",
"maybe_failure_category": null,
"progress_percentage": 100
},
"maybe_result": {
"entity_type": "media_file",
"entity_token": "m_ss682134tx19yhqs5y0nbnd0bd59a6",
"media_links": {
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4",
"maybe_thumbnail_template": null,
"maybe_video_previews": {
"still": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.jpg",
"animated": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.gif",
"still_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.jpg",
"animated_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.gif"
}
},
"maybe_successfully_completed_at": "2026-06-24T04:17:30Z"
},
"created_at": "2026-06-24T04:06:14Z",
"updated_at": "2026-06-24T04:17:30Z"
}
}
```
→ The video is at
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4`
(example, not a live URL).
**Example — completed image job** (from `POST /v1/omni_api/generate/image`):
```json
{
"maybe_result": {
"entity_type": "media_file",
"entity_token": "m_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6",
"media_links": {
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg",
"maybe_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg-thumb-{WIDTH}.jpg",
"maybe_video_previews": null
},
"maybe_successfully_completed_at": "2026-06-24T04:09:02Z"
}
}
```
→ The image is at
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg`
(example, not a live URL).
### Poll until complete
#### bash
```bash
JOB_TOKEN="jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
while :; do
resp=$(curl -s "http://localhost:12345/v1/omni_api/job_status/job/$JOB_TOKEN" \
-H "Authorization: Bearer YOUR_API_KEY")
status=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['status']['status'])")
echo "status=$status"
case "$status" in
complete_success)
echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['maybe_result']['media_links']['cdn_url'])"
break ;;
complete_failure|dead|cancelled_by_user|cancelled_by_system)
echo "job ended without a result: $status"; break ;;
esac
sleep 5
done
```
#### python
```python
import time
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
TERMINAL_FAILURES = {"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system"}
while True:
resp = requests.get(
f"{BASE_URL}/v1/omni_api/job_status/job/{JOB_TOKEN}",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30,
)
resp.raise_for_status()
state = resp.json()["state"]
status = state["status"]["status"]
print("status:", status, state["status"]["progress_percentage"], "%")
if status == "complete_success":
print("result URL:", state["maybe_result"]["media_links"]["cdn_url"])
break
if status in TERMINAL_FAILURES:
print("job ended without a result:", status)
break
time.sleep(5)
```
#### javascript
```javascript
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx";
const TERMINAL_FAILURES = new Set([
"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system",
]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
while (true) {
const resp = await fetch(`${BASE_URL}/v1/omni_api/job_status/job/${JOB_TOKEN}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const { state } = await resp.json();
const status = state.status.status;
console.log("status:", status, state.status.progress_percentage, "%");
if (status === "complete_success") {
console.log("result URL:", state.maybe_result.media_links.cdn_url);
break;
}
if (TERMINAL_FAILURES.has(status)) {
console.log("job ended without a result:", status);
break;
}
await sleep(5000);
}
```
---
## Notes & troubleshooting
- **Always send a fresh `idempotency_token`** (UUID). Reusing one is rejected as a duplicate.
- **No cookies.** The endpoint authenticates only via the `Authorization` header.
- **URL must be `http(s)`** and reachable by the server; redirects are followed.
- **Type mismatches** (e.g. a `.webm` passed as a reference video, which requires `mp4`) are
rejected with a `400` describing the unpermitted type.
- **`401 Unauthorized`** — missing/invalid API key, or the key's owner is banned.
- **`402 Payment required`** — insufficient credits/balance.
- There is a companion image endpoint, `POST /v1/omni_api/generate/image`, which accepts
`image_urls` (mutually exclusive with `image_media_tokens`) following the same rules.
-203
View File
@@ -1,203 +0,0 @@
# PageDraw — Visual Design & Architecture Review
**Surface:** `@storyteller/ui-pagedraw` image editor + its `artcraft-webapp` integration (`/edit-image`).
**Lens:** Awwwards / Linear-tier "premium app chrome" rubric, adapted for an in-product creative tool.
**Date:** 2026-06-15 · **Branch:** `worktree-pagedraw-webapp`
---
## 0. How to read this
PageDraw is a **professional canvas tool**, not a marketing page. So the elite-design rubric is
applied in its *app-chrome* register — the north star is **Figma / Linear / Photoshop-web**, not an
Awwwards landing page. That means:
- **Applies:** token discipline, concentric radii, hairline + inset-highlight surfaces ("double-bezel"),
island/pill controls, custom-eased micro-motion, z-index hygiene, premium type ramp.
- **Does _not_ apply:** `py-40` macro-whitespace, hero typography, editorial-split layouts, scroll-reveal
entrances. A tool surface is dense and persistent by design; forcing landing-page whitespace would hurt it.
Citations are `file:line` against the current tree.
---
## 1. Verdict
| Axis | Score | One-liner |
|---|---|---|
| Color system | ⚠️ 5/10 | Good token foundation, but hardcoded greys (`#1b1b1b`, `#303030`, `#404040`) leak through a *themeable lib*. |
| Typography | ❌ 3/10 | No type ramp, no font-family ownership; rides whatever the host sets. Banned-by-rubric default stack risk. |
| Radii | ⚠️ 4/10 | Five radius scales in play (`sm`→`2xl`→`full`) with no concentric nesting. |
| Surfaces (border/shadow) | ⚠️ 5/10 | Flat `shadow-lg` everywhere; no inset highlights; no double-bezel. Reads "functional", not "machined". |
| Motion | ❌ 3/10 | Only `transition-colors`/`ease-in-out`, durations 75–200ms; **zero custom easing**, no spring, no entrance choreography. |
| Layout / z-index | ⚠️ 6/10 | Clean fixed-panel composition, but a `z-[9999999]` escape hatch and a 6-tier ad-hoc z-stack. |
| Icons | ✅ 7/10 | FontAwesome Pro (solid + regular) — acceptable, but solid weight is heavier than the rubric's "ultra-light line" ideal. |
| Architecture | ⚠️ 5/10 | Clean adapter boundary; but `PaintSurface.tsx` (2005 LOC), `SceneState.ts` (1539), `PageDraw.tsx` (1292) are oversized, and the lib hardcodes host-theme colors. |
**Bottom line:** the editor is *structurally competent and functional* but visually reads as
"capable internal tool," not "$150k product surface." The single highest-leverage fix is a **shared
design-token layer** (color + radius + motion + type), because today the lib both (a) reaches for
host tokens (`bg-ui-panel`, `text-base-fg`) *and* (b) hardcodes its own greys — so it can never be
fully reskinned by the webapp vs. the desktop app.
---
## 2. What's already good (keep)
- **Token-aware base.** Most surfaces use semantic tokens (`bg-ui-panel`, `bg-ui-controls`,
`text-base-fg`, `border-ui-panel-border`) — the right instinct (SideToolbar, PromptEditor, HistoryStack).
- **Clean platform boundary.** The lib has zero REST/Tauri imports; everything I/O flows through
`PageDrawAdapter` (`adapter.ts`). The webapp adapter (`web-adapter.tsx`) and desktop adapter stay
swappable. This is genuinely good architecture and should be protected.
- **Performance-minded canvas.** Composite pre-baking on idle (`PageDraw.tsx` ~661–710) and worker
encoding (`generatePipeline.worker.ts`) keep the generate click off the main thread. Don't regress this.
- **CORS already handled** for browser canvas export (`crossOrigin="anonymous"` in `Node.ts:134`,
`SceneState.ts:1473`, `imageHelpers.ts:16`) — the web port works because of this.
---
## 3. Findings & recommendations
### 3.1 Color — stop hardcoding greys in a themeable lib ⚠️ P0
**Current:** semantic tokens *and* raw hex coexist:
- `#1b1b1b` pegboard (`pagedraw.css:6`), `#1A1A1A`/`#333333` context menu (`ContextMenu.tsx:44`),
`#303030`/`#404040` dividers + popovers (`SideToolbar.tsx:381,550`), `bg-zinc-700/600` swatch
(`SideToolbar.tsx:173`), plus ad-hoc `bg-blue-600/500` accents (`PageDraw.tsx:157`, base selector).
- Accent color is inconsistent: the lib's primary action uses raw **blue-600**, while the webapp's
design system uses `--primary` / `brand-primary`. So the editor's blue doesn't match the product.
**Why it fails the bar:** a premium component library exposes *one* surface ramp and *one* accent, both
driven by tokens, so it reskins per host. Hardcoded greys mean the desktop and web themes can drift, and
the raw `blue-600` clashes with `brand-primary` used elsewhere in the webapp.
**Do:**
- Replace every raw grey with the existing ramp: `#1b1b1b`→`bg-ui-background`, `#1A1A1A`→`bg-ui-panel`,
`#303030`/`#404040`→`border-ui-panel-border` / `bg-ui-controls`. For the pegboard, add a token
`--st-canvas` so hosts can theme the canvas backdrop.
- Replace `blue-600/500` accents with `bg-primary` / `text-primary` (the token the rest of the webapp uses),
so the Generate/Edit-3D buttons match `brand-primary`.
- Move the react-colorful overrides off pure `rgb(255,255,255)` borders (`pagedraw.css:15,19`) onto
`white/15` hairlines for consistency with the rest of the chrome.
### 3.2 Typography — own a type ramp ❌ P0
**Current:** no `font-family` anywhere in the lib (grep: 0 hits); only `text-xs/sm/base/lg/xl` +
`font-medium/semibold/bold`. The editor inherits whatever the host's body font is.
**Why it fails the bar:** the rubric explicitly bans the default web stack (Inter/Roboto/Arial/Helvetica).
Inheriting an unspecified stack means the tool can render in a banned font with no recourse, and there's
no deliberate hierarchy (numerics in sliders, labels, modal titles all share one undifferentiated ramp).
**Do:**
- Adopt the product's display/grotesk token (e.g. a `font-sans` mapped to Geist / Plus Jakarta Sans at
the app level) and reference it in the lib's root container, not per-component.
- Define a small semantic ramp: `text-[11px] tracking-[0.16em] uppercase` for toolbar/section labels,
`text-sm` for controls, `text-base font-semibold` for modal titles. Apply `tabular-nums` to slider
read-outs (`SliderWithIndicator.tsx`) and dimension fields (`BlankCanvasModal`) so numbers don't jitter.
### 3.3 Radii — collapse to a concentric scale ⚠️ P1
**Current:** `rounded-sm / md / lg / xl / 2xl / full` all appear, with no parent→child relationship
(e.g. `rounded-2xl` card at `PageDraw.tsx:1123`, `rounded-xl` panels in SideToolbar, `rounded-lg`
buttons in InpaintToolBar).
**Do:** pick a 3-step system — **`rounded-2xl` shells, `rounded-xl` inner cards, `rounded-lg` controls,
`rounded-full` pills** — and apply concentrically (the "double-bezel" curve math: inner radius =
outer − padding). Drop `rounded-sm`/`rounded-md` from the editor entirely.
### 3.4 Surfaces — add the "machined" double-bezel ⚠️ P1
**Current:** panels are a single `bg-ui-panel` + `border` + `shadow-lg`. No nested enclosure, no inset
top-highlight. Reads flat.
**Do (for the four floating panels — SideToolbar, HistoryStack, PromptEditor, InpaintToolBar):**
- Outer shell: `bg-ui-panel/80 ring-1 ring-white/10 backdrop-blur-xl p-1.5 rounded-2xl`.
- Inner core: own bg + `shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] rounded-xl`.
- This is the single change that most moves the surface from "tool" to "premium hardware." The panels are
already `fixed` (`PageDraw.tsx:1144,1175,1195`), so `backdrop-blur` here is GPU-safe per the perf rules.
### 3.5 Motion — replace linear/ease with spring-eased micro-motion ❌ P0 (cheap, high impact)
**Current:** `transition-colors`/`transition-all` with `ease-in-out` and `duration-75/150/200`
(`SideToolbar.tsx:381,550`, `SliderWithIndicator.tsx:65`, `ContextMenu.tsx:53`). No custom easing,
no entrance/exit on popovers, color pickers, or the context menu (they pop instantly).
**Why it fails the bar:** instant state changes and `ease-in-out` are explicitly banned. A pro tool still
deserves *interpolated* motion — it's what separates Figma from a jQuery plugin.
**Do:**
- Standardize on one curve: `transition-[transform,opacity,background-color] duration-300
ease-[cubic-bezier(0.32,0.72,0,1)]`.
- Tool buttons: `active:scale-[0.97]` press physics; selected tool gets a spring-eased indicator slide
rather than a hard background swap.
- Popovers / color picker / context menu: animate in with `opacity-0 translate-y-1 scale-[0.98]` →
resolved, ~180ms. Transform+opacity only (perf rule).
- History tiles: stagger their `opacity` reveal when a generation lands (they already use
`transition-opacity`, just add per-index delay).
### 3.6 Layout & z-index — tame the stack ⚠️ P1
**Current:** clean fixed-panel composition, but z-index is ad-hoc across 6 tiers and includes a
`z-[9999999]` on the context menu (`ContextMenu.tsx:44`) and a one-off `z-[60]` (`PageDraw.tsx:237`).
**Do:** define a named scale in one place — `canvas:0, chrome:10, floating-controls:20, overlay-button:40,
menu:50` — and replace the magic numbers. The portal menu only needs to clear the chrome, not `9999999`.
### 3.7 Responsive — declare intent ⚠️ P2
**Current:** essentially desktop-only; fixed sidebars assume a wide viewport; only LightboxDetails has
`sm:` variants. The webapp wrapper scopes the lib correctly (`pagedraw.tsx` `translateZ(0)` containing
block) but there's no mobile story.
**Do:** mirror the Edit-3D page's explicit **mobile gate** (`pagescene.tsx` `MobileGate`) on `/edit-image`
rather than letting fixed toolbars overlap on touch. A precision canvas tool is legitimately desktop-first;
just say so deliberately instead of degrading silently.
---
## 4. Architecture notes
- **Oversized modules.** `PaintSurface.tsx` (2005 LOC), `SceneState.ts` (1539), `PageDraw.tsx` (1292).
These are review/maintenance hotspots. Suggest extracting from `SceneState.ts`: history/undo slice,
base-image-load slice, and generation/pending slice into composable Zustand slices. `PageDraw.tsx`'s
generate-flow (cases 1–3, ~780–868) could move to a `useGenerate()` hook.
- **Theme ownership.** Per §3.1, the lib should *only* consume tokens. Today it mixes tokens + hex, which
defeats the adapter pattern's promise (one component, many themed hosts).
- **Adapter surface is healthy.** `PageDrawAdapter` is small and intentional; the web vs. Tauri split is
clean. The webapp's `enqueueInpaint` stub and 402→credits-modal handling are explicit and documented.
- **Icon weight.** FontAwesome **solid** is the heaviest option; if a line/light set is available
(Phosphor/Remix or FA light), the toolbar would read more refined. Low priority.
---
## 5. Prioritized roadmap
| P | Item | Effort | Payoff |
|---|---|---|---|
| **P0** | Token sweep: kill hardcoded greys; unify accent on `--primary` (§3.1) | M | Highest — enables true theming + product-consistent accent |
| **P0** | Own a font-family + semantic type ramp at the lib root (§3.2) | S | High — removes banned-font risk, adds hierarchy |
| **P0** | One custom easing curve + press/popover micro-motion (§3.5) | S | High — cheapest "feels expensive" win |
| **P1** | Double-bezel + inset highlights on the 4 floating panels (§3.4) | M | High — "machined" surface upgrade |
| **P1** | Concentric 3-step radius system (§3.3) | S | Medium — visual coherence |
| **P1** | Named z-index scale; remove `z-[9999999]` (§3.6) | S | Medium — maintainability |
| **P2** | Explicit mobile gate on `/edit-image` (§3.7) | S | Medium — correctness on touch |
| **P2** | Split `SceneState.ts` / `PaintSurface.tsx` into slices (§4) | L | Medium — maintainability |
---
## 6. Appendix — proposed token additions
```css
/* canvas + chrome surface ramp (lib should consume only these) */
--st-canvas: <theme>; /* replaces #1b1b1b pegboard */
--st-accent: var(--primary); /* replaces blue-600/500 */
--st-hairline: rgb(255 255 255 / 0.10);
--st-inset-hi: inset 0 1px 0 rgb(255 255 255 / 0.08);
/* motion */
--st-ease: cubic-bezier(0.32, 0.72, 0, 1);
--st-dur-fast: 180ms;
--st-dur: 300ms;
/* radius scale (concentric) */
--st-r-shell: 1.25rem; /* rounded-2xl */
--st-r-card: 0.875rem; /* rounded-xl */
--st-r-control: 0.5rem; /* rounded-lg */
```
> Scope note: every recommendation here is **styling/architecture only** — none change the generate,
> polling, upload, bg-removal, or adapter contracts validated during the functional debug pass.
@@ -1,42 +0,0 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
/// Canonical wire shape for an API key, used by the list rows and the
/// single-key GET.
///
/// NB: the full secret `api_key` is NEVER included here — only
/// `truncated_api_key` (its first 20 characters), enough to disambiguate keys
/// in a UI. The full value is returned exactly once, at creation time.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiKeyInfo {
pub token: ApiKeyToken,
/// First 20 characters of the API key. The full secret is never returned
/// after creation.
pub truncated_api_key: String,
pub name: String,
pub maybe_description: Option<String>,
pub owner_user_token: UserToken,
pub ip_address_creation: String,
pub ip_address_update: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// Soft-delete timestamp. `None` for live keys.
pub maybe_deleted_at: Option<DateTime<Utc>>,
}
/// Path info for endpoints addressed by the API key's `token` (NOT the secret
/// `api_key` value): `GET` / `DELETE` / `PUT` `/v1/api_keys/{api_key_token}`.
#[derive(Deserialize, ToSchema)]
pub struct ApiKeyPathInfo {
pub api_key_token: ApiKeyToken,
}
@@ -1,28 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::api_keys::ApiKeyToken;
// ── POST /v1/api_keys/create ──
#[derive(Deserialize, ToSchema)]
pub struct CreateApiKeyRequest {
/// Display name (title) for the key.
pub name: String,
/// Optional description.
pub maybe_description: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct CreateApiKeySuccessResponse {
pub success: bool,
/// The key's `token` — used for all subsequent management
/// (get / update / delete). Safe to store and display.
pub api_key_token: ApiKeyToken,
/// The full secret API key value. This is the ONLY time it is ever
/// returned — the caller must store it now, as it cannot be retrieved again.
pub api_key: String,
}
@@ -1,9 +0,0 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── DELETE /v1/api_keys/{api_key_token} ──
#[derive(Serialize, ToSchema)]
pub struct DeleteApiKeySuccessResponse {
pub success: bool,
}
@@ -1,12 +0,0 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
use crate::api_keys::common::ApiKeyInfo;
// ── GET /v1/api_keys/{api_key_token} ──
#[derive(Serialize, ToSchema)]
pub struct GetApiKeySuccessResponse {
pub success: bool,
pub api_key: ApiKeyInfo,
}
@@ -1,24 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::api_keys::common::ApiKeyInfo;
// ── GET /v1/api_keys/list ──
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListApiKeysQueryParams {
/// Page size. Defaults server-side if omitted.
pub limit: Option<u32>,
/// Number of rows to skip. Defaults to 0.
pub offset: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListApiKeysSuccessResponse {
pub success: bool,
/// The user's API keys (including soft-deleted ones), newest first. Each
/// entry's key value is truncated — the full secret is never listed.
pub api_keys: Vec<ApiKeyInfo>,
}
@@ -1,6 +0,0 @@
pub mod common;
pub mod create_api_key;
pub mod delete_api_key;
pub mod get_api_key;
pub mod list_api_keys;
pub mod update_api_key;
@@ -1,15 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
// ── PUT /v1/api_keys/{api_key_token} ──
#[derive(Deserialize, ToSchema)]
pub struct UpdateApiKeyRequest {
/// New description for the key. `None` clears it.
pub maybe_description: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct UpdateApiKeySuccessResponse {
pub success: bool,
}
@@ -64,31 +64,6 @@ pub struct FolderThumbnail {
/// Resizing template URL (with `{{width}}` / `{{height}}` placeholders)
/// when the media class supports it. `None` for media classes without
/// a thumbnail variant. (Video media files instead have video previews,
/// which, in turn, have their own thumbnail templates.)
/// a thumbnail variant.
pub maybe_thumbnail_template: Option<String>,
/// Video preview images (still and animated gif) for mp4 video files.
/// These are only set for video media files.
pub maybe_video_previews: Option<FolderThumbnailVideoPreviews>,
}
/// Video preview images for a video media file referenced by a folder.
/// Mirrors the shape of `MediaLinks`' video previews, but is its own type
/// so the folder wire shape can evolve independently.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct FolderThumbnailVideoPreviews {
/// A static single frame preview image of the video.
pub still: Url,
/// An animated gif preview of the video.
pub animated: Url,
/// A template used to construct the still thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub still_thumbnail_template: String,
/// A template used to construct the animated thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub animated_thumbnail_template: String,
}
@@ -1,5 +1,4 @@
pub mod analytics;
pub mod api_keys;
pub mod characters;
pub mod common;
pub mod credits;
@@ -8,7 +7,6 @@ pub mod generate;
pub mod jobs;
pub mod media_file;
pub mod moderation;
pub mod omni_api;
pub mod omni_gen;
pub mod prompts;
pub mod stripe_artcraft;
@@ -16,5 +14,4 @@ pub mod subscriptions;
pub mod user_referral_codes;
pub mod users;
pub mod utils;
pub mod video_info;
pub mod web_referrals;
@@ -1,2 +0,0 @@
pub mod omni_api_image_generate_request;
pub mod omni_api_video_generate_request;
@@ -1,58 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use tokens::tokens::media_files::MediaFileToken;
/// Request body for the Omni API image generation endpoint.
///
/// Forked from `OmniGenImageCostAndGenerateRequest` so the Omni API's request
/// shape can evolve independently without affecting the product-critical Omni
/// Gen endpoints.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniApiImageGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
/// If not supplied, we'll generate one for the required providers.
pub idempotency_token: Option<String>,
/// REQUIRED (even if marked optional)
/// Which model to use.
pub model: Option<CommonImageModel>,
/// The prompt for the image generation.
pub prompt: Option<String>,
/// Input images for image editing.
/// If present, we're doing image editing (image-to-image).
/// If absent, we're doing text-to-image.
pub image_media_tokens: Option<Vec<MediaFileToken>>,
/// URL alternative to `image_media_tokens`. Mutually exclusive with it.
/// Each URL is downloaded and stored as a media file before generation.
pub image_urls: Option<Vec<String>>,
/// The resolution to use.
pub resolution: Option<CommonResolution>,
/// The aspect ratio to use.
pub aspect_ratio: Option<CommonAspectRatio>,
/// The quality to use.
pub quality: Option<CommonQuality>,
/// How many images to generate.
pub image_batch_count: Option<u16>,
/// Only for angle manipulation models.
pub adjust_horizontal_angle: Option<f64>,
/// Only for angle manipulation models.
pub adjust_vertical_angle: Option<f64>,
/// Only for angle manipulation models.
pub adjust_zoom: Option<f64>,
}
@@ -1,96 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
/// Request body for the Omni API video generation endpoint.
///
/// Forked from `OmniGenVideoCostAndGenerateRequest` so the Omni API's request
/// shape can evolve independently without affecting the product-critical Omni
/// Gen endpoints.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniApiVideoGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
pub idempotency_token: Option<String>,
/// REQUIRED (even if marked optional)
/// Which model to use.
pub model: Option<CommonVideoModel>,
/// The text prompt for the video generation.
pub prompt: Option<String>,
/// Some models support negative text prompts.
pub negative_prompt: Option<String>,
/// Starting keyframe (optional).
pub start_frame_image_media_token: Option<MediaFileToken>,
/// URL alternative to `start_frame_image_media_token`. Mutually exclusive with it.
/// Downloaded and stored as a media file before generation.
pub start_frame_image_url: Option<String>,
/// Ending keyframe (optional).
pub end_frame_image_media_token: Option<MediaFileToken>,
/// URL alternative to `end_frame_image_media_token`. Mutually exclusive with it.
/// Downloaded and stored as a media file before generation.
pub end_frame_image_url: Option<String>,
/// Reference images (optional).
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// URL alternative to `reference_image_media_tokens`. Mutually exclusive with it.
/// Each URL is downloaded and stored as a media file before generation.
pub reference_image_urls: Option<Vec<String>>,
/// Reference videos (optional).
pub reference_video_media_tokens: Option<Vec<MediaFileToken>>,
/// URL alternative to `reference_video_media_tokens`. Mutually exclusive with it.
/// Each URL is downloaded and stored as a media file before generation.
pub reference_video_urls: Option<Vec<String>>,
/// Reference audio (optional).
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
/// URL alternative to `reference_audio_media_tokens`. Mutually exclusive with it.
/// Each URL is downloaded and stored as a media file before generation.
pub reference_audio_urls: Option<Vec<String>>,
/// Optional character tokens to reference in the prompt.
/// Characters are referenced in prompts as @CharacterName.
pub reference_character_tokens: Option<Vec<CharacterToken>>,
/// The resolution to use.
pub resolution: Option<CommonResolution>,
/// The aspect ratio to use.
pub aspect_ratio: Option<CommonAspectRatio>,
/// The output bitrate to use.
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<CommonBitrate>,
/// The quality to use.
pub quality: Option<CommonQuality>,
/// How many seconds to generate.
pub duration_seconds: Option<u16>,
/// How many videos to generate.
pub video_batch_count: Option<u16>,
/// Whether to turn on/off audio.
/// Not all models support audio, not all models have a choice.
/// Some models will default this to true, others will default it to false,
/// so it's best to be explicit.
pub generate_audio: Option<bool>,
}
@@ -1,3 +0,0 @@
pub mod omni_api_batch_get_job_status;
pub mod omni_api_get_job_status;
pub mod omni_api_job_status_payload;
@@ -1,25 +0,0 @@
use std::collections::HashSet;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
/// Query params for `GET /v1/omni_api/job_status/batch`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct OmniApiBatchGetJobStatusQueryParams {
/// A grab bag of job tokens. Each token family generally has its own prefix,
/// so a flat set is sufficient.
///
/// NB: handlers parse this with `actix_web_lab`'s `Query<T>` because the
/// default actix-web `Query<T>` doesn't decode repeated/sequence params yet.
/// See https://github.com/actix/actix-web/issues/1301
pub tokens: HashSet<String>,
}
/// Success body for `GET /v1/omni_api/job_status/batch`.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiBatchGetJobStatusSuccessResponse {
pub success: bool,
pub job_states: Vec<OmniApiJobStatusPayload>,
}
@@ -1,19 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
/// Path params for `GET /v1/omni_api/job_status/job/{token}`.
#[derive(Deserialize, ToSchema)]
pub struct OmniApiGetJobStatusPathInfo {
pub token: InferenceJobToken,
}
/// Success body for `GET /v1/omni_api/job_status/job/{token}`.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiGetJobStatusSuccessResponse {
pub success: bool,
pub state: OmniApiJobStatusPayload,
}
@@ -1,69 +0,0 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::job_status_plus::JobStatusPlus;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_links::MediaLinks;
/// Full status payload for a single inference job.
///
/// Shared by both the single (`/v1/omni_api/job_status/job/{token}`) and batch
/// (`/v1/omni_api/job_status/batch`) Omni API status endpoints. Forked from the
/// cookie-authenticated `/v1/jobs/*` response types so the Omni API surface can
/// evolve independently.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobStatusPayload {
pub job_token: InferenceJobToken,
pub request: OmniApiJobRequestDetails,
pub status: OmniApiJobStatusDetails,
pub maybe_result: Option<OmniApiJobResultDetails>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Details about what the user requested for generation.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobRequestDetails {
pub inference_category: InferenceCategory,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_model_type: Option<String>,
pub maybe_model_token: Option<String>,
}
/// Details about the ongoing job status.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobStatusDetails {
/// Primary status from the database (a state machine).
pub status: JobStatusPlus,
pub maybe_first_started_at: Option<DateTime<Utc>>,
/// An enum the frontend can use to display localized/I18N error
/// messages. These pertain to both transient and permanent failures.
pub maybe_failure_category: Option<FrontendFailureCategory>,
/// This is an integer number between 0 and 100 (both inclusive) that
/// reports the completeness.
pub progress_percentage: u8,
}
/// Details about the completed result (if any).
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobResultDetails {
pub entity_type: String,
pub entity_token: String,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
pub maybe_successfully_completed_at: Option<DateTime<Utc>>,
}
@@ -1,5 +0,0 @@
pub mod generate_requests;
pub mod job_status;
pub mod omni_upload_audio;
pub mod omni_upload_image;
pub mod omni_upload_video;
@@ -1,15 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
/// Success response for the API-key authenticated audio upload endpoint
/// (`POST /v1/omni_api/upload/audio`).
///
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniUploadAudioMediaFileSuccessResponse {
pub success: bool,
pub media_file_token: MediaFileToken,
}
@@ -1,15 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
/// Success response for the API-key authenticated image upload endpoint
/// (`POST /v1/omni_api/upload/image`).
///
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniUploadImageMediaFileSuccessResponse {
pub success: bool,
pub media_file_token: MediaFileToken,
}
@@ -1,15 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
/// Success response for the API-key authenticated video upload endpoint
/// (`POST /v1/omni_api/upload/video`).
///
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniUploadVideoMediaFileSuccessResponse {
pub success: bool,
pub media_file_token: MediaFileToken,
}
@@ -2,7 +2,6 @@ use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
@@ -51,10 +50,6 @@ pub struct OmniGenVideoCostAndGenerateRequest {
/// The aspect ratio to use.
pub aspect_ratio: Option<CommonAspectRatio>,
/// The output bitrate to use.
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<CommonBitrate>,
/// The quality to use.
pub quality: Option<CommonQuality>,
@@ -6,7 +6,6 @@ use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::common::generation::common_bitrate::CommonBitrate;
/// Video model to default to if none is specified
const DEFAULT_VIDEO_MODEL : CommonVideoModel = CommonVideoModel::Seedance2p0;
@@ -151,12 +150,6 @@ pub struct OmniGenVideoModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution_default: Option<CommonResolution>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bitrate_options: Option<Vec<CommonBitrate>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bitrate_default: Option<CommonBitrate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_options: Option<Vec<CommonQuality>>,
@@ -227,8 +220,6 @@ impl Default for OmniGenVideoModelDetails {
aspect_ratio_default: None,
resolution_options: None,
resolution_default: None,
bitrate_options: None,
bitrate_default: None,
quality_options: None,
default_quality: None,
duration_seconds_min: None,
@@ -2,7 +2,6 @@ use crate::prompts::get_prompt::GetPromptImageContextItem;
use chrono::{DateTime, Utc};
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_class::CommonModelClass;
use enums::common::generation::common_model_type::CommonModelType;
@@ -56,9 +55,6 @@ pub struct BatchPromptInfo {
/// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.)
pub maybe_resolution: Option<CommonResolution>,
/// OPTIONAL. The output bitrate (eg. normal, high).
pub maybe_bitrate: Option<CommonBitrate>,
/// OPTIONAL. The number of outputs to generate (0-255).
pub maybe_batch_count: Option<u8>,
@@ -3,7 +3,6 @@ use chrono::{DateTime, Utc};
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_resolution::CommonResolution;
@@ -60,9 +59,6 @@ pub struct PromptInfo {
/// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.)
pub maybe_resolution: Option<CommonResolution>,
/// OPTIONAL. The output bitrate (eg. normal, high).
pub maybe_bitrate: Option<CommonBitrate>,
/// OPTIONAL. The number of outputs to generate (0-255).
pub maybe_batch_count: Option<u8>,
@@ -1,3 +0,0 @@
pub mod notes;
pub mod read_only;
pub mod upload;
@@ -1,60 +0,0 @@
//! Request/response types for `POST /v1/video_info/notes`.
//!
//! A note is user-submitted context about an uploaded video (what model they
//! believe it is, where it came from, free-form comments). Always tied to an
//! [`UploadedVideoToken`]; pass a [`UploadVideoNoteToken`] to update an existing
//! note instead of creating a new one.
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_model_type::UploadedVideoNoteReportedModelType;
use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_website::UploadedVideoNoteReportedWebsite;
use tokens::tokens::uploaded_video_notes::UploadVideoNoteToken;
use tokens::tokens::uploaded_videos::UploadedVideoToken;
/// Request body for `POST /v1/video_info/notes`.
#[derive(Deserialize, ToSchema, Debug, Clone)]
pub struct VideoInfoNoteRequest {
/// The uploaded video this note is about. Required.
pub uploaded_video_token: UploadedVideoToken,
/// If present, update this existing note instead of creating a new one.
pub maybe_uploaded_video_note_token: Option<UploadVideoNoteToken>,
/// Original filename of the submitted clip.
pub maybe_filename: Option<String>,
/// The model type the submitter claims this video is (dropdown / enum).
pub maybe_reported_model_type: Option<UploadedVideoNoteReportedModelType>,
/// Free-form custom model name (trimmed; empty becomes absent).
pub maybe_reported_model_name: Option<String>,
/// The website / platform the video came from (dropdown / enum).
pub maybe_website: Option<UploadedVideoNoteReportedWebsite>,
/// Free-form website (trimmed; empty becomes absent).
pub maybe_other_website: Option<String>,
/// Free-form comments (trimmed; empty becomes absent).
pub maybe_comments: Option<String>,
/// Optional contact email (trimmed; empty becomes absent).
pub maybe_email_address: Option<String>,
/// Whether the submitter consents to us sharing the report. Defaults to false.
pub can_share_report: Option<bool>,
/// Whether the submitter believes they were scammed. Defaults to false.
pub was_scammed: Option<bool>,
}
/// Response body for `POST /v1/video_info/notes`.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct VideoInfoNoteResponse {
pub success: bool,
/// The created or updated note's token (e.g. `uvnote_…`).
pub uploaded_video_note_token: UploadVideoNoteToken,
}
@@ -1,167 +0,0 @@
//! Response types for `POST /v1/video_info/read_only`.
//!
//! The endpoint accepts a `multipart/form-data` video upload, inspects its
//! embedded provenance (C2PA manifests, AIGC labels, in-stream watermarks), and
//! returns what it found. These are the public, wire-stable API types — they
//! deliberately mirror, but do not re-export, the internal `video_info` crate
//! types so the parsing library can evolve independently of the HTTP contract.
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// Response for `POST /v1/video_info/read_only`.
///
/// [`kind`](Self::kind) names the detected provenance family; the matching
/// `maybe_*` field carries its details (all others are `null`). `unrecognized`
/// means no supported provenance was found — [`maybe_encoder`](Self::maybe_encoder)
/// still reports the container encoder tag when present (an `Lavf…` value means
/// the file was re-encoded through ffmpeg, which strips provenance).
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct VideoInfoReadOnlyResponse {
pub success: bool,
/// Which provenance family was detected (if any).
pub kind: VideoProvenanceKind,
/// The container `©too` encoder tag, e.g. `"Lavf60.16.100"` or `"Google"`.
pub maybe_encoder: Option<String>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Seedance`].
pub maybe_seedance: Option<SeedanceVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Veo`].
pub maybe_veo: Option<VeoVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Sora`].
pub maybe_sora: Option<SoraVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Dreamina`].
pub maybe_dreamina: Option<DreaminaVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Kling`].
pub maybe_kling: Option<KlingVideoInfo>,
}
/// The recognized AI-video provenance family.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum VideoProvenanceKind {
/// ByteDance Seedance API (Volcengine / BytePlus C2PA).
Seedance,
/// Google Veo (Google Generative AI video C2PA).
Veo,
/// OpenAI Sora (OpenAI Generative AI video C2PA).
Sora,
/// Dreamina (ByteDance/CapCut consumer app `ilst` metadata).
Dreamina,
/// Kling (Kuaishou AIGC-label / in-stream watermark).
Kling,
/// No supported provenance was found (often a re-encoded / stripped file).
Unrecognized,
}
/// Seedance (ByteDance) provenance from an embedded C2PA manifest.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct SeedanceVideoInfo {
/// `"volcengine"` or `"byteplus"`.
pub platform: String,
/// C2PA `softwareAgent`, e.g. `"Volcengine_Ark_CN"`.
pub software_agent: String,
pub maybe_software_agent_version: Option<String>,
/// Full model identifier, e.g. `"doubao-seedance-2-0-fast"`.
pub model_name: String,
pub maybe_model_brand: Option<String>,
/// Parsed version, e.g. `"2.0"` / `"1.0"`.
pub maybe_model_version: Option<String>,
pub is_fast: bool,
pub is_lite: bool,
/// Raw generation timestamp string (RFC 3339).
pub generated_at: String,
/// Normalized RFC 3339 timestamp, when parseable.
pub maybe_generated_at_utc: Option<String>,
pub maybe_log_id: Option<String>,
pub maybe_log_id_decoded_hex: Option<String>,
pub maybe_digital_source_type: Option<String>,
pub maybe_claim_generator: Option<String>,
pub maybe_claim_generator_version: Option<String>,
pub maybe_manifest_id: Option<String>,
pub maybe_instance_id: Option<String>,
pub maybe_signer_email: Option<String>,
pub maybe_signer_org_id: Option<String>,
pub maybe_signer_country: Option<String>,
pub maybe_cert_serial: Option<String>,
}
/// Google Veo provenance.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct VeoVideoInfo {
/// Always `"Google"`.
pub producer: String,
/// Whether a full Google C2PA manifest is present (`false` = recognized only
/// by the `encoder=Google` tag, C2PA likely stripped by a re-encode).
pub has_c2pa_manifest: bool,
pub maybe_encoder: Option<String>,
pub maybe_created_description: Option<String>,
pub has_synthid_watermark: bool,
pub maybe_synthid_description: Option<String>,
pub maybe_digital_source_type: Option<String>,
pub maybe_claim_generator: Option<String>,
pub maybe_claim_generator_version: Option<String>,
pub maybe_manifest_id: Option<String>,
pub maybe_instance_id: Option<String>,
pub maybe_cert_serial: Option<String>,
/// Issuing intermediate-CA common name, e.g. `"Google C2PA Media Services 1P ICA G3"`.
pub maybe_signer_ca: Option<String>,
/// Whether the manifest carries an RFC 3161 trusted timestamp.
pub is_timestamped: bool,
pub maybe_timestamp_authority: Option<String>,
}
/// OpenAI Sora provenance.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct SoraVideoInfo {
/// Always `"OpenAI"`.
pub producer: String,
pub maybe_model_name: Option<String>,
pub maybe_created_description: Option<String>,
pub maybe_digital_source_type: Option<String>,
pub maybe_claim_generator: Option<String>,
pub maybe_manifest_id: Option<String>,
pub maybe_instance_id: Option<String>,
pub maybe_cert_serial: Option<String>,
}
/// Dreamina (ByteDance/CapCut app) provenance from `ilst` metadata.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct DreaminaVideoInfo {
/// The product string, e.g. `"dreamina"`.
pub product: String,
pub maybe_export_type: Option<String>,
pub maybe_os: Option<String>,
pub maybe_source_info: Option<String>,
pub maybe_aigc_label_type: Option<i64>,
pub maybe_video_id: Option<String>,
pub has_c2pa: bool,
pub maybe_signer_org_id: Option<String>,
pub maybe_signer_country: Option<String>,
pub maybe_cert_serial: Option<String>,
}
/// Kling (Kuaishou) provenance from the AIGC label and/or in-stream watermark.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct KlingVideoInfo {
/// Model version when a clean export embeds it (e.g. `"2.5-pro"`); usually `null`.
pub maybe_model_version: Option<String>,
/// Whether labeled AI-generated (`Label == "1"`, per China GB 45438).
pub is_ai_generated: bool,
pub maybe_label: Option<String>,
pub maybe_content_producer: Option<String>,
pub maybe_produce_id: Option<String>,
pub maybe_content_propagator: Option<String>,
pub maybe_propagate_id: Option<String>,
pub maybe_reserved_code_1: Option<String>,
pub maybe_reserved_code_2: Option<String>,
/// Whether the in-stream `kling-ai` SEI watermark is present.
pub has_stream_watermark: bool,
pub maybe_watermark_uuid: Option<String>,
}
@@ -1,46 +0,0 @@
//! Response type for `POST /v1/video_info/upload`.
//!
//! Same detected provenance as the read-only endpoint, plus the persisted
//! [`UploadedVideoToken`] (used for follow-up requests, e.g. attaching a note).
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::uploaded_videos::UploadedVideoToken;
use crate::video_info::read_only::{
DreaminaVideoInfo, KlingVideoInfo, SeedanceVideoInfo, SoraVideoInfo, VeoVideoInfo,
VideoProvenanceKind,
};
/// Response for `POST /v1/video_info/upload`. Mirrors
/// [`crate::video_info::read_only::VideoInfoReadOnlyResponse`] and adds the
/// persisted record's token.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct VideoInfoUploadResponse {
pub success: bool,
/// The persisted `uploaded_videos` record token (e.g. `uvt_…`).
pub uploaded_video_token: UploadedVideoToken,
/// Which provenance family was detected (if any).
pub kind: VideoProvenanceKind,
/// The container `©too` encoder tag, e.g. `"Lavf60.16.100"` or `"Google"`.
pub maybe_encoder: Option<String>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Seedance`].
pub maybe_seedance: Option<SeedanceVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Veo`].
pub maybe_veo: Option<VeoVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Sora`].
pub maybe_sora: Option<SoraVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Dreamina`].
pub maybe_dreamina: Option<DreaminaVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Kling`].
pub maybe_kling: Option<KlingVideoInfo>,
}
@@ -1,20 +0,0 @@
[package]
name = "artcraft_api_keys"
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "artcraft_api_keys"
path = "src/lib.rs"
[dependencies]
# Hackari workspace (faster compile times)
workspace-build-acceleration.workspace = true
@@ -1,104 +0,0 @@
use std::fmt;
use crate::parse_from_header_value::parse_from_header_value;
pub mod parse_from_header_value;
/// Number of leading characters shown when an [`ArtcraftApiKey`] is formatted. The remainder of
/// the secret is redacted so the full key never lands in logs or error messages.
const REDACTED_PREFIX_CHAR_COUNT: usize = 20;
/// An Artcraft API key (e.g. `artcraft_api_<entropy>`).
///
/// `Debug` and `Display` both redact the value, printing only the first
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis so the full secret is never
/// accidentally logged.
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ArtcraftApiKey(pub String);
impl ArtcraftApiKey {
pub fn new_from_str(value: &str) -> ArtcraftApiKey {
ArtcraftApiKey(value.to_string())
}
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header, accepting the
/// `Bearer <key>`, `Key <key>`, and bare `<key>` forms. See [`parse_from_header_value`] for the
/// full parsing rules.
pub fn parse_from_authorization_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
parse_from_header_value(header_value)
}
/// The full, unredacted key value as a borrowed `&str`.
///
/// NB: unlike `Display`/`Debug` (which redact), this exposes the complete secret — the
/// `_be_careful` suffix is a reminder to use it only where the real value is genuinely needed
/// (storage, the create-key response, tests), never in logs or error messages.
pub fn as_str_be_careful(&self) -> &str {
&self.0
}
/// The full, unredacted key value as an owned `String`. See [`Self::as_str_be_careful`] for the
/// caveat behind the `_be_careful` suffix.
pub fn to_string_be_careful(&self) -> String {
self.0.clone()
}
/// The redacted form used by both `Debug` and `Display`: the first
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis.
fn redacted(&self) -> String {
let prefix: String = self.0.chars().take(REDACTED_PREFIX_CHAR_COUNT).collect();
format!("{prefix}…")
}
}
impl fmt::Display for ArtcraftApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.redacted())
}
}
impl fmt::Debug for ArtcraftApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ArtcraftApiKey({:?})", self.redacted())
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
#[test]
fn new_from_str_and_as_str_round_trip() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
assert_eq!(key.0, SAMPLE_KEY);
}
#[test]
fn display_redacts_to_first_twenty_characters() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(format!("{key}"), "artcraft_api_55ax0zh…");
}
#[test]
fn debug_redacts_to_first_twenty_characters() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(format!("{key:?}"), "ArtcraftApiKey(\"artcraft_api_55ax0zh…\")");
}
#[test]
fn formatting_does_not_leak_the_full_secret() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
let suffix = &SAMPLE_KEY[REDACTED_PREFIX_CHAR_COUNT..];
assert!(!format!("{key}").contains(suffix));
assert!(!format!("{key:?}").contains(suffix));
}
#[test]
fn shorter_than_prefix_is_printed_in_full() {
let key = ArtcraftApiKey::new_from_str("abc");
assert_eq!(format!("{key}"), "abc…");
}
}
@@ -1,125 +0,0 @@
use crate::ArtcraftApiKey;
/// `Bearer <api_key>` — the widely-accepted style for most APIs.
const BEARER_SCHEME: &str = "bearer";
/// `Key <api_key>` — legacy GitHub-style / Fal-style.
const KEY_SCHEME: &str = "key";
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header.
///
/// The value is accepted in three forms:
///
/// - `Bearer <api_key>` (widely-accepted style for most APIs)
/// - `Key <api_key>` (legacy GitHub-style / Fal-style)
/// - `<api_key>` (AWS-style — the bare key, no scheme)
///
/// The scheme keyword is matched case-insensitively, and surrounding/inner whitespace is trimmed.
/// Returns `None` if the value is empty, carries an unsupported scheme (e.g. `Basic ...`), or names
/// a scheme with no credential.
pub fn parse_from_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
let trimmed = header_value.trim();
if trimmed.is_empty() {
return None;
}
let key = match trimmed.split_once(char::is_whitespace) {
// A scheme word followed by a value: "Bearer <key>" / "Key <key>".
Some((scheme, rest)) => {
if is_supported_scheme(scheme) {
let key = rest.trim();
if key.is_empty() {
// A supported scheme with no credential.
return None;
}
key
} else {
// An unsupported scheme (e.g. "Basic ..."). A bare API key never
// contains whitespace, so a multi-token value we don't recognize is
// not a usable key.
return None;
}
}
// No whitespace: AWS-style bare API key ("<api_key>") — unless the value is
// just a bare scheme keyword (a malformed header carrying no credential).
None => {
if is_supported_scheme(trimmed) {
return None;
}
trimmed
}
};
Some(ArtcraftApiKey::new_from_str(key))
}
fn is_supported_scheme(word: &str) -> bool {
word.eq_ignore_ascii_case(BEARER_SCHEME) || word.eq_ignore_ascii_case(KEY_SCHEME)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
fn parsed(header_value: &str) -> Option<String> {
parse_from_header_value(header_value).map(|key| key.to_string_be_careful())
}
#[test]
fn bearer_scheme_is_case_insensitive() {
assert_eq!(parsed("Bearer abc"), Some("abc".to_string()));
assert_eq!(parsed("bearer abc"), Some("abc".to_string()));
assert_eq!(parsed("BEARER abc"), Some("abc".to_string()));
assert_eq!(parsed("BeArEr abc"), Some("abc".to_string()));
}
#[test]
fn key_scheme_is_case_insensitive() {
assert_eq!(parsed("Key abc"), Some("abc".to_string()));
assert_eq!(parsed("key abc"), Some("abc".to_string()));
assert_eq!(parsed("KEY abc"), Some("abc".to_string()));
}
#[test]
fn bare_key_aws_style() {
assert_eq!(parsed("abc"), Some("abc".to_string()));
assert_eq!(parsed(SAMPLE_KEY), Some(SAMPLE_KEY.to_string()));
}
#[test]
fn surrounding_and_inner_whitespace_is_trimmed() {
assert_eq!(parsed(" Bearer abc "), Some("abc".to_string()));
assert_eq!(parsed("\tKey\tabc\t"), Some("abc".to_string()));
assert_eq!(parsed(" abc "), Some("abc".to_string()));
}
#[test]
fn empty_or_blank_is_none() {
assert_eq!(parsed(""), None);
assert_eq!(parsed(" "), None);
}
#[test]
fn scheme_with_no_credential_is_none() {
assert_eq!(parsed("Bearer"), None);
assert_eq!(parsed("Bearer "), None);
assert_eq!(parsed("Key"), None);
assert_eq!(parsed("key "), None);
}
#[test]
fn unsupported_scheme_is_none() {
assert_eq!(parsed("Basic dXNlcjpwYXNz"), None);
assert_eq!(parsed("Digest abc"), None);
// Two non-scheme tokens — a bare key never contains whitespace.
assert_eq!(parsed("abc def"), None);
}
#[test]
fn returns_an_artcraft_api_key() {
let key = parse_from_header_value(&format!("Bearer {SAMPLE_KEY}")).expect("should parse");
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
}
}
@@ -3,7 +3,6 @@ pub mod character_list_ref;
pub mod image_list_ref;
pub mod image_ref;
pub mod router_aspect_ratio;
pub mod router_bitrate;
pub mod router_image_model;
pub mod router_provider;
pub mod router_quality;
@@ -1,10 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// Output bitrate levels you can specify when enqueuing a generation.
/// Not every model will use this; models that don't simply ignore it.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RouterBitrate {
Normal,
High,
}
@@ -11,11 +11,9 @@ use crate::generate::generate_image::providers::artcraft::midjourney_7::request:
/// | 3 | 19 | 19¢ | (interpolated; Kinovi doesn't expose 3)
/// | 4 | 25 | 25¢ |
///
/// Kinovi-side math (historical, 22,000-credit package at 193 credits/$1):
/// 12 Kinovi credits per Midjourney task → batches 1/2/4 land on 6/12/25¢;
/// batch 3 is `ceil((12 × 3 / 193) × 100) = 19`. Under the current
/// 525,000-credit package (243 credits/$1) Kinovi costs only 5/10/20¢ —
/// user pricing is intentionally kept at the 193-derived values.
/// Kinovi-side math: 12 Kinovi credits per Midjourney task; 193 Kinovi
/// credits per USD. Kinovi rounds to nearest cent, so batches 1/2/4 land
/// on 6/12/25. Batch 3 is `ceil((12 × 3 / 193) × 100) = 19`.
#[derive(Clone, Debug)]
pub struct ArtcraftMidjourney7CostState {
pub num_images: u16,
@@ -129,6 +127,38 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi for batches 1/2/4 ──
//
// Kinovi midjourney_7 returns 6/12/25¢ for batches 1/2/4. The artcraft
// provider must match those values byte-for-byte so users pay the same.
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap());
}
}
// ── Monotonicity ──
#[test]
@@ -82,4 +82,32 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi 7-niji ──
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap());
}
}
}
@@ -82,4 +82,32 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi v8 ──
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap());
}
}
}
@@ -37,9 +37,8 @@ impl KinoviMidjourney7CostState {
reference_image_urls: None,
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -86,27 +85,27 @@ mod tests {
}
}
// ── USD pricing (243 credits per dollar; rounded up) ──
// ── USD pricing (193 credits per dollar; rounded) ──
mod usd {
use super::*;
#[test]
fn batch_one_is_five_cents() {
// 12/243 × 100 = 4.94 → 5¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 5); // rounds UP
fn batch_one_is_six_cents() {
// 12 / 193 × 100 = 6.21 → 6¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 6);
}
#[test]
fn batch_two_is_ten_cents() {
// 24/243 × 100 = 9.88 → 10¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 10); // rounds UP
fn batch_two_is_twelve_cents() {
// 24 / 193 × 100 = 12.44 → 12¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 12);
}
#[test]
fn batch_four_is_twenty_cents() {
// 48/243 × 100 = 19.75 → 20¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 20);
fn batch_four_is_twentyfive_cents() {
// 48 / 193 × 100 = 24.87 → 25¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 25);
}
}
@@ -35,9 +35,8 @@ impl KinoviMidjourney7NijiCostState {
reference_image_urls: None,
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -56,11 +55,11 @@ mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_five_cents() {
fn batch_one_is_twelve_credits_and_six_cents() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(6));
}
#[test]
@@ -68,7 +67,7 @@ mod tests {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(12));
}
#[test]
@@ -76,7 +75,7 @@ mod tests {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(20));
assert_eq!(cost.cost_in_usd_cents, Some(25));
}
#[test]
@@ -35,9 +35,8 @@ impl KinoviMidjourney8CostState {
reference_image_urls: None,
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -56,11 +55,11 @@ mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_five_cents() {
fn batch_one_is_twelve_credits_and_six_cents() {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(6));
}
#[test]
@@ -68,7 +67,7 @@ mod tests {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(12));
}
#[test]
@@ -76,6 +75,6 @@ mod tests {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(20));
assert_eq!(cost.cost_in_usd_cents, Some(25));
}
}
@@ -1,7 +1,6 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_bitrate::RouterBitrate;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
@@ -104,10 +103,6 @@ pub struct GenerateVideoRequestBuilder {
/// The aspect ratio to use
pub aspect_ratio: Option<RouterAspectRatio>,
/// The output bitrate to use.
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<RouterBitrate>,
/// How many seconds to generate.
pub duration_seconds: Option<u16>,
@@ -144,7 +139,6 @@ impl Default for GenerateVideoRequestBuilder {
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
bitrate: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
@@ -1,11 +1,9 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_bitrate::RouterBitrate;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
@@ -51,7 +49,6 @@ pub fn build_artcraft_omni_video_request(
let resolution = plan_output_resolution(builder.resolution.take(), strategy, resolutions)?;
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let bitrate = plan_bitrate(builder.bitrate.take());
let prompt = builder.prompt.take();
let start_frame = resolve_image_ref(builder.start_frame.take())?;
@@ -74,7 +71,6 @@ pub fn build_artcraft_omni_video_request(
reference_character_tokens: reference_characters,
resolution,
aspect_ratio,
bitrate,
duration_seconds: duration_seconds.map(|d| d as u16),
video_batch_count: Some(batch_count),
negative_prompt: None,
@@ -238,19 +234,6 @@ pub fn plan_batch_count(
}
}
/// Translate the router-facing bitrate into the API `CommonBitrate`.
///
/// Bitrate does not affect cost. An unset value leaves the field `None` (the
/// API applies its default); `Normal` and `High` map to their `CommonBitrate`
/// counterparts.
fn plan_bitrate(bitrate: Option<RouterBitrate>) -> Option<CommonBitrateEnum> {
match bitrate {
None => None,
Some(RouterBitrate::Normal) => Some(CommonBitrateEnum::Normal),
Some(RouterBitrate::High) => Some(CommonBitrateEnum::High),
}
}
/// Duration: 4-15 seconds.
pub fn plan_duration(
duration_seconds: Option<u16>,
@@ -272,27 +255,3 @@ pub fn plan_duration(
},
}
}
#[cfg(test)]
mod tests {
use super::*;
mod bitrate_translation {
use super::*;
#[test]
fn none_stays_none() {
assert_eq!(plan_bitrate(None), None);
}
#[test]
fn normal_maps_to_common_normal() {
assert_eq!(plan_bitrate(Some(RouterBitrate::Normal)), Some(CommonBitrateEnum::Normal));
}
#[test]
fn high_maps_to_common_high() {
assert_eq!(plan_bitrate(Some(RouterBitrate::High)), Some(CommonBitrateEnum::High));
}
}
}
@@ -231,6 +231,176 @@ mod tests {
}
}
// ── Cross-provider margin: ArtCraft is consistently ~30% above GrokApi ──
mod small_margin {
use super::*;
use crate::api::router_aspect_ratio::RouterAspectRatio;
#[test]
fn small_margin() {
let resolutions = [
None,
Some(RouterResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP),
Some(RouterResolution::TenEightyP), // clamps to 720p on both sides
];
let durations: &[u16] = &[1, 4, 5, 8, 10, 12, 15];
let aspect_ratios = [
None,
Some(RouterAspectRatio::Auto),
Some(RouterAspectRatio::Square),
Some(RouterAspectRatio::WideSixteenByNine),
Some(RouterAspectRatio::TallNineBySixteen),
Some(RouterAspectRatio::WideFourByThree),
Some(RouterAspectRatio::TallThreeByFour),
Some(RouterAspectRatio::WideThreeByTwo),
Some(RouterAspectRatio::TallTwoByThree),
];
// (has_start_frame, num_reference_images). At most one of the two is
// non-default — start_frame and reference_images are mutually exclusive
// on the GrokApi wire (image-to-video vs reference-to-video), so we
// don't mix them here to keep the comparison apples-to-apples.
let image_combos: &[(bool, usize)] = &[
(false, 0), // text-to-video
(true, 0), // image-to-video, 1 source image
(false, 1), // reference-to-video, 1 ref image
(false, 2),
(false, 3),
];
let mut total_cases = 0;
let mut failures = Vec::new();
for &res in &resolutions {
for &dur in durations {
for &ar in &aspect_ratios {
for &(has_start, num_refs) in image_combos {
total_cases += 1;
let artcraft = artcraft_cents(res, dur, ar, has_start, num_refs);
let grokapi = grokapi_cents(res, dur, ar, has_start, num_refs);
// Margin requirement: artcraft / max(grokapi - 1, 1) >= 1.29.
// The (-1, 1) floor accounts for one cent of GrokApi-side
// ceiling-rounding noise (see test doc comment above).
let grokapi_compare = grokapi.saturating_sub(1).max(1);
let lhs = artcraft.saturating_mul(100);
let rhs = grokapi_compare.saturating_mul(129);
if lhs < rhs {
let pct = if grokapi == 0 { 0.0 }
else { (artcraft as f64 / grokapi as f64 - 1.0) * 100.0 };
failures.push(format!(
"res={:?} dur={}s ar={:?} start={} refs={} → \
artcraft={}¢ vs grokapi={}¢ (margin {:.1}%, want ≥ 29% after 1¢ tolerance)",
res, dur, ar, has_start, num_refs, artcraft, grokapi, pct,
));
}
// Defense in depth: ArtCraft must always cost at least as
// much as GrokApi, regardless of rounding noise.
assert!(
artcraft >= grokapi,
"ArtCraft ({artcraft}¢) cheaper than GrokApi ({grokapi}¢) for \
res={res:?} dur={dur}s ar={ar:?} start={has_start} refs={num_refs}",
);
}
}
}
}
if !failures.is_empty() {
let summary = failures.iter().take(10).cloned().collect::<Vec<_>>().join("\n ");
panic!(
"{} / {} (res × duration × aspect × image_combo) tuples failed \
the ≥29% (with 1¢ tolerance) margin check.\nFirst 10:\n {}",
failures.len(), total_cases, summary,
);
}
}
// ── Cross-provider helpers ──
fn artcraft_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
aspect_ratio: Option<RouterAspectRatio>,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
// ArtCraft's omni endpoint takes MediaFileTokens for image refs.
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::MediaFileTokens(
(0..num_reference_images)
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo,
provider: RouterProvider::Artcraft,
resolution,
aspect_ratio,
duration_seconds: Some(duration_seconds),
// Batch is fixed at 1 because GrokApi's per-request cost calculator
// doesn't multiply by batch (Grok returns one video per call); the
// markup test would compare apples to oranges otherwise.
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (artcraft) should succeed")
.estimate_cost()
.expect("estimate_cost (artcraft) should succeed")
.cost_in_usd_cents
.unwrap()
}
fn grokapi_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
aspect_ratio: Option<RouterAspectRatio>,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
// GrokApi (direct xAI) takes URLs for image refs.
Some(ImageRef::Url("https://example.com/start.png".to_string()))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::Urls(
(0..num_reference_images)
.map(|i| format!("https://example.com/ref_{i}.png"))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo,
provider: RouterProvider::GrokApi,
resolution,
aspect_ratio,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (grokapi) should succeed")
.estimate_cost()
.expect("estimate_cost (grokapi) should succeed")
.cost_in_usd_cents
.unwrap()
}
}
// ── Helpers ──
fn build_cost(
@@ -144,6 +144,113 @@ mod tests {
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ── Base cost parity with grok_api_client ─────────────────────────────
//
// The base cost (before ArtCraft markup, batch=1) must equal exactly what
// grok_api_client's calculator returns for an equivalent direct-Grok
// request. This protects against the artcraft and grok-api implementations
// silently drifting apart.
mod grok_api_parity {
use super::*;
// (resolution, duration, image_count)
//
// Durations are confined to [4, 15] because the ArtCraft pipeline clamps
// outside that range (see `plan_duration` in artcraft/build_common.rs).
// Every case carries exactly one image. v1.5 rejects text-to-video, AND
// the GrokApi build step collapses multi-image reference-to-video
// requests down to one `image` (promote-first; see grok_api side
// build.rs). So the only request shape where both sides agree on the
// input image count is the single-image case.
const PARITY_CASES: &[(Option<RouterResolution>, u16, u64)] = &[
// 480p variations
(Some(RouterResolution::FourEightyP), 4, 1),
(Some(RouterResolution::FourEightyP), 5, 1),
(Some(RouterResolution::FourEightyP), 8, 1),
(Some(RouterResolution::FourEightyP), 15, 1),
// 720p variations
(Some(RouterResolution::SevenTwentyP), 4, 1),
(Some(RouterResolution::SevenTwentyP), 5, 1),
(Some(RouterResolution::SevenTwentyP), 8, 1),
(Some(RouterResolution::SevenTwentyP), 15, 1),
// None defaults to 720p in our `from_request`, which matches the
// assumption Grok's calculator makes when fed a 720p request.
(None, 5, 1),
];
#[test]
fn base_cost_for_one_video_matches_grok_api_client() {
for &(router_res, duration, image_count) in PARITY_CASES {
let artcraft = artcraft_state(router_res, duration, image_count, 1);
let grok_cents = grok_cents(router_res, duration, image_count);
assert_eq!(
artcraft.base_cost_in_cents_for_one_video(),
grok_cents,
"parity mismatch at res={router_res:?} dur={duration} images={image_count}: \
artcraft_base={} grok={}",
artcraft.base_cost_in_cents_for_one_video(),
grok_cents,
);
}
}
#[test]
fn batch_one_base_total_equals_one_video_base() {
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1);
assert_eq!(s.base_cost_in_cents_for_batch(), s.base_cost_in_cents_for_one_video());
}
#[test]
fn batch_scales_base_linearly() {
// The ArtCraft pipeline only accepts batch counts of 1, 2, or 4 (see
// `plan_batch_count` in artcraft/build_common.rs); anything else gets
// clamped, so we can only meaningfully test those values.
let per_one = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1)
.base_cost_in_cents_for_one_video();
for batch in [1u16, 2, 4] {
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, batch);
assert_eq!(
s.base_cost_in_cents_for_batch(),
per_one * batch as u64,
"batch={batch}",
);
}
}
/// Builder-driven parity — exercises the full pipeline (build2 →
/// estimate_cost) on both providers and compares ArtCraft's base cost
/// (final / 1.05, accounting for ceiling) against GrokApi's direct
/// cost.
///
/// We verify the markup rather than the base here because the public
/// `estimate_cost()` returns the marked-up number; the explicit
/// `base_cost_in_cents_*` API is tested above.
#[test]
fn end_to_end_markup_is_at_least_five_percent_over_grok() {
// Single-image cases only — see PARITY_CASES comment above for why
// multi-image rows can't be apples-to-apples between the two sides.
let cases: &[(Option<RouterResolution>, u16, bool, usize)] = &[
(Some(RouterResolution::FourEightyP), 5, true, 0),
(Some(RouterResolution::FourEightyP), 10, true, 0),
(Some(RouterResolution::SevenTwentyP), 5, true, 0),
(Some(RouterResolution::SevenTwentyP), 8, true, 0),
(Some(RouterResolution::SevenTwentyP),15, true, 0),
];
for &(res, duration, has_start, refs) in cases {
let artcraft_cents = artcraft_end_to_end_cents(res, duration, 1, has_start, refs);
let grok_cents = grok_cents(res, duration, has_start as u64 + refs as u64);
let want_minimum = grok_cents.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
assert_eq!(
artcraft_cents, want_minimum,
"res={res:?} dur={duration} start={has_start} refs={refs}: \
artcraft={artcraft_cents}¢ vs ceil(grok({grok_cents})×1.05)={want_minimum}¢",
);
}
}
}
// ── Markup correctness ─────────────────────────────────────────────────
mod markup {
@@ -377,6 +484,42 @@ mod tests {
}
}
fn artcraft_end_to_end_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
batch_count: u16,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::MediaFileTokens(
(0..num_reference_images)
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(batch_count),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
fn build_cost_via_builder(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -399,4 +542,40 @@ mod tests {
.expect("estimate_cost should succeed")
}
/// Reference value: what GrokApi's own pipeline would charge for one
/// equivalent video. Used by parity tests.
///
/// The Grok build step prefers `start_frame` over `reference_images` when
/// both are supplied and drops the latter (see grok_api/grok_imagine_video_1p5/build.rs).
/// To keep parity with the ArtCraft input-image *count*, we put all source
/// images in `reference_images` only — the cost calculator counts the total
/// either way.
fn grok_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
image_count: u64,
) -> u64 {
let reference_images = if image_count >= 1 {
Some(ImageListRef::Urls(
(0..image_count).map(|i| format!("https://example.com/ref_{i}.png")).collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::GrokApi,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame: None,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (grokapi) should succeed")
.estimate_cost()
.expect("estimate_cost (grokapi) should succeed")
.cost_in_usd_cents
.unwrap()
}
}

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