mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 01:29:31 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c238a30776 |
@@ -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
|
||||
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
-124
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-157
@@ -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"
|
||||
}
|
||||
-157
@@ -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"
|
||||
}
|
||||
+4
-4
@@ -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"
|
||||
}
|
||||
-124
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-124
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
+4
-4
@@ -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"
|
||||
}
|
||||
-47
@@ -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"
|
||||
}
|
||||
@@ -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
-690
File diff suppressed because it is too large
Load Diff
+13
-20
@@ -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"
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
DROP COLUMN maybe_external_third_party_cost_credits;
|
||||
-6
@@ -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;
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
DROP COLUMN maybe_external_third_party_cost_usd_cents;
|
||||
-6
@@ -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;
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
DROP COLUMN maybe_system_cost_credits;
|
||||
-6
@@ -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;
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
DROP COLUMN maybe_system_cost_usd_cents;
|
||||
-6
@@ -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;
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE uploaded_videos
|
||||
DROP COLUMN maybe_filename;
|
||||
-6
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
@@ -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,3 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
-5
@@ -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]
|
||||
|
||||
+28
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-7
@@ -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]
|
||||
|
||||
+6
-7
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+170
@@ -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(
|
||||
|
||||
+179
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+45
-4
@@ -14,16 +14,13 @@ use crate::generate::generate_video::providers::artcraft::happy_horse_1p0::reque
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Batch count multiplies the total cost.
|
||||
// Credit package (historical): 22,000 credits for $114 (~193 credits/$1).
|
||||
// The upstream package is now 525,000 credits for $2,159.0909 (~243 credits/$1),
|
||||
// but user pricing is intentionally kept at the 193-derived rates.
|
||||
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
|
||||
//
|
||||
// ArtCraft credits: 100 credits = $1.00, so ArtCraft credits = USD cents.
|
||||
// We compute USD cents from upstream credits ÷ credits_per_dollar × 100.
|
||||
|
||||
const UPSTREAM_CREDITS_PER_SECOND_720P: u32 = 33;
|
||||
const UPSTREAM_CREDITS_PER_SECOND_1080P: u32 = 66;
|
||||
// Frozen at the historical rate so user pricing doesn't shift with the package deal.
|
||||
const UPSTREAM_CREDITS_PER_DOLLAR: f64 = 193.0;
|
||||
|
||||
pub struct ArtcraftHappyHorse1p0CostState {
|
||||
@@ -204,6 +201,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-check with Kinovi ──
|
||||
|
||||
mod cross_check_with_kinovi {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artcraft_matches_kinovi_all_combos() {
|
||||
let resolutions = [
|
||||
Some(RouterResolution::SevenTwentyP),
|
||||
None,
|
||||
Some(RouterResolution::TenEightyP),
|
||||
];
|
||||
let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
let batches: [u16; 3] = [1, 2, 4];
|
||||
|
||||
for res in &resolutions {
|
||||
for dur in &durations {
|
||||
for batch in &batches {
|
||||
let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
|
||||
let kinovi = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::HappyHorse1p0,
|
||||
provider: RouterProvider::Seedance2Pro,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
let kinovi_cost = kinovi.build2()
|
||||
.expect("kinovi build2")
|
||||
.estimate_cost()
|
||||
.expect("kinovi estimate_cost");
|
||||
|
||||
assert_eq!(
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn build_cost(
|
||||
|
||||
+45
-4
@@ -18,10 +18,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0::request:
|
||||
///
|
||||
/// We keep these as f64 because per-second rates are fractional; rounding
|
||||
/// happens once at the end after multiplying by duration × batch.
|
||||
///
|
||||
/// NB: Derivation is historical (193 credits/$1 package). The upstream
|
||||
/// package is now 525,000 credits for $2,159.0909 (~243 credits/$1), but
|
||||
/// user pricing is intentionally kept at these 193-derived rates.
|
||||
const CENTS_PER_SECOND_480P: f64 = 7.772;
|
||||
const CENTS_PER_SECOND_720P: f64 = 16.0;
|
||||
const CENTS_PER_SECOND_1080P: f64 = 46.632;
|
||||
@@ -218,6 +214,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Cross-check with Kinovi via builder --
|
||||
|
||||
mod cross_check_with_kinovi {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artcraft_matches_kinovi_all_combos() {
|
||||
let resolutions = [
|
||||
Some(RouterResolution::FourEightyP),
|
||||
Some(RouterResolution::TenEightyP),
|
||||
// NB: These are underpriced (for now)
|
||||
//Some(RouterResolution::SevenTwentyP),
|
||||
//None,
|
||||
];
|
||||
let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
let batches: [u16; 3] = [1, 2, 4];
|
||||
|
||||
for res in &resolutions {
|
||||
for dur in &durations {
|
||||
for batch in &batches {
|
||||
let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
|
||||
let kinovi = GenerateVideoRequestBuilder {
|
||||
provider: RouterProvider::Seedance2Pro,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
let kinovi_cost = kinovi.build2()
|
||||
.expect("kinovi build2")
|
||||
.estimate_cost()
|
||||
.expect("kinovi estimate_cost");
|
||||
|
||||
assert_eq!(
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
/// Build a cost estimate end-to-end: GenerateVideoRequestBuilder → build2 → estimate_cost.
|
||||
|
||||
-2
@@ -154,8 +154,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
-2
@@ -125,8 +125,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
-2
@@ -154,8 +154,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
-2
@@ -125,8 +125,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
+44
-2
@@ -13,8 +13,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
|
||||
|
||||
/// USD cents per second by resolution, derived from upstream Fast rates:
|
||||
/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s
|
||||
/// (historical 193 credits/$1 derivation; the upstream package is now
|
||||
/// ~243 credits/$1 but user pricing is intentionally unchanged)
|
||||
/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s
|
||||
///
|
||||
/// We keep these as f64 because per-second rates are fractional; rounding
|
||||
@@ -186,6 +184,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Cross-check with Kinovi Fast via builder --
|
||||
|
||||
//mod cross_check_with_kinovi {
|
||||
// use super::*;
|
||||
//
|
||||
// #[test]
|
||||
// fn artcraft_fast_matches_kinovi_fast_all_combos() {
|
||||
// let resolutions = [
|
||||
// Some(RouterResolution::FourEightyP),
|
||||
// Some(RouterResolution::SevenTwentyP),
|
||||
// None,
|
||||
// ];
|
||||
// let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
// let batches: [u16; 3] = [1, 2, 4];
|
||||
//
|
||||
// for res in &resolutions {
|
||||
// for dur in &durations {
|
||||
// for batch in &batches {
|
||||
// let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
//
|
||||
// let kinovi = GenerateVideoRequestBuilder {
|
||||
// model: RouterVideoModel::Seedance2p0Fast,
|
||||
// provider: RouterProvider::Seedance2Pro,
|
||||
// resolution: *res,
|
||||
// duration_seconds: Some(*dur),
|
||||
// video_batch_count: Some(*batch),
|
||||
// ..Default::default()
|
||||
// };
|
||||
// let kinovi_cost = kinovi.build2()
|
||||
// .expect("kinovi build2")
|
||||
// .estimate_cost()
|
||||
// .expect("kinovi estimate_cost");
|
||||
//
|
||||
// assert_eq!(
|
||||
// artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
// "USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
// res, dur, batch,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
|
||||
-2
@@ -159,8 +159,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
-2
@@ -144,8 +144,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
+12
-11
@@ -42,12 +42,11 @@ impl KinoviHappyHorse1p0CostState {
|
||||
start_frame_url: 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();
|
||||
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
cost_in_credits: Some(cost_in_credits as u64),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
@@ -98,8 +97,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p() {
|
||||
assert_eq!(usd_cents(None, 5, None), 68); // 16500/243 = 67.90 -> rounds UP
|
||||
assert_eq!(usd_cents(None, 15, None), 204); // 49500/243 = 203.70 -> rounds UP
|
||||
// 165 credits / 193 * 100 = 85.49 → 85¢
|
||||
assert_eq!(usd_cents(None, 5, None), 85);
|
||||
// 495 credits / 193 * 100 = 256.48 → 256¢
|
||||
assert_eq!(usd_cents(None, 15, None), 256);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,10 +130,10 @@ mod tests {
|
||||
#[test]
|
||||
fn usd_cents_1080p() {
|
||||
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
|
||||
// 330 credits; 33000/243 = 135.80 → rounds UP to 136¢
|
||||
assert_eq!(usd_cents(r, 5, None), 136);
|
||||
// 990 credits; 99000/243 = 407.41 → rounds UP to 408¢
|
||||
assert_eq!(usd_cents(r, 15, None), 408);
|
||||
// 330 credits / 193 * 100 = 170.98 → 171¢
|
||||
assert_eq!(usd_cents(r, 5, None), 171);
|
||||
// 990 credits / 193 * 100 = 512.95 → 513¢
|
||||
assert_eq!(usd_cents(r, 15, None), 513);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +237,7 @@ mod tests {
|
||||
);
|
||||
let cost = KinoviHappyHorse1p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(330));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(136));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(171));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-55
@@ -46,31 +46,23 @@ impl KinoviSeedance2p0CostState {
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: self.batch_count,
|
||||
|
||||
// PRESENCE of reference videos changes the price (per-second
|
||||
// surcharge); the URL contents don't.
|
||||
reference_video_urls: if self.has_video_reference {
|
||||
Some(vec!["pricing-placeholder".to_string()])
|
||||
} else {
|
||||
None
|
||||
},
|
||||
|
||||
// No impact on price
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
let costs = pricing_request.calculate_costs();
|
||||
let cost_in_credits = costs.total_cost.kinovi_credits;
|
||||
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
|
||||
let cost_in_credits = pricing_request.estimate_credits();
|
||||
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
cost_in_credits: Some(cost_in_credits as u64),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
@@ -103,26 +95,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 66);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 83);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 99);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 116);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 165);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 247);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 83);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 104);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 124);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 145);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 207);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 311);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 132);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 165);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 494);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 166);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 207);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 622);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 264);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 330);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 988);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 332);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 415);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 1244);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,20 +125,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 25);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 31);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 62);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 93);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 31);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 39);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 78);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 117);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 62);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 78);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 124);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 155);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,20 +149,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 149);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 186);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 371);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 556);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 187);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 233);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 466);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 699);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 371);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 466);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 741);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 933);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +202,7 @@ mod tests {
|
||||
// -- Video reference does NOT affect cost (yet) --
|
||||
|
||||
#[test]
|
||||
fn video_reference_adds_surcharge() {
|
||||
fn video_reference_does_not_affect_cost() {
|
||||
let base = KinoviSeedance2p0CostState {
|
||||
resolution: Some(KinoviOutputResolution::SevenTwentyP),
|
||||
duration_seconds: 5,
|
||||
@@ -219,10 +211,8 @@ mod tests {
|
||||
};
|
||||
let without = base.estimate_cost();
|
||||
let with = KinoviSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
|
||||
// 720p surcharge is +8 credits/s: 200 -> 240 credits (24000/243 = 98.77 -> 99 cents).
|
||||
assert_eq!(without.cost_in_credits, Some(200));
|
||||
assert_eq!(with.cost_in_credits, Some(240));
|
||||
assert_eq!(with.cost_in_usd_cents, Some(99));
|
||||
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
|
||||
assert_eq!(without.cost_in_credits, with.cost_in_credits);
|
||||
}
|
||||
|
||||
// ── from_request() tests ──
|
||||
@@ -238,7 +228,7 @@ mod tests {
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -246,21 +236,21 @@ mod tests {
|
||||
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_480p() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_1080p_batch_2() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::TenEightyP), 5, KinoviBatchCount::Two, false);
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(371));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(466));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -268,9 +258,8 @@ mod tests {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true);
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
|
||||
// Video refs don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -294,7 +283,7 @@ mod tests {
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -302,7 +291,7 @@ mod tests {
|
||||
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -311,7 +300,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::TenEightyP)));
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Four)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(741));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(933));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -319,9 +308,8 @@ mod tests {
|
||||
let draft = make_draft(5, 1, None, true);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
|
||||
// Video refs don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -337,7 +325,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert_eq!(cost.duration_seconds, 15);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(494));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(622));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-43
@@ -46,31 +46,23 @@ impl KinoviSeedance2p0FastCostState {
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: self.batch_count,
|
||||
|
||||
// PRESENCE of reference videos changes the price (per-second
|
||||
// surcharge); the URL contents don't.
|
||||
reference_video_urls: if self.has_video_reference {
|
||||
Some(vec!["pricing-placeholder".to_string()])
|
||||
} else {
|
||||
None
|
||||
},
|
||||
|
||||
// No impact on price
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
let costs = pricing_request.calculate_costs();
|
||||
let cost_in_credits = costs.total_cost.kinovi_credits;
|
||||
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
|
||||
let cost_in_credits = pricing_request.estimate_credits();
|
||||
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
cost_in_credits: Some(cost_in_credits as u64),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
@@ -100,51 +92,51 @@ mod tests {
|
||||
// ── Direct estimate_cost() tests ──
|
||||
// These must match the reference implementation values exactly.
|
||||
|
||||
// -- 720p (28 credits/sec, 243 credits/$1) --
|
||||
// -- 720p (28 credits/sec, 193 credits/$1) --
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 47);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 58);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 116);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 173);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 58);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 73);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 145);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 218);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 116);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 145);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 231);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 290);
|
||||
}
|
||||
}
|
||||
|
||||
// -- 480p (14 credits/sec, 243 credits/$1) --
|
||||
// -- 480p (14 credits/sec, 193 credits/$1) --
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 24);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 29);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 58);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 87);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 29);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 36);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 73);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 109);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 58);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 73);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 116);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 145);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +174,7 @@ mod tests {
|
||||
// -- Video reference does NOT affect cost (yet) --
|
||||
|
||||
#[test]
|
||||
fn video_reference_adds_surcharge() {
|
||||
fn video_reference_does_not_affect_cost() {
|
||||
let base = KinoviSeedance2p0FastCostState {
|
||||
resolution: Some(KinoviOutputResolution::SevenTwentyP),
|
||||
duration_seconds: 5,
|
||||
@@ -191,10 +183,8 @@ mod tests {
|
||||
};
|
||||
let without = base.estimate_cost();
|
||||
let with = KinoviSeedance2p0FastCostState { has_video_reference: true, ..base }.estimate_cost();
|
||||
// Fast 720p surcharge is +6 credits/s: 140 -> 170 credits (17000/243 = 69.96 -> 70 cents).
|
||||
assert_eq!(without.cost_in_credits, Some(140));
|
||||
assert_eq!(with.cost_in_credits, Some(170));
|
||||
assert_eq!(with.cost_in_usd_cents, Some(70));
|
||||
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
|
||||
assert_eq!(without.cost_in_credits, with.cost_in_credits);
|
||||
}
|
||||
|
||||
// ── from_request() tests ──
|
||||
@@ -210,7 +200,7 @@ mod tests {
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -218,14 +208,14 @@ mod tests {
|
||||
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert!(cost.resolution.is_none());
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_480p() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -233,9 +223,8 @@ mod tests {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
|
||||
// Video refs don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -259,7 +248,7 @@ mod tests {
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -267,7 +256,7 @@ mod tests {
|
||||
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -275,9 +264,8 @@ mod tests {
|
||||
let draft = make_draft(5, 1, None, true);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
|
||||
// Video refs don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -293,7 +281,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert_eq!(cost.duration_seconds, 15);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(346));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(435));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
[package]
|
||||
name = "bucket_client"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "bucket_client"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[dependencies]
|
||||
# cargo-hakari workspace hack (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# External
|
||||
rust-s3.workspace = true
|
||||
@@ -1,66 +0,0 @@
|
||||
use s3::Bucket;
|
||||
|
||||
use crate::bucket_client_error::BucketClientError;
|
||||
use crate::object_name::ObjectName;
|
||||
|
||||
/// Arguments for [`BucketClient::upload_file_bytes`].
|
||||
pub struct UploadFileBytesArgs<'a, S: AsRef<str>> {
|
||||
/// The destination object key (normalized via [`ObjectName`]).
|
||||
pub object_name: S,
|
||||
/// The raw bytes to upload.
|
||||
pub bytes: &'a [u8],
|
||||
/// Optional MIME type. Defaults to `application/octet-stream` when `None`.
|
||||
pub content_type: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// A client for a single S3-compatible bucket. Cheap to clone. Build one with
|
||||
/// [`crate::BucketClientBuilder`].
|
||||
#[derive(Clone)]
|
||||
pub struct BucketClient {
|
||||
bucket: Bucket,
|
||||
}
|
||||
|
||||
impl BucketClient {
|
||||
/// Wrap a fully-configured `Bucket`. Internal — use
|
||||
/// [`crate::BucketClientBuilder`] to construct a client.
|
||||
pub(crate) fn from_bucket(bucket: Bucket) -> Self {
|
||||
Self { bucket }
|
||||
}
|
||||
|
||||
/// The name of the bucket this client targets.
|
||||
pub fn bucket_name(&self) -> &str {
|
||||
self.bucket.name.as_str()
|
||||
}
|
||||
|
||||
/// Upload raw bytes to `object_name`, optionally with an explicit content type.
|
||||
///
|
||||
/// Returns [`BucketClientError::UploadFailed`] if the store responds with a
|
||||
/// non-2xx status, or [`BucketClientError::S3Error`] on a transport/protocol
|
||||
/// error.
|
||||
pub async fn upload_file_bytes<S: AsRef<str>>(
|
||||
&self,
|
||||
args: UploadFileBytesArgs<'_, S>,
|
||||
) -> Result<(), BucketClientError> {
|
||||
let object_name = ObjectName::new(&args.object_name);
|
||||
|
||||
let response = match args.content_type {
|
||||
Some(content_type) => {
|
||||
self
|
||||
.bucket
|
||||
.put_object_with_content_type(object_name.as_str(), args.bytes, content_type)
|
||||
.await?
|
||||
}
|
||||
None => self.bucket.put_object(object_name.as_str(), args.bytes).await?,
|
||||
};
|
||||
|
||||
let status_code = response.status_code();
|
||||
if !(200..300).contains(&status_code) {
|
||||
return Err(BucketClientError::UploadFailed {
|
||||
status_code,
|
||||
message: String::from_utf8_lossy(response.bytes()).into_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use s3::creds::Credentials;
|
||||
use s3::region::Region;
|
||||
use s3::Bucket;
|
||||
|
||||
use crate::bucket_client::BucketClient;
|
||||
use crate::bucket_client_error::BucketClientError;
|
||||
|
||||
/// Builder for [`BucketClient`].
|
||||
///
|
||||
/// `access_key`, `secret_key`, `region_name`, and `bucket_name` are required;
|
||||
/// `endpoint` and `bucket_request_timeout` are optional.
|
||||
///
|
||||
/// - With an `endpoint` set, the bucket targets a custom S3-compatible host
|
||||
/// (R2, GCS, MinIO, etc.) via `Region::Custom`, and uses path-style addressing
|
||||
/// (subdomain-style for Google Cloud Storage).
|
||||
/// - Without an `endpoint`, the `region_name` is parsed as an AWS region
|
||||
/// (e.g. `"us-east-1"`), which determines the AWS endpoint automatically.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct BucketClientBuilder {
|
||||
access_key: Option<String>,
|
||||
secret_key: Option<String>,
|
||||
region_name: Option<String>,
|
||||
bucket_name: Option<String>,
|
||||
endpoint: Option<String>,
|
||||
bucket_request_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl BucketClientBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn access_key(mut self, access_key: impl Into<String>) -> Self {
|
||||
self.access_key = Some(access_key.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn secret_key(mut self, secret_key: impl Into<String>) -> Self {
|
||||
self.secret_key = Some(secret_key.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn region_name(mut self, region_name: impl Into<String>) -> Self {
|
||||
self.region_name = Some(region_name.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bucket_name(mut self, bucket_name: impl Into<String>) -> Self {
|
||||
self.bucket_name = Some(bucket_name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Optional custom S3-compatible endpoint (e.g. an R2/GCS/MinIO URL). When
|
||||
/// unset, an AWS endpoint is derived from `region_name`.
|
||||
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
|
||||
self.endpoint = Some(endpoint.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bucket_request_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.bucket_request_timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate the configuration and construct a [`BucketClient`].
|
||||
pub fn build(self) -> Result<BucketClient, BucketClientError> {
|
||||
let access_key = require(self.access_key, "access_key")?;
|
||||
let secret_key = require(self.secret_key, "secret_key")?;
|
||||
let region_name = require(self.region_name, "region_name")?;
|
||||
let bucket_name = require(self.bucket_name, "bucket_name")?;
|
||||
|
||||
let credentials = Credentials {
|
||||
access_key: Some(access_key),
|
||||
secret_key: Some(secret_key),
|
||||
security_token: None,
|
||||
session_token: None,
|
||||
expiration: None,
|
||||
};
|
||||
|
||||
// A custom endpoint targets an S3-compatible host via `Region::Custom`;
|
||||
// otherwise the region name is parsed as a standard AWS region.
|
||||
let region = match self.endpoint.as_deref() {
|
||||
Some(endpoint) => Region::Custom {
|
||||
region: region_name.clone(),
|
||||
endpoint: endpoint.to_string(),
|
||||
},
|
||||
None => region_name.parse().map_err(|err| {
|
||||
BucketClientError::ClientBuilderSetupError(format!(
|
||||
"invalid region {region_name:?}: {err:?}"
|
||||
))
|
||||
})?,
|
||||
};
|
||||
|
||||
let mut bucket = Bucket::new(&bucket_name, region, credentials)?;
|
||||
bucket.set_request_timeout(self.bucket_request_timeout);
|
||||
|
||||
// Addressing style only matters for custom endpoints: Google Cloud Storage
|
||||
// needs subdomain (virtual-hosted) style; other S3-compatible stores use
|
||||
// path style. AWS uses the crate default (virtual-hosted).
|
||||
if let Some(endpoint) = self.endpoint.as_deref() {
|
||||
if endpoint == "https://storage.googleapis.com" {
|
||||
bucket.set_subdomain_style();
|
||||
} else {
|
||||
bucket.set_path_style();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BucketClient::from_bucket(*bucket))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a required builder field, or report which one was missing.
|
||||
fn require(value: Option<String>, field: &str) -> Result<String, BucketClientError> {
|
||||
value.ok_or_else(|| {
|
||||
BucketClientError::ClientBuilderSetupError(format!("{field} is required"))
|
||||
})
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
|
||||
/// Errors produced by [`crate::BucketClient`] and [`crate::BucketClientBuilder`].
|
||||
#[derive(Debug)]
|
||||
pub enum BucketClientError {
|
||||
/// An error surfaced by the underlying `rust-s3` client.
|
||||
S3Error(s3::error::S3Error),
|
||||
|
||||
/// A local I/O error (e.g. while reading bytes to upload).
|
||||
IoError(io::Error),
|
||||
|
||||
/// The builder was misconfigured — a required field was missing, or client
|
||||
/// construction failed for a reason we can describe.
|
||||
ClientBuilderSetupError(String),
|
||||
|
||||
/// The object store accepted the request but returned a non-success status.
|
||||
UploadFailed { status_code: u16, message: String },
|
||||
}
|
||||
|
||||
impl fmt::Display for BucketClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::S3Error(err) => write!(f, "S3 error: {}", err),
|
||||
Self::IoError(err) => write!(f, "I/O error: {}", err),
|
||||
Self::ClientBuilderSetupError(message) => {
|
||||
write!(f, "bucket client setup error: {}", message)
|
||||
}
|
||||
Self::UploadFailed { status_code, message } => {
|
||||
write!(f, "upload failed (HTTP {}): {}", status_code, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BucketClientError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::S3Error(err) => Some(err),
|
||||
Self::IoError(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<s3::error::S3Error> for BucketClientError {
|
||||
fn from(err: s3::error::S3Error) -> Self {
|
||||
Self::S3Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for BucketClientError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
Self::IoError(err)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//! bucket_client
|
||||
//!
|
||||
//! A small, ergonomic client for S3-compatible object storage, built on the
|
||||
//! `rust-s3` crate. Construct one with [`BucketClientBuilder`] and upload bytes
|
||||
//! with [`BucketClient::upload_file_bytes`].
|
||||
|
||||
pub mod bucket_client;
|
||||
pub mod bucket_client_builder;
|
||||
pub mod bucket_client_error;
|
||||
pub mod object_name;
|
||||
|
||||
pub use bucket_client::{BucketClient, UploadFileBytesArgs};
|
||||
pub use bucket_client_builder::BucketClientBuilder;
|
||||
pub use bucket_client_error::BucketClientError;
|
||||
pub use object_name::ObjectName;
|
||||
@@ -1,52 +0,0 @@
|
||||
use std::fmt;
|
||||
|
||||
/// A normalized object key for the bucket.
|
||||
///
|
||||
/// Leading slashes are stripped: S3/R2 treat a leading `/` as an empty leading
|
||||
/// path segment, which silently produces surprising keys (e.g. `/foo.mp4`
|
||||
/// becomes an object literally named `/foo.mp4`). Normalizing here keeps stored
|
||||
/// keys consistent regardless of how callers format the name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ObjectName(String);
|
||||
|
||||
impl ObjectName {
|
||||
/// Build a normalized object name from any string-like value.
|
||||
pub fn new(raw: impl AsRef<str>) -> Self {
|
||||
ObjectName(raw.as_ref().trim_start_matches('/').to_string())
|
||||
}
|
||||
|
||||
/// The normalized key as a string slice.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ObjectName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for ObjectName {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_leading_slashes() {
|
||||
assert_eq!(ObjectName::new("/foo/bar.mp4").as_str(), "foo/bar.mp4");
|
||||
assert_eq!(ObjectName::new("///a").as_str(), "a");
|
||||
assert_eq!(ObjectName::new("already/clean.txt").as_str(), "already/clean.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_string_and_str() {
|
||||
assert_eq!(ObjectName::new(String::from("x.bin")).as_str(), "x.bin");
|
||||
assert_eq!(ObjectName::new("x.bin").as_str(), "x.bin");
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -17,7 +17,7 @@ use tokio::io::AsyncReadExt;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LegacyBucketClient {
|
||||
pub struct BucketClient {
|
||||
bucket: Bucket,
|
||||
/// If set, put all files under this root path.
|
||||
optional_bucket_root: Option<String>,
|
||||
@@ -25,21 +25,21 @@ pub struct LegacyBucketClient {
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LegacyBucketClientError {
|
||||
pub enum BucketClientError {
|
||||
ErrorWithCodeAndMessage { code: u16, message: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LegacyBucketClientError {
|
||||
impl std::fmt::Display for BucketClientError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
LegacyBucketClientError::ErrorWithCodeAndMessage { code, message } => write!(f, "Error {}: {}", code, message),
|
||||
BucketClientError::ErrorWithCodeAndMessage { code, message } => write!(f, "Error {}: {}", code, message),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Error for LegacyBucketClientError {}
|
||||
impl Error for BucketClientError {}
|
||||
|
||||
|
||||
impl LegacyBucketClient {
|
||||
impl BucketClient {
|
||||
pub fn bucket_name(&self) -> String {
|
||||
self.bucket.name().to_string()
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
pub mod legacy_bucket_client;
|
||||
pub mod bucket_client;
|
||||
pub mod remote_file_manager;
|
||||
@@ -4,7 +4,7 @@ use tokio::time::Duration;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::legacy_bucket_client::LegacyBucketClient;
|
||||
use crate::bucket_client::BucketClient;
|
||||
|
||||
#[async_trait]
|
||||
#[deprecated(note="this abstraction is too complicated, use another bucket client")]
|
||||
@@ -106,13 +106,13 @@ impl BucketOrchestration {
|
||||
}
|
||||
|
||||
#[deprecated(note="this abstraction is too complicated, use another bucket client")]
|
||||
async fn get_bucket_with_visibility(&self, public: bool) -> AnyhowResult<LegacyBucketClient> {
|
||||
async fn get_bucket_with_visibility(&self, public: bool) -> AnyhowResult<BucketClient> {
|
||||
let bucket_timeout = easyenv::get_env_duration_seconds_or_default(
|
||||
"BUCKET_TIMEOUT_SECONDS", Duration::from_secs(60 * 10));
|
||||
let bucket_client = if public {
|
||||
// use public bucket client
|
||||
info!("Configuring public GCS bucket...");
|
||||
LegacyBucketClient::create(
|
||||
BucketClient::create(
|
||||
&self.access_key,
|
||||
&self.secret_key,
|
||||
&self.region_name,
|
||||
@@ -123,7 +123,7 @@ impl BucketOrchestration {
|
||||
)?
|
||||
} else {
|
||||
info!("Configuring private GCS bucket...");
|
||||
LegacyBucketClient::create(
|
||||
BucketClient::create(
|
||||
&self.access_key,
|
||||
&self.secret_key,
|
||||
&self.region_name,
|
||||
|
||||
+6
-6
@@ -20,7 +20,7 @@ pub struct RemoteCloudFileClient {
|
||||
|
||||
impl RemoteCloudFileClient {
|
||||
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
pub async fn get_remote_cloud_file_client() -> AnyhowResult<Self> {
|
||||
let bucket_orchestration = match BucketOrchestration::new_bucket_client_from_existing_env() {
|
||||
Ok(client) => client,
|
||||
@@ -34,7 +34,7 @@ impl RemoteCloudFileClient {
|
||||
})
|
||||
}
|
||||
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
pub fn new(bucket_orchestration_client: Box<dyn BucketOrchestrationCore>) -> Self {
|
||||
Self {
|
||||
bucket_orchestration_client
|
||||
@@ -42,7 +42,7 @@ impl RemoteCloudFileClient {
|
||||
}
|
||||
|
||||
// Where the to_system_file_path is let sd_checkpoint_path = work_temp_dir.path().join("sd_checkpoint.safetensors"); path and file name and extension
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
pub async fn download_file(&self, remote_cloud_bucket_details:RemoteCloudBucketDetails, to_system_file_path:String) -> AnyhowResult<()> {
|
||||
let file_descriptor = remote_cloud_bucket_details.file_descriptor_from_bucket_details();
|
||||
let file_bucket_directory = FileBucketDirectory::from_existing_bucket_details(remote_cloud_bucket_details);
|
||||
@@ -53,7 +53,7 @@ impl RemoteCloudFileClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
pub async fn download_media_file(&self, media_file_path: &MediaFileBucketPath, to_system_file_path: String) -> AnyhowResult<()> {
|
||||
const IS_PUBLIC: bool = true; // NB: Media files are always public
|
||||
let full_remote_cloud_file_path = media_file_path.get_full_object_path_str().to_string();
|
||||
@@ -63,7 +63,7 @@ impl RemoteCloudFileClient {
|
||||
}
|
||||
|
||||
// return error or success with meta data.
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
pub async fn upload_file(&self, file_descriptor:Box<dyn FileDescriptor>, from_system_file_path:&str) -> AnyhowResult<FileMetaData> {
|
||||
|
||||
// get file meta data
|
||||
@@ -99,7 +99,7 @@ impl RemoteCloudFileClient {
|
||||
}
|
||||
|
||||
// Retrieve the metadata from the file
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
pub fn get_file_meta_data(system_file_path:&str) -> AnyhowResult<FileMetaData> {
|
||||
let file_size_bytes = file_size(system_file_path.clone())?;
|
||||
let sha256_checksum = sha256_hash_file(system_file_path.clone())?;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
[package]
|
||||
name = "comet_api_client"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "comet_api_client"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# External
|
||||
log.workspace = true
|
||||
# `multipart` is enabled on top of the workspace feature set because the
|
||||
# CometAPI video creation endpoint takes `multipart/form-data` (the
|
||||
# `input_reference` image fields are binary parts).
|
||||
reqwest = { workspace = true, features = ["multipart"] }
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
test_data = { path = "../../testing/test_data" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -1,17 +0,0 @@
|
||||
/// A CometAPI key used for Bearer token authentication.
|
||||
#[derive(Clone)]
|
||||
pub struct CometApiKey(pub String);
|
||||
|
||||
impl CometApiKey {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self(api_key.trim().to_string())
|
||||
}
|
||||
|
||||
pub fn from_str(api_key: &str) -> Self {
|
||||
Self(api_key.trim().to_string())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod comet_api_key;
|
||||
@@ -1,157 +0,0 @@
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::error::comet_bad_request_api_error::CometBadRequestApiError;
|
||||
use crate::error::comet_error::CometError;
|
||||
use crate::error::comet_generic_api_error::CometGenericApiError;
|
||||
use crate::error::comet_specific_api_error::CometSpecificApiError;
|
||||
|
||||
/// Marker CometAPI returns from `GET /v1/videos/{id}` for unknown task ids.
|
||||
const TASK_NOT_EXIST_MARKER: &str = "task_not_exist";
|
||||
|
||||
/// Categorize a non-success response from the video creation endpoint
|
||||
/// (`POST /v1/videos`).
|
||||
///
|
||||
/// Per the docs, 400s from this endpoint mean the request itself was invalid
|
||||
/// (missing prompt, out-of-range duration, image input on an unsupported
|
||||
/// model) — the caller's fault, surfaced as [`CometBadRequestApiError`].
|
||||
pub fn categorize_create_video_error(
|
||||
status_code: StatusCode,
|
||||
body: String,
|
||||
) -> CometError {
|
||||
if status_code == StatusCode::UNAUTHORIZED {
|
||||
return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into();
|
||||
}
|
||||
|
||||
if status_code == StatusCode::BAD_REQUEST {
|
||||
return CometBadRequestApiError::InvalidRequestParameters { raw_body: body }.into();
|
||||
}
|
||||
|
||||
CometGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code,
|
||||
body,
|
||||
}.into()
|
||||
}
|
||||
|
||||
/// Categorize a non-success response from the poll endpoint
|
||||
/// (`GET /v1/videos/{id}`).
|
||||
///
|
||||
/// Unlike the creation endpoint, a 400 here means the task id is unknown
|
||||
/// ("task_not_exist") — a specific, well-known cause rather than a
|
||||
/// user-facing bad request.
|
||||
pub fn categorize_get_video_task_error(
|
||||
status_code: StatusCode,
|
||||
body: String,
|
||||
task_id: &str,
|
||||
) -> CometError {
|
||||
if status_code == StatusCode::UNAUTHORIZED {
|
||||
return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into();
|
||||
}
|
||||
|
||||
if status_code == StatusCode::BAD_REQUEST && body.contains(TASK_NOT_EXIST_MARKER) {
|
||||
return CometSpecificApiError::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
raw_body: body,
|
||||
}.into();
|
||||
}
|
||||
|
||||
CometGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code,
|
||||
body,
|
||||
}.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod create_video_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bad_request_is_caller_fault() {
|
||||
let body = r#"{"error":{"message":"seconds must be between 4 and 15","type":"invalid_request_error"}}"#.to_string();
|
||||
|
||||
let error = categorize_create_video_error(StatusCode::BAD_REQUEST, body.clone());
|
||||
|
||||
match error {
|
||||
CometError::ApiBadRequest(CometBadRequestApiError::InvalidRequestParameters { raw_body }) => {
|
||||
assert_eq!(raw_body, body);
|
||||
}
|
||||
other => panic!("Expected InvalidRequestParameters, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthorized_is_specific() {
|
||||
let body = r#"{"error":{"message":"invalid token","type":"cometapi_error"}}"#.to_string();
|
||||
|
||||
let error = categorize_create_video_error(StatusCode::UNAUTHORIZED, body);
|
||||
|
||||
match error {
|
||||
CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body }) => {
|
||||
assert!(raw_body.contains("invalid token"));
|
||||
}
|
||||
other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_errors_fall_back_to_uncategorized() {
|
||||
let error = categorize_create_video_error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"upstream timeout".to_string(),
|
||||
);
|
||||
|
||||
match &error {
|
||||
CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, .. }) => {
|
||||
assert_eq!(*status_code, StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
|
||||
}
|
||||
assert!(error.is_having_downtime_issues());
|
||||
}
|
||||
}
|
||||
|
||||
mod get_video_task_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn task_not_exist_is_task_not_found() {
|
||||
let body = r#"{"error":{"message":"task_not_exist","type":"cometapi_error"}}"#.to_string();
|
||||
|
||||
let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123");
|
||||
|
||||
match error {
|
||||
CometError::ApiSpecific(CometSpecificApiError::TaskNotFound { task_id, raw_body }) => {
|
||||
assert_eq!(task_id, "task_abc123");
|
||||
assert!(raw_body.contains("task_not_exist"));
|
||||
}
|
||||
other => panic!("Expected TaskNotFound, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_bad_requests_fall_back_to_uncategorized() {
|
||||
let body = r#"{"error":{"message":"something else"}}"#.to_string();
|
||||
|
||||
let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123");
|
||||
|
||||
match error {
|
||||
CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }) => {}
|
||||
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthorized_is_specific() {
|
||||
let body = r#"{"error":{"message":"invalid token"}}"#.to_string();
|
||||
|
||||
let error = categorize_get_video_task_error(StatusCode::UNAUTHORIZED, body, "task_abc123");
|
||||
|
||||
match error {
|
||||
CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { .. }) => {}
|
||||
other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// These represent known "400s" that are the fault of the client sending a
|
||||
/// bad request. They should surface to the end user as 400s.
|
||||
#[derive(Debug)]
|
||||
pub enum CometBadRequestApiError {
|
||||
/// The video creation request was rejected for invalid parameters (eg.
|
||||
/// missing prompt, out-of-range duration, an `input_reference` image on a
|
||||
/// model that doesn't support image input).
|
||||
InvalidRequestParameters { raw_body: String },
|
||||
}
|
||||
|
||||
impl Error for CometBadRequestApiError {}
|
||||
|
||||
impl Display for CometBadRequestApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidRequestParameters { raw_body } => write!(f, "Invalid request parameters: {}", raw_body),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CometClientError {
|
||||
/// An error was encountered building the reqwest client or the request.
|
||||
ReqwestClientError(reqwest::Error),
|
||||
|
||||
/// A request field couldn't be validated or converted to the value
|
||||
/// expected on the wire. Carries the field name and the raw input the
|
||||
/// caller supplied.
|
||||
InvalidRequestField { field: &'static str, raw_value: String, reason: String },
|
||||
}
|
||||
|
||||
impl Error for CometClientError {}
|
||||
|
||||
impl Display for CometClientError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ReqwestClientError(err) => write!(f, "Reqwest client error: {}", err),
|
||||
Self::InvalidRequestField { field, raw_value, reason } => {
|
||||
write!(f, "Invalid value for request field `{}`: {:?} ({})", field, raw_value, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use crate::error::comet_bad_request_api_error::CometBadRequestApiError;
|
||||
use crate::error::comet_client_error::CometClientError;
|
||||
use crate::error::comet_generic_api_error::CometGenericApiError;
|
||||
use crate::error::comet_specific_api_error::CometSpecificApiError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CometError {
|
||||
/// An error that happened client-side (before or while making the request).
|
||||
Client(CometClientError),
|
||||
|
||||
/// Known "400s" that are the fault of the client sending a bad request.
|
||||
/// These should surface to the end user as 400s.
|
||||
ApiBadRequest(CometBadRequestApiError),
|
||||
|
||||
/// Server-side errors with specific, well-known causes.
|
||||
ApiSpecific(CometSpecificApiError),
|
||||
|
||||
/// Server-side errors with unknown / grab-bag causes.
|
||||
ApiGeneric(CometGenericApiError),
|
||||
}
|
||||
|
||||
impl CometError {
|
||||
pub fn is_having_downtime_issues(&self) -> bool {
|
||||
match self {
|
||||
Self::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: _ }) => {
|
||||
matches!(status_code.as_u16(), 502 | 503 | 504 | 524)
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for CometError {}
|
||||
|
||||
impl Display for CometError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Client(e) => write!(f, "CometClientError: {:?}", e),
|
||||
Self::ApiBadRequest(e) => write!(f, "CometBadRequestApiError: {:?}", e),
|
||||
Self::ApiSpecific(e) => write!(f, "CometSpecificApiError: {:?}", e),
|
||||
Self::ApiGeneric(e) => write!(f, "CometGenericApiError: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CometClientError> for CometError {
|
||||
fn from(error: CometClientError) -> Self {
|
||||
Self::Client(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CometBadRequestApiError> for CometError {
|
||||
fn from(error: CometBadRequestApiError) -> Self {
|
||||
Self::ApiBadRequest(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CometSpecificApiError> for CometError {
|
||||
fn from(error: CometSpecificApiError) -> Self {
|
||||
Self::ApiSpecific(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CometGenericApiError> for CometError {
|
||||
fn from(error: CometGenericApiError) -> Self {
|
||||
Self::ApiGeneric(error)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user