diff --git a/.claude/rules/api-conventions.md b/.claude/rules/api-conventions.md index 8e489461e1..acd38325c5 100644 --- a/.claude/rules/api-conventions.md +++ b/.claude/rules/api-conventions.md @@ -19,9 +19,18 @@ Handlers in `storyteller_web` follow this structure: ### Session/Auth -- `require_user_session_using_connection()` reuses an open DB connection (preferred) -- `require_user_session()` acquires its own connection (deprecated) -- `require_moderator()` for admin-only endpoints +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. ### API Type Definitions diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index a6253b495c..e7a72079d5 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -7,10 +7,11 @@ - `#[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` instead -- SQLx queries: prefer `sqlx::query!` and `sqlx::query_as!` compile-time checked macros over - runtime `sqlx::query()` / `sqlx::query_as()`. The macros catch SQL typos, column mismatches, - and type errors at build time. Use runtime queries only when assembling complicated - dynamic/branching queries that cannot be expressed as a compile-time macro. +- 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`. - 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)` diff --git a/.sqlx/query-14e63e59f7d550181126151029bcd17d08278196ba0ee4d32450c2567b1da26e.json b/.sqlx/query-14e63e59f7d550181126151029bcd17d08278196ba0ee4d32450c2567b1da26e.json new file mode 100644 index 0000000000..395736f96d --- /dev/null +++ b/.sqlx/query-14e63e59f7d550181126151029bcd17d08278196ba0ee4d32450c2567b1da26e.json @@ -0,0 +1,12 @@ +{ + "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" +} diff --git a/.sqlx/query-ddbbd3886c00268edd3993508f635549fc8ddc877bb059fd58f14b8c07586fc9.json b/.sqlx/query-1703bc63ce65d98cbcad81d515cbcf211a943c533e38e887d2c12713c78b687d.json similarity index 83% rename from .sqlx/query-ddbbd3886c00268edd3993508f635549fc8ddc877bb059fd58f14b8c07586fc9.json rename to .sqlx/query-1703bc63ce65d98cbcad81d515cbcf211a943c533e38e887d2c12713c78b687d.json index a0ad19008e..b88037b37d 100644 --- a/.sqlx/query-ddbbd3886c00268edd3993508f635549fc8ddc877bb059fd58f14b8c07586fc9.json +++ b/.sqlx/query-1703bc63ce65d98cbcad81d515cbcf211a943c533e38e887d2c12713c78b687d.json @@ -1,6 +1,6 @@ { "db_name": "MySQL", - "query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.job_type = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ", + "query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,\n jobs.created_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.job_type = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ", "describe": { "columns": [ { @@ -92,6 +92,16 @@ "char_set": 224, "max_size": 64 } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } } ], "parameters": { @@ -106,8 +116,9 @@ false, true, true, - true + true, + false ] }, - "hash": "ddbbd3886c00268edd3993508f635549fc8ddc877bb059fd58f14b8c07586fc9" + "hash": "1703bc63ce65d98cbcad81d515cbcf211a943c533e38e887d2c12713c78b687d" } diff --git a/.sqlx/query-4a50b7bf06061f05e769a41e547cb927bd2b3a6f67c8ceb37883b744237ba34c.json b/.sqlx/query-1b69f192c3bb51bd249f97d37f209ae271d498bb46c978351b2c1d8759338bab.json similarity index 87% rename from .sqlx/query-4a50b7bf06061f05e769a41e547cb927bd2b3a6f67c8ceb37883b744237ba34c.json rename to .sqlx/query-1b69f192c3bb51bd249f97d37f209ae271d498bb46c978351b2c1d8759338bab.json index 6202e1380c..1af9cf6866 100644 --- a/.sqlx/query-4a50b7bf06061f05e769a41e547cb927bd2b3a6f67c8ceb37883b744237ba34c.json +++ b/.sqlx/query-1b69f192c3bb51bd249f97d37f209ae271d498bb46c978351b2c1d8759338bab.json @@ -1,6 +1,6 @@ { "db_name": "MySQL", - "query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_model_type as `maybe_model_type: enums::common::generation::common_model_type::CommonModelType`,\n p.maybe_generation_provider as `maybe_generation_provider: enums::common::generation_provider::GenerationProvider`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n\n p.maybe_generation_mode as `maybe_generation_mode: enums::common::generation::common_generation_mode::CommonGenerationMode`,\n p.maybe_aspect_ratio as `maybe_aspect_ratio: enums::common::generation::common_aspect_ratio::CommonAspectRatio`,\n p.maybe_resolution as `maybe_resolution: enums::common::generation::common_resolution::CommonResolution`,\n p.maybe_batch_count as `maybe_batch_count: u8`,\n p.maybe_generate_audio as `maybe_generate_audio: bool`,\n p.maybe_duration_seconds as `maybe_duration_seconds: u32`,\n\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ", + "query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_model_type as `maybe_model_type: enums::common::generation::common_model_type::CommonModelType`,\n p.maybe_generation_provider as `maybe_generation_provider: enums::common::generation_provider::GenerationProvider`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n\n p.maybe_generation_mode as `maybe_generation_mode: enums::common::generation::common_generation_mode::CommonGenerationMode`,\n p.maybe_aspect_ratio as `maybe_aspect_ratio: enums::common::generation::common_aspect_ratio::CommonAspectRatio`,\n p.maybe_resolution as `maybe_resolution: enums::common::generation::common_resolution::CommonResolution`,\n p.maybe_bitrate as `maybe_bitrate: enums::common::generation::common_bitrate::CommonBitrate`,\n p.maybe_batch_count as `maybe_batch_count: u8`,\n p.maybe_generate_audio as `maybe_generate_audio: bool`,\n p.maybe_duration_seconds as `maybe_duration_seconds: u32`,\n\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ", "describe": { "columns": [ { @@ -105,6 +105,16 @@ }, { "ordinal": 10, + "name": "maybe_bitrate: enums::common::generation::common_bitrate::CommonBitrate", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 11, "name": "maybe_batch_count: u8", "type_info": { "type": "Tiny", @@ -114,7 +124,7 @@ } }, { - "ordinal": 11, + "ordinal": 12, "name": "maybe_generate_audio: bool", "type_info": { "type": "Tiny", @@ -124,7 +134,7 @@ } }, { - "ordinal": 12, + "ordinal": 13, "name": "maybe_duration_seconds: u32", "type_info": { "type": "Long", @@ -134,7 +144,7 @@ } }, { - "ordinal": 13, + "ordinal": 14, "name": "maybe_other_args", "type_info": { "type": "Blob", @@ -144,7 +154,7 @@ } }, { - "ordinal": 14, + "ordinal": 15, "name": "creator_ip_address", "type_info": { "type": "VarString", @@ -154,7 +164,7 @@ } }, { - "ordinal": 15, + "ordinal": 16, "name": "created_at", "type_info": { "type": "Timestamp", @@ -182,9 +192,10 @@ true, true, true, + true, false, false ] }, - "hash": "4a50b7bf06061f05e769a41e547cb927bd2b3a6f67c8ceb37883b744237ba34c" + "hash": "1b69f192c3bb51bd249f97d37f209ae271d498bb46c978351b2c1d8759338bab" } diff --git a/.sqlx/query-3846628765bd34ea2ce01e15a0db732121ce473c5c542d0e971ad29b2b421193.json b/.sqlx/query-3846628765bd34ea2ce01e15a0db732121ce473c5c542d0e971ad29b2b421193.json index f5540107d6..a5a4c7b893 100644 --- a/.sqlx/query-3846628765bd34ea2ce01e15a0db732121ce473c5c542d0e971ad29b2b421193.json +++ b/.sqlx/query-3846628765bd34ea2ce01e15a0db732121ce473c5c542d0e971ad29b2b421193.json @@ -48,7 +48,7 @@ "name": "maybe_entity_token", "type_info": { "type": "VarString", - "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "flags": "MULTIPLE_KEY | BINARY", "char_set": 224, "max_size": 128 } @@ -132,7 +132,7 @@ false, false, true, - false, + true, true, true, true, diff --git a/.sqlx/query-4036dfd9d4e9e77e557776468c14b75f49170de1e55a1b9b9e83446a0c356e74.json b/.sqlx/query-4036dfd9d4e9e77e557776468c14b75f49170de1e55a1b9b9e83446a0c356e74.json new file mode 100644 index 0000000000..5d0e3d9a50 --- /dev/null +++ b/.sqlx/query-4036dfd9d4e9e77e557776468c14b75f49170de1e55a1b9b9e83446a0c356e74.json @@ -0,0 +1,12 @@ +{ + "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" +} diff --git a/.sqlx/query-408c893a0ced805de4c3c6cfdf0c6a4bcc93dd1eaf98382522eae46e72eb3aa5.json b/.sqlx/query-408c893a0ced805de4c3c6cfdf0c6a4bcc93dd1eaf98382522eae46e72eb3aa5.json new file mode 100644 index 0000000000..18b9c07234 --- /dev/null +++ b/.sqlx/query-408c893a0ced805de4c3c6cfdf0c6a4bcc93dd1eaf98382522eae46e72eb3aa5.json @@ -0,0 +1,157 @@ +{ + "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" +} diff --git a/.sqlx/query-489c6ef00507330fedbaf2b6e024ecf409a920efd85f75456829a8ed412d9bc3.json b/.sqlx/query-489c6ef00507330fedbaf2b6e024ecf409a920efd85f75456829a8ed412d9bc3.json new file mode 100644 index 0000000000..fafb78bf75 --- /dev/null +++ b/.sqlx/query-489c6ef00507330fedbaf2b6e024ecf409a920efd85f75456829a8ed412d9bc3.json @@ -0,0 +1,25 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT status as `status: enums::common::job_status_plus::JobStatusPlus`\nFROM generic_inference_jobs\nWHERE token = ?\nFOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status: enums::common::job_status_plus::JobStatusPlus", + "type_info": { + "type": "String", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM", + "char_set": 224, + "max_size": 76 + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "489c6ef00507330fedbaf2b6e024ecf409a920efd85f75456829a8ed412d9bc3" +} diff --git a/.sqlx/query-73d70c5ee77f5059801d324610a80b3f52da3ddec2e89f63e8f41fc68ff0aaec.json b/.sqlx/query-73d70c5ee77f5059801d324610a80b3f52da3ddec2e89f63e8f41fc68ff0aaec.json new file mode 100644 index 0000000000..caf3bd664c --- /dev/null +++ b/.sqlx/query-73d70c5ee77f5059801d324610a80b3f52da3ddec2e89f63e8f41fc68ff0aaec.json @@ -0,0 +1,157 @@ +{ + "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" +} diff --git a/.sqlx/query-ccf92ebe0328d3019ac6b01be4b442df706a183362fa7e5c331f5a4bf1a74a00.json b/.sqlx/query-7519c1dcc2946f4c31a8aad995bbb66a5519f6ee571797a6c0f597c9e57986a7.json similarity index 61% rename from .sqlx/query-ccf92ebe0328d3019ac6b01be4b442df706a183362fa7e5c331f5a4bf1a74a00.json rename to .sqlx/query-7519c1dcc2946f4c31a8aad995bbb66a5519f6ee571797a6c0f597c9e57986a7.json index c4f101dd57..d807bc048c 100644 --- a/.sqlx/query-ccf92ebe0328d3019ac6b01be4b442df706a183362fa7e5c331f5a4bf1a74a00.json +++ b/.sqlx/query-7519c1dcc2946f4c31a8aad995bbb66a5519f6ee571797a6c0f597c9e57986a7.json @@ -1,12 +1,12 @@ { "db_name": "MySQL", - "query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n \n maybe_model_type = ?,\n maybe_generation_provider = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n\n maybe_generation_mode = ?,\n maybe_aspect_ratio = ?,\n maybe_resolution = ?,\n maybe_batch_count = ?,\n maybe_generate_audio = ?,\n maybe_duration_seconds = ?,\n\n maybe_other_args = ?,\n\n creator_ip_address = ?\n ", + "query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n \n maybe_model_type = ?,\n maybe_generation_provider = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n\n maybe_generation_mode = ?,\n maybe_aspect_ratio = ?,\n maybe_resolution = ?,\n maybe_bitrate = ?,\n maybe_batch_count = ?,\n maybe_generate_audio = ?,\n maybe_duration_seconds = ?,\n\n maybe_other_args = ?,\n\n creator_ip_address = ?\n ", "describe": { "columns": [], "parameters": { - "Right": 15 + "Right": 16 }, "nullable": [] }, - "hash": "ccf92ebe0328d3019ac6b01be4b442df706a183362fa7e5c331f5a4bf1a74a00" + "hash": "7519c1dcc2946f4c31a8aad995bbb66a5519f6ee571797a6c0f597c9e57986a7" } diff --git a/.sqlx/query-9132f832752ea40736bedf4729c76af7fe019cc69ed5cc280fce87e68c3f4846.json b/.sqlx/query-9132f832752ea40736bedf4729c76af7fe019cc69ed5cc280fce87e68c3f4846.json index 8d3507f04b..54b0cb4cb3 100644 --- a/.sqlx/query-9132f832752ea40736bedf4729c76af7fe019cc69ed5cc280fce87e68c3f4846.json +++ b/.sqlx/query-9132f832752ea40736bedf4729c76af7fe019cc69ed5cc280fce87e68c3f4846.json @@ -48,7 +48,7 @@ "name": "maybe_entity_token", "type_info": { "type": "VarString", - "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "flags": "MULTIPLE_KEY | BINARY", "char_set": 224, "max_size": 128 } @@ -132,7 +132,7 @@ false, false, true, - false, + true, true, true, true, diff --git a/.sqlx/query-9312694d1651235b06d1744d904b59f64d66449b8927c1606bb08bd5fdc15c7e.json b/.sqlx/query-9312694d1651235b06d1744d904b59f64d66449b8927c1606bb08bd5fdc15c7e.json new file mode 100644 index 0000000000..6e2d0776ce --- /dev/null +++ b/.sqlx/query-9312694d1651235b06d1744d904b59f64d66449b8927c1606bb08bd5fdc15c7e.json @@ -0,0 +1,25 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT status as `status: enums::common::job_status_plus::JobStatusPlus`\nFROM generic_inference_jobs\nWHERE token = ?\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status: enums::common::job_status_plus::JobStatusPlus", + "type_info": { + "type": "String", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM", + "char_set": 224, + "max_size": 76 + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "9312694d1651235b06d1744d904b59f64d66449b8927c1606bb08bd5fdc15c7e" +} diff --git a/.sqlx/query-b1f30a79864351a28a58cae43ee38ff12a8d64d9393b8f27590fe03b0ae3ab05.json b/.sqlx/query-b1f30a79864351a28a58cae43ee38ff12a8d64d9393b8f27590fe03b0ae3ab05.json new file mode 100644 index 0000000000..47d20b2ebf --- /dev/null +++ b/.sqlx/query-b1f30a79864351a28a58cae43ee38ff12a8d64d9393b8f27590fe03b0ae3ab05.json @@ -0,0 +1,12 @@ +{ + "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" +} diff --git a/.sqlx/query-c6bbdc963e0d49ac55973a728d04b371967c20bfb9dd4bd75e4693dc7a114c99.json b/.sqlx/query-c6bbdc963e0d49ac55973a728d04b371967c20bfb9dd4bd75e4693dc7a114c99.json new file mode 100644 index 0000000000..79d98a9707 --- /dev/null +++ b/.sqlx/query-c6bbdc963e0d49ac55973a728d04b371967c20bfb9dd4bd75e4693dc7a114c99.json @@ -0,0 +1,12 @@ +{ + "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" +} diff --git a/.sqlx/query-f52c495a7ff5cafb357f79c4cdb503bbbef045fd193a5a37a66180fcac77f363.json b/.sqlx/query-f52c495a7ff5cafb357f79c4cdb503bbbef045fd193a5a37a66180fcac77f363.json new file mode 100644 index 0000000000..780eabe4e3 --- /dev/null +++ b/.sqlx/query-f52c495a7ff5cafb357f79c4cdb503bbbef045fd193a5a37a66180fcac77f363.json @@ -0,0 +1,47 @@ +{ + "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" +} diff --git a/CLAUDE.md b/CLAUDE.md index b2a7755987..3878d87808 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ artcraft/ - Rust with no minimum supported version - Actix-web for HTTP services -- SQLx for MySQL and SQLite; prefer `sqlx::query!` / `sqlx::query_as!` compile-time checked macros over runtime `sqlx::query()` whenever possible +- 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`.) - 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;` diff --git a/Cargo.lock b/Cargo.lock index 159bd14281..9c5b7fa284 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -712,6 +712,13 @@ dependencies = [ "workspace-build-acceleration", ] +[[package]] +name = "artcraft_api_keys" +version = "0.0.1" +dependencies = [ + "workspace-build-acceleration", +] + [[package]] name = "artcraft_client" version = "0.0.1" @@ -1723,6 +1730,14 @@ dependencies = [ "serde", ] +[[package]] +name = "bucket_client" +version = "0.0.1" +dependencies = [ + "rust-s3", + "workspace-build-acceleration", +] + [[package]] name = "bucket_paths" version = "0.0.1" @@ -7297,6 +7312,7 @@ name = "mysql_queries" version = "0.0.1" dependencies = [ "anyhow", + "artcraft_api_keys", "bucket_paths", "chrono", "composite_identifiers", @@ -10500,6 +10516,7 @@ dependencies = [ "log", "mysql_queries", "pager", + "rand 0.9.2", "reqwest 0.12.23", "rootly_client", "rootly_config", @@ -10988,6 +11005,14 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "sha1_hash" +version = "0.0.1" +dependencies = [ + "sha1 0.10.6", + "workspace-build-acceleration", +] + [[package]] name = "sha1_smol" version = "1.0.0" @@ -11556,6 +11581,7 @@ dependencies = [ "actix_helpers", "anyhow", "artcraft_api_defs", + "artcraft_api_keys", "artcraft_router", "async-stripe 0.26.0", "async-trait", @@ -11564,6 +11590,7 @@ dependencies = [ "billing_artcraft_component", "billing_component", "bootstrap", + "bucket_client", "bucket_paths", "bytes", "chrono", @@ -11635,6 +11662,7 @@ dependencies = [ "serde_derive", "serde_json", "server_environment", + "sha1_hash", "shared_env_var_config", "speculoos", "sqlx", @@ -11658,6 +11686,7 @@ dependencies = [ "utoipa", "utoipa-swagger-ui", "uuid_utils", + "video_info", "videos", "workspace-build-acceleration", "worldlabs_api_client", @@ -13887,6 +13916,14 @@ dependencies = [ "workspace-build-acceleration", ] +[[package]] +name = "video_info" +version = "0.0.1" +dependencies = [ + "chrono", + "workspace-build-acceleration", +] + [[package]] name = "videos" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index fad9ecea3d..e3d5975b8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,10 @@ 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", @@ -86,6 +88,7 @@ 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", @@ -93,6 +96,7 @@ 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", @@ -232,9 +236,11 @@ 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" } @@ -279,6 +285,7 @@ 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" } @@ -288,6 +295,7 @@ 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" } diff --git a/_database/sql/migrations/2026-06-19-181006-0000_uploaded_videos/down.sql b/_database/sql/migrations/2026-06-19-181006-0000_uploaded_videos/down.sql new file mode 100644 index 0000000000..be0078cf2d --- /dev/null +++ b/_database/sql/migrations/2026-06-19-181006-0000_uploaded_videos/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +DROP TABLE IF EXISTS uploaded_videos; diff --git a/_database/sql/migrations/2026-06-19-181006-0000_uploaded_videos/up.sql b/_database/sql/migrations/2026-06-19-181006-0000_uploaded_videos/up.sql new file mode 100644 index 0000000000..6fd0c27103 --- /dev/null +++ b/_database/sql/migrations/2026-06-19-181006-0000_uploaded_videos/up.sql @@ -0,0 +1,62 @@ +-- 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; diff --git a/_database/sql/migrations/2026-06-19-181008-0000_uploaded_video_notes/down.sql b/_database/sql/migrations/2026-06-19-181008-0000_uploaded_video_notes/down.sql new file mode 100644 index 0000000000..42071b6b08 --- /dev/null +++ b/_database/sql/migrations/2026-06-19-181008-0000_uploaded_video_notes/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +DROP TABLE IF EXISTS uploaded_video_notes; diff --git a/_database/sql/migrations/2026-06-19-181008-0000_uploaded_video_notes/up.sql b/_database/sql/migrations/2026-06-19-181008-0000_uploaded_video_notes/up.sql new file mode 100644 index 0000000000..c4a1b84dd5 --- /dev/null +++ b/_database/sql/migrations/2026-06-19-181008-0000_uploaded_video_notes/up.sql @@ -0,0 +1,69 @@ +-- 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; diff --git a/_database/sql/migrations/2026-06-20-124827-0000_alter_uploaded_videos_add_filename/down.sql b/_database/sql/migrations/2026-06-20-124827-0000_alter_uploaded_videos_add_filename/down.sql new file mode 100644 index 0000000000..0441977c29 --- /dev/null +++ b/_database/sql/migrations/2026-06-20-124827-0000_alter_uploaded_videos_add_filename/down.sql @@ -0,0 +1,6 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE uploaded_videos + DROP COLUMN maybe_filename; diff --git a/_database/sql/migrations/2026-06-20-124827-0000_alter_uploaded_videos_add_filename/up.sql b/_database/sql/migrations/2026-06-20-124827-0000_alter_uploaded_videos_add_filename/up.sql new file mode 100644 index 0000000000..3b01e811c5 --- /dev/null +++ b/_database/sql/migrations/2026-06-20-124827-0000_alter_uploaded_videos_add_filename/up.sql @@ -0,0 +1,6 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE uploaded_videos + ADD COLUMN maybe_filename VARCHAR(512) DEFAULT NULL AFTER filesize_bytes; diff --git a/_database/sql/migrations/2026-06-22-122536-0000_alter_prompts_add_bitrate/down.sql b/_database/sql/migrations/2026-06-22-122536-0000_alter_prompts_add_bitrate/down.sql new file mode 100644 index 0000000000..1fd7dc026d --- /dev/null +++ b/_database/sql/migrations/2026-06-22-122536-0000_alter_prompts_add_bitrate/down.sql @@ -0,0 +1,6 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE prompts + DROP COLUMN maybe_bitrate; diff --git a/_database/sql/migrations/2026-06-22-122536-0000_alter_prompts_add_bitrate/up.sql b/_database/sql/migrations/2026-06-22-122536-0000_alter_prompts_add_bitrate/up.sql new file mode 100644 index 0000000000..9b59d25066 --- /dev/null +++ b/_database/sql/migrations/2026-06-22-122536-0000_alter_prompts_add_bitrate/up.sql @@ -0,0 +1,8 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +-- Add bitrate parameter to prompts table. +-- Represented by the enum `CommonBitrate` (normal, high). +ALTER TABLE prompts + ADD COLUMN maybe_bitrate VARCHAR(16) DEFAULT NULL; diff --git a/_database/sql/migrations/2026-06-23-215501-0000_alter_staff_audit_logs_fix_non_null_column/down.sql b/_database/sql/migrations/2026-06-23-215501-0000_alter_staff_audit_logs_fix_non_null_column/down.sql new file mode 100644 index 0000000000..ca90a4ef85 --- /dev/null +++ b/_database/sql/migrations/2026-06-23-215501-0000_alter_staff_audit_logs_fix_non_null_column/down.sql @@ -0,0 +1,8 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +-- Revert `maybe_entity_token` back to NOT NULL. +-- NB: This will fail if any rows have a null `maybe_entity_token`. +ALTER TABLE staff_audit_logs + MODIFY COLUMN maybe_entity_token VARCHAR(32) NOT NULL; diff --git a/_database/sql/migrations/2026-06-23-215501-0000_alter_staff_audit_logs_fix_non_null_column/up.sql b/_database/sql/migrations/2026-06-23-215501-0000_alter_staff_audit_logs_fix_non_null_column/up.sql new file mode 100644 index 0000000000..d86add2cea --- /dev/null +++ b/_database/sql/migrations/2026-06-23-215501-0000_alter_staff_audit_logs_fix_non_null_column/up.sql @@ -0,0 +1,9 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +-- `maybe_entity_token` is optional (many audited actions have no entity), but it +-- was mistakenly created as NOT NULL, so inserts with a null entity token fail +-- with "Column 'maybe_entity_token' cannot be null". Make it nullable. +ALTER TABLE staff_audit_logs + MODIFY COLUMN maybe_entity_token VARCHAR(32) DEFAULT NULL; diff --git a/_database/sql/migrations_squashed/prompts/prompts.sql b/_database/sql/migrations_squashed/prompts/prompts.sql index 84b78b4e29..c173571c70 100644 --- a/_database/sql/migrations_squashed/prompts/prompts.sql +++ b/_database/sql/migrations_squashed/prompts/prompts.sql @@ -72,6 +72,10 @@ CREATE TABLE prompts ( -- The quality setting (eg. high, medium, low) maybe_quality VARCHAR(16) DEFAULT NULL, + -- Represented by the enum `CommonBitrate`. + -- The output bitrate setting (eg. normal, high) + maybe_bitrate VARCHAR(16) DEFAULT NULL, + -- The duration in seconds maybe_duration_seconds INT UNSIGNED DEFAULT NULL, diff --git a/_database/sql/migrations_squashed/staff_audit_logs.sql b/_database/sql/migrations_squashed/staff_audit_logs.sql index 8ca26db660..c2b962c616 100644 --- a/_database/sql/migrations_squashed/staff_audit_logs.sql +++ b/_database/sql/migrations_squashed/staff_audit_logs.sql @@ -17,7 +17,7 @@ CREATE TABLE staff_audit_logs ( maybe_entity_type VARCHAR(16) DEFAULT NULL, -- The token of the entity mutated (if any) - maybe_entity_token VARCHAR(32) NOT NULL, + maybe_entity_token VARCHAR(32) DEFAULT NULL, -- The staff user who performed the action. staff_user_token VARCHAR(32) DEFAULT NULL, diff --git a/_database/sql/migrations_squashed/uploaded_video_notes.sql b/_database/sql/migrations_squashed/uploaded_video_notes.sql new file mode 100644 index 0000000000..c4a1b84dd5 --- /dev/null +++ b/_database/sql/migrations_squashed/uploaded_video_notes.sql @@ -0,0 +1,69 @@ +-- 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; diff --git a/_database/sql/migrations_squashed/uploaded_videos.sql b/_database/sql/migrations_squashed/uploaded_videos.sql new file mode 100644 index 0000000000..3032c7b17a --- /dev/null +++ b/_database/sql/migrations_squashed/uploaded_videos.sql @@ -0,0 +1,65 @@ +-- 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; diff --git a/_docs/omni_api/artcraft_omni_api.md b/_docs/omni_api/artcraft_omni_api.md new file mode 100644 index 0000000000..5b958e10ea --- /dev/null +++ b/_docs/omni_api/artcraft_omni_api.md @@ -0,0 +1,425 @@ +# ArtCraft Omni API + +The **Omni API** is the API-key–authenticated surface for ArtCraft generation. It mirrors the +in-app "Omni Gen" endpoints but is designed for programmatic use: you authenticate with an API key +in the `Authorization` header instead of a browser session cookie. + +This guide covers **video generation from URL inputs** — passing image, video, and audio reference +URLs directly. The server downloads each URL, stores it as a media file on your account, and then +runs generation against it. You never have to upload files yourself. + +> All examples send **no cookies**. The Omni API is authenticated purely by your API key. If you +> send session cookies they are ignored. + +--- + +## Base URLs + +| Environment | Base URL | +| ------------- | ------------------------------ | +| Development | `http://localhost:12345` | +| Production | `https://api.storyteller.ai` | + +All paths below are relative to the base URL (e.g. `POST /v1/omni_api/generate/video`). + +The examples in this guide use the **development** base URL. To run against production, swap +`http://localhost:12345` for `https://api.storyteller.ai`. + +--- + +## Getting an API key + +API keys are created from a logged-in session. You only need to do this once; store the key +somewhere safe. + +1. **Log in** to obtain a session (browser, or the `/v1/login` endpoint). +2. **Create a key** by calling `POST /v1/api_keys/create` with your session: + + ```bash + curl -s -c /tmp/cookies.txt -X POST http://localhost:12345/v1/login \ + -H "Content-Type: application/json" \ + -d '{"username_or_email":"YOUR_USERNAME","password":"YOUR_PASSWORD"}' + + curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/api_keys/create \ + -H "Content-Type: application/json" \ + -d '{"name":"my-integration","maybe_description":"server-to-server video gen"}' + ``` + + The response returns the secret **once** — it can never be retrieved again: + + ```json + { + "success": true, + "api_key_token": "api_key_xxxxxxxxxxxxxxxxxxxxxxxxx", + "api_key": "artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ``` + + - `api_key` — the **secret**. Store it now; treat it like a password. This is the value you put + in the `Authorization` header. + - `api_key_token` — a non-secret handle (prefix `api_key_`) used to manage the key later + (get / update / delete). Safe to log and store. + +> **403 Forbidden when creating a key?** API-key creation is gated per account. Contact the +> Storyteller team to enable API access for your account. + +### What an API key looks like + +- Prefix: `artcraft_api_` +- Followed by 40 random lowercase Crockford-base32 characters (`0-9 a-z`, excluding `i l o u`) +- Total length: **53 characters**, e.g. `artcraft_api_3k7q9w0xv2hs5n8m4d6r1t8y0p2s4f6h8j0k2m4n6` + +Never commit a key to source control or paste it into logs. + +--- + +## Authentication + +Send the secret key in the `Authorization` header. Three forms are accepted: + +``` +Authorization: Bearer artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +Authorization: Key artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +Authorization: artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +`Bearer` is recommended. **Do not send cookies.** + +--- + +## Video generation — `POST /v1/omni_api/generate/video` + +Generates a video and returns an inference job token you can poll for results. + +### Request body + +| Field | Type | Notes | +| ---------------------------- | --------------- | ----- | +| `model` | string | Generation model, e.g. `seedance_2p0`. **Required.** | +| `prompt` | string | Text prompt. | +| `duration_seconds` | integer | Length of the output video, e.g. `6`. | +| `aspect_ratio` | string | e.g. `wide_sixteen_by_nine`, `tall_nine_by_sixteen`, `square`. | +| `idempotency_token` | string (UUID) | **Required.** A fresh UUID per request; prevents accidental duplicates. | +| `negative_prompt` | string | Optional. | +| `resolution` | string | Optional, e.g. `seven_twenty_p`, `ten_eighty_p`. | +| `generate_audio` | boolean | Optional. | +| **URL inputs** (this guide) | | | +| `start_frame_image_url` | string (URL) | Starting keyframe image. | +| `end_frame_image_url` | string (URL) | Ending keyframe image. | +| `reference_image_urls` | array of URL | Reference images. | +| `reference_video_urls` | array of URL | Reference videos (`mp4`). | +| `reference_audio_urls` | array of URL | Reference audio (`wav`, `mp3`, `ogg`, …). | +| **Media-token inputs** | | | +| `start_frame_image_media_token`, `end_frame_image_media_token`, `reference_image_media_tokens`, `reference_video_media_tokens`, `reference_audio_media_tokens` | token(s) | Use these instead if you've already uploaded media. | + +#### Rules for URL inputs + +- Each URL field and its media-token counterpart are **mutually exclusive**. Sending both + `reference_image_urls` and `reference_image_media_tokens`, for example, returns: + `Either reference_image_media_tokens or reference_image_urls must be set, not both`. +- Every URL must start with `http://` or `https://`, otherwise: + `URL must start with http:// or https://, bad URL: `. +- Redirects are followed (up to 10), so CDN/redirecting URLs work. +- The file type is detected from the downloaded bytes. Images must be a real image + (`jpeg`/`png`/`gif`/`webp`), reference videos must be `mp4`, reference audio must be a + supported audio type (`wav`, `mp3`, `aac`, `ogg`, `flac`, …). + +### Response + +```json +{ + "success": true, + "inference_job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx", + "all_job_tokens": ["jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"] +} +``` + +Use `inference_job_token` to poll for the finished video. `all_job_tokens` lists every job created +(batch requests can create more than one). + +--- + +## Examples + +The examples reuse these public sample URLs: + +- Images (Wikimedia Commons): an Atlantic puffin, the Matterhorn, a toco toucan +- Video: a short sample `mp4` +- Audio: a short sample `wav` + +Replace `YOUR_API_KEY` with your `artcraft_api_…` secret, and generate a fresh `idempotency_token` +(UUID) for every request. + +--- + +### Example 1 — Starting-frame Seedance (`start_frame_image_url`) + +Animate outward from a single starting image. + +#### bash + +```bash +curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "seedance_2p0", + "prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": "'"$(uuidgen)"'", + "start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900" + }' +``` + +#### python + +```python +import uuid +import requests + +BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai +API_KEY = "YOUR_API_KEY" + +resp = requests.post( + f"{BASE_URL}/v1/omni_api/generate/video", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={ + "model": "seedance_2p0", + "prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": str(uuid.uuid4()), + "start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + }, + timeout=240, +) +resp.raise_for_status() +print(resp.json()) +``` + +#### javascript + +```javascript +import { randomUUID } from "node:crypto"; + +const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai +const API_KEY = "YOUR_API_KEY"; + +const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, { + method: "POST", + headers: { + Authorization: `Bearer ${API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "seedance_2p0", + prompt: + "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.", + duration_seconds: 6, + aspect_ratio: "wide_sixteen_by_nine", + idempotency_token: randomUUID(), + start_frame_image_url: + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + }), +}); + +console.log(resp.status, await resp.json()); +``` + +--- + +### Example 2 — Image-reference Seedance (`reference_image_urls`) + +Guide the generation with one or more reference images. + +#### bash + +```bash +curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "seedance_2p0", + "prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": "'"$(uuidgen)"'", + "reference_image_urls": [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900" + ] + }' +``` + +#### python + +```python +import uuid +import requests + +BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai +API_KEY = "YOUR_API_KEY" + +resp = requests.post( + f"{BASE_URL}/v1/omni_api/generate/video", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={ + "model": "seedance_2p0", + "prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": str(uuid.uuid4()), + "reference_image_urls": [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900", + ], + }, + timeout=240, +) +resp.raise_for_status() +print(resp.json()) +``` + +#### javascript + +```javascript +import { randomUUID } from "node:crypto"; + +const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai +const API_KEY = "YOUR_API_KEY"; + +const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, { + method: "POST", + headers: { + Authorization: `Bearer ${API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "seedance_2p0", + prompt: + "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.", + duration_seconds: 6, + aspect_ratio: "wide_sixteen_by_nine", + idempotency_token: randomUUID(), + reference_image_urls: [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900", + ], + }), +}); + +console.log(resp.status, await resp.json()); +``` + +--- + +### Example 3 — Image + video reference Seedance (`reference_image_urls` + `reference_video_urls`) + +Combine reference images with a reference video (and, optionally, reference audio). This is the +fullest URL-driven request. + +#### bash + +```bash +curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "seedance_2p0", + "prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": "'"$(uuidgen)"'", + "reference_image_urls": [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900" + ], + "reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"], + "reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"] + }' +``` + +#### python + +```python +import uuid +import requests + +BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai +API_KEY = "YOUR_API_KEY" + +resp = requests.post( + f"{BASE_URL}/v1/omni_api/generate/video", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={ + "model": "seedance_2p0", + "prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": str(uuid.uuid4()), + "reference_image_urls": [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900", + ], + "reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"], + "reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"], + }, + timeout=240, +) +resp.raise_for_status() +print(resp.json()) +``` + +#### javascript + +```javascript +import { randomUUID } from "node:crypto"; + +const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai +const API_KEY = "YOUR_API_KEY"; + +const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, { + method: "POST", + headers: { + Authorization: `Bearer ${API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "seedance_2p0", + prompt: + "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.", + duration_seconds: 6, + aspect_ratio: "wide_sixteen_by_nine", + idempotency_token: randomUUID(), + reference_image_urls: [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900", + ], + reference_video_urls: ["https://download.samplelib.com/mp4/sample-5s.mp4"], + reference_audio_urls: ["https://download.samplelib.com/wav/sample-6s.wav"], + }), +}); + +console.log(resp.status, await resp.json()); +``` + +--- + +## Notes & troubleshooting + +- **Always send a fresh `idempotency_token`** (UUID). Reusing one is rejected as a duplicate. +- **No cookies.** The endpoint authenticates only via the `Authorization` header. +- **URL must be `http(s)`** and reachable by the server; redirects are followed. +- **Type mismatches** (e.g. a `.webm` passed as a reference video, which requires `mp4`) are + rejected with a `400` describing the unpermitted type. +- **`401 Unauthorized`** — missing/invalid API key, or the key's owner is banned. +- **`402 Payment required`** — insufficient credits/balance. +- There is a companion image endpoint, `POST /v1/omni_api/generate/image`, which accepts + `image_urls` (mutually exclusive with `image_media_tokens`) following the same rules. diff --git a/_docs/pagedraw_design_review.md b/_docs/pagedraw_design_review.md new file mode 100644 index 0000000000..cdcd0cb93b --- /dev/null +++ b/_docs/pagedraw_design_review.md @@ -0,0 +1,203 @@ +# 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: ; /* 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. diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs index a9d2efc0fb..2acf11f22f 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs @@ -8,6 +8,7 @@ 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; @@ -15,4 +16,5 @@ pub mod subscriptions; pub mod user_referral_codes; pub mod users; pub mod utils; +pub mod video_info; pub mod web_referrals; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/mod.rs new file mode 100644 index 0000000000..4dcdc2a47d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/mod.rs @@ -0,0 +1,2 @@ +pub mod omni_api_image_generate_request; +pub mod omni_api_video_generate_request; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_image_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_image_generate_request.rs new file mode 100644 index 0000000000..b0126c833b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_image_generate_request.rs @@ -0,0 +1,58 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_image_model::CommonImageModel; +use enums::common::generation::common_quality::CommonQuality; +use enums::common::generation::common_resolution::CommonResolution; +use tokens::tokens::media_files::MediaFileToken; + +/// Request body for the Omni API image generation endpoint. +/// +/// Forked from `OmniGenImageCostAndGenerateRequest` so the Omni API's request +/// shape can evolve independently without affecting the product-critical Omni +/// Gen endpoints. +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] +pub struct OmniApiImageGenerateRequest { + /// REQUIRED (even if marked optional) + /// Idempotency token to prevent duplicate requests. + /// If not supplied, we'll generate one for the required providers. + pub idempotency_token: Option, + + /// REQUIRED (even if marked optional) + /// Which model to use. + pub model: Option, + + /// The prompt for the image generation. + pub prompt: Option, + + /// Input images for image editing. + /// If present, we're doing image editing (image-to-image). + /// If absent, we're doing text-to-image. + pub image_media_tokens: Option>, + + /// URL alternative to `image_media_tokens`. Mutually exclusive with it. + /// Each URL is downloaded and stored as a media file before generation. + pub image_urls: Option>, + + /// The resolution to use. + pub resolution: Option, + + /// The aspect ratio to use. + pub aspect_ratio: Option, + + /// The quality to use. + pub quality: Option, + + /// How many images to generate. + pub image_batch_count: Option, + + /// Only for angle manipulation models. + pub adjust_horizontal_angle: Option, + + /// Only for angle manipulation models. + pub adjust_vertical_angle: Option, + + /// Only for angle manipulation models. + pub adjust_zoom: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_video_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_video_generate_request.rs new file mode 100644 index 0000000000..5d41a3941a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_video_generate_request.rs @@ -0,0 +1,96 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; +use enums::common::generation::common_quality::CommonQuality; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation::common_video_model::CommonVideoModel; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; + +/// Request body for the Omni API video generation endpoint. +/// +/// Forked from `OmniGenVideoCostAndGenerateRequest` so the Omni API's request +/// shape can evolve independently without affecting the product-critical Omni +/// Gen endpoints. +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] +pub struct OmniApiVideoGenerateRequest { + /// REQUIRED (even if marked optional) + /// Idempotency token to prevent duplicate requests. + pub idempotency_token: Option, + + /// REQUIRED (even if marked optional) + /// Which model to use. + pub model: Option, + + /// The text prompt for the video generation. + pub prompt: Option, + + /// Some models support negative text prompts. + pub negative_prompt: Option, + + /// Starting keyframe (optional). + pub start_frame_image_media_token: Option, + + /// URL alternative to `start_frame_image_media_token`. Mutually exclusive with it. + /// Downloaded and stored as a media file before generation. + pub start_frame_image_url: Option, + + /// Ending keyframe (optional). + pub end_frame_image_media_token: Option, + + /// URL alternative to `end_frame_image_media_token`. Mutually exclusive with it. + /// Downloaded and stored as a media file before generation. + pub end_frame_image_url: Option, + + /// Reference images (optional). + pub reference_image_media_tokens: Option>, + + /// URL alternative to `reference_image_media_tokens`. Mutually exclusive with it. + /// Each URL is downloaded and stored as a media file before generation. + pub reference_image_urls: Option>, + + /// Reference videos (optional). + pub reference_video_media_tokens: Option>, + + /// URL alternative to `reference_video_media_tokens`. Mutually exclusive with it. + /// Each URL is downloaded and stored as a media file before generation. + pub reference_video_urls: Option>, + + /// Reference audio (optional). + pub reference_audio_media_tokens: Option>, + + /// URL alternative to `reference_audio_media_tokens`. Mutually exclusive with it. + /// Each URL is downloaded and stored as a media file before generation. + pub reference_audio_urls: Option>, + + /// Optional character tokens to reference in the prompt. + /// Characters are referenced in prompts as @CharacterName. + pub reference_character_tokens: Option>, + + /// The resolution to use. + pub resolution: Option, + + /// The aspect ratio to use. + pub aspect_ratio: Option, + + /// The output bitrate to use. + /// Not all models support this; models that don't simply ignore it. + pub bitrate: Option, + + /// The quality to use. + pub quality: Option, + + /// How many seconds to generate. + pub duration_seconds: Option, + + /// How many videos to generate. + pub video_batch_count: Option, + + /// Whether to turn on/off audio. + /// Not all models support audio, not all models have a choice. + /// Some models will default this to true, others will default it to false, + /// so it's best to be explicit. + pub generate_audio: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/mod.rs new file mode 100644 index 0000000000..001846ca4b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/mod.rs @@ -0,0 +1,4 @@ +pub mod generate_requests; +pub mod omni_upload_audio; +pub mod omni_upload_image; +pub mod omni_upload_video; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_audio.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_audio.rs new file mode 100644 index 0000000000..8c4bb2588f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_audio.rs @@ -0,0 +1,15 @@ +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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_image.rs new file mode 100644 index 0000000000..b8e968d81f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_image.rs @@ -0,0 +1,15 @@ +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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_video.rs new file mode 100644 index 0000000000..da2c606ee5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_video.rs @@ -0,0 +1,15 @@ +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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs index 8106b25a24..07f91a32b8 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs @@ -2,6 +2,7 @@ use serde_derive::{Deserialize, Serialize}; use utoipa::ToSchema; use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; use enums::common::generation::common_quality::CommonQuality; use enums::common::generation::common_resolution::CommonResolution; use enums::common::generation::common_video_model::CommonVideoModel; @@ -50,6 +51,10 @@ pub struct OmniGenVideoCostAndGenerateRequest { /// The aspect ratio to use. pub aspect_ratio: Option, + /// The output bitrate to use. + /// Not all models support this; models that don't simply ignore it. + pub bitrate: Option, + /// The quality to use. pub quality: Option, diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_video_models.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_video_models.rs index 8ae16e56e7..8cbea2f5e0 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_video_models.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_video_models.rs @@ -6,6 +6,7 @@ use enums::common::generation::model_creator::ModelCreator; use enums::common::generation_provider::GenerationProvider; use serde_derive::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; +use enums::common::generation::common_bitrate::CommonBitrate; /// Video model to default to if none is specified const DEFAULT_VIDEO_MODEL : CommonVideoModel = CommonVideoModel::Seedance2p0; @@ -150,6 +151,12 @@ pub struct OmniGenVideoModelDetails { #[serde(skip_serializing_if = "Option::is_none")] pub resolution_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bitrate_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub bitrate_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub quality_options: Option>, @@ -220,6 +227,8 @@ impl Default for OmniGenVideoModelDetails { aspect_ratio_default: None, resolution_options: None, resolution_default: None, + bitrate_options: None, + bitrate_default: None, quality_options: None, default_quality: None, duration_seconds_min: None, diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/batch_get_prompts.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/batch_get_prompts.rs index 707ef7b702..ba409f5a7d 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/batch_get_prompts.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/batch_get_prompts.rs @@ -2,6 +2,7 @@ use crate::prompts::get_prompt::GetPromptImageContextItem; use chrono::{DateTime, Utc}; use enums::by_table::prompts::prompt_type::PromptType; use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; use enums::common::generation::common_generation_mode::CommonGenerationMode; use enums::common::generation::common_model_class::CommonModelClass; use enums::common::generation::common_model_type::CommonModelType; @@ -55,6 +56,9 @@ pub struct BatchPromptInfo { /// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.) pub maybe_resolution: Option, + /// OPTIONAL. The output bitrate (eg. normal, high). + pub maybe_bitrate: Option, + /// OPTIONAL. The number of outputs to generate (0-255). pub maybe_batch_count: Option, diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/get_prompt.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/get_prompt.rs index 77369ec369..ec096842c6 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/get_prompt.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/get_prompt.rs @@ -3,6 +3,7 @@ use chrono::{DateTime, Utc}; use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType; use enums::by_table::prompts::prompt_type::PromptType; use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; use enums::common::generation::common_generation_mode::CommonGenerationMode; use enums::common::generation::common_model_type::CommonModelType; use enums::common::generation::common_resolution::CommonResolution; @@ -59,6 +60,9 @@ pub struct PromptInfo { /// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.) pub maybe_resolution: Option, + /// OPTIONAL. The output bitrate (eg. normal, high). + pub maybe_bitrate: Option, + /// OPTIONAL. The number of outputs to generate (0-255). pub maybe_batch_count: Option, diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/mod.rs new file mode 100644 index 0000000000..af59a00150 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/mod.rs @@ -0,0 +1,3 @@ +pub mod notes; +pub mod read_only; +pub mod upload; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/notes.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/notes.rs new file mode 100644 index 0000000000..e3f3b2e151 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/notes.rs @@ -0,0 +1,60 @@ +//! 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, + + /// Original filename of the submitted clip. + pub maybe_filename: Option, + + /// The model type the submitter claims this video is (dropdown / enum). + pub maybe_reported_model_type: Option, + + /// Free-form custom model name (trimmed; empty becomes absent). + pub maybe_reported_model_name: Option, + + /// The website / platform the video came from (dropdown / enum). + pub maybe_website: Option, + + /// Free-form website (trimmed; empty becomes absent). + pub maybe_other_website: Option, + + /// Free-form comments (trimmed; empty becomes absent). + pub maybe_comments: Option, + + /// Optional contact email (trimmed; empty becomes absent). + pub maybe_email_address: Option, + + /// Whether the submitter consents to us sharing the report. Defaults to false. + pub can_share_report: Option, + + /// Whether the submitter believes they were scammed. Defaults to false. + pub was_scammed: Option, +} + +/// 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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/read_only.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/read_only.rs new file mode 100644 index 0000000000..6c6c9dffd5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/read_only.rs @@ -0,0 +1,167 @@ +//! 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, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Seedance`]. + pub maybe_seedance: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Veo`]. + pub maybe_veo: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Sora`]. + pub maybe_sora: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Dreamina`]. + pub maybe_dreamina: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Kling`]. + pub maybe_kling: Option, +} + +/// 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, + /// Full model identifier, e.g. `"doubao-seedance-2-0-fast"`. + pub model_name: String, + pub maybe_model_brand: Option, + /// Parsed version, e.g. `"2.0"` / `"1.0"`. + pub maybe_model_version: Option, + 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, + pub maybe_log_id: Option, + pub maybe_log_id_decoded_hex: Option, + pub maybe_digital_source_type: Option, + pub maybe_claim_generator: Option, + pub maybe_claim_generator_version: Option, + pub maybe_manifest_id: Option, + pub maybe_instance_id: Option, + pub maybe_signer_email: Option, + pub maybe_signer_org_id: Option, + pub maybe_signer_country: Option, + pub maybe_cert_serial: Option, +} + +/// 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, + pub maybe_created_description: Option, + pub has_synthid_watermark: bool, + pub maybe_synthid_description: Option, + pub maybe_digital_source_type: Option, + pub maybe_claim_generator: Option, + pub maybe_claim_generator_version: Option, + pub maybe_manifest_id: Option, + pub maybe_instance_id: Option, + pub maybe_cert_serial: Option, + /// Issuing intermediate-CA common name, e.g. `"Google C2PA Media Services 1P ICA G3"`. + pub maybe_signer_ca: Option, + /// Whether the manifest carries an RFC 3161 trusted timestamp. + pub is_timestamped: bool, + pub maybe_timestamp_authority: Option, +} + +/// OpenAI Sora provenance. +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct SoraVideoInfo { + /// Always `"OpenAI"`. + pub producer: String, + pub maybe_model_name: Option, + pub maybe_created_description: Option, + pub maybe_digital_source_type: Option, + pub maybe_claim_generator: Option, + pub maybe_manifest_id: Option, + pub maybe_instance_id: Option, + pub maybe_cert_serial: Option, +} + +/// 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, + pub maybe_os: Option, + pub maybe_source_info: Option, + pub maybe_aigc_label_type: Option, + pub maybe_video_id: Option, + pub has_c2pa: bool, + pub maybe_signer_org_id: Option, + pub maybe_signer_country: Option, + pub maybe_cert_serial: Option, +} + +/// 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, + /// Whether labeled AI-generated (`Label == "1"`, per China GB 45438). + pub is_ai_generated: bool, + pub maybe_label: Option, + pub maybe_content_producer: Option, + pub maybe_produce_id: Option, + pub maybe_content_propagator: Option, + pub maybe_propagate_id: Option, + pub maybe_reserved_code_1: Option, + pub maybe_reserved_code_2: Option, + /// Whether the in-stream `kling-ai` SEI watermark is present. + pub has_stream_watermark: bool, + pub maybe_watermark_uuid: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/upload.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/upload.rs new file mode 100644 index 0000000000..6ecf5b3a79 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/upload.rs @@ -0,0 +1,46 @@ +//! 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, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Seedance`]. + pub maybe_seedance: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Veo`]. + pub maybe_veo: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Sora`]. + pub maybe_sora: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Dreamina`]. + pub maybe_dreamina: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Kling`]. + pub maybe_kling: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_keys/Cargo.toml b/crates/api_clients/artcraft/artcraft_api_keys/Cargo.toml new file mode 100644 index 0000000000..6d94127f5d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_keys/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "artcraft_api_keys" +edition = "2024" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +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 diff --git a/crates/api_clients/artcraft/artcraft_api_keys/src/lib.rs b/crates/api_clients/artcraft/artcraft_api_keys/src/lib.rs new file mode 100644 index 0000000000..05d290fa25 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_keys/src/lib.rs @@ -0,0 +1,104 @@ +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_`). +/// +/// `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 `, and bare `` forms. See [`parse_from_header_value`] for the + /// full parsing rules. + pub fn parse_from_authorization_header_value(header_value: &str) -> Option { + 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…"); + } +} diff --git a/crates/api_clients/artcraft/artcraft_api_keys/src/parse_from_header_value.rs b/crates/api_clients/artcraft/artcraft_api_keys/src/parse_from_header_value.rs new file mode 100644 index 0000000000..61bdfce32c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_keys/src/parse_from_header_value.rs @@ -0,0 +1,125 @@ +use crate::ArtcraftApiKey; + +/// `Bearer ` — the widely-accepted style for most APIs. +const BEARER_SCHEME: &str = "bearer"; + +/// `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 ` (widely-accepted style for most APIs) +/// - `Key ` (legacy GitHub-style / Fal-style) +/// - `` (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 { + 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 ". + 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 ("") — 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 { + 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); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/api/mod.rs index fa7e90dfa5..1e3ded5a95 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod character_list_ref; pub mod image_list_ref; pub mod image_ref; pub mod router_aspect_ratio; +pub mod router_bitrate; pub mod router_image_model; pub mod router_provider; pub mod router_quality; diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_bitrate.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_bitrate.rs new file mode 100644 index 0000000000..ae9806a79f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_bitrate.rs @@ -0,0 +1,10 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Output bitrate levels you can specify when enqueuing a generation. +/// Not every model will use this; models that don't simply ignore it. +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RouterBitrate { + Normal, + High, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs index b9b98d7afb..70e21a6258 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs @@ -1,6 +1,7 @@ use crate::api::audio_list_ref::AudioListRef; use crate::api::character_list_ref::CharacterListRef; use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_bitrate::RouterBitrate; use crate::api::router_resolution::RouterResolution; use crate::api::router_video_model::RouterVideoModel; use crate::api::image_list_ref::ImageListRef; @@ -103,6 +104,10 @@ pub struct GenerateVideoRequestBuilder { /// The aspect ratio to use pub aspect_ratio: Option, + /// The output bitrate to use. + /// Not all models support this; models that don't simply ignore it. + pub bitrate: Option, + /// How many seconds to generate. pub duration_seconds: Option, @@ -139,6 +144,7 @@ impl Default for GenerateVideoRequestBuilder { reference_character_tokens: None, resolution: None, aspect_ratio: None, + bitrate: None, duration_seconds: None, video_batch_count: None, generate_audio: None, diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs index 35fb146521..7d7d6a5924 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs @@ -1,9 +1,11 @@ use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; +use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_bitrate::RouterBitrate; use crate::api::router_resolution::RouterResolution; use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; use crate::errors::artcraft_router_error::ArtcraftRouterError; @@ -49,6 +51,7 @@ pub fn build_artcraft_omni_video_request( let resolution = plan_output_resolution(builder.resolution.take(), strategy, resolutions)?; let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?; let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let bitrate = plan_bitrate(builder.bitrate.take()); let prompt = builder.prompt.take(); let start_frame = resolve_image_ref(builder.start_frame.take())?; @@ -71,6 +74,7 @@ pub fn build_artcraft_omni_video_request( reference_character_tokens: reference_characters, resolution, aspect_ratio, + bitrate, duration_seconds: duration_seconds.map(|d| d as u16), video_batch_count: Some(batch_count), negative_prompt: None, @@ -234,6 +238,19 @@ pub fn plan_batch_count( } } +/// Translate the router-facing bitrate into the API `CommonBitrate`. +/// +/// Bitrate does not affect cost. An unset value leaves the field `None` (the +/// API applies its default); `Normal` and `High` map to their `CommonBitrate` +/// counterparts. +fn plan_bitrate(bitrate: Option) -> Option { + match bitrate { + None => None, + Some(RouterBitrate::Normal) => Some(CommonBitrateEnum::Normal), + Some(RouterBitrate::High) => Some(CommonBitrateEnum::High), + } +} + /// Duration: 4-15 seconds. pub fn plan_duration( duration_seconds: Option, @@ -255,3 +272,27 @@ pub fn plan_duration( }, } } + +#[cfg(test)] +mod tests { + use super::*; + + mod bitrate_translation { + use super::*; + + #[test] + fn none_stays_none() { + assert_eq!(plan_bitrate(None), None); + } + + #[test] + fn normal_maps_to_common_normal() { + assert_eq!(plan_bitrate(Some(RouterBitrate::Normal)), Some(CommonBitrateEnum::Normal)); + } + + #[test] + fn high_maps_to_common_high() { + assert_eq!(plan_bitrate(Some(RouterBitrate::High)), Some(CommonBitrateEnum::High)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/build.rs index 24d87239d6..2a1a800991 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/build.rs @@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0(builder: GenerateVideoRequestBuilder) -> Resu mod tests { use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; @@ -72,6 +74,12 @@ mod tests { let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); assert_eq!(req.request.video_batch_count, Some(4)); } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } } // ── Resolution ── diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/build.rs index 6979d85b76..1e395222c4 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/build.rs @@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0_bp(builder: GenerateVideoRequestBuilder) -> R mod tests { use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; @@ -70,6 +72,12 @@ mod tests { let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); assert_eq!(req.request.video_batch_count, Some(4)); } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } } mod resolution_tests { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/build.rs index b998762ef0..4b9eebf3cc 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/build.rs @@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0_bp_fast(builder: GenerateVideoRequestBuilder) mod tests { use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; @@ -72,6 +74,12 @@ mod tests { let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); assert_eq!(req.request.video_batch_count, Some(4)); } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } } mod resolution_tests { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/build.rs index 7e21211117..6aa1fc2724 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/build.rs @@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0_bpu(builder: GenerateVideoRequestBuilder) -> mod tests { use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; @@ -70,6 +72,12 @@ mod tests { let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); assert_eq!(req.request.video_batch_count, Some(4)); } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } } mod resolution_tests { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/build.rs index 3519128bb2..5bf890b4ee 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/build.rs @@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0_bpu_fast(builder: GenerateVideoRequestBuilder mod tests { use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; @@ -72,6 +74,12 @@ mod tests { let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); assert_eq!(req.request.video_batch_count, Some(4)); } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } } mod resolution_tests { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/build.rs index fff7972e38..cd9bbba91d 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/build.rs @@ -25,6 +25,8 @@ mod tests { use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; @@ -83,6 +85,12 @@ mod tests { let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); })); assert_eq!(req.request.video_batch_count, Some(4)); } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } } // ── Aspect ratio ── diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_u/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_u/build.rs index 83e87126a1..7d93b672f8 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_u/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_u/build.rs @@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0_u(builder: GenerateVideoRequestBuilder) -> Re mod tests { use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; @@ -72,6 +74,12 @@ mod tests { let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); assert_eq!(req.request.video_batch_count, Some(4)); } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } } // ── Resolution ── diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_u_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_u_fast/build.rs index 00f665a691..b8f85eb71c 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_u_fast/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_u_fast/build.rs @@ -25,6 +25,8 @@ mod tests { use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; @@ -83,6 +85,12 @@ mod tests { let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); })); assert_eq!(req.request.video_batch_count, Some(4)); } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } } // ── Aspect ratio ── diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/build.rs index 07e1fa3882..c14ea23a33 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/build.rs @@ -1,10 +1,12 @@ use seedance2pro_client::generate::video::generate_seedance_2p0::{ KinoviSeedance2p0AspectRatio as KinoviAspectRatio, + KinoviSeedance2p0Bitrate as KinoviBitrate, KinoviSeedance2p0BatchCount as KinoviBatchCount, KinoviSeedance2p0OutputResolution as KinoviOutputResolution, }; use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_bitrate::RouterBitrate; use crate::api::router_resolution::RouterResolution; use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; use crate::errors::artcraft_router_error::ArtcraftRouterError; @@ -26,6 +28,7 @@ fn do_build_kinovi_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Res let resolution = plan_output_resolution(builder.resolution.take(), strategy)?; let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?; let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let bitrate = plan_bitrate(builder.bitrate.take()); let prompt = builder.prompt.take().unwrap_or_default(); let unhandled_request_state = KinoviSeedance2p0RemainingItems { @@ -42,6 +45,7 @@ fn do_build_kinovi_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Res resolution, batch_count, duration_seconds, + bitrate, prompt, unhandled_request_state: Some(unhandled_request_state), }) @@ -195,6 +199,16 @@ fn plan_duration( } } +// Seedance 2.0 Pro bitrate: "standard" (default, field omitted) or "high". +// Bitrate does not affect cost. `Normal` and an unset value both map to the +// standard bitrate (None); only `High` requests the higher bitrate. +fn plan_bitrate(bitrate: Option) -> Option { + match bitrate { + Some(RouterBitrate::High) => Some(KinoviBitrate::High), + Some(RouterBitrate::Normal) | None => None, + } +} + #[cfg(test)] mod tests { use seedance2pro_client::generate::video::generate_seedance_2p0::{ @@ -391,6 +405,39 @@ mod tests { } } + // ── Bitrate conversions ── + + mod bitrate_conversions { + use super::*; + + #[test] + fn bitrate_high() { + let builder = GenerateVideoRequestBuilder { + bitrate: Some(RouterBitrate::High), + ..seedance2pro_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.bitrate, Some(KinoviBitrate::High))); + } + + #[test] + fn bitrate_normal_maps_to_standard() { + let builder = GenerateVideoRequestBuilder { + bitrate: Some(RouterBitrate::Normal), + ..seedance2pro_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(draft.bitrate.is_none()); + } + + #[test] + fn bitrate_none_maps_to_standard() { + let builder = GenerateVideoRequestBuilder { bitrate: None, ..seedance2pro_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(draft.bitrate.is_none()); + } + } + // ── unhandled_request_state: media refs are placed there ── mod unhandled_request_state { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/cost.rs index 94e66049dd..bd2c15db63 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/cost.rs @@ -63,6 +63,7 @@ impl KinoviSeedance2p0CostState { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }; let costs = pricing_request.calculate_costs(); @@ -476,6 +477,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/draft.rs index 987dcc7a8c..91b6888abb 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/draft.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/draft.rs @@ -1,5 +1,5 @@ use seedance2pro_client::generate::video::generate_seedance_2p0::{ - GenerateSeedance2p0Request, KinoviSeedance2p0AspectRatio, + GenerateSeedance2p0Request, KinoviSeedance2p0AspectRatio, KinoviSeedance2p0Bitrate, KinoviSeedance2p0BatchCount, KinoviSeedance2p0OutputResolution, }; @@ -26,6 +26,7 @@ pub struct KinoviSeedance2p0DraftState { pub resolution: Option, pub duration_seconds: u8, pub batch_count: KinoviSeedance2p0BatchCount, + pub bitrate: Option, // Pending types that need to be queried. pub unhandled_request_state: Option, @@ -93,6 +94,7 @@ impl KinoviSeedance2p0DraftState { reference_audio_urls, character_ids, use_face_blur_hack: None, + bitrate: self.bitrate, }; Ok(KinoviSeedance2p0RequestState { request }) diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/build.rs index cc23e6c4af..03133005c0 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/build.rs @@ -1,10 +1,12 @@ use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{ KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio, + KinoviSeedance2p0FastBitrate as KinoviBitrate, KinoviSeedance2p0FastBatchCount as KinoviBatchCount, KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution, }; use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_bitrate::RouterBitrate; use crate::api::router_resolution::RouterResolution; use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; use crate::errors::artcraft_router_error::ArtcraftRouterError; @@ -26,6 +28,7 @@ fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) - let resolution = plan_output_resolution(builder.resolution.take(), strategy)?; let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?; let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let bitrate = plan_bitrate(builder.bitrate.take()); let prompt = builder.prompt.take().unwrap_or_default(); let unhandled_request_state = KinoviSeedance2p0FastRemainingItems { @@ -42,6 +45,7 @@ fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) - resolution, batch_count, duration_seconds, + bitrate, prompt, unhandled_request_state: Some(unhandled_request_state), }) @@ -210,6 +214,16 @@ fn plan_duration( } } +// Seedance 2.0 Fast bitrate: "standard" (default, field omitted) or "high". +// Bitrate does not affect cost. `Normal` and an unset value both map to the +// standard bitrate (None); only `High` requests the higher bitrate. +fn plan_bitrate(bitrate: Option) -> Option { + match bitrate { + Some(RouterBitrate::High) => Some(KinoviBitrate::High), + Some(RouterBitrate::Normal) | None => None, + } +} + #[cfg(test)] mod tests { use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{ @@ -430,6 +444,39 @@ mod tests { } } + // ── Bitrate conversions ── + + mod bitrate_conversions { + use super::*; + + #[test] + fn bitrate_high() { + let builder = GenerateVideoRequestBuilder { + bitrate: Some(RouterBitrate::High), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.bitrate, Some(KinoviBitrate::High))); + } + + #[test] + fn bitrate_normal_maps_to_standard() { + let builder = GenerateVideoRequestBuilder { + bitrate: Some(RouterBitrate::Normal), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(draft.bitrate.is_none()); + } + + #[test] + fn bitrate_none_maps_to_standard() { + let builder = GenerateVideoRequestBuilder { bitrate: None, ..seedance2pro_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(draft.bitrate.is_none()); + } + } + // ── unhandled_request_state: media refs are placed there ── mod unhandled_request_state { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/cost.rs index 10b616839c..10bb6beeaf 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/cost.rs @@ -63,6 +63,7 @@ impl KinoviSeedance2p0FastCostState { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }; let costs = pricing_request.calculate_costs(); @@ -428,6 +429,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/draft.rs index 00520b8e8d..988feb4c40 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/draft.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/draft.rs @@ -1,5 +1,5 @@ use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{ - GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastAspectRatio, + GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastAspectRatio, KinoviSeedance2p0FastBitrate, KinoviSeedance2p0FastBatchCount, KinoviSeedance2p0FastOutputResolution, }; @@ -24,6 +24,7 @@ pub struct KinoviSeedance2p0FastDraftState { pub resolution: Option, pub duration_seconds: u8, pub batch_count: KinoviSeedance2p0FastBatchCount, + pub bitrate: Option, pub unhandled_request_state: Option, } @@ -90,6 +91,7 @@ impl KinoviSeedance2p0FastDraftState { reference_audio_urls, character_ids, use_face_blur_hack: None, + bitrate: self.bitrate, }; Ok(KinoviSeedance2p0FastRequestState { request }) diff --git a/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs b/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs index e72a9b2c3e..0ddfa1a485 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs @@ -351,6 +351,7 @@ pub fn base_video_request() -> GenerateVideoRequestBuilder { reference_character_tokens: None, resolution: None, aspect_ratio: None, + bitrate: None, duration_seconds: None, video_batch_count: None, generate_audio: None, diff --git a/crates/api_clients/bucket_client/Cargo.toml b/crates/api_clients/bucket_client/Cargo.toml new file mode 100644 index 0000000000..38beb02a04 --- /dev/null +++ b/crates/api_clients/bucket_client/Cargo.toml @@ -0,0 +1,19 @@ +[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 diff --git a/crates/api_clients/bucket_client/src/bucket_client.rs b/crates/api_clients/bucket_client/src/bucket_client.rs new file mode 100644 index 0000000000..1ec52cff8a --- /dev/null +++ b/crates/api_clients/bucket_client/src/bucket_client.rs @@ -0,0 +1,66 @@ +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> { + /// 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>( + &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(()) + } +} diff --git a/crates/api_clients/bucket_client/src/bucket_client_builder.rs b/crates/api_clients/bucket_client/src/bucket_client_builder.rs new file mode 100644 index 0000000000..7881ebad40 --- /dev/null +++ b/crates/api_clients/bucket_client/src/bucket_client_builder.rs @@ -0,0 +1,119 @@ +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, + secret_key: Option, + region_name: Option, + bucket_name: Option, + endpoint: Option, + bucket_request_timeout: Option, +} + +impl BucketClientBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn access_key(mut self, access_key: impl Into) -> Self { + self.access_key = Some(access_key.into()); + self + } + + pub fn secret_key(mut self, secret_key: impl Into) -> Self { + self.secret_key = Some(secret_key.into()); + self + } + + pub fn region_name(mut self, region_name: impl Into) -> Self { + self.region_name = Some(region_name.into()); + self + } + + pub fn bucket_name(mut self, bucket_name: impl Into) -> 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) -> 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 { + 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, field: &str) -> Result { + value.ok_or_else(|| { + BucketClientError::ClientBuilderSetupError(format!("{field} is required")) + }) +} diff --git a/crates/api_clients/bucket_client/src/bucket_client_error.rs b/crates/api_clients/bucket_client/src/bucket_client_error.rs new file mode 100644 index 0000000000..2336796e0f --- /dev/null +++ b/crates/api_clients/bucket_client/src/bucket_client_error.rs @@ -0,0 +1,56 @@ +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 for BucketClientError { + fn from(err: s3::error::S3Error) -> Self { + Self::S3Error(err) + } +} + +impl From for BucketClientError { + fn from(err: io::Error) -> Self { + Self::IoError(err) + } +} diff --git a/crates/api_clients/bucket_client/src/lib.rs b/crates/api_clients/bucket_client/src/lib.rs new file mode 100644 index 0000000000..3131f8efb1 --- /dev/null +++ b/crates/api_clients/bucket_client/src/lib.rs @@ -0,0 +1,15 @@ +//! 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; diff --git a/crates/api_clients/bucket_client/src/object_name.rs b/crates/api_clients/bucket_client/src/object_name.rs new file mode 100644 index 0000000000..e80709870e --- /dev/null +++ b/crates/api_clients/bucket_client/src/object_name.rs @@ -0,0 +1,52 @@ +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) -> 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 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"); + } +} diff --git a/crates/api_clients/cloud_storage/src/bucket_client.rs b/crates/api_clients/cloud_storage/src/legacy_bucket_client.rs similarity index 95% rename from crates/api_clients/cloud_storage/src/bucket_client.rs rename to crates/api_clients/cloud_storage/src/legacy_bucket_client.rs index 40d4a21957..3b5801c47f 100644 --- a/crates/api_clients/cloud_storage/src/bucket_client.rs +++ b/crates/api_clients/cloud_storage/src/legacy_bucket_client.rs @@ -17,7 +17,7 @@ use tokio::io::AsyncReadExt; use errors::AnyhowResult; #[derive(Clone)] -pub struct BucketClient { +pub struct LegacyBucketClient { bucket: Bucket, /// If set, put all files under this root path. optional_bucket_root: Option, @@ -25,21 +25,21 @@ pub struct BucketClient { #[derive(Debug)] -pub enum BucketClientError { +pub enum LegacyBucketClientError { ErrorWithCodeAndMessage { code: u16, message: String }, } -impl std::fmt::Display for BucketClientError { +impl std::fmt::Display for LegacyBucketClientError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { - BucketClientError::ErrorWithCodeAndMessage { code, message } => write!(f, "Error {}: {}", code, message), + LegacyBucketClientError::ErrorWithCodeAndMessage { code, message } => write!(f, "Error {}: {}", code, message), } } } -impl Error for BucketClientError {} +impl Error for LegacyBucketClientError {} -impl BucketClient { +impl LegacyBucketClient { pub fn bucket_name(&self) -> String { self.bucket.name().to_string() } diff --git a/crates/api_clients/cloud_storage/src/lib.rs b/crates/api_clients/cloud_storage/src/lib.rs index 9b0de5823a..db6553426f 100644 --- a/crates/api_clients/cloud_storage/src/lib.rs +++ b/crates/api_clients/cloud_storage/src/lib.rs @@ -1,2 +1,2 @@ -pub mod bucket_client; +pub mod legacy_bucket_client; pub mod remote_file_manager; \ No newline at end of file diff --git a/crates/api_clients/cloud_storage/src/remote_file_manager/bucket_orchestration.rs b/crates/api_clients/cloud_storage/src/remote_file_manager/bucket_orchestration.rs index 2c728e827b..27bccdbeca 100644 --- a/crates/api_clients/cloud_storage/src/remote_file_manager/bucket_orchestration.rs +++ b/crates/api_clients/cloud_storage/src/remote_file_manager/bucket_orchestration.rs @@ -4,7 +4,7 @@ use tokio::time::Duration; use errors::AnyhowResult; -use crate::bucket_client::BucketClient; +use crate::legacy_bucket_client::LegacyBucketClient; #[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 { + async fn get_bucket_with_visibility(&self, public: bool) -> AnyhowResult { 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..."); - BucketClient::create( + LegacyBucketClient::create( &self.access_key, &self.secret_key, &self.region_name, @@ -123,7 +123,7 @@ impl BucketOrchestration { )? } else { info!("Configuring private GCS bucket..."); - BucketClient::create( + LegacyBucketClient::create( &self.access_key, &self.secret_key, &self.region_name, diff --git a/crates/api_clients/cloud_storage/src/remote_file_manager/remote_cloud_file_manager.rs b/crates/api_clients/cloud_storage/src/remote_file_manager/remote_cloud_file_manager.rs index 218013164e..771804037a 100644 --- a/crates/api_clients/cloud_storage/src/remote_file_manager/remote_cloud_file_manager.rs +++ b/crates/api_clients/cloud_storage/src/remote_file_manager/remote_cloud_file_manager.rs @@ -20,7 +20,7 @@ pub struct RemoteCloudFileClient { impl RemoteCloudFileClient { - #[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")] + #[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")] pub async fn get_remote_cloud_file_client() -> AnyhowResult { 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 `BucketClient`")] + #[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")] pub fn new(bucket_orchestration_client: Box) -> 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 `BucketClient`")] + #[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")] 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 `BucketClient`")] + #[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")] 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 `BucketClient`")] + #[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")] pub async fn upload_file(&self, file_descriptor:Box, from_system_file_path:&str) -> AnyhowResult { // 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 `BucketClient`")] + #[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")] pub fn get_file_meta_data(system_file_path:&str) -> AnyhowResult { let file_size_bytes = file_size(system_file_path.clone())?; let sha256_checksum = sha256_hash_file(system_file_path.clone())?; diff --git a/crates/api_clients/seedance2pro_client/src/generate/video/generate_happy_horse_1p0.rs b/crates/api_clients/seedance2pro_client/src/generate/video/generate_happy_horse_1p0.rs index 69895cea02..9e019ac2fd 100644 --- a/crates/api_clients/seedance2pro_client/src/generate/video/generate_happy_horse_1p0.rs +++ b/crates/api_clients/seedance2pro_client/src/generate/video/generate_happy_horse_1p0.rs @@ -129,6 +129,7 @@ pub async fn generate_happy_horse_1p0( reference_audio_urls: None, character_ids: None, use_face_blur_hack: Some(false), + bitrate: None, }; let raw_response = workflow_run_task(WorkflowRunTaskArgs { diff --git a/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs b/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs index 8a846e0168..f3f4046194 100644 --- a/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs +++ b/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0.rs @@ -3,7 +3,7 @@ use crate::error::seedance2pro_error::Seedance2ProError; use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost; use crate::requests::kinovi_host::KinoviHost; use crate::requests::workflow_run_task::workflow_run_task::{ - workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, + workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw, KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest, }; @@ -32,6 +32,9 @@ pub struct GenerateSeedance2p0Request { pub reference_audio_urls: Option>, pub character_ids: Option>, pub use_face_blur_hack: Option, + /// Output video bitrate. None defaults to "standard"; `High` requests a + /// higher bitrate. Does not affect cost. + pub bitrate: Option, } // ── Enums ── @@ -60,6 +63,11 @@ pub enum KinoviSeedance2p0BatchCount { Four, } +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0Bitrate { + High, +} + // ── Pricing ── // // Seedance 2.0 Pro credit pricing: @@ -178,6 +186,7 @@ pub async fn generate_seedance_2p0( reference_audio_urls: req.reference_audio_urls, character_ids: req.character_ids, use_face_blur_hack: req.use_face_blur_hack, + bitrate: map_bitrate(req.bitrate), }; let raw_response = workflow_run_task(WorkflowRunTaskArgs { @@ -224,6 +233,13 @@ fn map_batch_count(bc: Option) -> KinoviBatchCountR } } +fn map_bitrate(bitrate: Option) -> Option { + match bitrate { + Some(KinoviSeedance2p0Bitrate::High) => Some(KinoviBitrateRaw::High), + None => None, + } +} + // ── Tests ── #[cfg(test)] @@ -256,6 +272,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, } } @@ -810,6 +827,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }; assert_eq!( req.calculate_costs().total_cost.kinovi_credits, baseline, @@ -817,6 +835,21 @@ mod tests { ); } } + + // ── Bitrate doesn't affect cost ── + + #[test] + fn high_bitrate_does_not_affect_credits() { + let baseline = r720(5).calculate_costs().total_cost.kinovi_credits; + + let mut high = r720(5); + high.bitrate = Some(KinoviSeedance2p0Bitrate::High); + + assert_eq!( + high.calculate_costs().total_cost.kinovi_credits, baseline, + "High bitrate should not change credits from baseline {}", baseline, + ); + } } use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; @@ -849,6 +882,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id); @@ -879,6 +913,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id); @@ -912,6 +947,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("t2v 21:9 — task_id={}, order_id={}", result.task_id, result.order_id); @@ -943,6 +979,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id); @@ -978,6 +1015,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("keyframe — task_id={}, order_id={}", result.task_id, result.order_id); @@ -1010,6 +1048,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("keyframe start+end — task_id={}, order_id={}", result.task_id, result.order_id); @@ -1046,6 +1085,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("image ref — task_id={}, order_id={}", result.task_id, result.order_id); @@ -1082,6 +1122,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("video ref — task_id={}, order_id={}", result.task_id, result.order_id); @@ -1116,6 +1157,7 @@ mod tests { reference_audio_urls: None, character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]), use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("character ref — task_id={}, order_id={}", result.task_id, result.order_id); @@ -1146,6 +1188,7 @@ mod tests { reference_audio_urls: None, character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]), use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("two characters — task_id={}, order_id={}", result.task_id, result.order_id); diff --git a/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0_fast.rs b/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0_fast.rs index b12d9b05cd..8d3881f36b 100644 --- a/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0_fast.rs +++ b/crates/api_clients/seedance2pro_client/src/generate/video/generate_seedance_2p0_fast.rs @@ -3,7 +3,7 @@ use crate::error::seedance2pro_error::Seedance2ProError; use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost; use crate::requests::kinovi_host::KinoviHost; use crate::requests::workflow_run_task::workflow_run_task::{ - workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, + workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw, KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest, }; @@ -32,6 +32,9 @@ pub struct GenerateSeedance2p0FastRequest { pub reference_audio_urls: Option>, pub character_ids: Option>, pub use_face_blur_hack: Option, + /// Output video bitrate. None defaults to "standard"; `High` requests a + /// higher bitrate. Does not affect cost. + pub bitrate: Option, } // ── Enums ── @@ -59,6 +62,11 @@ pub enum KinoviSeedance2p0FastBatchCount { Four, } +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0FastBitrate { + High, +} + // ── Pricing ── // // Seedance 2.0 Fast credit pricing: @@ -173,6 +181,7 @@ pub async fn generate_seedance_2p0_fast( reference_audio_urls: req.reference_audio_urls, character_ids: req.character_ids, use_face_blur_hack: req.use_face_blur_hack, + bitrate: map_bitrate(req.bitrate), }; let raw_response = workflow_run_task(WorkflowRunTaskArgs { @@ -218,6 +227,13 @@ fn map_batch_count(bc: Option) -> KinoviBatchCo } } +fn map_bitrate(bitrate: Option) -> Option { + match bitrate { + Some(KinoviSeedance2p0FastBitrate::High) => Some(KinoviBitrateRaw::High), + None => None, + } +} + // ── Tests ── #[cfg(test)] @@ -250,6 +266,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, } } @@ -700,6 +717,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }; assert_eq!( req.calculate_costs().total_cost.kinovi_credits, baseline, @@ -707,6 +725,21 @@ mod tests { ); } } + + // ── Bitrate doesn't affect cost ── + + #[test] + fn high_bitrate_does_not_affect_credits() { + let baseline = r720(5).calculate_costs().total_cost.kinovi_credits; + + let mut high = r720(5); + high.bitrate = Some(KinoviSeedance2p0FastBitrate::High); + + assert_eq!( + high.calculate_costs().total_cost.kinovi_credits, baseline, + "High bitrate should not change credits from baseline {}", baseline, + ); + } } use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; @@ -739,6 +772,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("t2v fast default — task_id={}, order_id={}", result.task_id, result.order_id); @@ -769,6 +803,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("t2v fast 480p — task_id={}, order_id={}", result.task_id, result.order_id); @@ -802,6 +837,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("fast t2v 21:9 480p — task_id={}, order_id={}", result.task_id, result.order_id); @@ -833,6 +869,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("fast keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id); @@ -868,6 +905,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("fast keyframe — task_id={}, order_id={}", result.task_id, result.order_id); @@ -904,6 +942,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("fast image ref — task_id={}, order_id={}", result.task_id, result.order_id); @@ -940,6 +979,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("fast video ref — task_id={}, order_id={}", result.task_id, result.order_id); @@ -974,6 +1014,7 @@ mod tests { reference_audio_urls: None, character_ids: Some(vec![MOCHI_ID.to_string()]), use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("fast character — task_id={}, order_id={}", result.task_id, result.order_id); @@ -1004,6 +1045,7 @@ mod tests { reference_audio_urls: None, character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]), use_face_blur_hack: None, + bitrate: None, }, }).await?; println!("fast two characters — task_id={}, order_id={}", result.task_id, result.order_id); diff --git a/crates/api_clients/seedance2pro_client/src/requests/get_user_auth_details/get_user_auth_details.rs b/crates/api_clients/seedance2pro_client/src/requests/get_user_auth_details/get_user_auth_details.rs index 91d1925d4c..8756e7bb11 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/get_user_auth_details/get_user_auth_details.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/get_user_auth_details/get_user_auth_details.rs @@ -125,6 +125,23 @@ mod tests { Ok(Seedance2ProSession::from_cookies_string(cookies)) } + /// Regression: the Kinovi API started returning `credits` as a float + /// (e.g. `372215.18`) while `availableCredits` stays an int. Both must parse + /// and coerce to `u64` instead of failing with + /// "invalid type: floating point ..., expected u64". + #[test] + fn float_credits_are_coerced() { + let body = r#"[{"result":{"data":{"json":{ + "email":"hello@storyteller.ai", + "credits":372215.18, + "availableCredits":372215 + }}}}]"#; + let batch: Vec = serde_json::from_str(body).expect("parse user auth"); + let json = batch.into_iter().next().expect("non-empty batch").result.data.json; + assert_eq!(json.credits, 372215); + assert_eq!(json.available_credits, 372215); + } + #[tokio::test] #[ignore] // manually test — requires real cookies async fn test_get_user_auth_details() -> AnyhowResult<()> { diff --git a/crates/api_clients/seedance2pro_client/src/requests/get_user_auth_details/request_types.rs b/crates/api_clients/seedance2pro_client/src/requests/get_user_auth_details/request_types.rs index 46412eaafd..c5caccd599 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/get_user_auth_details/request_types.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/get_user_auth_details/request_types.rs @@ -1,5 +1,7 @@ use serde_derive::Deserialize; +use crate::utils::number_coercion::de_u64_int_or_float; + #[derive(Deserialize, Debug)] pub(super) struct BatchResponseItem { pub result: BatchResponseResult, @@ -19,6 +21,8 @@ pub(super) struct BatchResponseData { #[serde(rename_all = "camelCase")] pub(super) struct RawUserAuthDetails { pub email: String, + #[serde(deserialize_with = "de_u64_int_or_float")] pub credits: u64, + #[serde(deserialize_with = "de_u64_int_or_float")] pub available_credits: u64, } diff --git a/crates/api_clients/seedance2pro_client/src/requests/poll_orders/poll_orders.rs b/crates/api_clients/seedance2pro_client/src/requests/poll_orders/poll_orders.rs index e8e9b9d48b..5a7198801f 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/poll_orders/poll_orders.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/poll_orders/poll_orders.rs @@ -35,6 +35,41 @@ pub struct PollOrdersResponse { // --- Public types --- +/// The status of one order (one generation task — video or image). +#[derive(Debug, Clone)] +pub struct OrderStatus { + pub order_id: String, + + pub task_status: TaskStatus, + + /// Top-level result URL (video file for video orders, the first image of + /// the 4-image grid for Midjourney image orders). Populated when + /// `task_status` is `Completed`. + pub result_url: Option, + + /// Detailed result entries. One entry per video frame (video orders), or + /// four entries per Midjourney task (image orders). + pub results: Vec, + + /// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present. + pub fail_reason: Option, + + /// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`). + pub created_at: String, + + /// Parsed `created_at` as a `DateTime`. `None` if the raw string could not be parsed. + pub created_at_utc: Option>, + + /// Whether this order produced an image or a video. `None` for older + /// polling responses that didn't include the field — those came from the + /// video-only era and can be treated as video by callers that need to. + pub media_type: Option, + + /// The Kinovi credits charged for the order (the API's `totalCredits`). + /// `None` for older polling responses that didn't include the field. + pub total_credits: Option, +} + /// The lifecycle status of a video generation task. #[derive(Debug, Clone, PartialEq)] pub enum TaskStatus { @@ -70,19 +105,18 @@ impl TaskStatus { /// orders, or one of Midjourney's 4 generated images for image orders. /// (Originally named for the video-only days; the underlying shape is shared.) #[derive(Debug, Clone)] -pub struct VideoResult { +pub struct MediaResult { pub url: String, - pub width: u32, - pub height: u32, + /// Frame width in pixels. `None` when the API returns `null`/omits it + /// (it does so intermittently); callers should persist NULL, not a sentinel. + pub maybe_width: Option, + /// Frame height in pixels. `None` when the API returns `null`/omits it. + pub maybe_height: Option, // NB: We don't need these. - // /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null (e.g. width/height are 0). + // /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null. // pub ratio: Option, } -/// Type-neutral alias for [`VideoResult`]. Prefer this name in new -/// callers that deal with both image and video orders. -pub type MediaResult = VideoResult; - /// Media type of an order. Midjourney orders are `Image`; the various /// Seedance/keyframe/reference flows are `Video`. /// @@ -108,41 +142,6 @@ impl OrderMediaType { pub fn is_video(&self) -> bool { matches!(self, Self::Video) } } -/// The status of one order (one generation task — video or image). -#[derive(Debug, Clone)] -pub struct OrderStatus { - pub order_id: String, - - pub task_status: TaskStatus, - - /// Top-level result URL (video file for video orders, the first image of - /// the 4-image grid for Midjourney image orders). Populated when - /// `task_status` is `Completed`. - pub result_url: Option, - - /// Detailed result entries. One entry per video frame (video orders), or - /// four entries per Midjourney task (image orders). - pub results: Vec, - - /// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present. - pub fail_reason: Option, - - /// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`). - pub created_at: String, - - /// Parsed `created_at` as a `DateTime`. `None` if the raw string could not be parsed. - pub created_at_utc: Option>, - - /// Whether this order produced an image or a video. `None` for older - /// polling responses that didn't include the field — those came from the - /// video-only era and can be treated as video by callers that need to. - pub media_type: Option, - - /// The Kinovi credits charged for the order (the API's `totalCredits`). - /// `None` for older polling responses that didn't include the field. - pub total_credits: Option, -} - // --- Implementation --- pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result { @@ -234,10 +233,10 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + + const SECRETS_ENV: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../crates/service/job/seedance2_pro_job/config/seedance2-pro-job.development-secrets.env", + ); + const ACCOUNTS: &[&str] = &[ + "SEEDANCE2PRO_VOLCENGINE_COOKIES", + "SEEDANCE2PRO_BYTEPLUS_COOKIES", + "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", + ]; + const MAX_PAGES: usize = 5; + + let env_contents = std::fs::read_to_string(SECRETS_ENV) + .unwrap_or_else(|e| panic!("could not read secrets env at {}: {}", SECRETS_ENV, e)); + + for account in ACCOUNTS { + let Some(cookies) = read_env_value(&env_contents, account) else { + println!("[{}] not present in secrets env — skipping", account); + continue; + }; + if cookies.is_empty() { + println!("[{}] empty — skipping", account); + continue; + } + + let session = Seedance2ProSession::from_cookies_string(cookies); + let mut cursor: Option = None; + let mut pages = 0usize; + let mut total = 0usize; + let mut ok = true; + + for _ in 0..MAX_PAGES { + match poll_orders(PollOrdersArgs { session: &session, cursor, host_override: None }).await { + Ok(result) => { + pages += 1; + total += result.orders.len(); + cursor = result.next_cursor; + if cursor.is_none() { break; } + } + Err(err) => { + ok = false; + println!("[{}] FAILED on page {}: {:?}", account, pages + 1, err); + break; + } + } + } + + println!( + "[{}] {} — {} page(s), {} order(s) parsed cleanly", + account, if ok { "OK" } else { "ERROR" }, pages, total, + ); + } + + Ok(()) + } + + /// Minimal dotenv value reader: returns the value for `KEY=...` from raw env + /// file contents, trimming surrounding quotes. Used so the diagnostic can try + /// each account without depending on process-wide env being loaded. + fn read_env_value(contents: &str, key: &str) -> Option { + for line in contents.lines() { + let line = line.trim(); + if line.starts_with('#') { continue; } + let Some((k, v)) = line.split_once('=') else { continue; }; + if k.trim() != key { continue; } + let v = v.trim().trim_matches('"').trim_matches('\'').to_string(); + return Some(v); + } + None + } } diff --git a/crates/api_clients/seedance2pro_client/src/requests/poll_orders/request_types.rs b/crates/api_clients/seedance2pro_client/src/requests/poll_orders/request_types.rs index aa4e3fc75f..0e09b3968f 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/poll_orders/request_types.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/poll_orders/request_types.rs @@ -1,5 +1,9 @@ use serde_derive::Deserialize; +use crate::utils::number_coercion::{ + de_opt_u32_int_or_float, de_opt_u64_int_or_float, +}; + #[derive(Deserialize, Debug)] pub(super) struct BatchResponseItem { pub result: BatchResponseResult, @@ -19,6 +23,7 @@ pub(super) struct BatchResponseData { #[serde(rename_all = "camelCase")] pub(super) struct OrdersResponseJson { pub orders: Vec, + #[serde(default, deserialize_with = "de_opt_u64_int_or_float")] pub next_cursor: Option, } @@ -39,14 +44,18 @@ pub(super) struct RawOrder { /// The Kinovi credits charged for the order. `None` for older response /// shapes that didn't include the field. - #[serde(default)] + #[serde(default, deserialize_with = "de_opt_u32_int_or_float")] pub total_credits: Option, } #[derive(Deserialize, Debug)] pub(super) struct RawVideoResult { pub url: String, - pub width: u32, - pub height: u32, + /// The API intermittently returns `null` (or omits) these dimensions, so + /// they're optional rather than coerced to a sentinel. + #[serde(rename = "width", default, deserialize_with = "de_opt_u32_int_or_float")] + pub maybe_width: Option, + #[serde(rename = "height", default, deserialize_with = "de_opt_u32_int_or_float")] + pub maybe_height: Option, // pub ratio: Option, } diff --git a/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/request_types.rs b/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/request_types.rs index 3ae8a16c55..e2965d86a0 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/request_types.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/request_types.rs @@ -40,6 +40,8 @@ pub (super) struct ApiParams { pub audio_urls: Option>, #[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")] pub batch_count: Option, + #[serde(rename = "bitrate_mode", skip_serializing_if = "Option::is_none")] + pub bitrate_mode: Option<&'static str>, } #[derive(Deserialize, Debug)] diff --git a/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/workflow_run_task.rs b/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/workflow_run_task.rs index a481a197c4..0747ce5456 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/workflow_run_task.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/workflow_run_task/workflow_run_task.rs @@ -69,6 +69,10 @@ pub struct WorkflowRunTaskRequest { /// Controls the `faceBlurMode` field: true sends "on", false sends "off", None omits it. pub use_face_blur_hack: Option, + + /// Output video bitrate. None defaults to "standard" (the field is omitted); + /// `High` sends `bitrate_mode: "high"`. Does not affect cost. + pub bitrate: Option, } impl std::fmt::Debug for WorkflowRunTaskRequest { @@ -87,6 +91,7 @@ impl std::fmt::Debug for WorkflowRunTaskRequest { .field("character_ids", &self.character_ids) .field("output_resolution", &self.output_resolution) .field("use_face_blur_hack", &self.use_face_blur_hack) + .field("bitrate", &self.bitrate) .finish() } } @@ -268,6 +273,23 @@ impl KinoviModelTypeRaw { } } +/// Output video bitrate. When omitted, defaults to "standard". +#[derive(Debug, Clone, Copy)] +pub enum KinoviBitrateRaw { + /// High bitrate (`bitrate_mode: "high"`). + High, +} + +impl KinoviBitrateRaw { + /// Returns the API string to send, or None for "standard" (the default, + /// which is expressed by omitting the field entirely). + pub fn as_api_str(&self) -> Option<&'static str> { + match self { + Self::High => Some("high"), + } + } +} + // --- Response --- pub struct WorkflowRunTaskResponse { @@ -361,6 +383,7 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result) -> ApiParams { + ApiParams { + prompt: "a corgi".to_string(), + resolution: "1280x720".to_string(), + content_mode: "normal", + model: "seedance-20", + duration: "5s".to_string(), + mode: "keyframe", + output_resolution: None, + face_blur_mode: None, + character_ids: None, + uploaded_urls: None, + audio_urls: None, + batch_count: None, + bitrate_mode, + } + } + } + mod real_requests { use super::*; @@ -767,6 +835,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -800,6 +869,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -835,6 +905,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -869,6 +940,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -905,6 +977,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -958,6 +1031,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -1010,6 +1084,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -1062,6 +1137,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -1123,6 +1199,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -1177,6 +1254,7 @@ mod tests { reference_audio_urls: Some(vec![upload_result.public_url]), character_ids: None, use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -1216,6 +1294,7 @@ mod tests { reference_audio_urls: None, character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]), use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -1249,6 +1328,7 @@ mod tests { reference_audio_urls: None, character_ids: Some(vec![MOCHI_ID.to_string()]), use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -1284,6 +1364,7 @@ mod tests { reference_audio_urls: None, character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]), use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -1320,6 +1401,7 @@ mod tests { MOCHI_ID.to_string(), ]), use_face_blur_hack: None, + bitrate: None, output_resolution: None, }, }; @@ -1357,6 +1439,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: Some(false), + bitrate: None, output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP), }, }; @@ -1408,6 +1491,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: Some(false), + bitrate: None, output_resolution: None, }, }; @@ -1459,6 +1543,7 @@ mod tests { reference_audio_urls: None, character_ids: None, use_face_blur_hack: Some(false), + bitrate: None, output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP), }, }; @@ -1503,6 +1588,7 @@ mod tests { character_ids: None, output_resolution, use_face_blur_hack: None, + bitrate: None, }, } } diff --git a/crates/api_clients/seedance2pro_client/src/utils/mod.rs b/crates/api_clients/seedance2pro_client/src/utils/mod.rs index 8bf9a1c7b1..981286513a 100644 --- a/crates/api_clients/seedance2pro_client/src/utils/mod.rs +++ b/crates/api_clients/seedance2pro_client/src/utils/mod.rs @@ -1,2 +1,3 @@ pub mod categorize_seedance2pro_error; pub mod common_headers; +pub mod number_coercion; diff --git a/crates/api_clients/seedance2pro_client/src/utils/number_coercion.rs b/crates/api_clients/seedance2pro_client/src/utils/number_coercion.rs new file mode 100644 index 0000000000..250021bc45 --- /dev/null +++ b/crates/api_clients/seedance2pro_client/src/utils/number_coercion.rs @@ -0,0 +1,136 @@ +//! Deserialization helpers for numeric fields that the Kinovi API has started +//! returning as floating-point values where it previously returned integers +//! (e.g. `width: 568.5`, `credits: 372215.18`). +//! +//! Each helper accepts EITHER a JSON integer or a JSON float and coerces it to +//! an unsigned integer: exact integers pass through unchanged (no precision +//! loss for large `u64` credit balances), and floats are rounded to the nearest +//! integer. Negative / non-finite values clamp to `0`. + +use serde::{Deserialize, Deserializer}; +use serde_json::Number; + +/// Coerce a parsed JSON number to `u64`. Exact non-negative integers pass +/// through; floats are rounded; anything negative / non-finite becomes `0`. +fn coerce_to_u64(number: &Number) -> u64 { + if let Some(unsigned) = number.as_u64() { + return unsigned; + } + if let Some(float) = number.as_f64() { + if float.is_finite() && float >= 0.0 { + return float.round() as u64; + } + } + 0 +} + +fn coerce_to_u32(number: &Number) -> u32 { + coerce_to_u64(number).min(u32::MAX as u64) as u32 +} + +/// `#[serde(deserialize_with = "...")]` for a required `u64` field that may +/// arrive as an integer or a float. +pub fn de_u64_int_or_float<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let number = Number::deserialize(deserializer)?; + Ok(coerce_to_u64(&number)) +} + +/// `#[serde(deserialize_with = "...")]` for an optional `u64` field (handles +/// `null` / missing as `None`). +pub fn de_opt_u64_int_or_float<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let maybe_number = Option::::deserialize(deserializer)?; + Ok(maybe_number.as_ref().map(coerce_to_u64)) +} + +/// `#[serde(deserialize_with = "...")]` for an optional `u32` field (handles +/// `null` / missing as `None`). +pub fn de_opt_u32_int_or_float<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let maybe_number = Option::::deserialize(deserializer)?; + Ok(maybe_number.as_ref().map(coerce_to_u32)) +} + +/// `#[serde(deserialize_with = "...")]` for a required `u32` field that may +/// arrive as an integer or a float. +pub fn de_u32_int_or_float<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let number = Number::deserialize(deserializer)?; + Ok(coerce_to_u32(&number)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Deserialize)] + struct U32Holder { + #[serde(deserialize_with = "de_u32_int_or_float")] + value: u32, + } + + #[derive(Deserialize)] + struct U64Holder { + #[serde(deserialize_with = "de_u64_int_or_float")] + value: u64, + } + + #[derive(Deserialize)] + struct OptU32Holder { + #[serde(default, deserialize_with = "de_opt_u32_int_or_float")] + value: Option, + } + + fn parse_u32(json: &str) -> u32 { + serde_json::from_str::(json).unwrap().value + } + + fn parse_u64(json: &str) -> u64 { + serde_json::from_str::(json).unwrap().value + } + + #[test] + fn u32_accepts_integer() { + assert_eq!(parse_u32(r#"{"value": 568}"#), 568); + } + + #[test] + fn u32_accepts_float_and_rounds() { + assert_eq!(parse_u32(r#"{"value": 568.5}"#), 569); + assert_eq!(parse_u32(r#"{"value": 568.4}"#), 568); + } + + #[test] + fn u64_accepts_integer_exactly() { + // Large value that would lose precision via f64 — must pass through exact. + assert_eq!(parse_u64(r#"{"value": 372215}"#), 372215); + } + + #[test] + fn u64_accepts_float_and_rounds() { + assert_eq!(parse_u64(r#"{"value": 372215.18}"#), 372215); + } + + #[test] + fn negative_and_nonfinite_clamp_to_zero() { + assert_eq!(parse_u64(r#"{"value": -5}"#), 0); + assert_eq!(parse_u64(r#"{"value": -5.5}"#), 0); + } + + #[test] + fn optional_handles_null_missing_and_values() { + assert_eq!(serde_json::from_str::(r#"{"value": null}"#).unwrap().value, None); + assert_eq!(serde_json::from_str::(r#"{}"#).unwrap().value, None); + assert_eq!(serde_json::from_str::(r#"{"value": 12.9}"#).unwrap().value, Some(13)); + assert_eq!(serde_json::from_str::(r#"{"value": 12}"#).unwrap().value, Some(12)); + } +} diff --git a/crates/cli/devenv/dev-database-seed/src/bucket_clients.rs b/crates/cli/devenv/dev-database-seed/src/bucket_clients.rs index d0260a95dc..a96f13e713 100644 --- a/crates/cli/devenv/dev-database-seed/src/bucket_clients.rs +++ b/crates/cli/devenv/dev-database-seed/src/bucket_clients.rs @@ -2,12 +2,12 @@ use std::time::Duration; use log::info; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use errors::AnyhowResult; pub struct BucketClients { - pub public: BucketClient, - pub private: BucketClient, + pub public: LegacyBucketClient, + pub private: LegacyBucketClient, } pub fn get_bucket_clients() -> AnyhowResult { @@ -25,7 +25,7 @@ pub fn get_bucket_clients() -> AnyhowResult { info!("Configuring public GCS bucket..."); - let public_bucket_client = BucketClient::create( + let public_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, @@ -37,7 +37,7 @@ pub fn get_bucket_clients() -> AnyhowResult { info!("Configuring private GCS bucket..."); - let private_bucket_client = BucketClient::create( + let private_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, diff --git a/crates/cli/devenv/dev-database-seed/src/seeding/zero_shot_tts.rs b/crates/cli/devenv/dev-database-seed/src/seeding/zero_shot_tts.rs index c8d61de3ca..6e1ab62973 100644 --- a/crates/cli/devenv/dev-database-seed/src/seeding/zero_shot_tts.rs +++ b/crates/cli/devenv/dev-database-seed/src/seeding/zero_shot_tts.rs @@ -2,7 +2,7 @@ use log::info; use sqlx::{MySql, Pool}; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory; use enums::by_table::media_uploads::media_upload_type::MediaUploadType; use enums::by_table::zs_voices::encoding_type::ZsVoiceEncodingType; @@ -128,7 +128,7 @@ async fn create_voice_records( Ok((voice_token, dataset_token)) } -async fn seed_file_to_bucket(wav_file: &str, bucket_client: &BucketClient) -> AnyhowResult { +async fn seed_file_to_bucket(wav_file: &str, bucket_client: &LegacyBucketClient) -> AnyhowResult { info!("Uploading wav file {} ...", wav_file); let public_upload_path = MediaFileBucketPath::generate_new( diff --git a/crates/cli/devenv/dev-upload-media-file/src/main.rs b/crates/cli/devenv/dev-upload-media-file/src/main.rs index 41bcd788c7..a2cd1e81a6 100644 --- a/crates/cli/devenv/dev-upload-media-file/src/main.rs +++ b/crates/cli/devenv/dev-upload-media-file/src/main.rs @@ -4,7 +4,7 @@ use log::info; use sqlx::mysql::MySqlPoolOptions; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use shared_env_var_config::logging::DEFAULT_RUST_LOG; use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default; use enums::by_table::media_files::media_file_type::MediaFileType; @@ -48,7 +48,7 @@ pub async fn main() -> AnyhowResult<()> { info!("Configuring GCS bucket..."); - let public_bucket_client = BucketClient::create( + let public_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, diff --git a/crates/cli/production/migration-tool/src/deps.rs b/crates/cli/production/migration-tool/src/deps.rs index 0f71d9ab33..4d6cb4047b 100644 --- a/crates/cli/production/migration-tool/src/deps.rs +++ b/crates/cli/production/migration-tool/src/deps.rs @@ -1,14 +1,14 @@ use sqlx::{MySql, Pool}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; pub struct Deps { pub mysql_development : Pool, pub mysql_production : Pool, - pub bucket_development_public: BucketClient, - pub bucket_development_private: BucketClient, + pub bucket_development_public: LegacyBucketClient, + pub bucket_development_private: LegacyBucketClient, - pub bucket_production_public: BucketClient, - pub bucket_production_private: BucketClient, + pub bucket_production_public: LegacyBucketClient, + pub bucket_production_private: LegacyBucketClient, } diff --git a/crates/cli/production/migration-tool/src/main.rs b/crates/cli/production/migration-tool/src/main.rs index 4d3d176eb5..0f37a694e3 100644 --- a/crates/cli/production/migration-tool/src/main.rs +++ b/crates/cli/production/migration-tool/src/main.rs @@ -9,7 +9,7 @@ use log::info; use sqlx::{MySql, Pool}; use sqlx::mysql::MySqlPoolOptions; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use shared_env_var_config::logging::DEFAULT_RUST_LOG; use errors::AnyhowResult; @@ -68,7 +68,7 @@ async fn get_mysql(env_var_name: &str) -> AnyhowResult> { Ok(pool) } -pub fn get_bucket_client(env_var_suffix: &str) -> AnyhowResult { +pub fn get_bucket_client(env_var_suffix: &str) -> AnyhowResult { let env_var_suffix = env_var_suffix.to_uppercase(); let access_key = easyenv::get_env_string_required(&format!("ACCESS_KEY_{env_var_suffix}"))?; let secret_key = easyenv::get_env_string_required(&format!("SECRET_KEY_{env_var_suffix}"))?; @@ -84,7 +84,7 @@ pub fn get_bucket_client(env_var_suffix: &str) -> AnyhowResult { info!("Configuring GCS bucket {env_var_suffix} ..."); - Ok(BucketClient::create( + Ok(LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, diff --git a/crates/cli/production/support-tool/src/commands/artcraft/subcommands/generate_video.rs b/crates/cli/production/support-tool/src/commands/artcraft/subcommands/generate_video.rs index 4672666ac5..f543911959 100644 --- a/crates/cli/production/support-tool/src/commands/artcraft/subcommands/generate_video.rs +++ b/crates/cli/production/support-tool/src/commands/artcraft/subcommands/generate_video.rs @@ -119,6 +119,7 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu reference_character_tokens: None, resolution: None, aspect_ratio: None, + bitrate: None, quality: None, duration_seconds: Some(duration), video_batch_count: None, diff --git a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/find_job.rs b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/find_job.rs index 44a9ae50dc..921904ccb5 100644 --- a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/find_job.rs +++ b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/find_job.rs @@ -60,8 +60,8 @@ fn order_to_json(order: &seedance2pro_client::requests::poll_orders::poll_orders "results": order.results.iter().map(|r| { serde_json::json!({ "url": r.url, - "width": r.width, - "height": r.height, + "width": r.maybe_width, + "height": r.maybe_height, }) }).collect::>(), }) diff --git a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/generate_video.rs b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/generate_video.rs index d1dd60af2d..1fa2bee1b6 100644 --- a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/generate_video.rs +++ b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/generate_video.rs @@ -427,6 +427,7 @@ async fn generate( reference_audio_urls: media.reference_audio_urls, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, session, host_override: None, @@ -456,6 +457,7 @@ async fn generate( reference_audio_urls: media.reference_audio_urls, character_ids: None, use_face_blur_hack: None, + bitrate: None, }, session, host_override: None, diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs index 1196095c29..bddb27002f 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs @@ -146,6 +146,7 @@ mod tests { reference_character_tokens: None, resolution: None, aspect_ratio: None, + bitrate: None, duration_seconds: None, video_batch_count: None, generate_audio: None, diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/handle_artcraft_video_via_router.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/handle_artcraft_video_via_router.rs index 7f25442375..50924199a1 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/handle_artcraft_video_via_router.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/handle_artcraft_video_via_router.rs @@ -54,6 +54,7 @@ pub(super) async fn handle_artcraft_video_via_router( reference_character_tokens, resolution: request.resolution, aspect_ratio: request.aspect_ratio, + bitrate: None, duration_seconds: request.duration_seconds, video_batch_count: request.video_batch_count, generate_audio: request.generate_audio, diff --git a/crates/lib/actix_cors_configs/src/configs/artcraft_website.rs b/crates/lib/actix_cors_configs/src/configs/artcraft_website.rs index 157f0770ce..4949eee15f 100644 --- a/crates/lib/actix_cors_configs/src/configs/artcraft_website.rs +++ b/crates/lib/actix_cors_configs/src/configs/artcraft_website.rs @@ -9,6 +9,10 @@ pub fn add_artcraft_website(cors: Cors, _is_production: bool) -> Cors { // Hypothetical domains .allowed_origin("https://artcraft.ai") .allowed_origin("https://www.artcraft.ai") + // Development against production + .allowed_origin("http://localhost:4200") + .allowed_origin("http://localhost:4201") + .allowed_origin("http://localhost:4202") // Netlify project .allowed_origin_fn(|origin, _req_head| { netlify_branch_domain_matches(origin, "artcraft-website.netlify.app") diff --git a/crates/lib/actix_cors_configs/src/configs/mod.rs b/crates/lib/actix_cors_configs/src/configs/mod.rs index 520a7c47e2..5506579825 100644 --- a/crates/lib/actix_cors_configs/src/configs/mod.rs +++ b/crates/lib/actix_cors_configs/src/configs/mod.rs @@ -6,6 +6,7 @@ pub (crate) mod artcraft_website; pub (crate) mod development_only; pub (crate) mod fakeyou; pub (crate) mod legacy; +pub (crate) mod realseedance; pub (crate) mod storyteller; pub (crate) mod storyteller_board; pub (crate) mod storyteller_render; diff --git a/crates/lib/actix_cors_configs/src/configs/realseedance.rs b/crates/lib/actix_cors_configs/src/configs/realseedance.rs new file mode 100644 index 0000000000..eb17f386c0 --- /dev/null +++ b/crates/lib/actix_cors_configs/src/configs/realseedance.rs @@ -0,0 +1,14 @@ +use actix_cors::Cors; +use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches; + +/// CORS for the "Is That Real Seedance?" website (video-info-website). +pub fn add_realseedance(cors: Cors, _is_production: bool) -> Cors { + cors + // Actual domain + .allowed_origin("https://realseedance.com") + .allowed_origin("https://www.realseedance.com") + // Netlify project (bare domain + branch / deploy-preview subdomains) + .allowed_origin_fn(|origin, _req_head| { + netlify_branch_domain_matches(origin, "real-seedance.netlify.app") + }) +} diff --git a/crates/lib/actix_cors_configs/src/cors.rs b/crates/lib/actix_cors_configs/src/cors.rs index 59c3fb9e7e..7a0be125bb 100644 --- a/crates/lib/actix_cors_configs/src/cors.rs +++ b/crates/lib/actix_cors_configs/src/cors.rs @@ -9,6 +9,7 @@ use crate::configs::artcraft_website::add_artcraft_website; use crate::configs::development_only::add_development_only; use crate::configs::fakeyou::{add_fakeyou, add_fakeyou_dev_proxy}; use crate::configs::legacy::{add_legacy_storyteller_stream, add_legacy_trumped, add_legacy_vocodes, add_power_stream}; +use crate::configs::realseedance::add_realseedance; use crate::configs::storyteller::{add_storyteller, add_storyteller_dev_proxy}; use crate::configs::storyteller_board::add_storyteller_board; use crate::configs::storyteller_render::add_storyteller_render; @@ -52,6 +53,7 @@ fn do_build_cors_config(is_production: bool) -> Cors { cors = add_artcraft_webapp(cors, is_production); cors = add_artcraft_website(cors, is_production); cors = add_artcraft_admin_dashboard(cors, is_production); + cors = add_realseedance(cors, is_production); // Legacy cors = add_legacy_trumped(cors, is_production); @@ -117,6 +119,9 @@ mod tests { "https://artcraft.ai", "https://www.artcraft.ai", "https://artcraft-dashboard.netlify.app", + "https://realseedance.com", + "https://www.realseedance.com", + "https://real-seedance.netlify.app", ]; for origin in origins { assert_origin_ok(&production_cors, origin).await; @@ -181,6 +186,26 @@ mod tests { assert_origin_invalid(&development_cors, "https://staging.fakeyou.com").await; } + #[actix_rt::test] + async fn test_realseedance_production() { + let production_cors = build_cors_config(ServerEnvironment::Production); + + // Valid origins + assert_origin_ok(&production_cors, "https://realseedance.com").await; + assert_origin_ok(&production_cors, "https://www.realseedance.com").await; + assert_origin_ok(&production_cors, "https://real-seedance.netlify.app").await; + // Netlify deploy-preview / branch subdomain + assert_origin_ok( + &production_cors, + "https://deploy-preview-12--real-seedance.netlify.app", + ) + .await; + + // Invalid origins + assert_origin_invalid(&production_cors, "https://realseedance.fake.com").await; + assert_origin_invalid(&production_cors, "https://realseedance.netlify.app").await; + } + #[actix_rt::test] async fn test_storyteller_production() { let production_cors = build_cors_config(ServerEnvironment::Production); diff --git a/crates/lib/sha1_hash/Cargo.toml b/crates/lib/sha1_hash/Cargo.toml new file mode 100644 index 0000000000..3a0ff2e7ad --- /dev/null +++ b/crates/lib/sha1_hash/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "sha1_hash" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "sha1_hash" +path = "src/lib.rs" + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[dependencies] +# Workspace hack (faster compile times) +workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" } + +# External +sha1 = "0.10" diff --git a/crates/lib/sha1_hash/src/error.rs b/crates/lib/sha1_hash/src/error.rs new file mode 100644 index 0000000000..6b0e98204d --- /dev/null +++ b/crates/lib/sha1_hash/src/error.rs @@ -0,0 +1,17 @@ +use std::fmt; + +/// Errors produced while computing a SHA-1 hash. +#[derive(Debug)] +pub enum Sha1HashError { + /// The SHA-1 digest wasn't the expected 20 bytes. This should be unreachable + /// for a well-behaved SHA-1 implementation; it exists as a defensive guard. + UnexpectedDigestLength(usize), +} + +impl fmt::Display for Sha1HashError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl std::error::Error for Sha1HashError {} diff --git a/crates/lib/sha1_hash/src/lib.rs b/crates/lib/sha1_hash/src/lib.rs new file mode 100644 index 0000000000..ede7ca86e4 --- /dev/null +++ b/crates/lib/sha1_hash/src/lib.rs @@ -0,0 +1,11 @@ +//! sha1_hash +//! +//! SHA-1 hashing utilities. The primary entry point is +//! [`sha1_hash_bytes_as_crockford`], which hashes bytes with SHA-1 and returns +//! the 20-byte digest as a lowercase Crockford base32 string (32 chars). + +pub mod error; +pub mod sha1_hash_bytes_as_crockford; + +pub use error::Sha1HashError; +pub use sha1_hash_bytes_as_crockford::sha1_hash_bytes_as_crockford; diff --git a/crates/lib/sha1_hash/src/sha1_hash_bytes_as_crockford.rs b/crates/lib/sha1_hash/src/sha1_hash_bytes_as_crockford.rs new file mode 100644 index 0000000000..17817dcf0a --- /dev/null +++ b/crates/lib/sha1_hash/src/sha1_hash_bytes_as_crockford.rs @@ -0,0 +1,74 @@ +use sha1::{Digest, Sha1}; + +use crate::error::Sha1HashError; + +/// Lowercase Crockford base32 alphabet (excludes I, L, O, U). Matches the casing +/// used by the `tokens` crate's Crockford generators. +const CROCKFORD_LOWERCASE: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; + +/// The byte length of a SHA-1 digest. +const SHA1_DIGEST_LEN: usize = 20; + +/// SHA-1 hash `bytes` and return the 20-byte digest encoded as a lowercase +/// Crockford base32 string (32 characters, no padding — 160 bits / 5). +pub fn sha1_hash_bytes_as_crockford(bytes: &[u8]) -> Result { + let mut hasher = Sha1::new(); + hasher.update(bytes); + let digest = hasher.finalize(); + + if digest.len() != SHA1_DIGEST_LEN { + return Err(Sha1HashError::UnexpectedDigestLength(digest.len())); + } + + Ok(encode_crockford_lower(&digest)) +} + +/// Encode bytes as lowercase Crockford base32, reading 5 bits at a time. +fn encode_crockford_lower(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len() * 8 / 5 + 1); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + for &byte in data { + buffer = (buffer << 8) | byte as u32; + bits += 8; + while bits >= 5 { + bits -= 5; + let index = ((buffer >> bits) & 0x1f) as usize; + out.push(CROCKFORD_LOWERCASE[index] as char); + } + } + if bits > 0 { + let index = ((buffer << (5 - bits)) & 0x1f) as usize; + out.push(CROCKFORD_LOWERCASE[index] as char); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input_is_32_crockford_chars() { + // SHA-1("") = da39a3ee5e6b4b0d3255bfef95601890afd80709 + let out = sha1_hash_bytes_as_crockford(&[]).unwrap(); + assert_eq!(out.len(), 32); + assert!(out.bytes().all(|b| CROCKFORD_LOWERCASE.contains(&b))); + } + + #[test] + fn deterministic_and_distinct() { + let a = sha1_hash_bytes_as_crockford(b"hello").unwrap(); + let b = sha1_hash_bytes_as_crockford(b"hello").unwrap(); + let c = sha1_hash_bytes_as_crockford(b"world").unwrap(); + assert_eq!(a, b); + assert_ne!(a, c); + assert_eq!(a.len(), 32); + } + + #[test] + fn encodes_known_bytes() { + // 20 zero bytes → 32 '0' chars. + assert_eq!(encode_crockford_lower(&[0u8; 20]), "0".repeat(32)); + } +} diff --git a/crates/lib/video_info/Cargo.toml b/crates/lib/video_info/Cargo.toml new file mode 100644 index 0000000000..6ef451cae5 --- /dev/null +++ b/crates/lib/video_info/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "video_info" +edition = "2024" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[lib] +name = "video_info" +path = "src/lib.rs" + +# Optional CLI utility for inspecting a video's Seedance provenance. +[[bin]] +name = "video-info-parser" +path = "src/bin/video_info_parser.rs" + +[dependencies] +# Workspace hack (faster compile times) +workspace-build-acceleration.workspace = true + +# External +chrono.workspace = true diff --git a/crates/lib/video_info/src/bin/video_info_parser.rs b/crates/lib/video_info/src/bin/video_info_parser.rs new file mode 100644 index 0000000000..6790d1ec88 --- /dev/null +++ b/crates/lib/video_info/src/bin/video_info_parser.rs @@ -0,0 +1,407 @@ +//! `video-info-parser` — inspect AI-generation C2PA provenance (Seedance, Veo, +//! Sora, Dreamina, Kling) for a single video, or a whole directory of them. +//! +//! Usage: +//! video-info-parser # detailed report for one video +//! video-info-parser # summary table for every .mp4 in the dir +//! video-info-parser --filename +//! video-info-parser --truncate # shorten long filenames in the table + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use video_info::error::VideoInfoError; +use video_info::{DreaminaInfo, KlingInfo, SeedanceInfo, SoraInfo, VeoInfo, VideoInfo}; + +/// Max width before a cell value is truncated with an ellipsis. +const MAX_CELL: usize = 30; + +/// Parsed command-line arguments. +struct Args { + /// File or directory to inspect. + path: String, + /// Whether to shorten long filenames in directory-table output. Defaults to + /// `false` so full filenames are always shown. + truncate_names: bool, +} + +fn main() -> ExitCode { + let args = match parse_args(std::env::args().skip(1)) { + Ok(args) => args, + Err(msg) => { + eprintln!("{msg}"); + eprintln!("usage: video-info-parser [--truncate]"); + eprintln!(" video-info-parser --filename "); + return ExitCode::from(2); + } + }; + + match fs::metadata(&args.path) { + Ok(meta) if meta.is_dir() => run_directory(&args.path, args.truncate_names), + Ok(_) => run_single_file(&args.path), + Err(err) => { + eprintln!("error: cannot read {:?}: {err}", args.path); + ExitCode::FAILURE + } + } +} + +/// Accept the path as the first positional arg, or via `--filename ` / +/// `--filename=`. The path may be a single file or a directory. +/// `--truncate` shortens long filenames in directory-table output. +fn parse_args(args: impl Iterator) -> Result { + let mut path: Option = None; + let mut truncate_names = false; + let mut args = args.peekable(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--filename" | "-f" => { + let value = args.next().ok_or_else(|| "error: --filename requires a value".to_string())?; + path = Some(value); + } + "--truncate" | "--truncate-names" => truncate_names = true, + "--help" | "-h" => { + return Err("video-info-parser: print AI-generation provenance for a file or directory".to_string()); + } + _ => { + if let Some(value) = arg.strip_prefix("--filename=") { + path = Some(value.to_string()); + } else if arg.starts_with('-') { + return Err(format!("error: unknown flag {arg:?}")); + } else if path.is_none() { + path = Some(arg); + } else { + return Err(format!("error: unexpected extra argument {arg:?}")); + } + } + } + } + match path { + Some(path) => Ok(Args { path, truncate_names }), + None => Err("error: no path provided".to_string()), + } +} + +// ── Single-file mode ── + +fn run_single_file(filename: &str) -> ExitCode { + let bytes = match fs::read(filename) { + Ok(bytes) => bytes, + Err(err) => { + eprintln!("error: cannot read {filename:?}: {err}"); + return ExitCode::FAILURE; + } + }; + match VideoInfo::from_bytes(&bytes) { + Ok(VideoInfo::Seedance(info)) => print_seedance(filename, &info), + Ok(VideoInfo::Veo(info)) => print_veo(filename, &info), + Ok(VideoInfo::Sora(info)) => print_sora(filename, &info), + Ok(VideoInfo::Dreamina(info)) => print_dreamina(filename, &info), + Ok(VideoInfo::Kling(info)) => print_kling(filename, &info), + Err(VideoInfoError::Unrecognized) => { + print!("No recognized provenance (not Seedance, Veo, Sora, Dreamina, or Kling)"); + match video_info::encoder_tag(&bytes) { + Some(enc) if enc.starts_with("Lavf") => { + println!(" — re-encoded through ffmpeg (encoder: {enc}), which strips provenance"); + } + Some(enc) => println!(" — encoder: {enc}"), + None => println!(), + } + } + Err(err) => { + eprintln!("error: {err}"); + return ExitCode::FAILURE; + } + } + ExitCode::SUCCESS +} + +// ── Directory mode (summary table) ── + +const TABLE_HEADERS: [&str; 6] = ["FILE", "KIND", "MODEL", "DETAIL", "GENERATED", "SIGNER"]; + +fn run_directory(dir: &str, truncate_names: bool) -> ExitCode { + let files = match collect_mp4s(Path::new(dir)) { + Ok(files) => files, + Err(err) => { + eprintln!("error: cannot list {dir:?}: {err}"); + return ExitCode::FAILURE; + } + }; + if files.is_empty() { + println!("no .mp4 files found in {dir}"); + return ExitCode::SUCCESS; + } + + let mut rows: Vec<[String; 6]> = Vec::with_capacity(files.len()); + let mut tally: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for file in &files { + let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let row = match fs::read(file) { + Ok(bytes) => { + let encoder = video_info::encoder_tag(&bytes); + summary_row(&name, &VideoInfo::from_bytes(&bytes), encoder.as_deref(), truncate_names) + } + Err(err) => summary_row(&name, &Err(VideoInfoError::Io(err)), None, truncate_names), + }; + *tally.entry(row[1].clone()).or_default() += 1; + rows.push(row); + } + + render_table(&TABLE_HEADERS, &rows); + println!(); + let total = files.len(); + let breakdown = + tally.iter().map(|(kind, n)| format!("{kind}: {n}")).collect::>().join(", "); + println!("{total} file(s) — {breakdown}"); + ExitCode::SUCCESS +} + +/// Collect every `.mp4` (case-insensitive) directly under `dir`, sorted by name. +fn collect_mp4s(dir: &Path) -> std::io::Result> { + let mut out = Vec::new(); + for entry in fs::read_dir(dir)? { + let path = entry?.path(); + let is_mp4 = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("mp4")); + if path.is_file() && is_mp4 { + out.push(path); + } + } + out.sort(); + Ok(out) +} + +/// Reduce a parse result to one table row: `[file, kind, model, detail, generated, signer]`. +/// Filenames are shown in full unless `truncate_names` is set. `encoder` is the +/// `©too` tag, shown in DETAIL for unrecognized files to explain the absence. +fn summary_row( + name: &str, + result: &Result, + encoder: Option<&str>, + truncate_names: bool, +) -> [String; 6] { + let file = if truncate_names { truncate(name, MAX_CELL) } else { name.to_string() }; + let (kind, model, detail, generated, signer) = match result { + Ok(VideoInfo::Seedance(i)) => { + let variant = if i.is_fast { + " (fast)" + } else if i.is_lite { + " (lite)" + } else { + "" + }; + let detail = format!("{}{variant}", i.platform.as_str()); + ("Seedance", i.model_name.clone(), detail, i.generated_at.clone(), signer_line(&i.signer_country, &i.cert_serial)) + } + Ok(VideoInfo::Veo(i)) => { + let detail = if i.has_c2pa_manifest { + if i.has_synthid_watermark { "c2pa + synthid".to_string() } else { "c2pa".to_string() } + } else { + format!("encoder={}", i.encoder.as_deref().unwrap_or("?")) + }; + ("Veo", "Google Veo".to_string(), detail, String::new(), short_opt(&i.cert_serial)) + } + Ok(VideoInfo::Sora(i)) => ( + "Sora", + "OpenAI Sora".to_string(), + i.model_name.clone().unwrap_or_default(), + String::new(), + short_opt(&i.cert_serial), + ), + Ok(VideoInfo::Dreamina(i)) => { + let signer = if i.has_c2pa { signer_line(&i.signer_country, &i.cert_serial) } else { String::new() }; + ("Dreamina", i.product.clone(), i.video_id.clone().unwrap_or_default(), String::new(), signer) + } + Ok(VideoInfo::Kling(i)) => { + let model = match &i.model_version { + Some(v) => format!("Kling {v}"), + None => "Kling".to_string(), + }; + let detail = i.produce_id.clone().unwrap_or_else(|| { + if i.has_stream_watermark { "watermark-only".to_string() } else { String::new() } + }); + ("Kling", model, detail, String::new(), i.content_producer.clone().unwrap_or_default()) + } + Err(VideoInfoError::Unrecognized) => { + let detail = match encoder { + Some(enc) if enc.starts_with("Lavf") => format!("re-encoded: {enc}"), + Some(enc) => format!("encoder: {enc}"), + None => "no provenance".to_string(), + }; + ("—", String::new(), detail, String::new(), String::new()) + } + Err(err) => ("error", err.to_string(), String::new(), String::new(), String::new()), + }; + [ + file, + kind.to_string(), + truncate(&model, MAX_CELL), + truncate(&detail, MAX_CELL), + generated, + truncate(&signer, MAX_CELL), + ] +} + +/// `" "`, omitting whichever parts are absent. +fn signer_line(country: &Option, cert: &Option) -> String { + match (country.as_deref(), cert.as_deref()) { + (Some(c), Some(s)) => format!("{c} {}", short(s, 14)), + (Some(c), None) => c.to_string(), + (None, Some(s)) => short(s, 14), + (None, None) => String::new(), + } +} + +fn short_opt(value: &Option) -> String { + value.as_deref().map(|s| short(s, 14)).unwrap_or_default() +} + +fn short(value: &str, max: usize) -> String { + truncate(value, max) +} + +/// Truncate to `max` characters, appending `…` when shortened. +fn truncate(value: &str, max: usize) -> String { + if value.chars().count() <= max { + return value.to_string(); + } + let kept: String = value.chars().take(max.saturating_sub(1)).collect(); + format!("{kept}…") +} + +/// Render left-aligned columns sized to the widest cell in each. +fn render_table(headers: &[&str], rows: &[[String; 6]]) { + let mut widths: Vec = headers.iter().map(|h| h.chars().count()).collect(); + for row in rows { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.chars().count()); + } + } + let render = |cells: &[String]| { + cells + .iter() + .enumerate() + .map(|(i, c)| format!("{:>() + .join(" ") + }; + let header_cells: Vec = headers.iter().map(|h| h.to_string()).collect(); + println!("{}", render(&header_cells).trim_end()); + let separators: Vec = widths.iter().map(|w| "-".repeat(*w)).collect(); + println!("{}", render(&separators).trim_end()); + for row in rows { + println!("{}", render(row).trim_end()); + } +} + +// ── Printing ── + +fn row(label: &str, value: &str) { + println!(" {label:>24} : {value}"); +} + +fn opt(label: &str, value: &Option) { + row(label, value.as_deref().unwrap_or("(none)")); +} + +fn header(title: &str, filename: &str) { + println!("{title}"); + row("file", filename); + println!(" {}", "-".repeat(60)); +} + +fn print_seedance(filename: &str, info: &SeedanceInfo) { + header("Seedance video provenance", filename); + row("platform", info.platform.as_str()); + row("software agent", &info.software_agent); + opt("software agent version", &info.software_agent_version); + row("model name", &info.model_name); + opt("model brand", &info.model_brand); + opt("model version", &info.model_version); + row("fast variant", if info.is_fast { "yes" } else { "no" }); + row("lite variant", if info.is_lite { "yes" } else { "no" }); + row("generated at", &info.generated_at); + row( + "generated at (parsed)", + &info.generated_at_utc.map(|t| t.to_rfc3339()).unwrap_or_else(|| "(unparseable)".to_string()), + ); + opt("log id", &info.log_id); + opt("log id (decoded hex)", &info.log_id_decoded_hex); + opt("digital source type", &info.digital_source_type); + opt("claim generator", &info.claim_generator); + opt("claim generator version", &info.claim_generator_version); + opt("manifest id", &info.manifest_id); + opt("instance id", &info.instance_id); + opt("signer email", &info.signer_email); + opt("signer org id", &info.signer_org_id); + opt("signer country", &info.signer_country); + opt("cert serial", &info.cert_serial); +} + +fn print_veo(filename: &str, info: &VeoInfo) { + header("Google Veo video provenance", filename); + row("producer", &info.producer); + row("c2pa manifest", if info.has_c2pa_manifest { "yes" } else { "no (encoder tag only)" }); + opt("encoder", &info.encoder); + opt("created description", &info.created_description); + row("synthid watermark", if info.has_synthid_watermark { "yes" } else { "no" }); + opt("synthid description", &info.synthid_description); + opt("digital source type", &info.digital_source_type); + opt("claim generator", &info.claim_generator); + opt("claim generator version", &info.claim_generator_version); + opt("manifest id", &info.manifest_id); + opt("instance id", &info.instance_id); + opt("cert serial", &info.cert_serial); + opt("signer ca", &info.signer_ca); + row("timestamped", if info.is_timestamped { "yes" } else { "no" }); + opt("timestamp authority", &info.timestamp_authority); + row("model name", "(not embedded in metadata)"); +} + +fn print_sora(filename: &str, info: &SoraInfo) { + header("OpenAI Sora video provenance", filename); + row("producer", &info.producer); + opt("model name", &info.model_name); + opt("created description", &info.created_description); + opt("digital source type", &info.digital_source_type); + opt("claim generator", &info.claim_generator); + opt("manifest id", &info.manifest_id); + opt("instance id", &info.instance_id); + opt("cert serial", &info.cert_serial); +} + +fn print_dreamina(filename: &str, info: &DreaminaInfo) { + header("Dreamina (ByteDance/CapCut) video provenance", filename); + row("product", &info.product); + opt("export type", &info.export_type); + opt("os", &info.os); + opt("source info", &info.source_info); + row( + "aigc label type", + &info.aigc_label_type.map(|n| n.to_string()).unwrap_or_else(|| "(none)".to_string()), + ); + opt("video id", &info.video_id); + row("has c2pa", if info.has_c2pa { "yes" } else { "no" }); + opt("signer org id", &info.signer_org_id); + opt("signer country", &info.signer_country); + opt("cert serial", &info.cert_serial); +} + +fn print_kling(filename: &str, info: &KlingInfo) { + header("Kling (Kuaishou) video provenance", filename); + opt("model version", &info.model_version); + row("ai-generated", if info.is_ai_generated { "yes" } else { "no" }); + opt("aigc label", &info.label); + opt("content producer", &info.content_producer); + opt("produce id", &info.produce_id); + opt("content propagator", &info.content_propagator); + opt("propagate id", &info.propagate_id); + opt("reserved code 1", &info.reserved_code_1); + opt("reserved code 2", &info.reserved_code_2); + row("stream watermark", if info.has_stream_watermark { "yes" } else { "no" }); + opt("watermark uuid", &info.watermark_uuid); +} diff --git a/crates/lib/video_info/src/dreamina_info.rs b/crates/lib/video_info/src/dreamina_info.rs new file mode 100644 index 0000000000..419952648d --- /dev/null +++ b/crates/lib/video_info/src/dreamina_info.rs @@ -0,0 +1,159 @@ +//! Parse **Dreamina** (ByteDance / CapCut consumer app) provenance from an MP4. +//! +//! Unlike the Seedance API (which embeds a Volcengine/BytePlus C2PA manifest), +//! the Dreamina app stamps JSON metadata into the MP4's `moov/udta/meta/ilst` +//! atoms — e.g. `{"product":"dreamina","exportType":"generation",…}`, +//! `{"aigc_label_type":0,"source_info":"dreamina"}`, and a `vid:…` video id. +//! Some Dreamina exports additionally carry a Bytedance-signed C2PA box. +//! +//! Dreamina is ByteDance's consumer product built on the Seedance model family, +//! so this is a distinct provenance shape from [`crate::seedance_info`]. + +use std::fs; +use std::path::Path; + +use crate::error::VideoInfoError; +use crate::scan::{find, find_cert_serial, find_org_identifier, json_int_field, json_str_field}; + +/// Provenance extracted from a Dreamina (ByteDance/CapCut) video export. +#[derive(Debug, Clone, PartialEq)] +pub struct DreaminaInfo { + /// The product string, always `"dreamina"`. + pub product: String, + + /// Export type, e.g. `"generation"`. + pub export_type: Option, + + /// OS the export was made on (often empty). + pub os: Option, + + /// `source_info`, e.g. `"dreamina"`. + pub source_info: Option, + + /// `aigc_label_type` — ByteDance's AIGC labeling flag (e.g. `0`). + pub aigc_label_type: Option, + + /// Dreamina video id, e.g. `"v02870g10004d6jktf7og65h7ouce21g"` (the `vid:` tag). + pub video_id: Option, + + /// Whether the export also carries a (Bytedance-signed) C2PA manifest. + pub has_c2pa: bool, + + /// Signing-cert organization identifier (when a C2PA box is present), e.g. + /// `"NTRSG-201923456H"` (Bytedance Pte. Ltd.). + pub signer_org_id: Option, + + /// Country of the signing org (`"SG"`) when a C2PA box is present. + pub signer_country: Option, + + /// Leaf signing-cert hex serial when a C2PA box is present. + pub cert_serial: Option, +} + +impl DreaminaInfo { + /// Parse Dreamina provenance from a file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Parse Dreamina provenance from raw bytes. Returns + /// [`VideoInfoError::NotDreamina`] if the Dreamina markers aren't present. + pub fn from_bytes(data: &[u8]) -> Result { + let product = json_str_field(data, "product"); + let source_info = json_str_field(data, "source_info"); + let is_dreamina = product.as_deref() == Some("dreamina") + || source_info.as_deref() == Some("dreamina"); + if !is_dreamina { + return Err(VideoInfoError::NotDreamina); + } + + let has_c2pa = find(data, b"jumbf").is_some() || find(data, b"c2pa.claim").is_some(); + let (signer_org_id, signer_country) = if has_c2pa { + match find_org_identifier(data) { + Some((id, country)) => (Some(id), Some(country.to_string())), + None => (None, None), + } + } else { + (None, None) + }; + + Ok(DreaminaInfo { + product: product.unwrap_or_else(|| "dreamina".to_string()), + export_type: json_str_field(data, "exportType"), + os: json_str_field(data, "os"), + source_info, + aigc_label_type: json_int_field(data, "aigc_label_type"), + video_id: find_vid_token(data), + has_c2pa, + signer_org_id, + signer_country, + cert_serial: if has_c2pa { find_cert_serial(data) } else { None }, + }) + } +} + +/// Read the `vid:` token (id is lowercase-alphanumeric). +fn find_vid_token(data: &[u8]) -> Option { + const PREFIX: &[u8] = b"vid:"; + let i = find(data, PREFIX)?; + let start = i + PREFIX.len(); + let end = (start..data.len()) + .take_while(|&j| data[j].is_ascii_alphanumeric()) + .last() + .map(|j| j + 1)?; + if end == start { + return None; + } + std::str::from_utf8(&data[start..end]).ok().map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn synth(with_c2pa: bool) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"....ftypisom....moov....udta....ilst"); + v.extend_from_slice(br#"{"data":{"os":"","product":"dreamina","exportType":"generation","videoId":"0"}}"#); + v.extend_from_slice(br#"{"aigc_label_type":0,"source_info":"dreamina"}"#); + v.extend_from_slice(b"vid:v02870g10004d6jktf7og65h7ouce21g"); + if with_c2pa { + v.extend_from_slice(b"jumbfc2pa.claim"); + v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xAB, 0xCD, 0xEF]); + v.push(0x13); + v.push(16); + v.extend_from_slice(b"NTRSG-201923456H"); + v.push(0x31); + } + v + } + + #[test] + fn parses_dreamina_no_c2pa() { + let info = DreaminaInfo::from_bytes(&synth(false)).expect("should parse"); + assert_eq!(info.product, "dreamina"); + assert_eq!(info.export_type.as_deref(), Some("generation")); + assert_eq!(info.os.as_deref(), Some("")); + assert_eq!(info.source_info.as_deref(), Some("dreamina")); + assert_eq!(info.aigc_label_type, Some(0)); + assert_eq!(info.video_id.as_deref(), Some("v02870g10004d6jktf7og65h7ouce21g")); + assert!(!info.has_c2pa); + assert_eq!(info.cert_serial, None); + } + + #[test] + fn parses_dreamina_with_c2pa() { + let info = DreaminaInfo::from_bytes(&synth(true)).expect("should parse"); + assert!(info.has_c2pa); + assert_eq!(info.signer_org_id.as_deref(), Some("NTRSG-201923456H")); + assert_eq!(info.signer_country.as_deref(), Some("SG")); + assert_eq!(info.cert_serial.as_deref(), Some("ABCDEF")); + } + + #[test] + fn non_dreamina_returns_not_dreamina() { + let data = b"....ftypisom....just an mp4...."; + assert!(matches!(DreaminaInfo::from_bytes(data), Err(VideoInfoError::NotDreamina))); + } +} diff --git a/crates/lib/video_info/src/error.rs b/crates/lib/video_info/src/error.rs new file mode 100644 index 0000000000..be7c93475f --- /dev/null +++ b/crates/lib/video_info/src/error.rs @@ -0,0 +1,65 @@ +use std::fmt; +use std::io; + +/// Errors produced while reading and parsing video information. +#[derive(Debug)] +pub enum VideoInfoError { + /// The file could not be read. + Io(io::Error), + + /// The video has no recognizable Seedance / C2PA generative-AI manifest — + /// either it isn't a Seedance generation, or the provenance metadata was + /// stripped (e.g. by re-encoding). + NotSeedance, + + /// The video has no recognizable Google Veo / Generative-AI C2PA manifest. + NotVeo, + + /// The video has no recognizable OpenAI Sora C2PA manifest. + NotSora, + + /// The video has no recognizable Dreamina (ByteDance/CapCut) `ilst` metadata. + NotDreamina, + + /// The video has no recognizable Kling (Kuaishou) AIGC-label `ilst` metadata. + NotKling, + + /// The video has no recognized provenance of any supported kind (returned by + /// the [`crate::VideoInfo`] dispatcher when no format matches). + Unrecognized, + + /// A manifest was detected, but a required field could not be extracted + /// (truncated / unexpected manifest encoding). Carries a short description of + /// what was missing. + MalformedManifest(String), +} + +impl fmt::Display for VideoInfoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "I/O error reading video: {}", err), + Self::NotSeedance => write!(f, "no Seedance C2PA manifest found in video"), + Self::NotVeo => write!(f, "no Google Veo C2PA manifest found in video"), + Self::NotSora => write!(f, "no OpenAI Sora C2PA manifest found in video"), + Self::NotDreamina => write!(f, "no Dreamina metadata found in video"), + Self::NotKling => write!(f, "no Kling metadata found in video"), + Self::Unrecognized => write!(f, "no recognized provenance found in video"), + Self::MalformedManifest(detail) => write!(f, "malformed manifest: {}", detail), + } + } +} + +impl std::error::Error for VideoInfoError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for VideoInfoError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} diff --git a/crates/lib/video_info/src/kling_info.rs b/crates/lib/video_info/src/kling_info.rs new file mode 100644 index 0000000000..fd248b80c9 --- /dev/null +++ b/crates/lib/video_info/src/kling_info.rs @@ -0,0 +1,241 @@ +//! Parse **Kling** (Kuaishou) provenance from an MP4. +//! +//! Kling stamps provenance two independent ways, and this parser reads both: +//! +//! 1. **Container AIGC label** — a JSON object in `moov/udta/meta` under the +//! `AIGC` key, following China's AIGC content-labeling standard (GB 45438): +//! `{"Label":"1","ContentProducer":"…","ProduceID":"KLingMuse_…", +//! "ContentPropagator":"…","PropagateID":"KLingMuse_…", +//! "ReservedCode1":null,"ReservedCode2":null}`. +//! `Label:"1"` marks AI-generated content; the `001…C10100` codes are the +//! provider's China registration code; `KLingMuse_…` is Kling's pipeline id. +//! +//! 2. **In-stream watermark** — an H.264 SEI `user_data_unregistered` NAL with a +//! fixed UUID (`91ca60614aee385486142d5f73f4ae2e`) carrying the bytes +//! `kling-ai\0`. This survives a re-encode that strips the container metadata, +//! so a clip can be recognized as Kling from the watermark alone. +//! +//! Note on the **model version** (Kling 1.5 / 1.6 / 2.0 / 2.5 / 2.6, pro / std / +//! fast): it is **not** embedded in either signal. Re-encoded exports (all the +//! samples we have went through `Lavf`) zero the `mvhd` dates and drop any model +//! metadata, leaving only the AIGC label and the watermark — neither of which +//! encodes the version. [`KlingInfo::model_version`] holds it when a clean export +//! does carry a printable `kling-` token, and is `None` otherwise. + +use std::fs; +use std::path::Path; + +use crate::error::VideoInfoError; +use crate::scan::{find, find_from, json_str_field}; + +/// The fixed UUID of Kling's SEI `user_data_unregistered` watermark NAL, +/// immediately followed by the ASCII marker `kling-ai`. +const WATERMARK_MARKER: &[u8] = b"kling-ai"; + +/// Provenance extracted from a Kling (Kuaishou) video. +#[derive(Debug, Clone, PartialEq)] +pub struct KlingInfo { + /// Kling model version when a clean export embeds a printable `kling-` + /// token (e.g. `"2.5-pro"`). `None` for re-encoded exports, which don't carry + /// it — see the module docs. + pub model_version: Option, + + /// Whether the content is labeled AI-generated (`Label == "1"`, per GB 45438). + pub is_ai_generated: bool, + + /// Raw `Label` value (`"1"` = AI-generated). + pub label: Option, + + /// Content producer registration code — the org/service code that generated it. + pub content_producer: Option, + + /// Per-generation produce id, e.g. `"KLingMuse_3d917354-4294-47ff-…"`. + pub produce_id: Option, + + /// Content propagator registration code (who distributed it; usually == + /// producer, but can differ — e.g. a `…C20100` distribution channel). + pub content_propagator: Option, + + /// Per-generation propagate id (usually equal to [`KlingInfo::produce_id`]). + pub propagate_id: Option, + + /// `ReservedCode1` — reserved by the standard; observed `null` so far. + pub reserved_code_1: Option, + + /// `ReservedCode2` — reserved by the standard; observed `null` so far. + pub reserved_code_2: Option, + + /// Whether the in-stream `kling-ai` SEI watermark is present. + pub has_stream_watermark: bool, + + /// The 16-byte SEI watermark UUID (hex) when the watermark is present, + /// e.g. `"91ca60614aee385486142d5f73f4ae2e"`. + pub watermark_uuid: Option, +} + +impl KlingInfo { + /// Parse Kling provenance from a file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Parse Kling provenance from raw bytes. Returns [`VideoInfoError::NotKling`] + /// if neither the AIGC label nor the in-stream watermark is present. + pub fn from_bytes(data: &[u8]) -> Result { + let watermark_uuid = find_watermark_uuid(data); + let has_stream_watermark = watermark_uuid.is_some() || find(data, WATERMARK_MARKER).is_some(); + + let has_aigc_label = find(data, b"KLingMuse").is_some() + || (find(data, b"\"ContentProducer\"").is_some() && find(data, b"\"Label\"").is_some()); + if !has_aigc_label && !has_stream_watermark { + return Err(VideoInfoError::NotKling); + } + + let label = json_str_field(data, "Label"); + Ok(KlingInfo { + model_version: find_model_version(data), + is_ai_generated: label.as_deref() == Some("1"), + label, + content_producer: json_str_field(data, "ContentProducer"), + produce_id: json_str_field(data, "ProduceID"), + content_propagator: json_str_field(data, "ContentPropagator"), + propagate_id: json_str_field(data, "PropagateID"), + reserved_code_1: json_str_field(data, "ReservedCode1"), + reserved_code_2: json_str_field(data, "ReservedCode2"), + has_stream_watermark, + watermark_uuid, + }) + } +} + +/// Read the 16-byte UUID immediately preceding the `kling-ai` SEI marker (the +/// SEI `user_data_unregistered` payload is `<16-byte UUID><"kling-ai\0">`). +fn find_watermark_uuid(data: &[u8]) -> Option { + let i = find(data, WATERMARK_MARKER)?; + let uuid = data.get(i.checked_sub(16)?..i)?; + Some(uuid.iter().map(|b| format!("{:02x}", b)).collect()) +} + +/// Find a printable `kling[-_/ ]v?.[-]` model-version token. +/// +/// Conservative by design: it requires a real `.` after a `kling` +/// prefix, so the watermark marker (`kling-ai`) and the produce id (`KLingMuse_…`) +/// never match. Returns `None` when no such printable token exists. +fn find_model_version(data: &[u8]) -> Option { + const PREFIX: &[u8] = b"kling"; + const TIERS: [&[u8]; 5] = [b"pro", b"std", b"fast", b"master", b"turbo"]; + let mut from = 0; + while let Some(i) = find_from(data, PREFIX, from) { + from = i + PREFIX.len(); + let mut j = from; + if matches!(data.get(j), Some(b'-' | b'_' | b'/' | b' ')) { + j += 1; + } + if matches!(data.get(j), Some(b'v' | b'V')) { + j += 1; + } + let num_start = j; + while matches!(data.get(j), Some(c) if c.is_ascii_digit() || *c == b'.') { + j += 1; + } + if j == num_start || !data[num_start..j].contains(&b'.') { + continue; + } + let mut version = String::from_utf8_lossy(&data[num_start..j]).into_owned(); + // Optional `-` suffix (pro / std / fast / …). + if data.get(j) == Some(&b'-') { + for tier in TIERS { + if data.get(j + 1..j + 1 + tier.len()) == Some(tier) { + version.push('-'); + version.push_str(&String::from_utf8_lossy(tier)); + break; + } + } + } + return Some(version); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + const AIGC_JSON: &[u8] = br#"{"Label":"1","ContentProducer":"001191110108335469089C10100","ProduceID":"KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0","ReservedCode1":null,"ContentPropagator":"001191110108335469089C10100","PropagateID":"KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0","ReservedCode2":null}"#; + + fn with_watermark() -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"....mdat...."); + // SEI: type 6, payload 5, len 0x19, 16-byte UUID, then "kling-ai\0". + v.extend_from_slice(&[0x06, 0x05, 0x19]); + v.extend_from_slice(&[ + 0x91, 0xca, 0x60, 0x61, 0x4a, 0xee, 0x38, 0x54, 0x86, 0x14, 0x2d, 0x5f, 0x73, 0xf4, 0xae, + 0x2e, + ]); + v.extend_from_slice(b"kling-ai\0"); + v + } + + #[test] + fn parses_aigc_label() { + let mut data = b"....ftypisom....moov....udta....ilst".to_vec(); + data.extend_from_slice(AIGC_JSON); + let info = KlingInfo::from_bytes(&data).expect("should parse"); + assert!(info.is_ai_generated); + assert_eq!(info.label.as_deref(), Some("1")); + assert_eq!(info.content_producer.as_deref(), Some("001191110108335469089C10100")); + assert_eq!(info.produce_id.as_deref(), Some("KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0")); + assert_eq!(info.content_propagator.as_deref(), Some("001191110108335469089C10100")); + assert_eq!(info.propagate_id.as_deref(), Some("KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0")); + assert_eq!(info.reserved_code_1, None); + assert_eq!(info.reserved_code_2, None); + assert_eq!(info.model_version, None); + assert!(!info.has_stream_watermark); + } + + #[test] + fn detects_watermark_when_aigc_stripped() { + // A re-encoded Kling clip: no AIGC JSON, only the in-stream SEI watermark. + let info = KlingInfo::from_bytes(&with_watermark()).expect("should parse via watermark"); + assert!(info.has_stream_watermark); + assert_eq!(info.watermark_uuid.as_deref(), Some("91ca60614aee385486142d5f73f4ae2e")); + assert_eq!(info.label, None); + assert_eq!(info.produce_id, None); + assert!(!info.is_ai_generated); + } + + #[test] + fn reads_both_signals_together() { + let mut data = b"....ftypisom....ilst".to_vec(); + data.extend_from_slice(AIGC_JSON); + data.extend_from_slice(&with_watermark()); + let info = KlingInfo::from_bytes(&data).expect("should parse"); + assert!(info.is_ai_generated); + assert!(info.has_stream_watermark); + assert_eq!(info.watermark_uuid.as_deref(), Some("91ca60614aee385486142d5f73f4ae2e")); + } + + #[test] + fn extracts_model_version_from_clean_token() { + // A hypothetical clean export carrying a printable model token. + let data = b"....moov....model:kling-2.5-pro....mdat...."; + let info = KlingInfo::from_bytes(data); + // No AIGC/watermark here, so it won't classify as Kling, but the version + // extractor itself should recognize the token. + assert_eq!(find_model_version(data).as_deref(), Some("2.5-pro")); + assert!(matches!(info, Err(VideoInfoError::NotKling))); + } + + #[test] + fn watermark_marker_is_not_a_version() { + // `kling-ai` must not be misread as a version. + assert_eq!(find_model_version(&with_watermark()), None); + } + + #[test] + fn non_kling_returns_not_kling() { + let data = b"....ftypisom....just an mp4...."; + assert!(matches!(KlingInfo::from_bytes(data), Err(VideoInfoError::NotKling))); + } +} diff --git a/crates/lib/video_info/src/lib.rs b/crates/lib/video_info/src/lib.rs new file mode 100644 index 0000000000..47de32d6d6 --- /dev/null +++ b/crates/lib/video_info/src/lib.rs @@ -0,0 +1,38 @@ +//! Parse provenance / generation metadata out of video files. +//! +//! AI image/video generators increasingly embed [C2PA](https://c2pa.org/) +//! Content Credentials (a JUMBF box carrying a CBOR manifest) describing the +//! generating model, platform, and timestamp. This crate extracts that. +//! +//! Supported formats (see [`VideoInfo`]): +//! - **Seedance** (ByteDance API — Volcengine / BytePlus C2PA): [`seedance_info::SeedanceInfo`] +//! - **Veo** (Google Generative AI video C2PA): [`veo_info::VeoInfo`] +//! - **Sora** (OpenAI Generative AI video C2PA): [`sora_info::SoraInfo`] +//! - **Dreamina** (ByteDance/CapCut app `ilst` metadata): [`dreamina_info::DreaminaInfo`] +//! - **Kling** (Kuaishou AIGC-label `ilst` metadata): [`kling_info::KlingInfo`] + +mod scan; + +pub mod dreamina_info; +pub mod error; +pub mod kling_info; +pub mod seedance_info; +pub mod sora_info; +pub mod veo_info; +pub mod video; + +pub use dreamina_info::DreaminaInfo; +pub use error::VideoInfoError; +pub use kling_info::KlingInfo; +pub use seedance_info::{SeedanceInfo, SeedancePlatform}; +pub use sora_info::SoraInfo; +pub use veo_info::VeoInfo; +pub use video::VideoInfo; + +/// Read the container's `©too` encoder tag (e.g. `"Lavf60.16.100"`, `"Google"`), +/// if present. Useful context for videos with no recognized provenance: an +/// `Lavf…` value means the file was re-encoded through ffmpeg, which strips C2PA +/// manifests and AIGC metadata — explaining why provenance is absent. +pub fn encoder_tag(data: &[u8]) -> Option { + scan::find_encoder_tag(data) +} diff --git a/crates/lib/video_info/src/scan.rs b/crates/lib/video_info/src/scan.rs new file mode 100644 index 0000000000..df9c184fee --- /dev/null +++ b/crates/lib/video_info/src/scan.rs @@ -0,0 +1,273 @@ +//! Shared byte-scanning helpers for reading C2PA manifests embedded in MP4s. +//! +//! These avoid a full JUMBF + CBOR + X.509 stack: the values we want are small, +//! stably-encoded fields locatable by their key/marker. Used by both +//! [`crate::seedance_info`] and [`crate::veo_info`]. + +/// Find the first occurrence of `needle` in `haystack`. +pub(crate) fn find(haystack: &[u8], needle: &[u8]) -> Option { + find_from(haystack, needle, 0) +} + +/// Find `needle` at or after byte offset `start`. +pub(crate) fn find_from(haystack: &[u8], needle: &[u8], start: usize) -> Option { + if needle.is_empty() || start >= haystack.len() || needle.len() > haystack.len() - start { + return None; + } + haystack[start..] + .windows(needle.len()) + .position(|w| w == needle) + .map(|p| p + start) +} + +/// Read a CBOR text string (major type 3) starting at `at`. Supports inline +/// length (0x60–0x77), 1-byte length (0x78), and 2-byte length (0x79). +pub(crate) fn read_cbor_text(data: &[u8], at: usize) -> Option { + let first = *data.get(at)?; + let (len, value_offset) = match first { + 0x60..=0x77 => ((first - 0x60) as usize, 1), + 0x78 => (*data.get(at + 1)? as usize, 2), + 0x79 => { + let hi = *data.get(at + 1)? as usize; + let lo = *data.get(at + 2)? as usize; + ((hi << 8) | lo, 3) + } + _ => return None, + }; + let start = at + value_offset; + let bytes = data.get(start..start + len)?; + String::from_utf8(bytes.to_vec()).ok() +} + +/// Find `key` and read the CBOR text string immediately following it. +pub(crate) fn text_after_key(data: &[u8], key: &[u8]) -> Option { + let idx = find(data, key)?; + read_cbor_text(data, idx + key.len()) +} + +/// Like [`text_after_key`] but only searches at/after `start`. +pub(crate) fn text_after_key_from(data: &[u8], key: &[u8], start: usize) -> Option { + let idx = find_from(data, key, start)?; + read_cbor_text(data, idx + key.len()) +} + +/// First RFC 3339 `YYYY-MM-DDTHH:MM:SSZ` (20 chars) timestamp in the buffer. +pub(crate) fn find_rfc3339(data: &[u8]) -> Option { + const LEN: usize = 20; + data.windows(LEN).find(|w| is_rfc3339(w)).map(|w| String::from_utf8_lossy(w).into_owned()) +} + +fn is_rfc3339(w: &[u8]) -> bool { + // YYYY-MM-DDTHH:MM:SSZ + let d = |i: usize| w[i].is_ascii_digit(); + w.len() == 20 + && d(0) && d(1) && d(2) && d(3) && w[4] == b'-' + && d(5) && d(6) && w[7] == b'-' + && d(8) && d(9) && w[10] == b'T' + && d(11) && d(12) && w[13] == b':' + && d(14) && d(15) && w[16] == b':' + && d(17) && d(18) && w[19] == b'Z' +} + +/// Find a `` token (e.g. `urn:c2pa:…`, `xmp:iid:…`) and return the +/// full token including the prefix. The UUID is up to 36 hex/dash chars. +pub(crate) fn find_prefixed_uuid(data: &[u8], prefix: &[u8]) -> Option { + let i = find(data, prefix)?; + let start = i + prefix.len(); + let end = (start..data.len().min(start + 36)) + .take_while(|&j| data[j].is_ascii_hexdigit() || data[j] == b'-') + .last() + .map(|j| j + 1)?; + let uuid = std::str::from_utf8(&data[start..end]).ok()?; + let mut out = String::with_capacity(prefix.len() + uuid.len()); + out.push_str(&String::from_utf8_lossy(prefix)); + out.push_str(uuid); + Some(out) +} + +/// Hex serial number of the leaf signing certificate. +/// +/// X.509 v3 certs encode the TBS prefix as `A0 03 02 01 02` (the `[0] EXPLICIT` +/// version = v3) immediately followed by the serial `INTEGER` (`02 `). +/// That fixed prefix lets us read the serial without a full DER parser; the leaf +/// cert is the first such occurrence. +pub(crate) fn find_cert_serial(data: &[u8]) -> Option { + const TBS_VERSION_PREFIX: &[u8] = &[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02]; + let i = find(data, TBS_VERSION_PREFIX)?; + let len_pos = i + TBS_VERSION_PREFIX.len(); + let len = *data.get(len_pos)? as usize; + if len == 0 || len > 40 { + return None; + } + let start = len_pos + 1; + let bytes = data.get(start..start + len)?; + Some(bytes.iter().map(|b| format!("{:02X}", b)).collect()) +} + +/// Read a DER length-prefixed string that begins with `prefix` — the byte +/// immediately before `prefix` is its DER length (as in an X.509 subject/issuer +/// CN). Returns the full string, e.g. given `b"Google C2PA Media Services"` +/// returns `"Google C2PA Media Services 1P ICA G3"`. +pub(crate) fn read_der_string(data: &[u8], prefix: &[u8]) -> Option { + let i = find(data, prefix)?; + let len = *data.get(i.checked_sub(1)?)? as usize; + if len < prefix.len() || len > 128 { + return None; + } + let bytes = data.get(i..i + len)?; + if !bytes.iter().all(|b| b.is_ascii_graphic() || *b == b' ') { + return None; + } + std::str::from_utf8(bytes).ok().map(str::to_string) +} + +/// Minimal base64url (RFC 4648 §5) decoder — no external dependency. Ignores +/// `=` padding; returns `None` on any invalid character. +pub(crate) fn decode_base64url(input: &str) -> Option> { + fn sextet(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'-' => Some(62), + b'_' => Some(63), + _ => None, + } + } + let chars: Vec = input.bytes().filter(|&b| b != b'=').collect(); + let mut out = Vec::with_capacity(chars.len() * 3 / 4); + for chunk in chars.chunks(4) { + let mut buf = [0u8; 4]; + for (i, &c) in chunk.iter().enumerate() { + buf[i] = sextet(c)?; + } + out.push((buf[0] << 2) | (buf[1] >> 4)); + if chunk.len() >= 3 { + out.push((buf[1] << 4) | (buf[2] >> 2)); + } + if chunk.len() >= 4 { + out.push((buf[2] << 6) | buf[3]); + } + } + Some(out) +} + +/// The X.509 organization identifier (OID 2.5.4.97) from the signing cert, plus +/// the country implied by its registration scheme (`NTRSG-…` = SG, `NTRCN-…` = CN). +/// The value is a DER string whose length byte immediately precedes it. +pub(crate) fn find_org_identifier(data: &[u8]) -> Option<(String, &'static str)> { + for (prefix, country) in [(b"NTRSG-".as_slice(), "SG"), (b"NTRCN-".as_slice(), "CN")] { + let Some(start) = find(data, prefix) else { continue }; + if start == 0 { + continue; + } + let len = data[start - 1] as usize; + if len < prefix.len() || len > 40 { + continue; + } + let Some(bytes) = data.get(start..start + len) else { continue }; + if bytes.iter().all(|b| b.is_ascii_graphic()) { + if let Ok(value) = std::str::from_utf8(bytes) { + return Some((value.to_string(), country)); + } + } + } + None +} + +/// Read the iTunes-style `©too` encoder atom's value (the `encoder` tag exposed +/// by ffprobe). Layout: `©too` → `data` sub-box → 4-byte version/flags + 4-byte +/// reserved → the ASCII value. Returns the printable value (e.g. `"Google"`, +/// `"Lavf60.16.100"`). +pub(crate) fn find_encoder_tag(data: &[u8]) -> Option { + const TOO: &[u8] = &[0xA9, b't', b'o', b'o']; + let too = find(data, TOO)?; + // The `data` sub-box header sits a few bytes after `©too`; its value begins 8 + // bytes past the `data` marker (4 version/flags + 4 reserved/locale). + let data_marker = find_from(data, b"data", too)?; + let start = data_marker + 4 + 8; + let end = (start..data.len()) + .take_while(|&j| { + let b = data[j]; + b.is_ascii_graphic() || b == b'.' + }) + .last() + .map(|j| j + 1)?; + if end <= start { + return None; + } + std::str::from_utf8(&data[start..end]).ok().map(str::to_string) +} + +/// Extract a flat JSON string field value (`"key":"value"`) from raw bytes. +/// Assumes the value contains no escaped quotes (true for these manifests). +pub(crate) fn json_str_field(data: &[u8], key: &str) -> Option { + let pat = format!("\"{}\":\"", key); + let i = find(data, pat.as_bytes())?; + let start = i + pat.len(); + let rel_end = data[start..].iter().position(|&b| b == b'"')?; + String::from_utf8(data[start..start + rel_end].to_vec()).ok() +} + +/// Extract a flat JSON integer field value (`"key":`) from raw bytes. +pub(crate) fn json_int_field(data: &[u8], key: &str) -> Option { + let pat = format!("\"{}\":", key); + let i = find(data, pat.as_bytes())?; + let mut e = i + pat.len(); + let start = e; + if data.get(e) == Some(&b'-') { + e += 1; + } + while data.get(e).is_some_and(|b| b.is_ascii_digit()) { + e += 1; + } + if e == start || (e == start + 1 && data[start] == b'-') { + return None; + } + std::str::from_utf8(&data[start..e]).ok()?.parse().ok() +} + +/// CBOR-encode a text string (major type 3) — test helper for building manifests. +#[cfg(test)] +pub(crate) fn push_cbor_text(buf: &mut Vec, s: &str) { + let b = s.as_bytes(); + if b.len() <= 0x17 { + buf.push(0x60 + b.len() as u8); + } else { + buf.push(0x78); + buf.push(b.len() as u8); + } + buf.extend_from_slice(b); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cbor_text_reader_handles_length_encodings() { + let mut inline = Vec::new(); + push_cbor_text(&mut inline, "doubao-seedance-2-0"); // 19 chars, inline + assert_eq!(read_cbor_text(&inline, 0).as_deref(), Some("doubao-seedance-2-0")); + + let mut long = Vec::new(); + push_cbor_text(&mut long, "doubao-seedance-2-0-fast"); // 24 chars → 0x78 + assert_eq!(long[0], 0x78); + assert_eq!(read_cbor_text(&long, 0).as_deref(), Some("doubao-seedance-2-0-fast")); + } + + #[test] + fn base64url_decodes_log_id() { + // ATIAA7b8D_iKjF32GukAAAAA → 18 bytes, region byte 0x32 (Volcengine). + let bytes = decode_base64url("ATIAA7b8D_iKjF32GukAAAAA").unwrap(); + let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect(); + assert_eq!(hex, "01320003b6fc0ff88a8c5df61ae900000000"); + } + + #[test] + fn cert_serial_reads_v3_prefix() { + let mut data = vec![0xFF, 0x00]; + data.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xAB, 0xCD, 0xEF]); + assert_eq!(find_cert_serial(&data).as_deref(), Some("ABCDEF")); + } +} diff --git a/crates/lib/video_info/src/seedance_info.rs b/crates/lib/video_info/src/seedance_info.rs new file mode 100644 index 0000000000..6e7e01b358 --- /dev/null +++ b/crates/lib/video_info/src/seedance_info.rs @@ -0,0 +1,417 @@ +//! Parse Seedance (ByteDance) generation provenance out of an MP4. +//! +//! Seedance videos carry a [C2PA](https://c2pa.org/) manifest embedded as a +//! JUMBF box. Inside is a CBOR `c2pa.created` action whose `softwareAgent` +//! identifies the generating platform (Volcengine vs BytePlus), the model, the +//! generation timestamp, and an opaque generation `log_id`. +//! +//! Rather than fully decode JUMBF + CBOR (which would require pulling in a C2PA +//! stack), we scan for the small, stable set of CBOR text fields we care about. +//! The values are CBOR text strings (major type 3) immediately following their +//! key, so each is recovered by locating the key and reading the text string. + +use std::fs; +use std::path::Path; + +use chrono::{DateTime, Utc}; + +use crate::error::VideoInfoError; +use crate::scan::{ + decode_base64url, find, find_cert_serial, find_org_identifier, find_prefixed_uuid, find_rfc3339, + text_after_key, text_after_key_from, +}; + +// ── Markers in the embedded C2PA manifest ── + +const VENDOR_VOLCENGINE: &[u8] = b"Volcengine_Ark_CN"; +const VENDOR_BYTEPLUS: &[u8] = b"BytePlus_ModelArk"; + +/// The platform that produced a Seedance generation. The two share the same +/// underlying model family but are operated as separate products / regions +/// (Volcengine = ByteDance China "Doubao"/Ark; BytePlus = international "Dreamina"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeedancePlatform { + /// `Volcengine_Ark_CN`, signed by `…@volcengine.com`. + Volcengine, + /// `BytePlus_ModelArk`, signed by `Byteplus Pte. Ltd.` (`…@byteplus.com`). + BytePlus, +} + +impl SeedancePlatform { + pub fn as_str(&self) -> &'static str { + match self { + Self::Volcengine => "volcengine", + Self::BytePlus => "byteplus", + } + } +} + +/// Provenance extracted from a Seedance video's embedded C2PA manifest. +#[derive(Debug, Clone, PartialEq)] +pub struct SeedanceInfo { + /// Volcengine vs BytePlus. + pub platform: SeedancePlatform, + + /// The C2PA `softwareAgent` name, e.g. `"Volcengine_Ark_CN"` / `"BytePlus_ModelArk"`. + pub software_agent: String, + + /// The `softwareAgent` version, e.g. `"1.0.0"`. + pub software_agent_version: Option, + + /// Full model identifier, e.g. `"doubao-seedance-2-0-fast"`, + /// `"doubao-seedance-2-0"`, `"dreamina-seedance-2-0"`, + /// `"doubao-seedance-1-0-lite"`. + pub model_name: String, + + /// Brand prefix of the model, e.g. `"doubao"` (Volcengine) / `"dreamina"` (BytePlus). + pub model_brand: Option, + + /// Model version parsed from the name, e.g. `"2.0"` (from `…-2-0`) or `"1.0"` + /// (from `…-1-0-lite`). The parser is version-agnostic — Seedance 1.0 and 2.0 + /// share the same Ark C2PA shape and both land here. + pub model_version: Option, + + /// Whether this is the `-fast` variant of the model. + pub is_fast: bool, + + /// Whether this is the `-lite` variant of the model (e.g. Seedance 1.0 Lite). + pub is_lite: bool, + + /// Raw generation timestamp string (RFC 3339, e.g. `"2026-06-19T01:32:58Z"`). + pub generated_at: String, + + /// Parsed [`generated_at`](Self::generated_at), when it is valid RFC 3339. + pub generated_at_utc: Option>, + + /// Per-generation log / request id, base64url-encoded + /// (e.g. `"ATIAA7b8D_iKjF32GukAAAAA"`). + pub log_id: Option, + + /// [`log_id`](Self::log_id) base64url-decoded to hex (18 bytes). It is a + /// structured id: byte 0 = version (`01`), byte 1 = region/platform + /// (`0x32` = Volcengine, `0x33` = BytePlus), bytes 2–3 constant (`0003`), + /// bytes 4–13 a per-generation value, bytes 14–17 zero padding. The region + /// byte independently corroborates [`platform`](Self::platform). + pub log_id_decoded_hex: Option, + + /// IPTC digital source type URL — always trained-algorithmic-media for these. + pub digital_source_type: Option, + + /// C2PA claim generator, e.g. `"c2pa-rs"`. + pub claim_generator: Option, + + /// C2PA claim generator version, e.g. `"0.78.4"`. + pub claim_generator_version: Option, + + /// The manifest URN, e.g. `"urn:c2pa:40a5f6fe-b88a-4a2c-ae72-f76a1e5a6012"`. + /// Identifies the manifest; not a stable per-asset identity. + pub manifest_id: Option, + + /// The XMP asset instance id, e.g. `"xmp:iid:95a3ac67-cb28-4a0d-ac54-911f1787b7de"`. + /// Unique per generated asset (distinct from [`manifest_id`](Self::manifest_id)). + pub instance_id: Option, + + /// Signing certificate email, e.g. `"certificate_center@volcengine.com"`. + pub signer_email: Option, + + /// Signing certificate organization identifier (X.509 OID 2.5.4.97), e.g. + /// `"NTRSG-202024632W"` (Byteplus Pte. Ltd., Singapore) or + /// `"NTRCN-91110108MA01R70K8D"` (Beijing Volcano Engine, China). The `NTRxx` + /// scheme prefix is itself a strong jurisdiction signal. + pub signer_org_id: Option, + + /// Two-letter country of the signing organization, derived from the org + /// identifier scheme (`"SG"` for BytePlus, `"CN"` for Volcengine). + pub signer_country: Option, + + /// Hex serial number of the leaf signing certificate, e.g. + /// `"3687D48590A2FD5101AB0316"`. Stable per signing certificate (Volcengine + /// and BytePlus each reuse one), so it identifies the signer across assets — + /// useful for grouping/auditing, not as a per-asset id. + pub cert_serial: Option, +} + +impl SeedanceInfo { + /// Parse Seedance provenance from a video file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Parse Seedance provenance from raw video bytes. + /// + /// Returns [`VideoInfoError::NotSeedance`] if no Seedance/C2PA generative + /// manifest is present, or [`VideoInfoError::MalformedManifest`] if the + /// manifest is present but a required field can't be read. + pub fn from_bytes(data: &[u8]) -> Result { + let (platform, vendor_marker) = if let Some(_) = find(data, VENDOR_VOLCENGINE) { + (SeedancePlatform::Volcengine, VENDOR_VOLCENGINE) + } else if let Some(_) = find(data, VENDOR_BYTEPLUS) { + (SeedancePlatform::BytePlus, VENDOR_BYTEPLUS) + } else { + return Err(VideoInfoError::NotSeedance); + }; + + let software_agent = String::from_utf8_lossy(vendor_marker).into_owned(); + let vendor_end = find(data, vendor_marker).map(|i| i + vendor_marker.len()).unwrap_or(0); + + // model_name is required for a meaningful Seedance manifest. + let model_name = text_after_key(data, b"model_name") + .ok_or_else(|| VideoInfoError::MalformedManifest("missing model_name".to_string()))?; + + if !model_name.contains("seedance") { + // The vendor marker is present but the model isn't Seedance — treat as + // "not a Seedance generation" rather than a parse failure. + return Err(VideoInfoError::NotSeedance); + } + + let generated_at = find_rfc3339(data) + .ok_or_else(|| VideoInfoError::MalformedManifest("missing generation timestamp".to_string()))?; + let generated_at_utc = DateTime::parse_from_rfc3339(&generated_at) + .ok() + .map(|dt| dt.with_timezone(&Utc)); + + let (model_brand, model_version, is_fast, is_lite) = parse_model_name(&model_name); + + let log_id = text_after_key(data, b"log_id"); + let log_id_decoded_hex = log_id + .as_deref() + .and_then(decode_base64url) + .map(|bytes| bytes.iter().map(|b| format!("{:02x}", b)).collect::()); + + let software_agent_version = text_after_key_from(data, b"version", vendor_end); + + let claim_generator = find(data, b"c2pa-rs").map(|_| "c2pa-rs".to_string()); + let claim_generator_version = find(data, b"c2pa-rs") + .and_then(|i| text_after_key_from(data, b"version", i + b"c2pa-rs".len())); + + let (signer_org_id, signer_country) = match find_org_identifier(data) { + Some((org_id, country)) => (Some(org_id), Some(country.to_string())), + None => (None, None), + }; + + Ok(SeedanceInfo { + platform, + software_agent, + software_agent_version, + model_name, + model_brand, + model_version, + is_fast, + is_lite, + generated_at, + generated_at_utc, + log_id, + log_id_decoded_hex, + digital_source_type: text_after_key(data, b"digitalSourceType"), + claim_generator, + claim_generator_version, + manifest_id: find_prefixed_uuid(data, b"urn:c2pa:"), + instance_id: find_prefixed_uuid(data, b"xmp:iid:"), + signer_email: find_signer_email(data), + signer_org_id, + signer_country, + cert_serial: find_cert_serial(data), + }) + } +} + +// ── Model name parsing ── + +/// Split `"{brand}-seedance-{maj}-{min}[-{variant}]"` into +/// (brand, version, is_fast, is_lite). +/// +/// e.g. `"doubao-seedance-2-0-fast"` → (`"doubao"`, `"2.0"`, `true`, `false`); +/// `"doubao-seedance-1-0-lite"` → (`"doubao"`, `"1.0"`, `false`, `true`). +/// The version is the leading run of numeric segments after `seedance-`, so a +/// trailing variant token (`fast`/`lite`) doesn't leak into it. +fn parse_model_name(model_name: &str) -> (Option, Option, bool, bool) { + let brand = model_name.split("-seedance").next() + .filter(|s| !s.is_empty() && *s != model_name) + .map(|s| s.to_string()); + + let mut version = None; + let mut is_fast = false; + let mut is_lite = false; + if let Some(after) = model_name.split("seedance-").nth(1) { + let segments: Vec<&str> = after.split('-').collect(); + // Leading numeric segments form the version: ["1","0"] → "1.0". + let nums: Vec<&str> = segments + .iter() + .take_while(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())) + .copied() + .collect(); + if !nums.is_empty() { + version = Some(nums.join(".")); + } + for segment in &segments { + match *segment { + "fast" => is_fast = true, + "lite" => is_lite = true, + _ => {} + } + } + } + + (brand, version, is_fast, is_lite) +} + +// ── Seedance-specific scanners ── + +/// Signing certificate email (`…@volcengine.com` / `…@byteplus.com`), recovered +/// by finding the domain and walking back over the local part. +fn find_signer_email(data: &[u8]) -> Option { + for domain in [b"@volcengine.com".as_slice(), b"@byteplus.com".as_slice()] { + if let Some(at) = find(data, domain) { + let mut start = at; + while start > 0 { + let c = data[start - 1]; + if c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'%' | b'+' | b'-') { + start -= 1; + } else { + break; + } + } + let end = at + domain.len(); + if let Ok(email) = std::str::from_utf8(&data[start..end]) { + if email.starts_with(|c: char| c.is_ascii_alphanumeric()) { + return Some(email.to_string()); + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan::push_cbor_text as push_text; + + // ── Helpers to synthesize a minimal C2PA-like manifest ── + + fn synth_manifest(vendor: &str, model: &str, time: &str, log_id: &str, signer: &str, org_id: &str) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"....ftypisom....moov....c2pa"); + // X.509 v3 TBS prefix + serial INTEGER (02 ) for serial 3687D485… + v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x0C]); + v.extend_from_slice(&[0x36, 0x87, 0xD4, 0x85, 0x90, 0xA2, 0xFD, 0x51, 0x01, 0xAB, 0x03, 0x16]); + v.extend_from_slice(b"urn:c2pa:40a5f6fe-b88a-4a2c-ae72-f76a1e5a6012"); + v.extend_from_slice(b"c2pa.createddwhen"); + push_text(&mut v, time); + v.extend_from_slice(b"msoftwareAgentdname"); + push_text(&mut v, vendor); + v.extend_from_slice(b"gversion"); + push_text(&mut v, "1.0.0"); + v.extend_from_slice(b"jparametersflog_id"); + push_text(&mut v, log_id); + v.extend_from_slice(b"dtime"); + push_text(&mut v, time); + v.extend_from_slice(b"jmodel_name"); + push_text(&mut v, model); + v.extend_from_slice(b"qdigitalSourceType"); + push_text(&mut v, "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"); + v.extend_from_slice(b"jinstanceID"); + push_text(&mut v, "xmp:iid:95a3ac67-cb28-4a0d-ac54-911f1787b7de"); + v.extend_from_slice(b"tclaim_generator_infodname"); + push_text(&mut v, "c2pa-rs"); + v.extend_from_slice(b"gversion"); + push_text(&mut v, "0.78.4"); + // In a real file the signing cert's email is preceded by binary DER bytes; + // emulate that boundary so the email local-part walk-back terminates here. + v.push(0x00); + v.extend_from_slice(signer.as_bytes()); + // Org identifier (2.5.4.97) preceded by its DER length byte, as in a real cert. + v.push(0x31); // a DER tag byte before the length, like a real subject RDN + v.push(org_id.len() as u8); + v.extend_from_slice(org_id.as_bytes()); + v.push(0x31); // trailing DER byte after the value + v + } + + #[test] + fn parses_volcengine_fast() { + let data = synth_manifest( + "Volcengine_Ark_CN", "doubao-seedance-2-0-fast", + "2026-06-19T01:32:58Z", "ATIAA7b8D_iKjF32GukAAAAA", + "certificate_center@volcengine.com", "NTRCN-91110108MA01R70K8D", + ); + let info = SeedanceInfo::from_bytes(&data).expect("should parse"); + assert_eq!(info.platform, SeedancePlatform::Volcengine); + assert_eq!(info.signer_org_id.as_deref(), Some("NTRCN-91110108MA01R70K8D")); + assert_eq!(info.signer_country.as_deref(), Some("CN")); + assert_eq!(info.cert_serial.as_deref(), Some("3687D48590A2FD5101AB0316")); + assert_eq!(info.software_agent, "Volcengine_Ark_CN"); + assert_eq!(info.software_agent_version.as_deref(), Some("1.0.0")); + assert_eq!(info.model_name, "doubao-seedance-2-0-fast"); + assert_eq!(info.model_brand.as_deref(), Some("doubao")); + assert_eq!(info.model_version.as_deref(), Some("2.0")); + assert!(info.is_fast); + assert!(!info.is_lite); + assert_eq!(info.generated_at, "2026-06-19T01:32:58Z"); + assert!(info.generated_at_utc.is_some()); + assert_eq!(info.log_id.as_deref(), Some("ATIAA7b8D_iKjF32GukAAAAA")); + assert_eq!(info.log_id_decoded_hex.as_deref(), Some("01320003b6fc0ff88a8c5df61ae900000000")); + assert_eq!(info.claim_generator.as_deref(), Some("c2pa-rs")); + assert_eq!(info.claim_generator_version.as_deref(), Some("0.78.4")); + assert_eq!(info.manifest_id.as_deref(), Some("urn:c2pa:40a5f6fe-b88a-4a2c-ae72-f76a1e5a6012")); + assert_eq!(info.instance_id.as_deref(), Some("xmp:iid:95a3ac67-cb28-4a0d-ac54-911f1787b7de")); + assert_eq!(info.signer_email.as_deref(), Some("certificate_center@volcengine.com")); + assert!(info.digital_source_type.as_deref().unwrap().contains("trainedAlgorithmicMedia")); + } + + #[test] + fn parses_byteplus_non_fast() { + let data = synth_manifest( + "BytePlus_ModelArk", "dreamina-seedance-2-0", + "2026-06-19T01:27:48Z", "ATMAA7b36J8cLZO2iUkAAAAA", + "certificate@byteplus.com", "NTRSG-202024632W", + ); + let info = SeedanceInfo::from_bytes(&data).expect("should parse"); + assert_eq!(info.platform, SeedancePlatform::BytePlus); + assert_eq!(info.model_brand.as_deref(), Some("dreamina")); + assert_eq!(info.model_version.as_deref(), Some("2.0")); + assert!(!info.is_fast); + assert_eq!(info.signer_email.as_deref(), Some("certificate@byteplus.com")); + assert_eq!(info.signer_org_id.as_deref(), Some("NTRSG-202024632W")); + assert_eq!(info.signer_country.as_deref(), Some("SG")); + // BytePlus region byte is 0x33 (vs 0x32 for Volcengine). + assert_eq!(info.log_id_decoded_hex.as_deref(), Some("01330003b6f7e89f1c2d93b6894900000000")); + } + + #[test] + fn parses_seedance_1_0_lite() { + let data = synth_manifest( + "Volcengine_Ark_CN", "doubao-seedance-1-0-lite", + "2026-06-19T01:30:00Z", "ATIAA7b8D_iKjF32GukAAAAA", + "certificate_center@volcengine.com", "NTRCN-91110108MA01R70K8D", + ); + let info = SeedanceInfo::from_bytes(&data).expect("should parse"); + assert_eq!(info.model_name, "doubao-seedance-1-0-lite"); + assert_eq!(info.model_brand.as_deref(), Some("doubao")); + // The `-lite` token must not leak into the version. + assert_eq!(info.model_version.as_deref(), Some("1.0")); + assert!(info.is_lite); + assert!(!info.is_fast); + } + + #[test] + fn non_seedance_returns_not_seedance() { + let data = b"....ftypisom....just a normal mp4 with no provenance...."; + match SeedanceInfo::from_bytes(data) { + Err(VideoInfoError::NotSeedance) => {} + other => panic!("expected NotSeedance, got {:?}", other), + } + } + + #[test] + fn vendor_without_model_is_malformed() { + // Vendor marker present, but no model_name key. + let mut data = Vec::new(); + data.extend_from_slice(b"....Volcengine_Ark_CN....2026-06-19T01:32:58Z...."); + match SeedanceInfo::from_bytes(&data) { + Err(VideoInfoError::MalformedManifest(_)) => {} + other => panic!("expected MalformedManifest, got {:?}", other), + } + } +} diff --git a/crates/lib/video_info/src/sora_info.rs b/crates/lib/video_info/src/sora_info.rs new file mode 100644 index 0000000000..c4fcdb1acd --- /dev/null +++ b/crates/lib/video_info/src/sora_info.rs @@ -0,0 +1,148 @@ +//! Parse OpenAI **Sora** (OpenAI Generative AI video) provenance out of an MP4. +//! +//! Like Veo, Sora attaches a [C2PA](https://c2pa.org/) Content Credentials +//! manifest (a JUMBF box with a CBOR claim) signed by OpenAI. The manifest's +//! `claim_generator` / signer identifies OpenAI; `digitalSourceType` is +//! `trainedAlgorithmicMedia`. +//! +//! ⚠️ **Unverified format.** Every Sora clip in our local sample set had been +//! re-encoded through ffmpeg (encoder `Lavf…`), which strips the C2PA box — so +//! this parser is modeled on OpenAI's documented Content Credentials structure +//! and the shared C2PA layout used by [`crate::veo_info`], but has **not** been +//! validated against an untouched Sora export. Field extraction is intentionally +//! generic (the C2PA markers we're confident about) until a clean sample exists. + +use std::fs; +use std::path::Path; + +use crate::error::VideoInfoError; +use crate::scan::{find, find_cert_serial, find_prefixed_uuid, text_after_key}; + +// ── Detection markers ── + +/// OpenAI's signer / claim-generator identity in the C2PA manifest. +const OPENAI_MARKER: &[u8] = b"OpenAI"; +/// A C2PA manifest must be present for these markers to be meaningful. +const C2PA_CLAIM_MARKER: &[u8] = b"c2pa.claim"; +const JUMBF_MARKER: &[u8] = b"jumbf"; +/// The Sora model token, when embedded. +const SORA_MARKER: &[u8] = b"sora"; + +/// Provenance extracted from an OpenAI Sora (OpenAI Generative AI) video. +#[derive(Debug, Clone, PartialEq)] +pub struct SoraInfo { + /// Always `"OpenAI"` — the producer established by the C2PA signer / generator. + pub producer: String, + + /// The model token if embedded (e.g. `"sora"`); often absent, like Veo. + pub model_name: Option, + + /// The `c2pa.created` action description, when present. + pub created_description: Option, + + /// IPTC digital source type URL — trained-algorithmic-media for these. + pub digital_source_type: Option, + + /// C2PA claim generator string. + pub claim_generator: Option, + + /// The manifest URN, e.g. `"urn:c2pa:…"`. + pub manifest_id: Option, + + /// The per-asset instance id. + pub instance_id: Option, + + /// Hex serial number of the leaf signing certificate. + pub cert_serial: Option, +} + +impl SoraInfo { + /// Parse Sora provenance from a video file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Parse Sora provenance from raw video bytes. + /// + /// Returns [`VideoInfoError::NotSora`] if no OpenAI C2PA manifest is present. + pub fn from_bytes(data: &[u8]) -> Result { + let has_c2pa = find(data, C2PA_CLAIM_MARKER).is_some() || find(data, JUMBF_MARKER).is_some(); + let is_openai = find(data, OPENAI_MARKER).is_some(); + if !(has_c2pa && is_openai) { + return Err(VideoInfoError::NotSora); + } + + Ok(SoraInfo { + producer: "OpenAI".to_string(), + model_name: find(data, SORA_MARKER).map(|_| "sora".to_string()), + created_description: find_description_containing(data, b"OpenAI") + .or_else(|| find_description_containing(data, b"Generated")), + digital_source_type: text_after_key(data, b"digitalSourceType"), + claim_generator: text_after_key(data, b"claim_generator"), + manifest_id: find_prefixed_uuid(data, b"urn:c2pa:"), + instance_id: text_after_key(data, b"instanceID"), + cert_serial: find_cert_serial(data), + }) + } +} + +/// Find a CBOR `description` value whose text contains `needle`. +fn find_description_containing(data: &[u8], needle: &[u8]) -> Option { + let mut from = 0; + while let Some(i) = crate::scan::find_from(data, b"description", from) { + if let Some(text) = crate::scan::read_cbor_text(data, i + b"description".len()) { + if text.as_bytes().windows(needle.len()).any(|w| w == needle) { + return Some(text); + } + } + from = i + b"description".len(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan::push_cbor_text as push_text; + + fn synth_sora() -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"....ftypisom....moov....jumbfc2pa.claim"); + v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xAB, 0xCD, 0xEF]); + v.extend_from_slice(b"OpenAI, OPC LLC"); + v.extend_from_slice(b"urn:c2pa:11111111-2222-3333-4444-555555555555"); + v.extend_from_slice(b"c2pa.createdkdescription"); + push_text(&mut v, "Generated by OpenAI."); + v.extend_from_slice(b"qdigitalSourceType"); + push_text(&mut v, "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"); + v.extend_from_slice(b"jinstanceID"); + push_text(&mut v, "abcd1234-0000-0000-0000-000000000000"); + v.extend_from_slice(b"sora"); + v + } + + #[test] + fn parses_sora_manifest() { + let info = SoraInfo::from_bytes(&synth_sora()).expect("should parse"); + assert_eq!(info.producer, "OpenAI"); + assert_eq!(info.model_name.as_deref(), Some("sora")); + assert_eq!(info.created_description.as_deref(), Some("Generated by OpenAI.")); + assert_eq!(info.manifest_id.as_deref(), Some("urn:c2pa:11111111-2222-3333-4444-555555555555")); + assert_eq!(info.instance_id.as_deref(), Some("abcd1234-0000-0000-0000-000000000000")); + assert_eq!(info.cert_serial.as_deref(), Some("ABCDEF")); + assert!(info.digital_source_type.as_deref().unwrap().contains("trainedAlgorithmicMedia")); + } + + #[test] + fn non_openai_returns_not_sora() { + let data = b"....ftypisom....jumbfc2pa.claim....Volcengine_Ark_CN...."; + assert!(matches!(SoraInfo::from_bytes(data), Err(VideoInfoError::NotSora))); + } + + #[test] + fn openai_without_c2pa_returns_not_sora() { + let data = b"....ftypisom....mentions OpenAI but no manifest...."; + assert!(matches!(SoraInfo::from_bytes(data), Err(VideoInfoError::NotSora))); + } +} diff --git a/crates/lib/video_info/src/veo_info.rs b/crates/lib/video_info/src/veo_info.rs new file mode 100644 index 0000000000..8465f64f7f --- /dev/null +++ b/crates/lib/video_info/src/veo_info.rs @@ -0,0 +1,240 @@ +//! Parse Google **Veo** (Google Generative AI video) provenance out of an MP4. +//! +//! Like Seedance, Veo embeds a [C2PA](https://c2pa.org/) manifest as a JUMBF box. +//! Unlike Seedance, the manifest does **not** carry an explicit model name — +//! origin is established by the Google C2PA signer chain (`pki.goog`), the +//! `encoder=Google` tag, and the `c2pa.created` action description +//! "Created by Google Generative AI." A second `c2pa.edited` action records the +//! "Applied imperceptible SynthID watermark." step (Google DeepMind's SynthID). +//! +//! So "the model is Veo" is an inference from "Google-generated video" — we +//! surface every Google/C2PA field we can and leave model name `None` because it +//! genuinely isn't in the metadata. + +use std::fs; +use std::path::Path; + +use crate::error::VideoInfoError; +use crate::scan::{ + find, find_cert_serial, find_encoder_tag, find_prefixed_uuid, read_der_string, text_after_key, +}; + +// ── Detection markers ── + +/// The generative `c2pa.created` action description Google stamps. +const CREATED_BY_GOOGLE: &[u8] = b"Created by Google Generative AI"; +/// Google's C2PA signer infrastructure (cert CN / ICA names). +const GOOGLE_C2PA_MARKER: &[u8] = b"Google C2PA"; +/// The SynthID watermark edit description. +const SYNTHID_MARKER: &[u8] = b"SynthID watermark"; +/// Prefix of the issuing intermediate-CA CN that signs Veo manifests. +const SIGNER_CA_PREFIX: &[u8] = b"Google C2PA Media Services"; +/// Prefix of the RFC 3161 time-stamping CA CN. +const TIMESTAMP_CA_PREFIX: &[u8] = b"Google C2PA Core Time-Stamping"; +/// The bare `©too` encoder value Google stamps on Veo/Flow exports — survives a +/// re-encode that strips the C2PA box, so it's a weaker fallback signal. +const GOOGLE_ENCODER: &str = "Google"; + +/// Provenance extracted from a Google Veo (Google Generative AI) video. +#[derive(Debug, Clone, PartialEq)] +pub struct VeoInfo { + /// Always `"Google"` — the producer established by the signer chain + encoder. + pub producer: String, + + /// Whether a full Google C2PA manifest is present. `false` means the video was + /// recognized only by its `encoder=Google` tag (C2PA likely stripped by a + /// re-encode) — all the C2PA fields below will be `None`. + pub has_c2pa_manifest: bool, + + /// The `©too` encoder atom value, e.g. `"Google"`. + pub encoder: Option, + + /// The `c2pa.created` action description, e.g. `"Created by Google Generative AI."`. + pub created_description: Option, + + /// Whether a SynthID watermark edit action is present (Google DeepMind's + /// imperceptible watermark — applied to all Veo output). + pub has_synthid_watermark: bool, + + /// The SynthID edit description, e.g. `"Applied imperceptible SynthID watermark."`. + pub synthid_description: Option, + + /// IPTC digital source type URL — trained-algorithmic-media for these. + pub digital_source_type: Option, + + /// C2PA claim generator, e.g. `"Google C2PA Core Generator Library"`. + pub claim_generator: Option, + + /// C2PA claim generator version, e.g. `"929410399:929410399"`. + pub claim_generator_version: Option, + + /// The manifest URN, e.g. `"urn:c2pa:a6b2b914-8d34-c7f1-717f-4c622328d8f0"`. + pub manifest_id: Option, + + /// The per-asset instance id (bare UUID; Veo does not prefix it with `xmp:iid:`). + pub instance_id: Option, + + /// Hex serial number of the leaf signing certificate (Google's `pki.goog`), + /// e.g. `"E2139E2DAA977AC13E775A1CBD006D32BF4ACB"`. + pub cert_serial: Option, + + /// Issuing intermediate-CA common name, e.g. + /// `"Google C2PA Media Services 1P ICA G3"` — identifies Google's signing path. + pub signer_ca: Option, + + /// Whether the manifest carries an RFC 3161 trusted timestamp (a + /// `Google C2PA Core Time-Stamping` authority is present in the chain). + pub is_timestamped: bool, + + /// The time-stamping authority CN when present, e.g. + /// `"Google C2PA Core Time-Stamping ICA G3"`. + pub timestamp_authority: Option, +} + +impl VeoInfo { + /// Parse Veo provenance from a video file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Parse Veo provenance from raw video bytes. + /// + /// Returns [`VideoInfoError::NotVeo`] if no Google generative C2PA manifest is + /// present. + pub fn from_bytes(data: &[u8]) -> Result { + let has_c2pa_manifest = + find(data, CREATED_BY_GOOGLE).is_some() || find(data, GOOGLE_C2PA_MARKER).is_some(); + let encoder = find_encoder_tag(data); + let encoder_is_google = encoder.as_deref() == Some(GOOGLE_ENCODER); + if !has_c2pa_manifest && !encoder_is_google { + return Err(VideoInfoError::NotVeo); + } + + // The created/edited descriptions follow the CBOR `description` key; there + // are two `description` values (created, edited) and order can vary, so read + // by matching the leading text rather than positional assumptions. + let created_description = find_description_containing(data, b"Created by"); + let synthid_description = find_description_containing(data, b"SynthID"); + let has_synthid_watermark = + synthid_description.is_some() || find(data, SYNTHID_MARKER).is_some(); + + let claim_generator = text_after_key(data, b"claim_generator_info") + .or_else(|| find(data, b"Google C2PA Core Generator Library").map(|_| { + "Google C2PA Core Generator Library".to_string() + })); + let claim_generator_version = find(data, b"Google C2PA Core Generator Library") + .and_then(|i| { + crate::scan::text_after_key_from(data, b"version", i + "Google C2PA Core Generator Library".len()) + }); + + Ok(VeoInfo { + producer: "Google".to_string(), + has_c2pa_manifest, + encoder, + created_description, + has_synthid_watermark, + synthid_description, + digital_source_type: text_after_key(data, b"digitalSourceType"), + claim_generator, + claim_generator_version, + manifest_id: find_prefixed_uuid(data, b"urn:c2pa:"), + instance_id: text_after_key(data, b"instanceID"), + cert_serial: find_cert_serial(data), + signer_ca: read_der_string(data, SIGNER_CA_PREFIX), + is_timestamped: find(data, TIMESTAMP_CA_PREFIX).is_some(), + timestamp_authority: read_der_string(data, TIMESTAMP_CA_PREFIX), + }) + } +} + +/// Find a CBOR `description` value whose text begins with `needle`. There can be +/// several descriptions in the manifest (one per action); this returns the one +/// starting with the given text. +fn find_description_containing(data: &[u8], needle: &[u8]) -> Option { + let mut from = 0; + while let Some(i) = crate::scan::find_from(data, b"description", from) { + if let Some(text) = crate::scan::read_cbor_text(data, i + b"description".len()) { + if text.as_bytes().windows(needle.len()).any(|w| w == needle) { + return Some(text); + } + } + from = i + b"description".len(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan::push_cbor_text as push_text; + + fn synth_veo() -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"....ftypisom....moov....c2pa"); + // X.509 v3 serial prefix. + v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xE2, 0x13, 0x9E]); + // DER length-prefixed issuer + time-stamping CA CNs (length byte precedes). + let signer_ca = b"Google C2PA Media Services 1P ICA G3"; + v.push(signer_ca.len() as u8); + v.extend_from_slice(signer_ca); + let ts_ca = b"Google C2PA Core Time-Stamping ICA G3"; + v.push(ts_ca.len() as u8); + v.extend_from_slice(ts_ca); + v.extend_from_slice(b"urn:c2pa:a6b2b914-8d34-c7f1-717f-4c622328d8f0"); + v.extend_from_slice(b"c2pa.createdkdescription"); + push_text(&mut v, "Created by Google Generative AI."); + v.extend_from_slice(b"qdigitalSourceType"); + push_text(&mut v, "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"); + v.extend_from_slice(b"c2pa.editedkdescription"); + push_text(&mut v, "Applied imperceptible SynthID watermark."); + v.extend_from_slice(b"jinstanceID"); + push_text(&mut v, "e9738dbe-1dbb-9b61-516c-46a64daa7499"); + v.extend_from_slice(b"dnamex\"Google C2PA Core Generator Librarygversion"); + push_text(&mut v, "929410399:929410399"); + v + } + + #[test] + fn parses_veo_manifest() { + let info = VeoInfo::from_bytes(&synth_veo()).expect("should parse"); + assert_eq!(info.producer, "Google"); + assert!(info.has_c2pa_manifest); + assert_eq!(info.created_description.as_deref(), Some("Created by Google Generative AI.")); + assert!(info.has_synthid_watermark); + assert_eq!(info.synthid_description.as_deref(), Some("Applied imperceptible SynthID watermark.")); + assert_eq!(info.claim_generator.as_deref(), Some("Google C2PA Core Generator Library")); + assert_eq!(info.claim_generator_version.as_deref(), Some("929410399:929410399")); + assert_eq!(info.manifest_id.as_deref(), Some("urn:c2pa:a6b2b914-8d34-c7f1-717f-4c622328d8f0")); + assert_eq!(info.instance_id.as_deref(), Some("e9738dbe-1dbb-9b61-516c-46a64daa7499")); + assert_eq!(info.cert_serial.as_deref(), Some("E2139E")); + assert_eq!(info.signer_ca.as_deref(), Some("Google C2PA Media Services 1P ICA G3")); + assert!(info.is_timestamped); + assert_eq!(info.timestamp_authority.as_deref(), Some("Google C2PA Core Time-Stamping ICA G3")); + assert!(info.digital_source_type.as_deref().unwrap().contains("trainedAlgorithmicMedia")); + } + + #[test] + fn detects_veo_by_encoder_tag_when_c2pa_stripped() { + // A re-encoded Veo: no C2PA manifest, but the `©too` encoder atom is "Google". + let mut v = Vec::new(); + v.extend_from_slice(b"....ftypisom....moov....udta....meta....ilst"); + v.extend_from_slice(&[0xA9, b't', b'o', b'o']); + v.extend_from_slice(b"....data"); + v.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 0]); // version/flags + reserved + v.extend_from_slice(b"Google"); + let info = VeoInfo::from_bytes(&v).expect("should parse via encoder tag"); + assert_eq!(info.producer, "Google"); + assert!(!info.has_c2pa_manifest); + assert_eq!(info.encoder.as_deref(), Some("Google")); + assert_eq!(info.cert_serial, None); + assert_eq!(info.signer_ca, None); + assert!(!info.is_timestamped); + } + + #[test] + fn non_google_returns_not_veo() { + let data = b"....ftypisom....Volcengine_Ark_CN doubao-seedance-2-0...."; + assert!(matches!(VeoInfo::from_bytes(data), Err(VideoInfoError::NotVeo))); + } +} diff --git a/crates/lib/video_info/src/video.rs b/crates/lib/video_info/src/video.rs new file mode 100644 index 0000000000..f346298f0f --- /dev/null +++ b/crates/lib/video_info/src/video.rs @@ -0,0 +1,81 @@ +//! [`VideoInfo`] — a union over the supported AI-video provenance formats. + +use std::fs; +use std::path::Path; + +use crate::dreamina_info::DreaminaInfo; +use crate::error::VideoInfoError; +use crate::kling_info::KlingInfo; +use crate::seedance_info::SeedanceInfo; +use crate::sora_info::SoraInfo; +use crate::veo_info::VeoInfo; + +/// Recognized AI-video provenance, dispatched by [`VideoInfo::from_bytes`]. +#[derive(Debug, Clone, PartialEq)] +pub enum VideoInfo { + /// ByteDance Seedance API (Volcengine / BytePlus C2PA). + Seedance(SeedanceInfo), + /// Google Veo (Google Generative AI video C2PA). + Veo(VeoInfo), + /// OpenAI Sora (OpenAI Generative AI video C2PA). + Sora(SoraInfo), + /// Dreamina (ByteDance/CapCut consumer app `ilst` metadata). + Dreamina(DreaminaInfo), + /// Kling (Kuaishou AIGC-label `ilst` metadata). + Kling(KlingInfo), +} + +impl VideoInfo { + /// Detect and parse provenance from a video file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Detect and parse provenance from raw video bytes. + /// + /// C2PA-based formats (Seedance, Veo) are tried before the `ilst`-based + /// consumer-app formats (Dreamina, Kling): some Dreamina exports carry *both* + /// a Bytedance C2PA box and Dreamina `ilst` JSON, but they lack the Ark + /// vendor markers so [`SeedanceInfo`] declines them and they fall through to + /// Dreamina. Returns [`VideoInfoError::Unrecognized`] if no format matches. + pub fn from_bytes(data: &[u8]) -> Result { + match SeedanceInfo::from_bytes(data) { + Ok(info) => return Ok(VideoInfo::Seedance(info)), + Err(VideoInfoError::NotSeedance) => {} + Err(other) => return Err(other), + } + match VeoInfo::from_bytes(data) { + Ok(info) => return Ok(VideoInfo::Veo(info)), + Err(VideoInfoError::NotVeo) => {} + Err(other) => return Err(other), + } + match SoraInfo::from_bytes(data) { + Ok(info) => return Ok(VideoInfo::Sora(info)), + Err(VideoInfoError::NotSora) => {} + Err(other) => return Err(other), + } + match DreaminaInfo::from_bytes(data) { + Ok(info) => return Ok(VideoInfo::Dreamina(info)), + Err(VideoInfoError::NotDreamina) => {} + Err(other) => return Err(other), + } + match KlingInfo::from_bytes(data) { + Ok(info) => return Ok(VideoInfo::Kling(info)), + Err(VideoInfoError::NotKling) => {} + Err(other) => return Err(other), + } + Err(VideoInfoError::Unrecognized) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unrecognized_for_plain_video() { + let data = b"....ftypisom....just a normal mp4...."; + assert!(matches!(VideoInfo::from_bytes(data), Err(VideoInfoError::Unrecognized))); + } +} diff --git a/crates/schema/database/mysql_queries/Cargo.toml b/crates/schema/database/mysql_queries/Cargo.toml index 6841c9dbb0..b5184750f8 100644 --- a/crates/schema/database/mysql_queries/Cargo.toml +++ b/crates/schema/database/mysql_queries/Cargo.toml @@ -20,6 +20,7 @@ path = "src/lib.rs" workspace-build-acceleration.workspace = true # Internal +artcraft_api_keys.workspace = true bucket_paths.workspace = true composite_identifiers = { path = "../../../schema/public/composite_identifiers" } enums = { workspace = true, features = ["database"] } diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/get_api_key_user_by_value.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/get_api_key_user_by_value.rs new file mode 100644 index 0000000000..59b8202ee2 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/get_api_key_user_by_value.rs @@ -0,0 +1,63 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use artcraft_api_keys::ArtcraftApiKey; +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +use crate::helpers::boolean_converters::i8_to_bool; + +pub struct GetApiKeyUserByValueArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + /// The actual API key secret presented by the client. + pub api_key: &'e ArtcraftApiKey, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// A live API key joined to its owning user. This is the minimal identity needed to authenticate +/// an API-key request — far smaller than a cookie `SessionUserRecord`. +pub struct ApiKeyUserRecord { + pub api_key_token: ApiKeyToken, + pub user_token: UserToken, + pub is_banned: bool, +} + +/// Look up the user behind an API key secret, for authenticating an incoming request. +/// +/// Returns `Ok(None)` when no row matches, when the matching API key is soft-deleted +/// (`maybe_deleted_at IS NOT NULL`), or when the owning user row does not exist (the `INNER JOIN` +/// requires both records to be present). The full secret is never echoed back. +pub async fn get_api_key_user_by_value<'e, 'c: 'e, E>( + args: GetApiKeyUserByValueArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +SELECT + api_keys.token as `api_key_token: ApiKeyToken`, + users.token as `user_token: UserToken`, + users.is_banned +FROM api_keys +INNER JOIN users + ON users.token = api_keys.owner_user_token +WHERE api_keys.api_key = ? + AND api_keys.maybe_deleted_at IS NULL +LIMIT 1 + "#, + args.api_key.as_str_be_careful(), + ) + .fetch_optional(args.mysql_executor) + .await?; + + Ok(result.map(|r| ApiKeyUserRecord { + api_key_token: r.api_key_token, + user_token: r.user_token, + is_banned: i8_to_bool(r.is_banned), + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/insert_api_key.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/insert_api_key.rs index 30787e42f1..cfd0f95d3e 100644 --- a/crates/schema/database/mysql_queries/src/queries/api_keys/insert_api_key.rs +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/insert_api_key.rs @@ -2,6 +2,7 @@ use std::marker::PhantomData; use sqlx::{Executor, MySql}; +use artcraft_api_keys::ArtcraftApiKey; use tokens::tokens::api_keys::ApiKeyToken; use tokens::tokens::users::UserToken; @@ -13,7 +14,7 @@ where pub ip_address: &'e str, pub name: &'e str, pub maybe_description: Option<&'e str>, - pub api_key: &'e str, + pub api_key: &'e ArtcraftApiKey, pub mysql_executor: E, pub phantom: PhantomData<&'c E>, } @@ -44,7 +45,7 @@ SET created_at = NOW() "#, token.as_str(), - args.api_key, + args.api_key.as_str_be_careful(), // NB: This is okay in this case. args.name, args.maybe_description, args.owner_user_token.as_str(), diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/mod.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/mod.rs index 898cc84f80..deff03cc5b 100644 --- a/crates/schema/database/mysql_queries/src/queries/api_keys/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/mod.rs @@ -1,6 +1,7 @@ pub mod api_key_row; pub mod delete_api_key; pub mod get_api_key_by_token; +pub mod get_api_key_user_by_value; pub mod get_live_api_key_by_value; pub mod insert_api_key; pub mod list_api_keys_for_user; diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/seedance2pro/list_pending_seedance2pro_video_jobs.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/seedance2pro/list_pending_seedance2pro_video_jobs.rs index f385d29f56..53ba201b69 100644 --- a/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/seedance2pro/list_pending_seedance2pro_video_jobs.rs +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/seedance2pro/list_pending_seedance2pro_video_jobs.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use log::warn; use sqlx::MySqlPool; @@ -12,7 +13,7 @@ use tokens::tokens::users::UserToken; use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; /// A Seedance2Pro job that is waiting for a result from the external API. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct PendingSeedance2ProJob { pub job_token: InferenceJobToken, @@ -29,9 +30,13 @@ pub struct PendingSeedance2ProJob { pub maybe_wallet_ledger_entry_token: Option, pub maybe_platform_type: Option, + + /// When the job row was created. Useful for surfacing how long the oldest + /// pending job has been waiting. + pub created_at: DateTime, } -#[derive(Debug, Default)] +#[derive(Debug)] struct RawRecord { job_token: InferenceJobToken, order_id: Option, @@ -42,6 +47,7 @@ struct RawRecord { maybe_prompt_token: Option, maybe_wallet_ledger_entry_token: Option, maybe_platform_type: Option, + created_at: DateTime, } /// Returns all non-terminal Seedance2Pro video jobs that have an associated order_id. @@ -62,7 +68,8 @@ SELECT jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`, jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`, jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`, - jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType` + jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`, + jobs.created_at FROM generic_inference_jobs as jobs @@ -100,6 +107,7 @@ LIMIT 25000 maybe_prompt_token: record.maybe_prompt_token, maybe_wallet_ledger_entry_token: record.maybe_wallet_ledger_entry_token, maybe_platform_type: record.maybe_platform_type, + created_at: record.created_at, }) }) .collect(); diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mark_job_failed_by_token.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mark_job_failed_by_token.rs index 13b72b75d7..74f6a806ca 100644 --- a/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mark_job_failed_by_token.rs +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mark_job_failed_by_token.rs @@ -47,6 +47,28 @@ pub async fn mark_job_failed_by_token_from_connection(args: MarkJobFailedByToken }).await } +/// Permanently mark an inference job as failed using an arbitrary executor. +/// Pass `&mut *transaction` to write inside an existing transaction (e.g. +/// alongside a refund, after a `SELECT ... FOR UPDATE` re-check). +pub async fn mark_job_failed_by_token_with_executor<'e, E>( + executor: E, + job_token: &InferenceJobToken, + maybe_public_failure_reason: Option<&str>, + internal_debugging_failure_reason: &str, + maybe_frontend_failure_category: Option, +) -> AnyhowResult<()> +where + E: sqlx::Executor<'e, Database = MySql>, +{ + execute_mark_failed(ExecuteMarkJobFailedByTokenArgs { + executor, + job_token, + maybe_public_failure_reason, + internal_debugging_failure_reason, + maybe_frontend_failure_category, + }).await +} + struct ExecuteMarkJobFailedByTokenArgs<'a, E> { executor: E, job_token: &'a InferenceJobToken, diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mod.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mod.rs index affd5103bf..248fed1e77 100644 --- a/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mod.rs @@ -7,3 +7,4 @@ pub mod mark_job_failed_by_token; pub mod mark_generic_inference_job_pending_and_grab_lock; pub mod mark_generic_inference_job_successfully_done; pub mod reap_stale_fakeyou_jobs; +pub mod select_inference_job_status_for_update; diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/job/select_inference_job_status_for_update.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/job/select_inference_job_status_for_update.rs new file mode 100644 index 0000000000..f6714b9ecb --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/job/select_inference_job_status_for_update.rs @@ -0,0 +1,35 @@ +use sqlx::{Executor, MySql}; + +use enums::common::job_status_plus::JobStatusPlus; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +/// `SELECT ... FOR UPDATE` the `status` of an inference job, locking the row +/// for the duration of the surrounding transaction. +/// +/// Pass `&mut *transaction` as the executor. The row lock prevents a concurrent +/// finalizer (e.g. another poller, the web cancel path) from changing the +/// job's terminal state between this read and the caller's subsequent write, +/// so the caller can safely read-then-write the conclusion exactly once. +/// +/// Returns `Ok(None)` when no row matches the token. +pub async fn select_inference_job_status_for_update<'e, E>( + executor: E, + job_token: &InferenceJobToken, +) -> Result, sqlx::Error> +where + E: Executor<'e, Database = MySql>, +{ + let maybe_row = sqlx::query!( + r#" +SELECT status as `status: enums::common::job_status_plus::JobStatusPlus` +FROM generic_inference_jobs +WHERE token = ? +FOR UPDATE + "#, + job_token.as_str(), + ) + .fetch_optional(executor) + .await?; + + Ok(maybe_row.map(|row| row.status)) +} diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/web/get_inference_job_status_value.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/web/get_inference_job_status_value.rs new file mode 100644 index 0000000000..d829acb81f --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/web/get_inference_job_status_value.rs @@ -0,0 +1,27 @@ +use sqlx::MySqlPool; + +use enums::common::job_status_plus::JobStatusPlus; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +/// Lightweight lookup of just an inference job's `status` by token. +/// +/// Unlike [`get_inference_job_status`](super::get_inference_job_status), this +/// avoids the large multi-table join — it reads a single column. Returns +/// `Ok(None)` when no row matches the token. +pub async fn get_inference_job_status_value( + pool: &MySqlPool, + job_token: &InferenceJobToken, +) -> Result, sqlx::Error> { + let maybe_row = sqlx::query!( + r#" +SELECT status as `status: enums::common::job_status_plus::JobStatusPlus` +FROM generic_inference_jobs +WHERE token = ? + "#, + job_token.as_str(), + ) + .fetch_optional(pool) + .await?; + + Ok(maybe_row.map(|row| row.status)) +} diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/web/mark_generic_inference_job_successfully_done_by_token_with_executor.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/web/mark_generic_inference_job_successfully_done_by_token_with_executor.rs new file mode 100644 index 0000000000..a0e607d7cc --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/web/mark_generic_inference_job_successfully_done_by_token_with_executor.rs @@ -0,0 +1,60 @@ +use std::time::Duration; + +use sqlx::{Executor, MySql}; + +use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +pub struct MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs<'a, E> { + pub executor: E, + pub token: &'a InferenceJobToken, + pub maybe_entity_type: Option, + pub maybe_entity_token: Option<&'a str>, + pub total_job_duration: Option, + pub inference_duration: Option, +} + +/// Mark a job complete using an arbitrary executor. Pass `&mut *transaction` +/// as the executor to perform the write inside an existing transaction (e.g. +/// right after a `SELECT ... FOR UPDATE` re-check, to finalize exactly once). +pub async fn mark_generic_inference_job_successfully_done_by_token_with_executor<'e, 'a, E>( + args: MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs<'a, E>, +) -> Result<(), sqlx::Error> +where + E: Executor<'e, Database = MySql>, +{ + // NB: MySql's unsigned int (32 bits) can store integers up to 4,294,967,295. + // Given milliseconds, this is ~49.71 days, which should be plenty for us. + let truncated_total_job_execution_millis = args.total_job_duration + .map(|duration| duration.as_millis() as u32) + .unwrap_or(0); + let truncated_inference_execution_millis = args.inference_duration + .map(|duration| duration.as_millis() as u32) + .unwrap_or(0); + + sqlx::query!( + r#" +UPDATE generic_inference_jobs +SET + status = "complete_success", + on_success_result_entity_type = ?, + on_success_result_entity_token = ?, + failure_reason = NULL, + internal_debugging_failure_reason = NULL, + success_execution_millis = ?, + success_inference_execution_millis = ?, + retry_at = NULL, + successfully_completed_at = NOW() +WHERE token = ? + "#, + args.maybe_entity_type, + args.maybe_entity_token, + truncated_total_job_execution_millis, + truncated_inference_execution_millis, + args.token.as_str(), + ) + .execute(args.executor) + .await?; + + Ok(()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/web/mod.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/web/mod.rs index 40cd427ba4..d998ba65db 100644 --- a/crates/schema/database/mysql_queries/src/queries/generic_inference/web/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/web/mod.rs @@ -1,6 +1,7 @@ pub mod batch_get_inference_job_status; pub mod dismiss_finished_jobs_for_user; pub mod get_inference_job_status; +pub mod get_inference_job_status_value; pub mod get_pending_inference_job_count; pub mod insert_generic_inference_job; pub mod job_status; @@ -9,4 +10,5 @@ pub mod list_user_jobs_for_moderation; pub mod kill_jobs_in_development; pub mod list_session_jobs; pub mod mark_generic_inference_job_cancelled_by_user; -pub mod mark_generic_inference_job_successfully_done_by_token; \ No newline at end of file +pub mod mark_generic_inference_job_successfully_done_by_token; +pub mod mark_generic_inference_job_successfully_done_by_token_with_executor; \ No newline at end of file diff --git a/crates/schema/database/mysql_queries/src/queries/mod.rs b/crates/schema/database/mysql_queries/src/queries/mod.rs index bc4474b920..8968b34858 100644 --- a/crates/schema/database/mysql_queries/src/queries/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/mod.rs @@ -37,6 +37,8 @@ pub mod trending_model_analytics; pub mod tts; pub mod twitch; pub mod unsubscribe_reason; +pub mod uploaded_video_notes; +pub mod uploaded_videos; pub mod user_impersonation_requests; pub mod user_referral_codes; pub mod user_referrals; diff --git a/crates/schema/database/mysql_queries/src/queries/prompts/batch_get_prompts.rs b/crates/schema/database/mysql_queries/src/queries/prompts/batch_get_prompts.rs index d0e2bc7590..f2b9b71655 100644 --- a/crates/schema/database/mysql_queries/src/queries/prompts/batch_get_prompts.rs +++ b/crates/schema/database/mysql_queries/src/queries/prompts/batch_get_prompts.rs @@ -5,6 +5,7 @@ use sqlx::{FromRow, MySql, QueryBuilder, Row}; use enums::by_table::prompts::prompt_type::PromptType; use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; use enums::common::generation::common_generation_mode::CommonGenerationMode; use enums::common::generation::common_model_type::CommonModelType; use enums::common::generation::common_resolution::CommonResolution; @@ -23,6 +24,7 @@ pub struct BatchPromptResult { pub maybe_generation_mode: Option, pub maybe_aspect_ratio: Option, pub maybe_resolution: Option, + pub maybe_bitrate: Option, pub maybe_batch_count: Option, pub maybe_generate_audio: Option, pub maybe_duration_seconds: Option, @@ -46,6 +48,8 @@ impl FromRow<'_, MySqlRow> for BatchPromptResult { .and_then(|s| CommonAspectRatio::from_str(&s).ok()), maybe_resolution: row.try_get::, _>("maybe_resolution")? .and_then(|s| CommonResolution::from_str(&s).ok()), + maybe_bitrate: row.try_get::, _>("maybe_bitrate")? + .and_then(|s| CommonBitrate::from_str(&s).ok()), maybe_batch_count: row.try_get("maybe_batch_count")?, maybe_generate_audio: row.try_get("maybe_generate_audio")?, maybe_duration_seconds: row.try_get("maybe_duration_seconds")?, @@ -73,6 +77,7 @@ SELECT p.maybe_generation_mode, p.maybe_aspect_ratio, p.maybe_resolution, + p.maybe_bitrate, p.maybe_batch_count, p.maybe_generate_audio, p.maybe_duration_seconds, diff --git a/crates/schema/database/mysql_queries/src/queries/prompts/get_prompt.rs b/crates/schema/database/mysql_queries/src/queries/prompts/get_prompt.rs index f25c8893d4..437647d740 100644 --- a/crates/schema/database/mysql_queries/src/queries/prompts/get_prompt.rs +++ b/crates/schema/database/mysql_queries/src/queries/prompts/get_prompt.rs @@ -8,6 +8,7 @@ use chrono::{DateTime, Utc}; use enums::by_table::prompts::prompt_type::PromptType; use enums::common::generation_provider::GenerationProvider; use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; use enums::common::generation::common_generation_mode::CommonGenerationMode; use enums::common::generation::common_model_type::CommonModelType; use enums::common::generation::common_resolution::CommonResolution; @@ -38,6 +39,7 @@ pub struct Prompt { pub maybe_generation_mode: Option, pub maybe_aspect_ratio: Option, pub maybe_resolution: Option, + pub maybe_bitrate: Option, pub maybe_batch_count: Option, pub maybe_generate_audio: Option, pub maybe_duration_seconds: Option, @@ -67,6 +69,7 @@ pub struct PromptRaw { pub maybe_generation_mode: Option, pub maybe_aspect_ratio: Option, pub maybe_resolution: Option, + pub maybe_bitrate: Option, pub maybe_batch_count: Option, pub maybe_generate_audio: Option, pub maybe_duration_seconds: Option, @@ -114,6 +117,7 @@ pub async fn get_prompt_from_connection( maybe_generation_mode: record.maybe_generation_mode, maybe_aspect_ratio: record.maybe_aspect_ratio, maybe_resolution: record.maybe_resolution, + maybe_bitrate: record.maybe_bitrate, maybe_batch_count: record.maybe_batch_count, maybe_generate_audio: record.maybe_generate_audio, maybe_duration_seconds: record.maybe_duration_seconds, @@ -149,6 +153,7 @@ SELECT p.maybe_generation_mode as `maybe_generation_mode: enums::common::generation::common_generation_mode::CommonGenerationMode`, p.maybe_aspect_ratio as `maybe_aspect_ratio: enums::common::generation::common_aspect_ratio::CommonAspectRatio`, p.maybe_resolution as `maybe_resolution: enums::common::generation::common_resolution::CommonResolution`, + p.maybe_bitrate as `maybe_bitrate: enums::common::generation::common_bitrate::CommonBitrate`, p.maybe_batch_count as `maybe_batch_count: u8`, p.maybe_generate_audio as `maybe_generate_audio: bool`, p.maybe_duration_seconds as `maybe_duration_seconds: u32`, diff --git a/crates/schema/database/mysql_queries/src/queries/prompts/insert_prompt.rs b/crates/schema/database/mysql_queries/src/queries/prompts/insert_prompt.rs index a1cec5b011..5a463126c3 100644 --- a/crates/schema/database/mysql_queries/src/queries/prompts/insert_prompt.rs +++ b/crates/schema/database/mysql_queries/src/queries/prompts/insert_prompt.rs @@ -7,6 +7,7 @@ use sqlx::{Executor, MySql}; use enums::by_table::prompts::prompt_type::PromptType; use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; use enums::common::generation::common_generation_mode::CommonGenerationMode; use enums::common::generation::common_model_type::CommonModelType; use enums::common::generation::common_resolution::CommonResolution; @@ -36,6 +37,7 @@ pub struct InsertPromptArgs<'e, 'c, E> pub maybe_generation_mode: Option, pub maybe_aspect_ratio: Option, pub maybe_resolution: Option, + pub maybe_bitrate: Option, pub maybe_batch_count: Option, pub maybe_generate_audio: Option, pub maybe_duration_seconds: Option, @@ -89,6 +91,7 @@ SET maybe_generation_mode = ?, maybe_aspect_ratio = ?, maybe_resolution = ?, + maybe_bitrate = ?, maybe_batch_count = ?, maybe_generate_audio = ?, maybe_duration_seconds = ?, @@ -107,6 +110,7 @@ SET args.maybe_generation_mode.map(|m| m.to_str()), args.maybe_aspect_ratio.map(|a| a.to_str()), args.maybe_resolution.map(|r| r.to_str()), + args.maybe_bitrate.map(|b| b.to_str()), args.maybe_batch_count, args.maybe_generate_audio, args.maybe_duration_seconds, diff --git a/crates/schema/database/mysql_queries/src/queries/uploaded_video_notes/insert_uploaded_video_note.rs b/crates/schema/database/mysql_queries/src/queries/uploaded_video_notes/insert_uploaded_video_note.rs new file mode 100644 index 0000000000..3222669b0b --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/uploaded_video_notes/insert_uploaded_video_note.rs @@ -0,0 +1,79 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +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; + +pub struct InsertUploadedVideoNoteArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub uploaded_video_token: &'e UploadedVideoToken, + + pub maybe_filename: Option<&'e str>, + pub maybe_reported_model_type: Option, + pub maybe_reported_model_name: Option<&'e str>, + pub maybe_website: Option, + pub maybe_other_website: Option<&'e str>, + pub maybe_comments: Option<&'e str>, + + pub maybe_email_address: Option<&'e str>, + pub can_share_report: bool, + pub was_scammed: bool, + + pub comment_create_ip_address: &'e str, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Insert a new `uploaded_video_notes` record. The `UploadVideoNoteToken` is +/// minted here (in the data-access layer, not the handler) and returned. +/// `created_at` is set to `NOW()`. +pub async fn insert_uploaded_video_note<'e, 'c: 'e, E>( + args: InsertUploadedVideoNoteArgs<'e, 'c, E>, +) -> Result +where + E: 'e + Executor<'c, Database = MySql>, +{ + let token = UploadVideoNoteToken::generate(); + + sqlx::query!( + r#" +INSERT INTO uploaded_video_notes +SET + token = ?, + uploaded_video_token = ?, + maybe_filename = ?, + maybe_reported_model_type = ?, + maybe_reported_model_name = ?, + maybe_website = ?, + maybe_other_website = ?, + maybe_comments = ?, + maybe_email_address = ?, + can_share_report = ?, + was_scammed = ?, + comment_create_ip_address = ?, + created_at = NOW() + "#, + token.as_str(), + args.uploaded_video_token.as_str(), + args.maybe_filename, + args.maybe_reported_model_type.map(|model_type| model_type.to_str()), + args.maybe_reported_model_name, + args.maybe_website.map(|website| website.to_str()), + args.maybe_other_website, + args.maybe_comments, + args.maybe_email_address, + args.can_share_report, + args.was_scammed, + args.comment_create_ip_address, + ) + .execute(args.mysql_executor) + .await?; + + Ok(token) +} diff --git a/crates/schema/database/mysql_queries/src/queries/uploaded_video_notes/mod.rs b/crates/schema/database/mysql_queries/src/queries/uploaded_video_notes/mod.rs new file mode 100644 index 0000000000..42575f4dbb --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/uploaded_video_notes/mod.rs @@ -0,0 +1,2 @@ +pub mod insert_uploaded_video_note; +pub mod update_uploaded_video_note; diff --git a/crates/schema/database/mysql_queries/src/queries/uploaded_video_notes/update_uploaded_video_note.rs b/crates/schema/database/mysql_queries/src/queries/uploaded_video_notes/update_uploaded_video_note.rs new file mode 100644 index 0000000000..e1689d1bbf --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/uploaded_video_notes/update_uploaded_video_note.rs @@ -0,0 +1,77 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +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; + +pub struct UpdateUploadedVideoNoteArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub token: &'e UploadVideoNoteToken, + + pub maybe_filename: Option<&'e str>, + pub maybe_reported_model_type: Option, + pub maybe_reported_model_name: Option<&'e str>, + pub maybe_website: Option, + pub maybe_other_website: Option<&'e str>, + pub maybe_comments: Option<&'e str>, + + pub maybe_email_address: Option<&'e str>, + pub can_share_report: bool, + pub was_scammed: bool, + + /// IP address of the request making the update; stamped on `maybe_comment_update_ip_address`. + pub maybe_comment_update_ip_address: Option<&'e str>, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Update the mutable fields of an `uploaded_video_notes` record by `token`. The +/// `version` vector clock is incremented by 1, and `updated_at` is refreshed via +/// the column's `ON UPDATE` clause. Returns the number of rows affected (0 if no +/// row matched). +pub async fn update_uploaded_video_note<'e, 'c: 'e, E>( + args: UpdateUploadedVideoNoteArgs<'e, 'c, E>, +) -> Result +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +UPDATE uploaded_video_notes +SET + maybe_filename = ?, + maybe_reported_model_type = ?, + maybe_reported_model_name = ?, + maybe_website = ?, + maybe_other_website = ?, + maybe_comments = ?, + maybe_email_address = ?, + can_share_report = ?, + was_scammed = ?, + maybe_comment_update_ip_address = ?, + version = version + 1 +WHERE token = ? +LIMIT 1 + "#, + args.maybe_filename, + args.maybe_reported_model_type.map(|model_type| model_type.to_str()), + args.maybe_reported_model_name, + args.maybe_website.map(|website| website.to_str()), + args.maybe_other_website, + args.maybe_comments, + args.maybe_email_address, + args.can_share_report, + args.was_scammed, + args.maybe_comment_update_ip_address, + args.token.as_str(), + ) + .execute(args.mysql_executor) + .await?; + + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/uploaded_videos/get_uploaded_video_by_sha1_checksum.rs b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/get_uploaded_video_by_sha1_checksum.rs new file mode 100644 index 0000000000..e21f5a1cf8 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/get_uploaded_video_by_sha1_checksum.rs @@ -0,0 +1,68 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use enums::by_table::uploaded_videos::uploaded_video_detected_model_family::UploadedVideoDetectedModelFamily; +use enums::by_table::uploaded_videos::uploaded_video_detected_model_type::UploadedVideoDetectedModelType; +use tokens::tokens::uploaded_videos::UploadedVideoToken; + +use crate::queries::uploaded_videos::uploaded_video_row::UploadedVideoRow; + +pub struct GetUploadedVideoBySha1ChecksumArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub sha1_checksum: &'e str, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Look up an `uploaded_videos` record by its `sha1_checksum`. `Ok(None)` if no +/// row matches. Useful for de-duplicating re-uploads of the same bytes. +pub async fn get_uploaded_video_by_sha1_checksum<'e, 'c: 'e, E>( + args: GetUploadedVideoBySha1ChecksumArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +SELECT + token as `token: UploadedVideoToken`, + sha1_checksum, + filesize_bytes as `filesize_bytes: u32`, + maybe_width as `maybe_width: u32`, + maybe_height as `maybe_height: u32`, + maybe_resolution, + maybe_detected_model_family as `maybe_detected_model_family: UploadedVideoDetectedModelFamily`, + maybe_detected_model_type as `maybe_detected_model_type: UploadedVideoDetectedModelType`, + maybe_report, + upload_ip_address, + maybe_updated_ip_address, + created_at, + updated_at +FROM uploaded_videos +WHERE sha1_checksum = ? +LIMIT 1 + "#, + args.sha1_checksum, + ) + .fetch_optional(args.mysql_executor) + .await?; + + Ok(result.map(|r| UploadedVideoRow { + token: r.token, + sha1_checksum: r.sha1_checksum, + filesize_bytes: r.filesize_bytes, + maybe_width: r.maybe_width, + maybe_height: r.maybe_height, + maybe_resolution: r.maybe_resolution, + maybe_detected_model_family: r.maybe_detected_model_family, + maybe_detected_model_type: r.maybe_detected_model_type, + maybe_report: r.maybe_report, + upload_ip_address: r.upload_ip_address, + maybe_updated_ip_address: r.maybe_updated_ip_address, + created_at: r.created_at, + updated_at: r.updated_at, + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/uploaded_videos/get_uploaded_video_by_token.rs b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/get_uploaded_video_by_token.rs new file mode 100644 index 0000000000..a07162f9e4 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/get_uploaded_video_by_token.rs @@ -0,0 +1,67 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use enums::by_table::uploaded_videos::uploaded_video_detected_model_family::UploadedVideoDetectedModelFamily; +use enums::by_table::uploaded_videos::uploaded_video_detected_model_type::UploadedVideoDetectedModelType; +use tokens::tokens::uploaded_videos::UploadedVideoToken; + +use crate::queries::uploaded_videos::uploaded_video_row::UploadedVideoRow; + +pub struct GetUploadedVideoByTokenArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub token: &'e UploadedVideoToken, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Look up an `uploaded_videos` record by its `token`. `Ok(None)` if no row matches. +pub async fn get_uploaded_video_by_token<'e, 'c: 'e, E>( + args: GetUploadedVideoByTokenArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +SELECT + token as `token: UploadedVideoToken`, + sha1_checksum, + filesize_bytes as `filesize_bytes: u32`, + maybe_width as `maybe_width: u32`, + maybe_height as `maybe_height: u32`, + maybe_resolution, + maybe_detected_model_family as `maybe_detected_model_family: UploadedVideoDetectedModelFamily`, + maybe_detected_model_type as `maybe_detected_model_type: UploadedVideoDetectedModelType`, + maybe_report, + upload_ip_address, + maybe_updated_ip_address, + created_at, + updated_at +FROM uploaded_videos +WHERE token = ? +LIMIT 1 + "#, + args.token.as_str(), + ) + .fetch_optional(args.mysql_executor) + .await?; + + Ok(result.map(|r| UploadedVideoRow { + token: r.token, + sha1_checksum: r.sha1_checksum, + filesize_bytes: r.filesize_bytes, + maybe_width: r.maybe_width, + maybe_height: r.maybe_height, + maybe_resolution: r.maybe_resolution, + maybe_detected_model_family: r.maybe_detected_model_family, + maybe_detected_model_type: r.maybe_detected_model_type, + maybe_report: r.maybe_report, + upload_ip_address: r.upload_ip_address, + maybe_updated_ip_address: r.maybe_updated_ip_address, + created_at: r.created_at, + updated_at: r.updated_at, + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/uploaded_videos/insert_uploaded_video.rs b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/insert_uploaded_video.rs new file mode 100644 index 0000000000..29b9f0ad15 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/insert_uploaded_video.rs @@ -0,0 +1,75 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use enums::by_table::uploaded_videos::uploaded_video_detected_model_family::UploadedVideoDetectedModelFamily; +use enums::by_table::uploaded_videos::uploaded_video_detected_model_type::UploadedVideoDetectedModelType; +use tokens::tokens::uploaded_videos::UploadedVideoToken; + +pub struct InsertUploadedVideoArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub sha1_checksum: &'e str, + pub filesize_bytes: u32, + pub maybe_filename: Option<&'e str>, + + pub maybe_width: Option, + pub maybe_height: Option, + pub maybe_resolution: Option<&'e str>, + + pub maybe_detected_model_family: Option, + pub maybe_detected_model_type: Option, + pub maybe_report: Option<&'e str>, + + pub upload_ip_address: &'e str, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Insert a new `uploaded_videos` record. The `UploadedVideoToken` is minted +/// here (in the data-access layer, not the handler) and returned. `created_at` +/// is set to `NOW()`. +pub async fn insert_uploaded_video<'e, 'c: 'e, E>( + args: InsertUploadedVideoArgs<'e, 'c, E>, +) -> Result +where + E: 'e + Executor<'c, Database = MySql>, +{ + let token = UploadedVideoToken::generate(); + + sqlx::query!( + r#" +INSERT INTO uploaded_videos +SET + token = ?, + sha1_checksum = ?, + filesize_bytes = ?, + maybe_filename = ?, + maybe_width = ?, + maybe_height = ?, + maybe_resolution = ?, + maybe_detected_model_family = ?, + maybe_detected_model_type = ?, + maybe_report = ?, + upload_ip_address = ?, + created_at = NOW() + "#, + token.as_str(), + args.sha1_checksum, + args.filesize_bytes, + args.maybe_filename, + args.maybe_width, + args.maybe_height, + args.maybe_resolution, + args.maybe_detected_model_family.map(|family| family.to_str()), + args.maybe_detected_model_type.map(|model_type| model_type.to_str()), + args.maybe_report, + args.upload_ip_address, + ) + .execute(args.mysql_executor) + .await?; + + Ok(token) +} diff --git a/crates/schema/database/mysql_queries/src/queries/uploaded_videos/mod.rs b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/mod.rs new file mode 100644 index 0000000000..be54484288 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/mod.rs @@ -0,0 +1,5 @@ +pub mod get_uploaded_video_by_sha1_checksum; +pub mod get_uploaded_video_by_token; +pub mod insert_uploaded_video; +pub mod update_uploaded_video; +pub mod uploaded_video_row; diff --git a/crates/schema/database/mysql_queries/src/queries/uploaded_videos/update_uploaded_video.rs b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/update_uploaded_video.rs new file mode 100644 index 0000000000..d194d94ddc --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/update_uploaded_video.rs @@ -0,0 +1,72 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use enums::by_table::uploaded_videos::uploaded_video_detected_model_family::UploadedVideoDetectedModelFamily; +use enums::by_table::uploaded_videos::uploaded_video_detected_model_type::UploadedVideoDetectedModelType; +use tokens::tokens::uploaded_videos::UploadedVideoToken; + +pub struct UpdateUploadedVideoArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub token: &'e UploadedVideoToken, + + pub maybe_filename: Option<&'e str>, + + pub maybe_width: Option, + pub maybe_height: Option, + pub maybe_resolution: Option<&'e str>, + + pub maybe_detected_model_family: Option, + pub maybe_detected_model_type: Option, + pub maybe_report: Option<&'e str>, + + /// IP address of the request making the update; stamped on `maybe_updated_ip_address`. + pub maybe_updated_ip_address: Option<&'e str>, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Update the mutable fields of an `uploaded_videos` record by `token` +/// (dimensions, detection, report, updater IP). The `version` vector clock is +/// incremented by 1, and `updated_at` is refreshed via the column's `ON UPDATE` +/// clause. Returns the number of rows affected (0 if no row matched). +pub async fn update_uploaded_video<'e, 'c: 'e, E>( + args: UpdateUploadedVideoArgs<'e, 'c, E>, +) -> Result +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +UPDATE uploaded_videos +SET + maybe_filename = ?, + maybe_width = ?, + maybe_height = ?, + maybe_resolution = ?, + maybe_detected_model_family = ?, + maybe_detected_model_type = ?, + maybe_report = ?, + maybe_updated_ip_address = ?, + version = version + 1 +WHERE token = ? +LIMIT 1 + "#, + args.maybe_filename, + args.maybe_width, + args.maybe_height, + args.maybe_resolution, + args.maybe_detected_model_family.map(|family| family.to_str()), + args.maybe_detected_model_type.map(|model_type| model_type.to_str()), + args.maybe_report, + args.maybe_updated_ip_address, + args.token.as_str(), + ) + .execute(args.mysql_executor) + .await?; + + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/uploaded_videos/uploaded_video_row.rs b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/uploaded_video_row.rs new file mode 100644 index 0000000000..64d27b87cb --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/uploaded_videos/uploaded_video_row.rs @@ -0,0 +1,28 @@ +use chrono::{DateTime, Utc}; + +use enums::by_table::uploaded_videos::uploaded_video_detected_model_family::UploadedVideoDetectedModelFamily; +use enums::by_table::uploaded_videos::uploaded_video_detected_model_type::UploadedVideoDetectedModelType; +use tokens::tokens::uploaded_videos::UploadedVideoToken; + +/// A materialized `uploaded_videos` row (minus the internal `id`). +#[derive(Debug, Clone)] +pub struct UploadedVideoRow { + pub token: UploadedVideoToken, + + pub sha1_checksum: String, + pub filesize_bytes: u32, + + pub maybe_width: Option, + pub maybe_height: Option, + pub maybe_resolution: Option, + + pub maybe_detected_model_family: Option, + pub maybe_detected_model_type: Option, + pub maybe_report: Option, + + pub upload_ip_address: String, + pub maybe_updated_ip_address: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/schema/public/enums/src/by_table/mod.rs b/crates/schema/public/enums/src/by_table/mod.rs index f128ccd3a3..fbc1bd4f9c 100644 --- a/crates/schema/public/enums/src/by_table/mod.rs +++ b/crates/schema/public/enums/src/by_table/mod.rs @@ -21,6 +21,8 @@ pub mod staff_audit_logs; pub mod tag_uses; pub mod trending_model_analytics; pub mod tts_models; +pub mod uploaded_video_notes; +pub mod uploaded_videos; pub mod usages; pub mod user_bookmarks; pub mod user_ratings; diff --git a/crates/schema/public/enums/src/by_table/uploaded_video_notes/mod.rs b/crates/schema/public/enums/src/by_table/uploaded_video_notes/mod.rs new file mode 100644 index 0000000000..42912c9d79 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_video_notes/mod.rs @@ -0,0 +1,2 @@ +pub mod uploaded_video_note_reported_model_type; +pub mod uploaded_video_note_reported_website; diff --git a/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_model_type.rs b/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_model_type.rs new file mode 100644 index 0000000000..28a5fca764 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_model_type.rs @@ -0,0 +1,253 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `uploaded_video_notes` table in a `VARCHAR(32)` field. +/// +/// The model the note's submitter claims the video is. The variant set mirrors +/// [`crate::common::generation::common_video_model::CommonVideoModel`]. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +pub enum UploadedVideoNoteReportedModelType { + #[serde(rename = "grok_video")] + GrokVideo, + #[serde(rename = "grok_imagine_video")] + GrokImagineVideo, + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + #[serde(rename = "kling_1p6_pro")] + Kling16Pro, + #[serde(rename = "kling_2p1_pro")] + Kling21Pro, + #[serde(rename = "kling_2p1_master")] + Kling21Master, + #[serde(rename = "kling_2p5_turbo_pro")] + Kling2p5TurboPro, + #[serde(rename = "kling_2p6_pro")] + Kling2p6Pro, + #[serde(rename = "kling_3p0_standard")] + Kling3p0Standard, + #[serde(rename = "kling_3p0_pro")] + Kling3p0Pro, + #[serde(rename = "happy_horse_1p0")] + HappyHorse1p0, + #[serde(rename = "seedance_1p0_lite")] + Seedance10Lite, + #[serde(rename = "seedance_1p5_pro")] + Seedance1p5Pro, + // `preview_model` is a retired alias that collapses onto Seedance 2.0. + #[serde(rename = "seedance_2p0", alias = "preview_model")] + Seedance2p0, + // `preview_model_fast` is a retired alias that collapses onto Seedance 2.0 Fast. + #[serde(rename = "seedance_2p0_fast", alias = "preview_model_fast")] + Seedance2p0Fast, + #[serde(rename = "seedance_2p0_bp")] + Seedance2p0BytePlus, + #[serde(rename = "seedance_2p0_bp_fast")] + Seedance2p0BytePlusFast, + #[serde(rename = "seedance_2p0_u")] + Seedance2p0Ultra, + #[serde(rename = "seedance_2p0_u_fast")] + Seedance2p0UltraFast, + #[serde(rename = "seedance_2p0_bpu")] + Seedance2p0BytePlusUltra, + #[serde(rename = "seedance_2p0_bpu_fast")] + Seedance2p0BytePlusUltraFast, + #[serde(rename = "sora_2")] + Sora2, + #[serde(rename = "sora_2_pro")] + Sora2Pro, + #[serde(rename = "veo_2")] + Veo2, + #[serde(rename = "veo_3")] + Veo3, + #[serde(rename = "veo_3_fast")] + Veo3Fast, + #[serde(rename = "veo_3p1")] + Veo3p1, + #[serde(rename = "veo_3p1_fast")] + Veo3p1Fast, +} + +impl_enum_display_and_debug_using_to_str!(UploadedVideoNoteReportedModelType); +impl_mysql_enum_coders!(UploadedVideoNoteReportedModelType); +impl_mysql_from_row!(UploadedVideoNoteReportedModelType); + +impl UploadedVideoNoteReportedModelType { + pub fn to_str(&self) -> &'static str { + match self { + Self::GrokVideo => "grok_video", + Self::GrokImagineVideo => "grok_imagine_video", + Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5", + Self::Kling16Pro => "kling_1p6_pro", + Self::Kling21Pro => "kling_2p1_pro", + Self::Kling21Master => "kling_2p1_master", + Self::Kling2p5TurboPro => "kling_2p5_turbo_pro", + Self::Kling2p6Pro => "kling_2p6_pro", + Self::Kling3p0Standard => "kling_3p0_standard", + Self::Kling3p0Pro => "kling_3p0_pro", + Self::HappyHorse1p0 => "happy_horse_1p0", + Self::Seedance10Lite => "seedance_1p0_lite", + Self::Seedance1p5Pro => "seedance_1p5_pro", + Self::Seedance2p0 => "seedance_2p0", + Self::Seedance2p0Fast => "seedance_2p0_fast", + Self::Seedance2p0BytePlus => "seedance_2p0_bp", + Self::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast", + Self::Seedance2p0Ultra => "seedance_2p0_u", + Self::Seedance2p0UltraFast => "seedance_2p0_u_fast", + Self::Seedance2p0BytePlusUltra => "seedance_2p0_bpu", + Self::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast", + Self::Sora2 => "sora_2", + Self::Sora2Pro => "sora_2_pro", + Self::Veo2 => "veo_2", + Self::Veo3 => "veo_3", + Self::Veo3Fast => "veo_3_fast", + Self::Veo3p1 => "veo_3p1", + Self::Veo3p1Fast => "veo_3p1_fast", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "grok_video" => Ok(Self::GrokVideo), + "grok_imagine_video" => Ok(Self::GrokImagineVideo), + "grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5), + "kling_1p6_pro" => Ok(Self::Kling16Pro), + "kling_2p1_pro" => Ok(Self::Kling21Pro), + "kling_2p1_master" => Ok(Self::Kling21Master), + "kling_2p5_turbo_pro" => Ok(Self::Kling2p5TurboPro), + "kling_2p6_pro" => Ok(Self::Kling2p6Pro), + "kling_3p0_standard" => Ok(Self::Kling3p0Standard), + "kling_3p0_pro" => Ok(Self::Kling3p0Pro), + "happy_horse_1p0" => Ok(Self::HappyHorse1p0), + "seedance_1p0_lite" => Ok(Self::Seedance10Lite), + "seedance_1p5_pro" => Ok(Self::Seedance1p5Pro), + "seedance_2p0" => Ok(Self::Seedance2p0), + "seedance_2p0_fast" => Ok(Self::Seedance2p0Fast), + "seedance_2p0_bp" => Ok(Self::Seedance2p0BytePlus), + "seedance_2p0_bp_fast" => Ok(Self::Seedance2p0BytePlusFast), + "seedance_2p0_u" => Ok(Self::Seedance2p0Ultra), + "seedance_2p0_u_fast" => Ok(Self::Seedance2p0UltraFast), + "seedance_2p0_bpu" => Ok(Self::Seedance2p0BytePlusUltra), + "seedance_2p0_bpu_fast" => Ok(Self::Seedance2p0BytePlusUltraFast), + "sora_2" => Ok(Self::Sora2), + "sora_2_pro" => Ok(Self::Sora2Pro), + "veo_2" => Ok(Self::Veo2), + "veo_3" => Ok(Self::Veo3), + "veo_3_fast" => Ok(Self::Veo3Fast), + "veo_3p1" => Ok(Self::Veo3p1), + "veo_3p1_fast" => Ok(Self::Veo3p1Fast), + // Retired values: collapse onto the Seedance 2.0 equivalents. + "preview_model" => Ok(Self::Seedance2p0), + "preview_model_fast" => Ok(Self::Seedance2p0Fast), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::GrokVideo, + Self::GrokImagineVideo, + Self::GrokImagineVideo1p5, + Self::Kling16Pro, + Self::Kling21Pro, + Self::Kling21Master, + Self::Kling2p5TurboPro, + Self::Kling2p6Pro, + Self::Kling3p0Standard, + Self::Kling3p0Pro, + Self::HappyHorse1p0, + Self::Seedance10Lite, + Self::Seedance1p5Pro, + Self::Seedance2p0, + Self::Seedance2p0Fast, + Self::Seedance2p0BytePlus, + Self::Seedance2p0BytePlusFast, + Self::Seedance2p0Ultra, + Self::Seedance2p0UltraFast, + Self::Seedance2p0BytePlusUltra, + Self::Seedance2p0BytePlusUltraFast, + Self::Sora2, + Self::Sora2Pro, + Self::Veo2, + Self::Veo3, + Self::Veo3Fast, + Self::Veo3p1, + Self::Veo3p1Fast, + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + for variant in UploadedVideoNoteReportedModelType::all_variants() { + assert_eq!(variant, UploadedVideoNoteReportedModelType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UploadedVideoNoteReportedModelType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UploadedVideoNoteReportedModelType::from_str(&format!("{:?}", variant)).unwrap()); + } + assert!(UploadedVideoNoteReportedModelType::from_str("foo").is_err()); + } + + #[test] + fn serde_round_trip() { + for variant in UploadedVideoNoteReportedModelType::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: UploadedVideoNoteReportedModelType = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized); + } + } + + /// The MySQL string (to_str) and the JSON string (serde) must stay aligned. + #[test] + fn to_str_matches_serde() { + for variant in UploadedVideoNoteReportedModelType::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, format!("\"{}\"", variant.to_str())); + } + } + + #[test] + fn variant_count() { + use strum::IntoEnumIterator; + assert_eq!(UploadedVideoNoteReportedModelType::all_variants().len(), UploadedVideoNoteReportedModelType::iter().len()); + assert_eq!(UploadedVideoNoteReportedModelType::COUNT, 28); + } + + /// Retired `preview_model*` values must still decode, collapsing onto Seedance 2.0. + #[test] + fn legacy_preview_values_map_to_seedance() { + assert_eq!( + UploadedVideoNoteReportedModelType::from_str("preview_model").unwrap(), + UploadedVideoNoteReportedModelType::Seedance2p0 + ); + assert_eq!( + UploadedVideoNoteReportedModelType::from_str("preview_model_fast").unwrap(), + UploadedVideoNoteReportedModelType::Seedance2p0Fast + ); + let decoded: UploadedVideoNoteReportedModelType = + serde_json::from_str("\"preview_model\"").unwrap(); + assert_eq!(decoded, UploadedVideoNoteReportedModelType::Seedance2p0); + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in UploadedVideoNoteReportedModelType::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_website.rs b/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_website.rs new file mode 100644 index 0000000000..7e4f2a796f --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_website.rs @@ -0,0 +1,122 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `uploaded_video_notes` table in a `VARCHAR(32)` field. +/// +/// The website / platform the note's submitter says the video came from. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum UploadedVideoNoteReportedWebsite { + Runway, + Higgsfield, + Krea, + OpenArt, + Artcraft, + Magnific, + FreePik, +} + +impl_enum_display_and_debug_using_to_str!(UploadedVideoNoteReportedWebsite); +impl_mysql_enum_coders!(UploadedVideoNoteReportedWebsite); +impl_mysql_from_row!(UploadedVideoNoteReportedWebsite); + +impl UploadedVideoNoteReportedWebsite { + pub fn to_str(&self) -> &'static str { + match self { + Self::Runway => "runway", + Self::Higgsfield => "higgsfield", + Self::Krea => "krea", + Self::OpenArt => "open_art", + Self::Artcraft => "artcraft", + Self::Magnific => "magnific", + Self::FreePik => "free_pik", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "runway" => Ok(Self::Runway), + "higgsfield" => Ok(Self::Higgsfield), + "krea" => Ok(Self::Krea), + "open_art" => Ok(Self::OpenArt), + "artcraft" => Ok(Self::Artcraft), + "magnific" => Ok(Self::Magnific), + "free_pik" => Ok(Self::FreePik), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Runway, + Self::Higgsfield, + Self::Krea, + Self::OpenArt, + Self::Artcraft, + Self::Magnific, + Self::FreePik, + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(UploadedVideoNoteReportedWebsite::Runway, "runway"); + assert_serialization(UploadedVideoNoteReportedWebsite::Higgsfield, "higgsfield"); + assert_serialization(UploadedVideoNoteReportedWebsite::Krea, "krea"); + assert_serialization(UploadedVideoNoteReportedWebsite::OpenArt, "open_art"); + assert_serialization(UploadedVideoNoteReportedWebsite::Artcraft, "artcraft"); + assert_serialization(UploadedVideoNoteReportedWebsite::Magnific, "magnific"); + assert_serialization(UploadedVideoNoteReportedWebsite::FreePik, "free_pik"); + } + + #[test] + fn round_trip() { + for variant in UploadedVideoNoteReportedWebsite::all_variants() { + assert_eq!(variant, UploadedVideoNoteReportedWebsite::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UploadedVideoNoteReportedWebsite::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UploadedVideoNoteReportedWebsite::from_str(&format!("{:?}", variant)).unwrap()); + } + assert!(UploadedVideoNoteReportedWebsite::from_str("foo").is_err()); + } + + #[test] + fn to_str_matches_serde() { + for variant in UploadedVideoNoteReportedWebsite::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, format!("\"{}\"", variant.to_str())); + } + } + + #[test] + fn variant_count() { + use strum::IntoEnumIterator; + assert_eq!(UploadedVideoNoteReportedWebsite::all_variants().len(), UploadedVideoNoteReportedWebsite::iter().len()); + assert_eq!(UploadedVideoNoteReportedWebsite::COUNT, 7); + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in UploadedVideoNoteReportedWebsite::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/uploaded_videos/mod.rs b/crates/schema/public/enums/src/by_table/uploaded_videos/mod.rs new file mode 100644 index 0000000000..c6f2f0c5e3 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_videos/mod.rs @@ -0,0 +1,2 @@ +pub mod uploaded_video_detected_model_family; +pub mod uploaded_video_detected_model_type; diff --git a/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_family.rs b/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_family.rs new file mode 100644 index 0000000000..61e4aa55e2 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_family.rs @@ -0,0 +1,117 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `uploaded_videos` table in a `VARCHAR(32)` field. +/// +/// The detected provenance family of an uploaded video. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum UploadedVideoDetectedModelFamily { + Seedance, + Veo, + Sora, + Kling, + Grok, + HappyHorse, +} + +impl_enum_display_and_debug_using_to_str!(UploadedVideoDetectedModelFamily); +impl_mysql_enum_coders!(UploadedVideoDetectedModelFamily); +impl_mysql_from_row!(UploadedVideoDetectedModelFamily); + +impl UploadedVideoDetectedModelFamily { + pub fn to_str(&self) -> &'static str { + match self { + Self::Seedance => "seedance", + Self::Veo => "veo", + Self::Sora => "sora", + Self::Kling => "kling", + Self::Grok => "grok", + Self::HappyHorse => "happy_horse", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "seedance" => Ok(Self::Seedance), + "veo" => Ok(Self::Veo), + "sora" => Ok(Self::Sora), + "kling" => Ok(Self::Kling), + "grok" => Ok(Self::Grok), + "happy_horse" => Ok(Self::HappyHorse), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Seedance, + Self::Veo, + Self::Sora, + Self::Kling, + Self::Grok, + Self::HappyHorse, + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(UploadedVideoDetectedModelFamily::Seedance, "seedance"); + assert_serialization(UploadedVideoDetectedModelFamily::Veo, "veo"); + assert_serialization(UploadedVideoDetectedModelFamily::Sora, "sora"); + assert_serialization(UploadedVideoDetectedModelFamily::Kling, "kling"); + assert_serialization(UploadedVideoDetectedModelFamily::Grok, "grok"); + assert_serialization(UploadedVideoDetectedModelFamily::HappyHorse, "happy_horse"); + } + + #[test] + fn round_trip() { + for variant in UploadedVideoDetectedModelFamily::all_variants() { + assert_eq!(variant, UploadedVideoDetectedModelFamily::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UploadedVideoDetectedModelFamily::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UploadedVideoDetectedModelFamily::from_str(&format!("{:?}", variant)).unwrap()); + } + assert!(UploadedVideoDetectedModelFamily::from_str("foo").is_err()); + } + + #[test] + fn to_str_matches_serde() { + for variant in UploadedVideoDetectedModelFamily::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, format!("\"{}\"", variant.to_str())); + } + } + + #[test] + fn variant_count() { + use strum::IntoEnumIterator; + assert_eq!(UploadedVideoDetectedModelFamily::all_variants().len(), UploadedVideoDetectedModelFamily::iter().len()); + assert_eq!(UploadedVideoDetectedModelFamily::COUNT, 6); + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in UploadedVideoDetectedModelFamily::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_type.rs b/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_type.rs new file mode 100644 index 0000000000..9a79c0e8cf --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_type.rs @@ -0,0 +1,253 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `uploaded_videos` table in a `VARCHAR(32)` field. +/// +/// The detected model / variant of an uploaded video. The variant set mirrors +/// [`crate::common::generation::common_video_model::CommonVideoModel`]. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +pub enum UploadedVideoDetectedModelType { + #[serde(rename = "grok_video")] + GrokVideo, + #[serde(rename = "grok_imagine_video")] + GrokImagineVideo, + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + #[serde(rename = "kling_1p6_pro")] + Kling16Pro, + #[serde(rename = "kling_2p1_pro")] + Kling21Pro, + #[serde(rename = "kling_2p1_master")] + Kling21Master, + #[serde(rename = "kling_2p5_turbo_pro")] + Kling2p5TurboPro, + #[serde(rename = "kling_2p6_pro")] + Kling2p6Pro, + #[serde(rename = "kling_3p0_standard")] + Kling3p0Standard, + #[serde(rename = "kling_3p0_pro")] + Kling3p0Pro, + #[serde(rename = "happy_horse_1p0")] + HappyHorse1p0, + #[serde(rename = "seedance_1p0_lite")] + Seedance10Lite, + #[serde(rename = "seedance_1p5_pro")] + Seedance1p5Pro, + // `preview_model` is a retired alias that collapses onto Seedance 2.0. + #[serde(rename = "seedance_2p0", alias = "preview_model")] + Seedance2p0, + // `preview_model_fast` is a retired alias that collapses onto Seedance 2.0 Fast. + #[serde(rename = "seedance_2p0_fast", alias = "preview_model_fast")] + Seedance2p0Fast, + #[serde(rename = "seedance_2p0_bp")] + Seedance2p0BytePlus, + #[serde(rename = "seedance_2p0_bp_fast")] + Seedance2p0BytePlusFast, + #[serde(rename = "seedance_2p0_u")] + Seedance2p0Ultra, + #[serde(rename = "seedance_2p0_u_fast")] + Seedance2p0UltraFast, + #[serde(rename = "seedance_2p0_bpu")] + Seedance2p0BytePlusUltra, + #[serde(rename = "seedance_2p0_bpu_fast")] + Seedance2p0BytePlusUltraFast, + #[serde(rename = "sora_2")] + Sora2, + #[serde(rename = "sora_2_pro")] + Sora2Pro, + #[serde(rename = "veo_2")] + Veo2, + #[serde(rename = "veo_3")] + Veo3, + #[serde(rename = "veo_3_fast")] + Veo3Fast, + #[serde(rename = "veo_3p1")] + Veo3p1, + #[serde(rename = "veo_3p1_fast")] + Veo3p1Fast, +} + +impl_enum_display_and_debug_using_to_str!(UploadedVideoDetectedModelType); +impl_mysql_enum_coders!(UploadedVideoDetectedModelType); +impl_mysql_from_row!(UploadedVideoDetectedModelType); + +impl UploadedVideoDetectedModelType { + pub fn to_str(&self) -> &'static str { + match self { + Self::GrokVideo => "grok_video", + Self::GrokImagineVideo => "grok_imagine_video", + Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5", + Self::Kling16Pro => "kling_1p6_pro", + Self::Kling21Pro => "kling_2p1_pro", + Self::Kling21Master => "kling_2p1_master", + Self::Kling2p5TurboPro => "kling_2p5_turbo_pro", + Self::Kling2p6Pro => "kling_2p6_pro", + Self::Kling3p0Standard => "kling_3p0_standard", + Self::Kling3p0Pro => "kling_3p0_pro", + Self::HappyHorse1p0 => "happy_horse_1p0", + Self::Seedance10Lite => "seedance_1p0_lite", + Self::Seedance1p5Pro => "seedance_1p5_pro", + Self::Seedance2p0 => "seedance_2p0", + Self::Seedance2p0Fast => "seedance_2p0_fast", + Self::Seedance2p0BytePlus => "seedance_2p0_bp", + Self::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast", + Self::Seedance2p0Ultra => "seedance_2p0_u", + Self::Seedance2p0UltraFast => "seedance_2p0_u_fast", + Self::Seedance2p0BytePlusUltra => "seedance_2p0_bpu", + Self::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast", + Self::Sora2 => "sora_2", + Self::Sora2Pro => "sora_2_pro", + Self::Veo2 => "veo_2", + Self::Veo3 => "veo_3", + Self::Veo3Fast => "veo_3_fast", + Self::Veo3p1 => "veo_3p1", + Self::Veo3p1Fast => "veo_3p1_fast", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "grok_video" => Ok(Self::GrokVideo), + "grok_imagine_video" => Ok(Self::GrokImagineVideo), + "grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5), + "kling_1p6_pro" => Ok(Self::Kling16Pro), + "kling_2p1_pro" => Ok(Self::Kling21Pro), + "kling_2p1_master" => Ok(Self::Kling21Master), + "kling_2p5_turbo_pro" => Ok(Self::Kling2p5TurboPro), + "kling_2p6_pro" => Ok(Self::Kling2p6Pro), + "kling_3p0_standard" => Ok(Self::Kling3p0Standard), + "kling_3p0_pro" => Ok(Self::Kling3p0Pro), + "happy_horse_1p0" => Ok(Self::HappyHorse1p0), + "seedance_1p0_lite" => Ok(Self::Seedance10Lite), + "seedance_1p5_pro" => Ok(Self::Seedance1p5Pro), + "seedance_2p0" => Ok(Self::Seedance2p0), + "seedance_2p0_fast" => Ok(Self::Seedance2p0Fast), + "seedance_2p0_bp" => Ok(Self::Seedance2p0BytePlus), + "seedance_2p0_bp_fast" => Ok(Self::Seedance2p0BytePlusFast), + "seedance_2p0_u" => Ok(Self::Seedance2p0Ultra), + "seedance_2p0_u_fast" => Ok(Self::Seedance2p0UltraFast), + "seedance_2p0_bpu" => Ok(Self::Seedance2p0BytePlusUltra), + "seedance_2p0_bpu_fast" => Ok(Self::Seedance2p0BytePlusUltraFast), + "sora_2" => Ok(Self::Sora2), + "sora_2_pro" => Ok(Self::Sora2Pro), + "veo_2" => Ok(Self::Veo2), + "veo_3" => Ok(Self::Veo3), + "veo_3_fast" => Ok(Self::Veo3Fast), + "veo_3p1" => Ok(Self::Veo3p1), + "veo_3p1_fast" => Ok(Self::Veo3p1Fast), + // Retired values: collapse onto the Seedance 2.0 equivalents. + "preview_model" => Ok(Self::Seedance2p0), + "preview_model_fast" => Ok(Self::Seedance2p0Fast), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::GrokVideo, + Self::GrokImagineVideo, + Self::GrokImagineVideo1p5, + Self::Kling16Pro, + Self::Kling21Pro, + Self::Kling21Master, + Self::Kling2p5TurboPro, + Self::Kling2p6Pro, + Self::Kling3p0Standard, + Self::Kling3p0Pro, + Self::HappyHorse1p0, + Self::Seedance10Lite, + Self::Seedance1p5Pro, + Self::Seedance2p0, + Self::Seedance2p0Fast, + Self::Seedance2p0BytePlus, + Self::Seedance2p0BytePlusFast, + Self::Seedance2p0Ultra, + Self::Seedance2p0UltraFast, + Self::Seedance2p0BytePlusUltra, + Self::Seedance2p0BytePlusUltraFast, + Self::Sora2, + Self::Sora2Pro, + Self::Veo2, + Self::Veo3, + Self::Veo3Fast, + Self::Veo3p1, + Self::Veo3p1Fast, + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + for variant in UploadedVideoDetectedModelType::all_variants() { + assert_eq!(variant, UploadedVideoDetectedModelType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UploadedVideoDetectedModelType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UploadedVideoDetectedModelType::from_str(&format!("{:?}", variant)).unwrap()); + } + assert!(UploadedVideoDetectedModelType::from_str("foo").is_err()); + } + + #[test] + fn serde_round_trip() { + for variant in UploadedVideoDetectedModelType::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: UploadedVideoDetectedModelType = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized); + } + } + + /// The MySQL string (to_str) and the JSON string (serde) must stay aligned. + #[test] + fn to_str_matches_serde() { + for variant in UploadedVideoDetectedModelType::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, format!("\"{}\"", variant.to_str())); + } + } + + #[test] + fn variant_count() { + use strum::IntoEnumIterator; + assert_eq!(UploadedVideoDetectedModelType::all_variants().len(), UploadedVideoDetectedModelType::iter().len()); + assert_eq!(UploadedVideoDetectedModelType::COUNT, 28); + } + + /// Retired `preview_model*` values must still decode, collapsing onto Seedance 2.0. + #[test] + fn legacy_preview_values_map_to_seedance() { + assert_eq!( + UploadedVideoDetectedModelType::from_str("preview_model").unwrap(), + UploadedVideoDetectedModelType::Seedance2p0 + ); + assert_eq!( + UploadedVideoDetectedModelType::from_str("preview_model_fast").unwrap(), + UploadedVideoDetectedModelType::Seedance2p0Fast + ); + let decoded: UploadedVideoDetectedModelType = + serde_json::from_str("\"preview_model\"").unwrap(); + assert_eq!(decoded, UploadedVideoDetectedModelType::Seedance2p0); + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in UploadedVideoDetectedModelType::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/users/user_feature_flag.rs b/crates/schema/public/enums/src/by_table/users/user_feature_flag.rs index 9d6dd57084..b1e7cd5264 100644 --- a/crates/schema/public/enums/src/by_table/users/user_feature_flag.rs +++ b/crates/schema/public/enums/src/by_table/users/user_feature_flag.rs @@ -19,6 +19,7 @@ pub enum UserFeatureFlag { /// (For now, it's hidden until we get an NSFW filter.) ExploreMedia, + /// Access to studio features Studio, @@ -44,6 +45,10 @@ pub enum UserFeatureFlag { /// Access to the referrals program #[serde(rename = "referrals")] ReferralsProgram, + + /// Access to API key creation + #[serde(rename = "api_key")] + ApiKey, } // TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! @@ -63,6 +68,7 @@ impl UserFeatureFlag { Self::HappyHorse => "hh", Self::HappyHorseRateLimit => "hh_rl", Self::ReferralsProgram => "referrals", + Self::ApiKey => "api_key", } } @@ -76,6 +82,7 @@ impl UserFeatureFlag { "hh" => Ok(Self::HappyHorse), "hh_rl" => Ok(Self::HappyHorseRateLimit), "referrals" => Ok(Self::ReferralsProgram), + "api_key" => Ok(Self::ApiKey), _ => Err(format!("invalid value: {:?}", value)), } } @@ -90,6 +97,7 @@ impl UserFeatureFlag { Self::HappyHorse => "Happy Horse (no Rate Limit)", Self::HappyHorseRateLimit => "Happy Horse (with Rate Limit)", Self::ReferralsProgram => "Referrals Program", + Self::ApiKey => "API Keys", } } @@ -103,6 +111,7 @@ impl UserFeatureFlag { Self::HappyHorse => "Access to Happy Horse (without a rate limit - dangerous)", Self::HappyHorseRateLimit => "Access to Happy Horse (with a rate limit)", Self::ReferralsProgram => "Access to the referrals program", + Self::ApiKey => "Access to API key creation", } } @@ -118,6 +127,7 @@ impl UserFeatureFlag { Self::HappyHorse, Self::HappyHorseRateLimit, Self::ReferralsProgram, + Self::ApiKey, ]) } } @@ -140,6 +150,7 @@ mod tests { assert_serialization(UserFeatureFlag::HappyHorse, "hh"); assert_serialization(UserFeatureFlag::HappyHorseRateLimit, "hh_rl"); assert_serialization(UserFeatureFlag::ReferralsProgram, "referrals"); + assert_serialization(UserFeatureFlag::ApiKey, "api_key"); } #[test] @@ -152,6 +163,7 @@ mod tests { assert_eq!(UserFeatureFlag::HappyHorse.to_str(), "hh"); assert_eq!(UserFeatureFlag::HappyHorseRateLimit.to_str(), "hh_rl"); assert_eq!(UserFeatureFlag::ReferralsProgram.to_str(), "referrals"); + assert_eq!(UserFeatureFlag::ApiKey.to_str(), "api_key"); } #[test] @@ -164,13 +176,14 @@ mod tests { assert_eq!(UserFeatureFlag::from_str("hh").unwrap(), UserFeatureFlag::HappyHorse); assert_eq!(UserFeatureFlag::from_str("hh_rl").unwrap(), UserFeatureFlag::HappyHorseRateLimit); assert_eq!(UserFeatureFlag::from_str("referrals").unwrap(), UserFeatureFlag::ReferralsProgram); + assert_eq!(UserFeatureFlag::from_str("api_key").unwrap(), UserFeatureFlag::ApiKey); assert!(UserFeatureFlag::from_str("foo").is_err()); } #[test] fn all_variants() { let mut variants = UserFeatureFlag::all_variants(); - assert_eq!(variants.len(), 8); + assert_eq!(variants.len(), 9); assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ExploreMedia)); assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Studio)); assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Upload3d)); @@ -179,6 +192,7 @@ mod tests { assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorse)); assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorseRateLimit)); assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ReferralsProgram)); + assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ApiKey)); assert_eq!(variants.pop_first(), None); } } diff --git a/crates/schema/public/enums/src/common/generation/common_bitrate.rs b/crates/schema/public/enums/src/common/generation/common_bitrate.rs new file mode 100644 index 0000000000..3dde645ffe --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_bitrate.rs @@ -0,0 +1,162 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::error::enum_error::EnumError; + +/// Maximum serialized string length for database storage. +pub const MAX_LENGTH: usize = 16; + +/// Common output bitrate levels for generation. +/// +/// Most models default to `Normal`; `High` requests a higher bitrate where the +/// model supports it. Models that don't support it simply ignore the value. +/// +/// NB: Keep the max serialized length to 16 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonBitrate { + Normal, + High, +} + +impl_enum_display_and_debug_using_to_str!(CommonBitrate); +impl_mysql_enum_coders!(CommonBitrate); +impl_mysql_from_row!(CommonBitrate); + +impl CommonBitrate { + pub fn to_str(&self) -> &'static str { + match self { + Self::Normal => "normal", + Self::High => "high", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "normal" => Ok(Self::Normal), + "high" => Ok(Self::High), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Normal, + Self::High, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::generation::common_bitrate::CommonBitrate; + use crate::common::generation::common_bitrate::MAX_LENGTH; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(CommonBitrate::Normal, "normal"); + assert_serialization(CommonBitrate::High, "high"); + } + + #[test] + fn to_str() { + assert_eq!(CommonBitrate::Normal.to_str(), "normal"); + assert_eq!(CommonBitrate::High.to_str(), "high"); + } + + #[test] + fn from_str() { + assert_eq!(CommonBitrate::from_str("normal").unwrap(), CommonBitrate::Normal); + assert_eq!(CommonBitrate::from_str("high").unwrap(), CommonBitrate::High); + } + + #[test] + fn from_str_err() { + let result = CommonBitrate::from_str("invalid"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "invalid"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = CommonBitrate::all_variants(); + assert_eq!(variants.len(), 2); + assert_eq!(variants.pop_first(), Some(CommonBitrate::Normal)); + assert_eq!(variants.pop_first(), Some(CommonBitrate::High)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(CommonBitrate::all_variants().len(), CommonBitrate::iter().len()); + } + + #[test] + fn round_trip() { + for variant in CommonBitrate::all_variants() { + assert_eq!(variant, CommonBitrate::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, CommonBitrate::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, CommonBitrate::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + for variant in CommonBitrate::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant); + } + for variant in CommonBitrate::all_variants() { + let json = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json); + } + } + + #[test] + fn serialized_names_must_not_contain_dots() { + for variant in CommonBitrate::all_variants() { + let to_str_value = variant.to_str(); + assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value); + } + } + + #[test] + fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() { + let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap(); + + for variant in CommonBitrate::all_variants() { + let to_str_value = variant.to_str(); + assert!(valid_pattern.is_match(to_str_value), + "to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(valid_pattern.is_match(&json_value), + "JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/mod.rs b/crates/schema/public/enums/src/common/generation/mod.rs index 11128e7ac3..76f6b5890e 100644 --- a/crates/schema/public/enums/src/common/generation/mod.rs +++ b/crates/schema/public/enums/src/common/generation/mod.rs @@ -1,4 +1,5 @@ pub mod common_aspect_ratio; +pub mod common_bitrate; pub mod common_generation_mode; pub mod common_image_model; pub mod common_quality; diff --git a/crates/schema/public/enums/src/common/platform_type.rs b/crates/schema/public/enums/src/common/platform_type.rs index d1778f2c57..472e703062 100644 --- a/crates/schema/public/enums/src/common/platform_type.rs +++ b/crates/schema/public/enums/src/common/platform_type.rs @@ -26,6 +26,9 @@ pub enum PlatformType { /// Desktop app users DesktopApp, + /// Requests authenticated via an `Authorization` header API key (the Omni API surface). + ApiKey, + // ========== Other clients ========== Curl, @@ -42,6 +45,7 @@ impl PlatformType { match self { Self::Web => "web", Self::DesktopApp => "desktop_app", + Self::ApiKey => "api_key", Self::Curl => "curl", Self::PythonRequests => "python_requests", Self::Postman => "postman", @@ -52,6 +56,7 @@ impl PlatformType { match value { "web" => Ok(Self::Web), "desktop_app" => Ok(Self::DesktopApp), + "api_key" => Ok(Self::ApiKey), "curl" => Ok(Self::Curl), "python_requests" => Ok(Self::PythonRequests), "postman" => Ok(Self::Postman), @@ -64,6 +69,7 @@ impl PlatformType { BTreeSet::from([ Self::Web, Self::DesktopApp, + Self::ApiKey, Self::Curl, Self::PythonRequests, Self::Postman, @@ -84,6 +90,7 @@ mod tests { fn test_serialization() { assert_serialization(PlatformType::Web, "web"); assert_serialization(PlatformType::DesktopApp, "desktop_app"); + assert_serialization(PlatformType::ApiKey, "api_key"); assert_serialization(PlatformType::Curl, "curl"); assert_serialization(PlatformType::PythonRequests, "python_requests"); assert_serialization(PlatformType::Postman, "postman"); @@ -93,6 +100,7 @@ mod tests { fn to_str() { assert_eq!(PlatformType::Web.to_str(), "web"); assert_eq!(PlatformType::DesktopApp.to_str(), "desktop_app"); + assert_eq!(PlatformType::ApiKey.to_str(), "api_key"); assert_eq!(PlatformType::Curl.to_str(), "curl"); assert_eq!(PlatformType::PythonRequests.to_str(), "python_requests"); assert_eq!(PlatformType::Postman.to_str(), "postman"); @@ -102,6 +110,7 @@ mod tests { fn from_str() { assert_eq!(PlatformType::from_str("web").unwrap(), PlatformType::Web); assert_eq!(PlatformType::from_str("desktop_app").unwrap(), PlatformType::DesktopApp); + assert_eq!(PlatformType::from_str("api_key").unwrap(), PlatformType::ApiKey); assert_eq!(PlatformType::from_str("curl").unwrap(), PlatformType::Curl); assert_eq!(PlatformType::from_str("python_requests").unwrap(), PlatformType::PythonRequests); assert_eq!(PlatformType::from_str("postman").unwrap(), PlatformType::Postman); @@ -121,9 +130,10 @@ mod tests { #[test] fn all_variants() { let mut variants = PlatformType::all_variants(); - assert_eq!(variants.len(), 5); + assert_eq!(variants.len(), 6); assert_eq!(variants.pop_first(), Some(PlatformType::Web)); assert_eq!(variants.pop_first(), Some(PlatformType::DesktopApp)); + assert_eq!(variants.pop_first(), Some(PlatformType::ApiKey)); assert_eq!(variants.pop_first(), Some(PlatformType::Curl)); assert_eq!(variants.pop_first(), Some(PlatformType::PythonRequests)); assert_eq!(variants.pop_first(), Some(PlatformType::Postman)); diff --git a/crates/schema/public/tokens/src/prefixes.rs b/crates/schema/public/tokens/src/prefixes.rs index 671f543701..9f9ae3788f 100644 --- a/crates/schema/public/tokens/src/prefixes.rs +++ b/crates/schema/public/tokens/src/prefixes.rs @@ -46,6 +46,8 @@ pub(crate) enum TokenPrefix { StaffAuditLog, Tag, TtsRenderTask, // NB: aichatbot / sqlite + UploadedVideo, + UploadedVideoNote, User, UserImpersonationRequest, UserBookmark, @@ -136,6 +138,8 @@ impl PrefixGenerator for TokenPrefix { Self::StaffAuditLog => "stfaud_", Self::Tag => "tag_", Self::TtsRenderTask => "tts_task_", + Self::UploadedVideo => "uvt_", + Self::UploadedVideoNote => "uvnote_", Self::User => "user_", // NB: Previously "U:" Self::UserImpersonationRequest => "uimpr_", Self::UserBookmark => "ub_", diff --git a/crates/schema/public/tokens/src/tokens/mod.rs b/crates/schema/public/tokens/src/tokens/mod.rs index 0d356f836f..5e639547b8 100644 --- a/crates/schema/public/tokens/src/tokens/mod.rs +++ b/crates/schema/public/tokens/src/tokens/mod.rs @@ -31,6 +31,8 @@ pub mod tts_results; pub mod twitch_event_rule; pub mod twitch_oauth_tokens_grouping; pub mod twitch_oauth_tokens_internal; +pub mod uploaded_video_notes; +pub mod uploaded_videos; pub mod user_bookmarks; pub mod user_impersonation_requests; pub mod user_referral_codes; diff --git a/crates/schema/public/tokens/src/tokens/uploaded_video_notes.rs b/crates/schema/public/tokens/src/tokens/uploaded_video_notes.rs new file mode 100644 index 0000000000..93095d675e --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/uploaded_video_notes.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for uploaded_video_notes. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UploadVideoNoteToken(pub String); + +impl_string_token!(UploadVideoNoteToken); +impl_mysql_token_from_row!(UploadVideoNoteToken); +impl_crockford_generator!(UploadVideoNoteToken, 32usize, TokenPrefix::UploadedVideoNote, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/uploaded_videos.rs b/crates/schema/public/tokens/src/tokens/uploaded_videos.rs new file mode 100644 index 0000000000..80a36c6af2 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/uploaded_videos.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for uploaded_videos. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UploadedVideoToken(pub String); + +impl_string_token!(UploadedVideoToken); +impl_mysql_token_from_row!(UploadedVideoToken); +impl_crockford_generator!(UploadedVideoToken, 32usize, TokenPrefix::UploadedVideo, CrockfordLower); diff --git a/crates/service/job/deprecated/download_job/src/job_state.rs b/crates/service/job/deprecated/download_job/src/job_state.rs index d535769bda..152b3f475f 100644 --- a/crates/service/job/deprecated/download_job/src/job_state.rs +++ b/crates/service/job/deprecated/download_job/src/job_state.rs @@ -5,7 +5,7 @@ use r2d2_redis::RedisConnectionManager; use sqlx::MySqlPool; use bootstrap::bootstrap::ContainerEnvironment; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier; use google_drive_downloader::google_drive_download_command::GoogleDriveDownloadCommand; use mysql_queries::common_inputs::container_environment_arg::ContainerEnvironmentArg; @@ -33,8 +33,8 @@ pub struct JobState { pub redis_pool: r2d2::Pool, - pub public_bucket_client: BucketClient, - pub private_bucket_client: BucketClient, + pub public_bucket_client: LegacyBucketClient, + pub private_bucket_client: LegacyBucketClient, pub firehose_publisher: FirehosePublisher, pub badge_granter: BadgeGranter, diff --git a/crates/service/job/deprecated/download_job/src/job_types/voice_conversion/rvc_v2/pretrained_hubert_model.rs b/crates/service/job/deprecated/download_job/src/job_types/voice_conversion/rvc_v2/pretrained_hubert_model.rs index 70a39c1b7c..6029da9eb5 100644 --- a/crates/service/job/deprecated/download_job/src/job_types/voice_conversion/rvc_v2/pretrained_hubert_model.rs +++ b/crates/service/job/deprecated/download_job/src/job_types/voice_conversion/rvc_v2/pretrained_hubert_model.rs @@ -4,7 +4,7 @@ use anyhow::anyhow; use log::info; use tempdir::TempDir; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use errors::AnyhowResult; use filesys::create_dir_all_if_missing::create_dir_all_if_missing; use filesys::file_exists::file_exists; @@ -38,7 +38,7 @@ impl PretrainedHubertModel { pub async fn download_if_not_on_filesystem( &self, - bucket_client: &BucketClient, + bucket_client: &LegacyBucketClient, temp_dir: &TempDir, ) -> AnyhowResult<()> { if file_exists(&self.filesystem_path) { diff --git a/crates/service/job/deprecated/download_job/src/main.rs b/crates/service/job/deprecated/download_job/src/main.rs index 9362a92fb1..04db0e1bdb 100644 --- a/crates/service/job/deprecated/download_job/src/main.rs +++ b/crates/service/job/deprecated/download_job/src/main.rs @@ -28,7 +28,7 @@ use sqlx::mysql::MySqlPoolOptions; use tokio::runtime::Runtime; use bootstrap::bootstrap::{bootstrap, BootstrapArgs}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier; use config::common_env::CommonEnv; use shared_env_var_config::logging::DEFAULT_RUST_LOG; @@ -96,7 +96,7 @@ async fn main() -> AnyhowResult<()> { let bucket_timeout = easyenv::get_env_duration_seconds_or_default("BUCKET_TIMEOUT_SECONDS", Duration::from_secs(60 * 5)); - let private_bucket_client = BucketClient::create( + let private_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, @@ -106,7 +106,7 @@ async fn main() -> AnyhowResult<()> { Some(bucket_timeout), )?; - let public_bucket_client = BucketClient::create( + let public_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/gpt_sovits/model_package/download_package.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/gpt_sovits/model_package/download_package.rs index 97891b3e20..3db1d6b12e 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/gpt_sovits/model_package/download_package.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/gpt_sovits/model_package/download_package.rs @@ -5,7 +5,7 @@ use log::{debug, error, info, warn}; use tempdir::TempDir; use bucket_paths::legacy::typified_paths::public::weight_files::bucket_directory::WeightFileBucketDirectory; use bucket_paths::legacy::typified_paths::public::weight_files::bucket_file_path::WeightFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use filesys::rename_across_devices::{rename_across_devices, RenameError}; use filesys::file_deletion::safe_delete_directory::safe_delete_directory; use tokens::tokens::model_weights::ModelWeightToken; @@ -18,7 +18,7 @@ pub async fn download_package( model_weight_token: ModelWeightToken, weights_file_bucket_directory: &WeightFileBucketDirectory, download_directory: &Path, - bucket_client: &BucketClient, + bucket_client: &LegacyBucketClient, scoped_tempdir_creator: &ScopedTempDirCreator, force_download: bool, ) -> Result<(), ProcessSingleJobError> { @@ -69,7 +69,7 @@ pub async fn do_download_package( model_weight_token: ModelWeightToken, weights_file_bucket_directory: &WeightFileBucketDirectory, download_directory: &Path, - bucket_client: &BucketClient, + bucket_client: &LegacyBucketClient, temp_dir: &TempDir, force_download: bool, entry: GptSovitsPackageFileType, diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/gpt_sovits/upload_model/extract_and_upload_gpt_sovits_package.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/gpt_sovits/upload_model/extract_and_upload_gpt_sovits_package.rs index 0373be0e6c..a31e9385e1 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/gpt_sovits/upload_model/extract_and_upload_gpt_sovits_package.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/gpt_sovits/upload_model/extract_and_upload_gpt_sovits_package.rs @@ -5,7 +5,7 @@ use zip::ZipArchive; use bucket_paths::legacy::typified_paths::public::weight_files::bucket_directory::WeightFileBucketDirectory; use bucket_paths::legacy::typified_paths::public::weight_files::bucket_file_path::WeightFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use filesys::path_to_string::path_to_string; use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; @@ -14,7 +14,7 @@ use crate::job::job_types::gpt_sovits::upload_model::get_gptsovits_zip_entries:: pub async fn extract_and_upload_gpt_sovits_package_files( zip_container_file_bytes: &[u8], - bucket_client: &BucketClient, + bucket_client: &LegacyBucketClient, weights_file_bucket_directory: &WeightFileBucketDirectory, ) -> Result { let cursor = Cursor::new(zip_container_file_bytes); diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/image_generation/sd/process_job_inference.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/image_generation/sd/process_job_inference.rs index 55f8d266fd..50733c06fb 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/image_generation/sd/process_job_inference.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/image_generation/sd/process_job_inference.rs @@ -332,6 +332,7 @@ pub async fn process_job_inference( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: Some(&prompt_token), prompt_type: PromptType::StableDiffusion, maybe_creator_user_token: job.maybe_creator_user_token_typed.as_ref(), diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/lipsync/sad_talker/download_audio_file.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/lipsync/sad_talker/download_audio_file.rs index bea518cf98..ef4040ee50 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/lipsync/sad_talker/download_audio_file.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/lipsync/sad_talker/download_audio_file.rs @@ -8,7 +8,7 @@ use tempdir::TempDir; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; use bucket_paths::legacy::typified_paths::public::media_uploads::bucket_file_path::MediaUploadOriginalFilePath; use bucket_paths::legacy::typified_paths::public::voice_conversion_results::bucket_file_path::VoiceConversionResultOriginalFilePath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporter; use mysql_queries::payloads::generic_inference_args::inner_payloads::lipsync_payload::LipsyncAnimationAudioSource; use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob; @@ -28,7 +28,7 @@ pub struct AudioFile { pub struct DownloadAudioFileArgs<'a> { pub audio_source: &'a LipsyncAnimationAudioSource, - pub public_bucket_client: &'a BucketClient, + pub public_bucket_client: &'a LegacyBucketClient, pub job_progress_reporter: &'a mut Box, pub job: &'a AvailableInferenceJob, pub temp_dir_creator: &'a ScopedTempDirCreator, diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/lipsync/sad_talker/download_image_file.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/lipsync/sad_talker/download_image_file.rs index d5ca04b665..c0b939f96c 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/lipsync/sad_talker/download_image_file.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/lipsync/sad_talker/download_image_file.rs @@ -7,7 +7,7 @@ use tempdir::TempDir; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; use bucket_paths::legacy::typified_paths::public::media_uploads::bucket_file_path::MediaUploadOriginalFilePath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporter; use mysql_queries::payloads::generic_inference_args::inner_payloads::lipsync_payload::LipsyncAnimationImageSource; use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob; @@ -26,7 +26,7 @@ pub struct ImageFile { pub struct DownloadImageFileArgs<'a> { pub image_source: &'a LipsyncAnimationImageSource, - pub public_bucket_client: &'a BucketClient, + pub public_bucket_client: &'a LegacyBucketClient, pub job_progress_reporter: &'a mut Box, pub job: &'a AvailableInferenceJob, pub temp_dir_creator: &'a ScopedTempDirCreator, diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/mocap/mocap_net/download_video_file.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/mocap/mocap_net/download_video_file.rs index d14e19812e..fb8e7184c0 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/mocap/mocap_net/download_video_file.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/mocap/mocap_net/download_video_file.rs @@ -7,7 +7,7 @@ use tempdir::TempDir; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; use bucket_paths::legacy::typified_paths::public::media_uploads::bucket_file_path::MediaUploadOriginalFilePath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporter; use mysql_queries::payloads::generic_inference_args::inner_payloads::mocap_payload::MocapVideoSource; use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob; @@ -27,7 +27,7 @@ pub struct VideoFile { // TODO: Replace with remote cloud client pub async fn download_video_file( video_source: &MocapVideoSource, - public_bucket_client: &BucketClient, + public_bucket_client: &LegacyBucketClient, job_progress_reporter: &mut Box, job: &AvailableInferenceJob, temp_dir_creator: &ScopedTempDirCreator, diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/tts/styletts2/process_job_create_voice.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/tts/styletts2/process_job_create_voice.rs index aec9bdd631..802782aa9a 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/tts/styletts2/process_job_create_voice.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/tts/styletts2/process_job_create_voice.rs @@ -8,7 +8,7 @@ use log::{error, info, warn}; use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_directory::{ModelCategory, ModelType}; use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_file_path::ZeroShotVoiceEmbeddingBucketPath; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier; use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType; use enums::by_table::zs_voices::encoding_type::ZsVoiceEncodingType; @@ -295,7 +295,7 @@ pub struct AudioFile { pub async fn download_audio_from_hash( bucket_hash: &str, name: &str, - private_bucket_client: &BucketClient, + private_bucket_client: &LegacyBucketClient, path: &PathBuf ) -> Result { let unifer = BucketPathUnifier::default_paths(); diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/tts/styletts2/process_job_inference_voice.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/tts/styletts2/process_job_inference_voice.rs index 06bd91995f..a0cba630df 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/tts/styletts2/process_job_inference_voice.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/tts/styletts2/process_job_inference_voice.rs @@ -8,7 +8,7 @@ use log::{error, info, warn}; use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_directory::{ModelCategory, ModelType}; use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_file_path::ZeroShotVoiceEmbeddingBucketPath; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier; use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType; use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType; @@ -244,7 +244,7 @@ pub struct VoiceFile { pub async fn download_voice_embedding_from_hash( bucket_hash: &str, name: &str, - private_bucket_client: &BucketClient, + private_bucket_client: &LegacyBucketClient, path: &PathBuf ) -> Result { let unifer = BucketPathUnifier::default_paths(); @@ -271,7 +271,7 @@ pub async fn download_voice_embedding_from_hash( pub async fn download_voice_embedding( voice: &ZsVoice, - private_bucket_client: &BucketClient, + private_bucket_client: &LegacyBucketClient, download_path: &PathBuf, ) -> Result { diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/tts/vall_e_x/process_job_create_voice.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/tts/vall_e_x/process_job_create_voice.rs index c9ede5aaa3..777e9eef7f 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/tts/vall_e_x/process_job_create_voice.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/tts/vall_e_x/process_job_create_voice.rs @@ -8,7 +8,7 @@ use log::{error, info, warn}; use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_directory::{ModelCategory, ModelType}; use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_file_path::ZeroShotVoiceEmbeddingBucketPath; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier; use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType; use enums::by_table::zs_voices::encoding_type::ZsVoiceEncodingType; @@ -316,7 +316,7 @@ pub struct AudioFile { pub async fn download_audio_from_hash( bucket_hash: &str, name: &str, - private_bucket_client: &BucketClient, + private_bucket_client: &LegacyBucketClient, path: &PathBuf ) -> Result { let unifer = BucketPathUnifier::default_paths(); diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/tts/vall_e_x/process_job_inference_voice.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/tts/vall_e_x/process_job_inference_voice.rs index f4578b55d4..eb3bca1426 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/tts/vall_e_x/process_job_inference_voice.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/tts/vall_e_x/process_job_inference_voice.rs @@ -8,7 +8,7 @@ use log::{error, info, warn}; use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_directory::{ModelCategory, ModelType}; use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_file_path::ZeroShotVoiceEmbeddingBucketPath; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier; use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType; use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType; @@ -260,7 +260,7 @@ pub struct VoiceFile { pub async fn download_voice_embedding_from_hash( bucket_hash: &str, name: &str, - private_bucket_client: &BucketClient, + private_bucket_client: &LegacyBucketClient, path: &PathBuf ) -> Result { let unifer = BucketPathUnifier::default_paths(); @@ -287,7 +287,7 @@ pub async fn download_voice_embedding_from_hash( pub async fn download_voice_embedding( voice: &ZsVoice, - private_bucket_client: &BucketClient, + private_bucket_client: &LegacyBucketClient, download_path: &PathBuf, ) -> Result { diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/vc/rvc_v2/pretrained_hubert_model.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/vc/rvc_v2/pretrained_hubert_model.rs index 078730b7f8..20bd38865c 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/vc/rvc_v2/pretrained_hubert_model.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/vc/rvc_v2/pretrained_hubert_model.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use anyhow::anyhow; use log::{error, info}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use filesys::create_dir_all_if_missing::create_dir_all_if_missing; use filesys::file_exists::file_exists; use filesys::rename_across_devices::rename_across_devices; @@ -41,7 +41,7 @@ impl PretrainedHubertModel { pub async fn download_if_not_on_filesystem( &self, - bucket_client: &BucketClient, + bucket_client: &LegacyBucketClient, scoped_tempdir_creator: &ScopedTempDirCreator, ) -> Result<(), ProcessSingleJobError> { if file_exists(&self.filesystem_path) { diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/videofilter/rerender_a_video/download_model_file.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/videofilter/rerender_a_video/download_model_file.rs index 0070dbac24..ea1d8c71a8 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/videofilter/rerender_a_video/download_model_file.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/videofilter/rerender_a_video/download_model_file.rs @@ -4,7 +4,7 @@ use anyhow::anyhow; use tempdir::TempDir; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporter; use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob; use mysql_queries::queries::model_weights::get::get_weight::RetrievedModelWeight; @@ -19,7 +19,7 @@ pub struct ModelFile { // TODO replace this with remote cloud file manager. pub async fn download_model_file( model_record: &Option, - public_bucket_client: &BucketClient, + public_bucket_client: &LegacyBucketClient, job_progress_reporter: &mut Box, job: &AvailableInferenceJob, temp_dir_creator: &ScopedTempDirCreator, diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/videofilter/rerender_a_video/download_video_file.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/videofilter/rerender_a_video/download_video_file.rs index 6ca478aeec..11cda744c6 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/videofilter/rerender_a_video/download_video_file.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/videofilter/rerender_a_video/download_video_file.rs @@ -7,7 +7,7 @@ use tempdir::TempDir; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; use bucket_paths::legacy::typified_paths::public::media_uploads::bucket_file_path::MediaUploadOriginalFilePath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporter; use mysql_queries::payloads::generic_inference_args::inner_payloads::videofilter_payload::VideofilterVideoSource; use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob; @@ -26,7 +26,7 @@ pub struct VideoFile { // TODO replace this with remote cloud file manager. pub async fn download_video_file( video_source: &VideofilterVideoSource, - public_bucket_client: &BucketClient, + public_bucket_client: &LegacyBucketClient, job_progress_reporter: &mut Box, job: &AvailableInferenceJob, temp_dir_creator: &ScopedTempDirCreator, diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/workflow/video_style_transfer/steps/validate_and_save_results.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/workflow/video_style_transfer/steps/validate_and_save_results.rs index b08c40de50..267b1262bc 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/workflow/video_style_transfer/steps/validate_and_save_results.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/workflow/video_style_transfer/steps/validate_and_save_results.rs @@ -287,6 +287,7 @@ pub async fn validate_and_save_results(args: SaveResultsArgs<'_>) -> Result }, UploadMultipleFrames { requests: Vec, - bucket_client: BucketClient, + bucket_client: LegacyBucketClient, result: oneshot::Sender>, }, } @@ -39,7 +39,7 @@ struct BucketUploadActor { rx: mpsc::Receiver, } -fn handle_single_frame_upload(disk_path: PathBuf, bucket_path: MediaFileBucketPath, bucket_client: BucketClient) -> (PathBuf,BucketUploadResult) { +fn handle_single_frame_upload(disk_path: PathBuf, bucket_path: MediaFileBucketPath, bucket_client: LegacyBucketClient) -> (PathBuf,BucketUploadResult) { let disk_path2 = disk_path.clone(); let handle = tokio::runtime::Handle::current(); let attempt = handle.block_on( @@ -139,7 +139,7 @@ impl BucketUploadActorHandle { } } - pub async fn upload_frame(&self, disk_path: PathBuf, bucket_path: MediaFileBucketPath, bucket_client: BucketClient) -> () { + pub async fn upload_frame(&self, disk_path: PathBuf, bucket_path: MediaFileBucketPath, bucket_client: LegacyBucketClient) -> () { let (tx, rx) = oneshot::channel(); let message = BucketUploadMessage::UploadFrame { disk_path, @@ -151,7 +151,7 @@ impl BucketUploadActorHandle { let _ = rx.await; } - pub async fn upload_multiple_frames(&self, requests: Vec, bucket_client: BucketClient) -> Vec<(PathBuf,BucketUploadResult)> { + pub async fn upload_multiple_frames(&self, requests: Vec, bucket_client: LegacyBucketClient) -> Vec<(PathBuf,BucketUploadResult)> { let (tx, rx) = oneshot::channel(); let message = BucketUploadMessage::UploadMultipleFrames { requests, diff --git a/crates/service/job/deprecated/inference_job/src/job/job_types/workflow/video_style_transfer/util/process_preview_updates.rs b/crates/service/job/deprecated/inference_job/src/job/job_types/workflow/video_style_transfer/util/process_preview_updates.rs index c74a890802..9f5c254803 100644 --- a/crates/service/job/deprecated/inference_job/src/job/job_types/workflow/video_style_transfer/util/process_preview_updates.rs +++ b/crates/service/job/deprecated/inference_job/src/job/job_types/workflow/video_style_transfer/util/process_preview_updates.rs @@ -14,7 +14,7 @@ use crate::job::job_types::workflow::video_style_transfer::util::preview_process use crate::job::job_types::workflow::video_style_transfer::util::preview_processor::dir_walker::{DirWalker, PathBatch}; use bucket_paths::legacy::typified_paths::public::media_files::bucket_directory::MediaFileBucketDirectory; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use crockford::crockford_entropy_lower; use redis_schema::keys::inference_job::style_transfer_progress_key::StyleTransferProgressKey; use redis_schema::payloads::inference_job::style_transfer_progress_state::{InferenceProgressDetailsResponse, InferenceStageDetails}; @@ -306,7 +306,7 @@ impl StageDirectoryState { } - async fn upload_frame_from_disk(&self, bucket_client: &BucketClient, frame_number: u32, disk_path: PathBuf) -> PreviewFrameUpdate { + async fn upload_frame_from_disk(&self, bucket_client: &LegacyBucketClient, frame_number: u32, disk_path: PathBuf) -> PreviewFrameUpdate { let local_file_extension = match disk_path.extension().and_then(|s| s.to_str()) { Some(e) => { if ALLOWED_TYPES_FRAMES.contains(e) { @@ -355,7 +355,7 @@ impl StageDirectoryState { } } - async fn upload_frame_from_disk_with_retry(&self, bucket_client: &BucketClient, frame_number: u32, disk_path: PathBuf) -> PreviewFrameUpdate { + async fn upload_frame_from_disk_with_retry(&self, bucket_client: &LegacyBucketClient, frame_number: u32, disk_path: PathBuf) -> PreviewFrameUpdate { let mut retries = 0; loop { if retries > 3 { @@ -385,7 +385,7 @@ impl StageDirectoryState { } - async fn upload_multiple_frames_from_disk(&self, bucket_client: &BucketClient, frames: Vec<(u32, PathBuf)>) -> Vec { + async fn upload_multiple_frames_from_disk(&self, bucket_client: &LegacyBucketClient, frames: Vec<(u32, PathBuf)>) -> Vec { let mut bucket_upload_requests = Vec::with_capacity(frames.len()); let mut updates = Vec::with_capacity(frames.len()); @@ -635,7 +635,7 @@ impl PreviewProcessor { let _r: String = redis.set_ex(key.to_string(), serde_json::to_string(&status).unwrap(), expire_at).unwrap(); } - pub async fn start_processing(&mut self, bucket_client: &BucketClient) -> () { + pub async fn start_processing(&mut self, bucket_client: &LegacyBucketClient) -> () { let (frames_tx, mut frames_rx) = mpsc::channel(100); let walker = DirWalker::new_with_batch_size(self.base_directory.clone(), frames_tx.clone(), 30); let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5)); @@ -693,7 +693,7 @@ impl PreviewProcessor { } } - pub async fn process_upload(&mut self, bucket_client: &BucketClient, disk_path: PathBuf) -> () { + pub async fn process_upload(&mut self, bucket_client: &LegacyBucketClient, disk_path: PathBuf) -> () { if !self.is_disk_path_valid(&disk_path) { log::warn!("Invalid disk path: {:?}", disk_path); return; @@ -707,7 +707,7 @@ impl PreviewProcessor { }; } - async fn process_batch_upload(&mut self, bucket_client: &BucketClient, disk_paths: PathBatch) -> () { + async fn process_batch_upload(&mut self, bucket_client: &LegacyBucketClient, disk_paths: PathBatch) -> () { let mut paths_by_stage: HashMap> = HashMap::new(); for disk_path in disk_paths.get_paths() { if !self.is_disk_path_valid(&disk_path) { @@ -749,7 +749,7 @@ impl PreviewProcessor { } } - async fn upload_frame_from_disk(&self, stage: PreviewStage, frame_number: u32, bucket_client: &BucketClient, disk_path: PathBuf) -> PreviewFrameUploadResult { + async fn upload_frame_from_disk(&self, stage: PreviewStage, frame_number: u32, bucket_client: &LegacyBucketClient, disk_path: PathBuf) -> PreviewFrameUploadResult { let stage_state = self.stages.get(&stage).unwrap(); if stage_state.state == PreviewStageState::UploadComplete { log::debug!("Skipped re-uploading frame: {:?}", disk_path); diff --git a/crates/service/job/deprecated/inference_job/src/main.rs b/crates/service/job/deprecated/inference_job/src/main.rs index c60534d1e6..68df1bae65 100644 --- a/crates/service/job/deprecated/inference_job/src/main.rs +++ b/crates/service/job/deprecated/inference_job/src/main.rs @@ -28,7 +28,7 @@ use sqlx::mysql::MySqlPoolOptions; use bootstrap::bootstrap::{bootstrap, BootstrapArgs}; use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use config::common_env::CommonEnv; use shared_env_var_config::logging::DEFAULT_RUST_LOG; @@ -117,7 +117,7 @@ async fn main() -> AnyhowResult<()> { let bucket_timeout = easyenv::get_env_duration_seconds_or_default("BUCKET_TIMEOUT_SECONDS", Duration::from_secs(60 * 5)); - let private_bucket_client = BucketClient::create( + let private_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, @@ -127,7 +127,7 @@ async fn main() -> AnyhowResult<()> { Some(bucket_timeout), )?; - let public_bucket_client = BucketClient::create( + let public_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, @@ -137,7 +137,7 @@ async fn main() -> AnyhowResult<()> { Some(bucket_timeout), )?; - let auto_gc_bucket_client = BucketClient::create( + let auto_gc_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, diff --git a/crates/service/job/deprecated/inference_job/src/state/job_dependencies.rs b/crates/service/job/deprecated/inference_job/src/state/job_dependencies.rs index 091288cf09..bace096d3a 100644 --- a/crates/service/job/deprecated/inference_job/src/state/job_dependencies.rs +++ b/crates/service/job/deprecated/inference_job/src/state/job_dependencies.rs @@ -10,7 +10,7 @@ use r2d2_redis::RedisConnectionManager; use sqlx::MySqlPool; use bootstrap::bootstrap::ContainerEnvironment; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporterBuilder; @@ -135,9 +135,9 @@ pub struct DatabaseDependencies { } pub struct BucketDependencies { - pub private_bucket_client: BucketClient, - pub public_bucket_client: BucketClient, - pub auto_gc_bucket_client: BucketClient, + pub private_bucket_client: LegacyBucketClient, + pub public_bucket_client: LegacyBucketClient, + pub auto_gc_bucket_client: LegacyBucketClient, pub bucket_path_unifier: BucketPathUnifier, } diff --git a/crates/service/job/deprecated/inference_job/src/util/downloaders/maybe_download_file_from_bucket.rs b/crates/service/job/deprecated/inference_job/src/util/downloaders/maybe_download_file_from_bucket.rs index 70a9bfd21e..2f36fa9885 100644 --- a/crates/service/job/deprecated/inference_job/src/util/downloaders/maybe_download_file_from_bucket.rs +++ b/crates/service/job/deprecated/inference_job/src/util/downloaders/maybe_download_file_from_bucket.rs @@ -2,7 +2,7 @@ use std::path::Path; use log::{error, info, warn}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use filesys::file_size::file_size; use filesys::rename_across_devices::{rename_across_devices, RenameError}; use filesys::file_deletion::safe_delete_directory::safe_delete_directory; @@ -19,7 +19,7 @@ pub struct MaybeDownloadArgs<'a> { pub name_or_description_of_file: &'a str, pub final_filesystem_file_path: &'a Path, pub bucket_object_path: &'a Path, - pub bucket_client: &'a BucketClient, + pub bucket_client: &'a LegacyBucketClient, pub job_progress_reporter: &'a mut Box, pub job_progress_update_description: &'a str, pub job_id: i64, diff --git a/crates/service/job/deprecated/inference_job/src/util/downloaders/model_downloader.rs b/crates/service/job/deprecated/inference_job/src/util/downloaders/model_downloader.rs index 2a791f336f..c44a908bb1 100644 --- a/crates/service/job/deprecated/inference_job/src/util/downloaders/model_downloader.rs +++ b/crates/service/job/deprecated/inference_job/src/util/downloaders/model_downloader.rs @@ -4,7 +4,7 @@ use anyhow::anyhow; use async_trait::async_trait; use log::{error, info}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use filesys::create_dir_all_if_missing::create_dir_all_if_missing; use filesys::file_exists::file_exists; use filesys::rename_across_devices::{rename_across_devices, RenameError}; @@ -29,7 +29,7 @@ pub trait ModelDownloader { async fn download_if_not_on_filesystem( &self, - bucket_client: &BucketClient, + bucket_client: &LegacyBucketClient, scoped_tempdir_creator: &ScopedTempDirCreator, ) -> Result<(), ProcessSingleJobError> { let filesystem_path = self.get_filesystem_path(); diff --git a/crates/service/job/deprecated/inference_job/src/util/model_weights_cache/model_weights_cache_directory.rs b/crates/service/job/deprecated/inference_job/src/util/model_weights_cache/model_weights_cache_directory.rs index bb6a09a4a3..923fe05811 100644 --- a/crates/service/job/deprecated/inference_job/src/util/model_weights_cache/model_weights_cache_directory.rs +++ b/crates/service/job/deprecated/inference_job/src/util/model_weights_cache/model_weights_cache_directory.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use log::{error, info, warn}; use bucket_paths::legacy::typified_paths::public::weight_files::bucket_file_path::WeightFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use errors::AnyhowResult; use filesys::file_size::file_size; use filesys::rename_across_devices::{rename_across_devices, RenameError}; @@ -26,7 +26,7 @@ pub struct ModelWeightsCacheDirectory { pub scoped_tempdir_creator: ScopedTempDirCreator, /// A client to download from the public bucket. - pub public_bucket_client: BucketClient, + pub public_bucket_client: LegacyBucketClient, /// NB: Sometimes the downloader incompletely downloads files. Typically, this is a zero file /// size, but the Rust system may report a non-zero (but small) number of bytes. This should @@ -49,7 +49,7 @@ impl ModelWeightsCacheDirectory { /// Configure and try to create the directory if it doesn't exist. pub fn setup_from_env_and_deps( scoped_temp_dir_creator: &ScopedTempDirCreator, - public_bucket_client: &BucketClient, + public_bucket_client: &LegacyBucketClient, ) -> AnyhowResult { let directory = easyenv::get_env_pathbuf_or_default( "MODEL_WEIGHTS_CACHE_DIR", diff --git a/crates/service/job/deprecated/tts_download_job/src/main.rs b/crates/service/job/deprecated/tts_download_job/src/main.rs index e8e0b65b76..b6b22fdfcc 100644 --- a/crates/service/job/deprecated/tts_download_job/src/main.rs +++ b/crates/service/job/deprecated/tts_download_job/src/main.rs @@ -28,7 +28,7 @@ use sqlx::mysql::MySqlPoolOptions; use sqlx::MySqlPool; use tempdir::TempDir; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier; use config::bad_urls::is_bad_tts_model_download_url; use config::common_env::CommonEnv; @@ -74,7 +74,7 @@ struct Downloader { pub redis_pool: r2d2::Pool, - pub bucket_client: BucketClient, + pub bucket_client: LegacyBucketClient, pub firehose_publisher: FirehosePublisher, pub badge_granter: BadgeGranter, pub google_drive_downloader: GoogleDriveDownloadCommand, @@ -128,7 +128,7 @@ async fn main() -> AnyhowResult<()> { let bucket_timeout = easyenv::get_env_duration_seconds_or_default("BUCKET_TIMEOUT_SECONDS", Duration::from_secs(60 * 5)); - let bucket_client = BucketClient::create( + let bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, diff --git a/crates/service/job/deprecated/tts_inference_job/src/job_steps/download_file_from_bucket.rs b/crates/service/job/deprecated/tts_inference_job/src/job_steps/download_file_from_bucket.rs index 8ff53b2835..27165d0ae0 100644 --- a/crates/service/job/deprecated/tts_inference_job/src/job_steps/download_file_from_bucket.rs +++ b/crates/service/job/deprecated/tts_inference_job/src/job_steps/download_file_from_bucket.rs @@ -2,7 +2,7 @@ use std::path::Path; use log::{info, warn}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporter; use crate::{ProcessSingleJobError, ScopedTempDirCreator}; @@ -13,7 +13,7 @@ pub async fn maybe_download_file_from_bucket( name_or_description_of_file: &str, file_path: &Path, bucket_object_path: &Path, - bucket_client: &BucketClient, + bucket_client: &LegacyBucketClient, job_progress_reporter: &mut Box, job_progress_update_description: &str, job_id: i64, diff --git a/crates/service/job/deprecated/tts_inference_job/src/job_steps/job_args.rs b/crates/service/job/deprecated/tts_inference_job/src/job_steps/job_args.rs index acdd711a44..b3453f25ae 100644 --- a/crates/service/job/deprecated/tts_inference_job/src/job_steps/job_args.rs +++ b/crates/service/job/deprecated/tts_inference_job/src/job_steps/job_args.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use sqlx::MySqlPool; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier; use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporterBuilder; use jobs_common::job_stats::JobStats; @@ -25,8 +25,8 @@ pub struct JobArgs { pub job_progress_reporter: Box, - pub private_bucket_client: BucketClient, - pub public_bucket_client: BucketClient, + pub private_bucket_client: LegacyBucketClient, + pub public_bucket_client: LegacyBucketClient, pub firehose_publisher: FirehosePublisher, diff --git a/crates/service/job/deprecated/tts_inference_job/src/main.rs b/crates/service/job/deprecated/tts_inference_job/src/main.rs index 94f852092b..b8b9136b1a 100644 --- a/crates/service/job/deprecated/tts_inference_job/src/main.rs +++ b/crates/service/job/deprecated/tts_inference_job/src/main.rs @@ -25,7 +25,7 @@ use r2d2_redis::RedisConnectionManager; use sqlx::mysql::MySqlPoolOptions; use sqlx::MySqlPool; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier; use collections::multiple_random_from_vec::multiple_random_from_vec; use config::common_env::CommonEnv; @@ -126,7 +126,7 @@ async fn main() -> AnyhowResult<()> { let bucket_timeout = easyenv::get_env_duration_seconds_or_default("BUCKET_TIMEOUT_SECONDS", Duration::from_secs(60 * 5)); - let private_bucket_client = BucketClient::create( + let private_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, @@ -136,7 +136,7 @@ async fn main() -> AnyhowResult<()> { Some(bucket_timeout), )?; - let public_bucket_client = BucketClient::create( + let public_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, diff --git a/crates/service/job/gmicloud_job/src/job_dependencies.rs b/crates/service/job/gmicloud_job/src/job_dependencies.rs index 1f3cdfc656..3edf9bbad3 100644 --- a/crates/service/job/gmicloud_job/src/job_dependencies.rs +++ b/crates/service/job/gmicloud_job/src/job_dependencies.rs @@ -1,4 +1,4 @@ -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey; use jobs_common::job_stats::JobStats; @@ -10,7 +10,7 @@ pub struct JobDependencies { pub mysql_pool: MySqlPool, /// Public GCS/S3 bucket for storing generated videos. - pub public_bucket_client: BucketClient, + pub public_bucket_client: LegacyBucketClient, /// API key for GmiCloud. pub gmicloud_api_key: GmiCloudApiKey, diff --git a/crates/service/job/gmicloud_job/src/main.rs b/crates/service/job/gmicloud_job/src/main.rs index 7b2a6b0123..3f010118be 100644 --- a/crates/service/job/gmicloud_job/src/main.rs +++ b/crates/service/job/gmicloud_job/src/main.rs @@ -16,7 +16,7 @@ use log::{info, warn}; use sqlx::mysql::MySqlPoolOptions; use bootstrap::bootstrap::{bootstrap, BootstrapArgs}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use shared_env_var_config::logging::DEFAULT_RUST_LOG; use errors::AnyhowResult; @@ -88,7 +88,7 @@ async fn main() -> AnyhowResult<()> { Duration::from_secs(60 * 5), ); - let public_bucket_client = BucketClient::create( + let public_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, diff --git a/crates/service/job/grok_api_job/src/job_dependencies.rs b/crates/service/job/grok_api_job/src/job_dependencies.rs index 70dd6e1526..806fc86c60 100644 --- a/crates/service/job/grok_api_job/src/job_dependencies.rs +++ b/crates/service/job/grok_api_job/src/job_dependencies.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use grok_api_client::creds::grok_api_key::GrokApiKey; use jobs_common::job_stats::JobStats; @@ -14,7 +14,7 @@ pub struct JobDependencies { pub mysql_pool: MySqlPool, /// Public GCS/S3 bucket for storing generated videos. - pub public_bucket_client: BucketClient, + pub public_bucket_client: LegacyBucketClient, /// API key for xAI's Imagine API. pub grok_api_key: GrokApiKey, diff --git a/crates/service/job/grok_api_job/src/main.rs b/crates/service/job/grok_api_job/src/main.rs index b161b73850..3a86143946 100644 --- a/crates/service/job/grok_api_job/src/main.rs +++ b/crates/service/job/grok_api_job/src/main.rs @@ -18,7 +18,7 @@ use sqlx::mysql::MySqlPoolOptions; use tokio::sync::Notify; use bootstrap::bootstrap::{bootstrap, BootstrapArgs}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use errors::AnyhowResult; use grok_api_client::creds::grok_api_key::GrokApiKey; @@ -89,7 +89,7 @@ async fn main() -> AnyhowResult<()> { Duration::from_secs(60 * 5), ); - let public_bucket_client = BucketClient::create( + let public_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, diff --git a/crates/service/job/seedance2_pro_job/Cargo.toml b/crates/service/job/seedance2_pro_job/Cargo.toml index 81cf23d945..c41523d9a5 100644 --- a/crates/service/job/seedance2_pro_job/Cargo.toml +++ b/crates/service/job/seedance2_pro_job/Cargo.toml @@ -43,6 +43,7 @@ tokens.workspace = true anyhow.workspace = true chrono.workspace = true log.workspace = true +rand.workspace = true sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ] } diff --git a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/alert_on_error.rs b/crates/service/job/seedance2_pro_job/src/alert_on_error.rs similarity index 100% rename from crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/alert_on_error.rs rename to crates/service/job/seedance2_pro_job/src/alert_on_error.rs diff --git a/crates/service/job/seedance2_pro_job/src/job_dependencies.rs b/crates/service/job/seedance2_pro_job/src/job_dependencies.rs index 159a882ada..f42b5d54a0 100644 --- a/crates/service/job/seedance2_pro_job/src/job_dependencies.rs +++ b/crates/service/job/seedance2_pro_job/src/job_dependencies.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use crate::kinovi_version::KinoviVersion; +use crate::order_reconciler::OrderReconciler; use chrono::Duration; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use jobs_common::job_stats::JobStats; use pager::client::pager::Pager; @@ -16,7 +17,7 @@ pub struct JobDependencies { pub mysql_pool: MySqlPool, /// Public GCS/S3 bucket for storing generated videos. - pub public_bucket_client: BucketClient, + pub public_bucket_client: LegacyBucketClient, /// Session credentials for polling seedance2-pro.com. pub seedance2pro_session: Seedance2ProSession, @@ -31,11 +32,6 @@ pub struct JobDependencies { /// How long to sleep between poll iterations (milliseconds). pub poll_interval_millis: u64, - /// If set, process jobs in batches of this many pages instead of - /// exhausting all pages before processing. This prevents starvation - /// when the order list is very long. - pub maybe_pages_per_batch: Option, - /// If set, stop paginating backwards through orders once we encounter /// an order older than this duration. This prevents endlessly scanning /// ancient orders that will never match a pending job. @@ -61,4 +57,8 @@ pub struct JobDependencies { /// Pager client for sending alerts. pub pager: Pager, + + /// Hand-off between the polling loop (producer) and the processing loop + /// (consumer): finished Kinovi orders staged for reconciliation into our DB. + pub order_reconciler: OrderReconciler, } diff --git a/crates/service/job/seedance2_pro_job/src/jobs/mod.rs b/crates/service/job/seedance2_pro_job/src/jobs/mod.rs index 65a731a111..b420e68100 100644 --- a/crates/service/job/seedance2_pro_job/src/jobs/mod.rs +++ b/crates/service/job/seedance2_pro_job/src/jobs/mod.rs @@ -1,3 +1,4 @@ pub mod character_polling_job; pub mod credits_checking_job; -pub mod video_polling_job; +pub mod order_polling_job; +pub mod order_processing_job; diff --git a/crates/service/job/seedance2_pro_job/src/jobs/order_polling_job/mod.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_polling_job/mod.rs new file mode 100644 index 0000000000..a5385f26fd --- /dev/null +++ b/crates/service/job/seedance2_pro_job/src/jobs/order_polling_job/mod.rs @@ -0,0 +1 @@ +pub mod order_polling_main_loop; diff --git a/crates/service/job/seedance2_pro_job/src/jobs/order_polling_job/order_polling_main_loop.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_polling_job/order_polling_main_loop.rs new file mode 100644 index 0000000000..2d89fc81c7 --- /dev/null +++ b/crates/service/job/seedance2_pro_job/src/jobs/order_polling_job/order_polling_main_loop.rs @@ -0,0 +1,376 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use chrono::Utc; +use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty; +use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType; +use log::{error, info, warn}; +use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::{list_pending_seedance2pro_video_jobs, PendingSeedance2ProJob}; +use pager::notification::notification_details_builder::NotificationDetailsBuilder; +use pager::notification::notification_urgency::NotificationUrgency; +use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, PollOrdersResponse}; + +use crate::alert_on_error::alert_pager_and_return_err; +use crate::job_dependencies::JobDependencies; +use crate::kinovi_version::KinoviVersion; + +const POLL_ALERT_THRESHOLD: Duration = Duration::from_mins(6); + +/// How many of the newest order pages to re-scan when we periodically revisit +/// the head of the list. Recently-submitted jobs finish soonest, so checking +/// the first few pages more often shortens their tail latency without waiting +/// for a full deep walk to complete. +const HEAD_PAGE_COUNT: u32 = 2; + +/// After this many pages of the deep (long-tail) walk, pause and re-scan the +/// head pages before continuing onward. +const DEEP_PAGES_BETWEEN_HEAD_RECHECKS: u32 = 5; + +/// The producer half of the pipeline. +/// +/// Walks Kinovi's order list looking for orders that have *finished* (completed +/// or failed) and match one of our pending database jobs, and stages each into +/// the shared [`OrderReconciler`](crate::order_reconciler::OrderReconciler). +/// It does no downloading, uploading, or job settling itself — that's the +/// processing loop's job. Staging happens the moment a finished order is seen, +/// so the processor can pick it up immediately rather than waiting for a whole +/// batch (or the whole walk) to complete. +pub async fn order_polling_main_loop(job_dependencies: JobDependencies) { + while !job_dependencies.application_shutdown.get() { + let start = Instant::now(); + + let result = run_poll_iteration(&job_dependencies).await; + + let elapsed = start.elapsed(); + + if let Err(err) = result { + error!("Error in poll iteration: {:?}", err); + let _ = alert_pager_and_return_err::<()>(&job_dependencies.pager, "Kinovi poll iteration error", err, None); + let _ = job_dependencies.job_stats.increment_failure_count(); + } + + if elapsed > POLL_ALERT_THRESHOLD { + warn!("Poll iteration took {:.1}s (threshold: {}s)", elapsed.as_secs_f64(), POLL_ALERT_THRESHOLD.as_secs()); + + let notification = NotificationDetailsBuilder::from_title( + "Kinovi poll iteration slow".to_string()) + .set_description(Some(format!( + "Poll iteration took {:.1} seconds, exceeding the threshold.", + elapsed.as_secs_f64(), + ))) + .set_urgency(Some(NotificationUrgency::Medium)) + .build(); + + if let Err(pager_err) = job_dependencies.pager.enqueue_page(notification) { + error!("Failed to enqueue slow iteration alert: {:?}", pager_err); + } + } + + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(job_dependencies.poll_interval_millis)) => {} + _ = job_dependencies.shutdown_notify.notified() => {} + } + } + + warn!("Kinovi order polling main loop is shut down."); +} + +async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> { + info!("Querying database jobs for type: {:?}", deps.kinovi_version); + + let (job_type, third_party) = match deps.kinovi_version { + KinoviVersion::Volcengine => ( + InferenceJobType::Seedance2ProQueue, + InferenceJobExternalThirdParty::Seedance2Pro, + ), + KinoviVersion::BytePlus => ( + InferenceJobType::Seedance2ProAltQueue, + InferenceJobExternalThirdParty::Seedance2ProAlt, + ), + KinoviVersion::BytePlusUltra => ( + InferenceJobType::Seedance2ProBytePlusUltraQueue, + InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra, + ), + }; + + // 1. Query all (limit 25,000) non-terminal Seedance2Pro jobs from the DB. + let pending_jobs = match list_pending_seedance2pro_video_jobs(&deps.mysql_pool, third_party, job_type).await { + Ok(jobs) => jobs, + Err(err) => { + error!("Failed to list pending database jobs: {:?}", err); + return alert_pager_and_return_err(&deps.pager, "Jobs DB query failed", err.into(), None); + } + }; + + if pending_jobs.is_empty() { + info!("No pending database jobs found."); + return Ok(()); + } + + info!("Found {} pending database job(s).", pending_jobs.len()); + + let result = walk_orders(deps, pending_jobs).await?; + + info!( + "Order poll iteration complete: {} pages seen, {} orders seen, {} finished orders staged. \ + Reconciler currently holds {} order(s) awaiting processing.", + result.pages_seen, + result.orders_seen, + result.orders_staged, + deps.order_reconciler.len(), + ); + + Ok(()) +} + +#[derive(Default)] +struct WalkResult { + pages_seen: u32, + orders_seen: u32, + orders_staged: u32, +} + +/// Walk Kinovi's order pages newest-first, staging finished orders that match a +/// pending job. Periodically pauses the deep walk to re-scan the head pages so +/// recent jobs are caught with lower latency than the long tail. +async fn walk_orders( + deps: &JobDependencies, + pending_jobs: Vec, +) -> anyhow::Result { + let job_by_order_id: HashMap = pending_jobs + .into_iter() + .map(|job| (job.order_id.clone(), job)) + .collect(); + + let pending_db_job_count = job_by_order_id.len(); + let oldest_pending_created_at = job_by_order_id.values().map(|job| job.created_at).min(); + + let iteration_start = Instant::now(); + let mut result = WalkResult::default(); + let mut cursor: Option = None; + let mut pages_since_head_recheck: u32 = 0; + + loop { + if deps.application_shutdown.get() { + info!("Shutdown requested during pagination. Stopping early."); + break; + } + + // Log the iteration state up front (before the poll) so that, if the poll + // itself fails, we still have the surrounding context in the logs. + let iteration_elapsed = iteration_start.elapsed(); + + let pending_db_jobs_display = match oldest_pending_created_at { + Some(oldest) if pending_db_job_count > 0 => format!( + "{} (oldest: {} ago)", + pending_db_job_count, + format_duration_ago(Utc::now() - oldest), + ), + _ => pending_db_job_count.to_string(), + }; + + info!( + "Requesting Kinovi page:\n \ + kinovi page: {page}\n \ + kinovi cursor: {cursor:?}\n \ + orders seen so far: {orders_seen}\n \ + pending DB jobs: {pending_db}\n \ + reconciler backlog: {backlog} order(s) awaiting processing\n \ + iteration elapsed: {secs}s ({millis}ms)", + page = result.pages_seen, + cursor = cursor, + orders_seen = result.orders_seen, + pending_db = pending_db_jobs_display, + backlog = deps.order_reconciler.len(), + secs = iteration_elapsed.as_secs(), + millis = iteration_elapsed.as_millis(), + ); + + let response = poll_orders_with_retry(deps, cursor).await?; + + let page_summary = stage_finished_orders(deps, &response.orders, &job_by_order_id); + + result.pages_seen += 1; + result.orders_seen += response.orders.len() as u32; + result.orders_staged += page_summary.staged; + + let maybe_oldest_created_at = response.orders + .iter() + .filter_map(|order| order.created_at_utc) + .last(); + + info!( + "Polled Kinovi page {}: {} orders (oldest on page created at {:?}); {} finished orders staged from this page.", + result.pages_seen, + response.orders.len(), + maybe_oldest_created_at, + page_summary.staged, + ); + + cursor = response.next_cursor; + + let exceeded_max_age = match (deps.maybe_max_job_age, maybe_oldest_created_at) { + (Some(max_age), Some(oldest)) => { + let age = Utc::now() - oldest; + if age > max_age { + info!( + "Oldest order on page {} is {} hours old (threshold {} hours). Stopping deep walk.", + result.pages_seen, age.num_hours(), max_age.num_hours(), + ); + true + } else { + false + } + } + _ => false, + }; + + if cursor.is_none() || exceeded_max_age { + break; + } + + // Periodically revisit the head of the list mid-walk so freshly-finished + // recent orders don't have to wait for the entire long tail to be scanned. + pages_since_head_recheck += 1; + if pages_since_head_recheck >= DEEP_PAGES_BETWEEN_HEAD_RECHECKS { + pages_since_head_recheck = 0; + let head = recheck_head_pages(deps, &job_by_order_id).await?; + result.pages_seen += head.pages_seen; + result.orders_seen += head.orders_seen; + result.orders_staged += head.orders_staged; + } + } + + Ok(result) +} + +/// Re-scan the first [`HEAD_PAGE_COUNT`] pages (newest orders) and stage any +/// finished matches. Used mid-walk to keep recent-job latency low. +async fn recheck_head_pages( + deps: &JobDependencies, + job_by_order_id: &HashMap, +) -> anyhow::Result { + info!("Re-checking the {} newest order page(s).", HEAD_PAGE_COUNT); + + let mut result = WalkResult::default(); + let mut cursor: Option = None; + + for _ in 0..HEAD_PAGE_COUNT { + if deps.application_shutdown.get() { + break; + } + + let response = poll_orders_with_retry(deps, cursor).await?; + + let page_summary = stage_finished_orders(deps, &response.orders, job_by_order_id); + + result.pages_seen += 1; + result.orders_seen += response.orders.len() as u32; + result.orders_staged += page_summary.staged; + + cursor = response.next_cursor; + if cursor.is_none() { + break; + } + } + + Ok(result) +} + +struct PageStageSummary { + staged: u32, +} + +/// Stage every finished (completed/failed) order on this page that matches a +/// pending job. Orders still in progress are ignored — we'll see them again on +/// a later poll. Staging is idempotent: the reconciler drops duplicates. +fn stage_finished_orders( + deps: &JobDependencies, + orders: &[OrderStatus], + job_by_order_id: &HashMap, +) -> PageStageSummary { + let mut staged = 0u32; + + for order in orders { + let job = match job_by_order_id.get(&order.order_id) { + Some(job) => job, + None => continue, // Not one of our pending jobs. + }; + + if !order.task_status.is_terminal() { + continue; // Still pending/processing — check again next poll. + } + + let newly_staged = deps.order_reconciler.push_order( + order.order_id.clone(), + order.clone(), + job.clone(), + ); + + if newly_staged { + staged += 1; + info!( + "Staged finished order {} (status {:?}) for job {}.", + order.order_id, order.task_status, job.job_token.as_str(), + ); + } + } + + PageStageSummary { staged } +} + +/// Format a positive elapsed duration as `"{h} hours {m} minutes {s} seconds"` +/// (hours are unbounded; e.g. a 50-hour-old job reads `"50 hours ..."`). +/// Negative durations clamp to zero. +fn format_duration_ago(elapsed: chrono::Duration) -> String { + let total_seconds = elapsed.num_seconds().max(0); + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + format!("{} hours {} minutes {} seconds", hours, minutes, seconds) +} + +/// Poll orders from Kinovi with retries. On transient failures, waits with +/// increasing delay (attempt × 2s, capped at `poll_retry_max_delay_millis`). +/// After exhausting retries, alerts the pager and returns an error. +async fn poll_orders_with_retry( + deps: &JobDependencies, + cursor: Option, +) -> anyhow::Result { + let max_retries = deps.poll_max_retries; + + for attempt in 1..=max_retries { + match poll_orders(PollOrdersArgs { + session: &deps.seedance2pro_session, + cursor, + host_override: None, + }).await { + Ok(response) => return Ok(response), + Err(err) => { + warn!( + "Error polling Kinovi orders (attempt {}/{}): {:?}", + attempt, max_retries, err + ); + + if attempt >= max_retries { + return alert_pager_and_return_err( + &deps.pager, + "Kinovi API polling failed after retries", + anyhow::anyhow!("poll_orders failed after {} attempts: {:?}", attempt, err), + None, + ); + } + + let delay_millis = (attempt as u64 * 2_000).min(deps.poll_retry_max_delay_millis); + tokio::time::sleep(Duration::from_millis(delay_millis)).await; + } + } + } + + alert_pager_and_return_err( + &deps.pager, + "Kinovi API polling failed after max retries", + anyhow::anyhow!("poll_orders failed after {} attempts", max_retries), + None, + ) +} diff --git a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/is_job_status_terminal.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/is_job_status_terminal.rs new file mode 100644 index 0000000000..aaeeb4e8cf --- /dev/null +++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/is_job_status_terminal.rs @@ -0,0 +1,8 @@ +use enums::common::job_status_plus::JobStatusPlus; + +/// Whether a job's status means it has already been fully settled and must not +/// be re-processed. Matches the terminal set excluded by the pending-jobs +/// lister (`complete_success` / `complete_failure`). +pub fn is_job_status_terminal(status: JobStatusPlus) -> bool { + matches!(status, JobStatusPlus::CompleteSuccess | JobStatusPlus::CompleteFailure) +} diff --git a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/mod.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/mod.rs new file mode 100644 index 0000000000..40ba7f3b20 --- /dev/null +++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/mod.rs @@ -0,0 +1,6 @@ +pub mod is_job_status_terminal; +pub mod order_processing_main_loop; +pub mod process_one_order; +pub mod process_failed_job; +pub mod process_successful_image_job; +pub mod process_successful_video_job; diff --git a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/order_processing_main_loop.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/order_processing_main_loop.rs new file mode 100644 index 0000000000..1435063980 --- /dev/null +++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/order_processing_main_loop.rs @@ -0,0 +1,96 @@ +use std::time::Duration; + +use log::{error, info, warn}; +use mysql_queries::queries::generic_inference::web::get_inference_job_status_value::get_inference_job_status_value; + +use crate::job_dependencies::JobDependencies; +use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal; +use crate::jobs::order_processing_job::process_one_order::process_one_order; + +/// How long to nap when the reconciler is empty before peeking again. +const IDLE_SLEEP: Duration = Duration::from_millis(500); + +/// How long to back off after a transient error before retrying. +const ERROR_BACKOFF: Duration = Duration::from_secs(2); + +/// The consumer half of the pipeline. +/// +/// Drains finished orders staged by the polling loop and reconciles each into +/// our system: a lightweight DB pre-check (skip jobs already settled before +/// downloading anything), then download + upload, then a `SELECT ... FOR +/// UPDATE` transaction that re-checks the job is still pending before writing +/// the conclusion. Runs independently of polling, so an order is handled as +/// soon as it's staged rather than after a whole batch. +pub async fn order_processing_main_loop(deps: JobDependencies) { + while !deps.application_shutdown.get() { + let details = match deps.order_reconciler.peek_random() { + Some(details) => details, + None => { + nap(&deps, IDLE_SLEEP).await; + continue; + } + }; + + let order_id = details.kinovi_record.order_id.clone(); + let job_token = details.database_record.job_token.clone(); + + // Lightweight pre-check: if the DB already considers the job terminal, drop + // the staged order without doing any expensive download/upload work. + match get_inference_job_status_value(&deps.mysql_pool, &job_token).await { + Ok(Some(status)) if is_job_status_terminal(status) => { + info!( + "Job {} is already terminal ({:?}); dropping staged order {}.", + job_token.as_str(), status, order_id, + ); + deps.order_reconciler.remove(&order_id); + continue; + } + Ok(Some(_)) => { + // Still pending — proceed to process. + } + Ok(None) => { + warn!( + "Job {} for staged order {} no longer exists in the DB; dropping.", + job_token.as_str(), order_id, + ); + deps.order_reconciler.remove(&order_id); + continue; + } + Err(err) => { + error!( + "Failed to read status for job {} (order {}): {:?}. Leaving staged; backing off.", + job_token.as_str(), order_id, err, + ); + nap(&deps, ERROR_BACKOFF).await; + continue; + } + } + + // Commit to this order: pop it so we don't peek it again while working. + deps.order_reconciler.remove(&order_id); + + match process_one_order(&deps, &details).await { + Ok(()) => { + let _ = deps.job_stats.increment_success_count(); + } + Err(err) => { + warn!( + "Error processing order {} (job {}): {:?}. It will be re-staged on a \ + later poll if the job is still pending.", + order_id, job_token.as_str(), err, + ); + let _ = deps.job_stats.increment_failure_count(); + } + } + } + + warn!("Kinovi order processing main loop is shut down."); +} + +/// Sleep, but wake immediately on shutdown. +async fn nap(deps: &JobDependencies, duration: Duration) { + tokio::select! { + _ = tokio::time::sleep(duration) => {} + _ = deps.shutdown_notify.notified() => {} + } +} diff --git a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_failed_job.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_failed_job.rs similarity index 58% rename from crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_failed_job.rs rename to crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_failed_job.rs index cd8d0fac4a..18bbee0983 100644 --- a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_failed_job.rs +++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_failed_job.rs @@ -1,8 +1,9 @@ use log::{error, info, warn}; use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory; -use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs}; use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob; +use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::mark_job_failed_by_token_with_executor; +use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update; use mysql_queries::queries::wallets::refund::try_to_refund_ledger_entry::{try_to_refund_ledger_entry, WalletRefundOutcome}; use pager::notification::notification_details_builder::NotificationDetailsBuilder; use pager::notification::notification_urgency::NotificationUrgency; @@ -10,7 +11,16 @@ use seedance2pro_client::requests::poll_orders::failure_type::FailureType; use seedance2pro_client::requests::poll_orders::poll_orders::OrderStatus; use crate::job_dependencies::JobDependencies; +use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal; +/// Reconcile a failed order: refund the user (if billed) and mark the job +/// failed — atomically, in one transaction. +/// +/// The whole thing runs under a `SELECT ... FOR UPDATE` row lock: we re-read +/// the job's status, bail if another finalizer already settled it, and +/// otherwise refund + mark-failed + commit together. Because refund and the +/// failure write share a transaction, a crash can never leave a failed job +/// without its refund (or vice versa), and the refund itself is idempotent. pub async fn process_failed_job( deps: &JobDependencies, job: &PendingSeedance2ProJob, @@ -22,12 +32,77 @@ pub async fn process_failed_job( .map(|fr| fr.reason.as_str()) .unwrap_or("unknown failure reason"); - // --- Step 1: Attempt the refund before touching the job status. --- - // - // We do this first so that a crash or error between the refund and the status update - // can never result in a failed job with no refund. If the refund itself fails we bail - // out early: the job stays pending and will be retried on the next poll cycle. + let frontend_failure_category = order.fail_reason.as_ref().map(|fr| { + match fr.failure_type { + FailureType::RuleBansUserImage => FrontendFailureCategory::RuleBansUserImage, + FailureType::RuleBansUserImageWithFaces => FrontendFailureCategory::RuleBansUserImageWithFaces, + FailureType::RuleBansUserTextPrompt => FrontendFailureCategory::RuleBansUserTextPrompt, + FailureType::RuleBansUserContent => FrontendFailureCategory::RuleBansUserContent, + FailureType::RuleBansGeneratedVideo => FrontendFailureCategory::RuleBansGeneratedVideo, + FailureType::RuleBansGeneratedAudio => FrontendFailureCategory::RuleBansGeneratedAudio, + FailureType::RuleBansGeneratedContent => FrontendFailureCategory::RuleBansGeneratedContent, + FailureType::GenerationFailed => FrontendFailureCategory::GenerationFailed, + FailureType::OtherUnknownReason => FrontendFailureCategory::GenerationFailed, + } + }); + let mut transaction = match deps.mysql_pool.begin().await { + Ok(tx) => tx, + Err(err) => { + error!( + "Failed to begin failure transaction for job {}: {:?}. \ + Job will NOT be marked failed yet and will be retried next poll.", + job.job_token.as_str(), err, + ); + return; + } + }; + + // ── Terminal-state guard (do NOT remove) ── + // + // Bail unless the job is still pending. Another finalizer may have already + // settled (and refunded) this job between the processing loop's pre-check and + // this locked re-read. If we don't stop here we could double-refund the user. + // This is the single most important check in this function, so it's a + // discrete, early-returning step. + + let maybe_status = match select_inference_job_status_for_update(&mut *transaction, &job.job_token).await { + Ok(maybe_status) => maybe_status, + Err(err) => { + error!( + "Failed to lock job {} for failure finalize: {:?}. Will retry next poll.", + job.job_token.as_str(), err, + ); + let _ = transaction.rollback().await; + return; + } + }; + + let status = match maybe_status { + Some(status) => status, + None => { + warn!( + "Job {} vanished before failure finalize (order {}); skipping.", + job.job_token.as_str(), order.order_id, + ); + let _ = transaction.rollback().await; + return; + } + }; + + if is_job_status_terminal(status) { + info!( + "Job {} is already terminal ({:?}) before failure finalize (order {}); skipping.", + job.job_token.as_str(), status, order.order_id, + ); + let _ = transaction.rollback().await; + return; + } + + // Still pending — refund and mark failed within the locked transaction. + // + // Refund first (within the same transaction), so the refund and the failure + // write commit or roll back together. match &job.maybe_wallet_ledger_entry_token { None => { // No ledger token recorded — job was likely submitted before billing was wired up. @@ -37,18 +112,6 @@ pub async fn process_failed_job( ); } Some(ledger_token) => { - let mut transaction = match deps.mysql_pool.begin().await { - Ok(tx) => tx, - Err(err) => { - error!( - "Failed to begin refund transaction for job {} (ledger {}): {:?}. \ - Job will NOT be marked failed yet and will be retried next poll.", - job.job_token.as_str(), ledger_token.as_str(), err - ); - return; - } - }; - match try_to_refund_ledger_entry(ledger_token, &mut transaction).await { Ok(WalletRefundOutcome::Refunded(summary)) => { info!( @@ -58,23 +121,13 @@ pub async fn process_failed_job( ledger_token.as_str(), summary.refund_ledger_entry_token.as_str(), ); - if let Err(err) = transaction.commit().await { - error!( - "Failed to commit refund transaction for job {} (ledger {}): {:?}. \ - Job will NOT be marked failed yet and will be retried next poll.", - job.job_token.as_str(), ledger_token.as_str(), err - ); - return; - } } Ok(WalletRefundOutcome::AlreadyRefunded) => { - // Idempotent — nothing to do, safe to proceed. info!( "Ledger entry {} for job {} was already refunded; proceeding to mark job failed.", ledger_token.as_str(), job.job_token.as_str(), ); - let _ = transaction.rollback().await; } Err(err) => { error!( @@ -86,7 +139,7 @@ pub async fn process_failed_job( ); let notification = NotificationDetailsBuilder::from_boxed_error(err.into()) - .set_title("Seedance2Pro refund failed".to_string()) + .set_title("Kinovi refund failed".to_string()) .set_inference_job_token(Some(job.job_token.to_string())) .set_third_party_id(Some(job.order_id.to_string())) .set_user_token(job.maybe_creator_user_token.as_ref().map(|t| t.to_string())) @@ -104,36 +157,18 @@ pub async fn process_failed_job( } } - // --- Step 2: Mark the job record as failed. --- - - let frontend_failure_category = order.fail_reason.as_ref().map(|fr| { - match fr.failure_type { - FailureType::RuleBansUserImage => FrontendFailureCategory::RuleBansUserImage, - FailureType::RuleBansUserImageWithFaces => FrontendFailureCategory::RuleBansUserImageWithFaces, - FailureType::RuleBansUserTextPrompt => FrontendFailureCategory::RuleBansUserTextPrompt, - FailureType::RuleBansUserContent => FrontendFailureCategory::RuleBansUserContent, - FailureType::RuleBansGeneratedVideo => FrontendFailureCategory::RuleBansGeneratedVideo, - FailureType::RuleBansGeneratedAudio => FrontendFailureCategory::RuleBansGeneratedAudio, - FailureType::RuleBansGeneratedContent => FrontendFailureCategory::RuleBansGeneratedContent, - FailureType::GenerationFailed => FrontendFailureCategory::GenerationFailed, - FailureType::OtherUnknownReason => FrontendFailureCategory::GenerationFailed, - } - }); - warn!( "Order {} failed: {}. Marking job {} failed.", order.order_id, reason, job.job_token.as_str() ); - let mark_failed_result = mark_job_failed_by_token(MarkJobFailedByTokenArgs { - pool: &deps.mysql_pool, - job_token: &job.job_token, - maybe_public_failure_reason: Some(reason), - internal_debugging_failure_reason: reason, - maybe_frontend_failure_category: frontend_failure_category, - }).await; - - if let Err(err) = mark_failed_result { + if let Err(err) = mark_job_failed_by_token_with_executor( + &mut *transaction, + &job.job_token, + Some(reason), + reason, + frontend_failure_category, + ).await { error!( "Error marking job {} as failed: {:?}", job.job_token.as_str(), @@ -141,12 +176,23 @@ pub async fn process_failed_job( ); let notification = NotificationDetailsBuilder::from_boxed_error(err.into()) - .set_title("Seedance2Pro mark job failed error".to_string()) + .set_title("Kinovi mark job failed error".to_string()) .set_urgency(Some(NotificationUrgency::Medium)) .build(); if let Err(pager_err) = deps.pager.enqueue_page(notification) { error!("Failed to enqueue pager alert: {:?}", pager_err); } + + let _ = transaction.rollback().await; + return; + } + + if let Err(err) = transaction.commit().await { + error!( + "Failed to commit failure transaction for job {}: {:?}. \ + Will be retried next poll.", + job.job_token.as_str(), err, + ); } } diff --git a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_one_order.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_one_order.rs new file mode 100644 index 0000000000..b24d5da19d --- /dev/null +++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_one_order.rs @@ -0,0 +1,60 @@ +use log::warn; + +use errors::AnyhowResult; +use seedance2pro_client::requests::poll_orders::poll_orders::{OrderMediaType, TaskStatus}; + +use crate::job_dependencies::JobDependencies; +use crate::jobs::order_processing_job::process_failed_job::process_failed_job; +use crate::jobs::order_processing_job::process_successful_image_job::process_successful_image_job; +use crate::jobs::order_processing_job::process_successful_video_job::process_successful_video_job; +use crate::order_reconciler::OrderDetails; + +/// Reconcile a single finished order into our system, dispatching on its +/// terminal status and media type. +pub async fn process_one_order( + deps: &JobDependencies, + details: &OrderDetails, +) -> AnyhowResult<()> { + let job = &details.database_record; + let order = &details.kinovi_record; + + match &order.task_status { + TaskStatus::Completed => { + // Dispatch on media type: Midjourney image orders return ~4 PNGs and need + // different download/insert handling than the video path. `None` (older + // response shapes that pre-date the `mediaType` field) and `Some(Video)` + // both fall to the video handler for back-compat. + match &order.media_type { + Some(OrderMediaType::Image) => process_successful_image_job(deps, job, order).await, + Some(OrderMediaType::Video) | None => process_successful_video_job(deps, job, order).await, + Some(OrderMediaType::Unknown(other)) => { + warn!( + "Order {} has unrecognised media_type {:?}; treating as video.", + order.order_id, other, + ); + process_successful_video_job(deps, job, order).await + } + } + } + TaskStatus::Failed => { + process_failed_job(deps, job, order).await; + Ok(()) + } + // The poller only stages terminal orders, so these shouldn't occur — but be + // defensive rather than mis-process a non-final order. + TaskStatus::Pending | TaskStatus::Processing => { + warn!( + "Staged order {} is unexpectedly still in progress ({:?}); skipping.", + order.order_id, order.task_status, + ); + Ok(()) + } + TaskStatus::Unknown(status) => { + warn!( + "Staged order {} has unknown status {:?}; skipping.", + order.order_id, status, + ); + Ok(()) + } + } +} diff --git a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_successful_image_job.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_image_job.rs similarity index 72% rename from crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_successful_image_job.rs rename to crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_image_job.rs index 8ff7f77550..0c7cb82919 100644 --- a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_successful_image_job.rs +++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_image_job.rs @@ -11,14 +11,16 @@ use enums::common::generation_provider::GenerationProvider; use errors::AnyhowResult; use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob; -use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token; +use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update; +use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token_with_executor::{mark_generic_inference_job_successfully_done_by_token_with_executor, MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs}; use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder; -use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, VideoResult}; +use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, MediaResult}; use tokens::tokens::batch_generations::BatchGenerationToken; use tokens::tokens::media_files::MediaFileToken; +use crate::alert_on_error::alert_pager_and_return_err; use crate::job_dependencies::JobDependencies; -use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err; +use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal; const PREFIX: &str = "artcraft_"; const SUFFIX: &str = ".png"; @@ -90,30 +92,78 @@ pub async fn process_successful_image_job( })?; info!( - "Created {} media file(s) for order {} (primary={}). Marking job {} complete.", + "Created {} media file(s) for order {} (primary={}). Finalizing job {}.", created_tokens.len(), order.order_id, primary_token.as_str(), job.job_token.as_str(), ); - if let Err(err) = mark_generic_inference_job_successfully_done_by_token( - &deps.mysql_pool, - &job.job_token, - Some(InferenceResultType::MediaFile), - Some(primary_token.as_str()), - None, - None, + // Finalize inside a transaction: re-check the job is still pending under a row + // lock, then mark it complete. See `process_successful_video_job` for the rationale. + let mut transaction = deps.mysql_pool.begin().await.map_err(|err| { + anyhow!("error beginning finalize transaction for job {}: {:?}", job.job_token.as_str(), err) + })?; + + let maybe_status = select_inference_job_status_for_update(&mut *transaction, &job.job_token) + .await + .map_err(|err| anyhow!("error locking job {} for finalize: {:?}", job.job_token.as_str(), err))?; + + // ── Terminal-state guard (do NOT remove) ── + // + // Bail unless the job is still pending. A concurrent finalizer (another poll, + // a web cancel) may have settled it between the processing loop's pre-check + // and this locked re-read. If we don't stop here we'd re-mark a finished job + // and leak the just-uploaded media files. This is the single most important + // check in this function, so it's a discrete, early-returning step. + + let status = match maybe_status { + Some(status) => status, + None => { + let _ = transaction.rollback().await; + return Err(anyhow!( + "Job {} vanished before finalize (order {})", + job.job_token.as_str(), order.order_id, + )); + } + }; + + if is_job_status_terminal(status) { + warn!( + "Job {} is already terminal ({:?}); skipping mark-done (order {}). \ + {} media file(s) may be orphaned.", + job.job_token.as_str(), status, order.order_id, created_tokens.len(), + ); + let _ = transaction.rollback().await; + return Ok(()); + } + + // Still pending — mark it done within the locked transaction. + + if let Err(err) = mark_generic_inference_job_successfully_done_by_token_with_executor( + MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs { + executor: &mut *transaction, + token: &job.job_token, + maybe_entity_type: Some(InferenceResultType::MediaFile), + maybe_entity_token: Some(primary_token.as_str()), + total_job_duration: None, + inference_duration: None, + }, ).await { + let _ = transaction.rollback().await; error!("Error marking image job {} done: {:?}", job.job_token.as_str(), err); return alert_pager_and_return_err( &deps.pager, - "Seedance2Pro image job completion update failed", + "Kinovi image job completion update failed", anyhow!("error marking job done: {:?}", err), Some(job), ); } + transaction.commit().await.map_err(|err| { + anyhow!("error committing finalize transaction for job {}: {:?}", job.job_token.as_str(), err) + })?; + info!("Image job {} completed successfully.", job.job_token.as_str()); Ok(()) @@ -125,7 +175,7 @@ async fn download_and_store_one_image( deps: &JobDependencies, job: &PendingSeedance2ProJob, order: &OrderStatus, - result: &VideoResult, + result: &MediaResult, index: usize, maybe_batch_token: Option<&BatchGenerationToken>, ) -> AnyhowResult { @@ -174,8 +224,8 @@ async fn download_and_store_one_image( .maybe_batch_generation_token(maybe_batch_token) .maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref()) .maybe_creator_user(job.maybe_creator_user_token.as_ref()) - .maybe_frame_height(Some(result.height)) - .maybe_frame_width(Some(result.width)) + .maybe_frame_height(result.maybe_height) + .maybe_frame_width(result.maybe_width) .maybe_generation_provider(Some(GenerationProvider::Artcraft)) .maybe_prompt_token(job.maybe_prompt_token.as_ref()) .maybe_platform_type(job.maybe_platform_type) diff --git a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_successful_job.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_video_job.rs similarity index 60% rename from crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_successful_job.rs rename to crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_video_job.rs index 4d7eb1e647..ed810634d9 100644 --- a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_successful_job.rs +++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_video_job.rs @@ -1,5 +1,5 @@ use anyhow::anyhow; -use log::{error, info}; +use log::{error, info, warn}; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType; @@ -11,18 +11,20 @@ use enums::common::generation_provider::GenerationProvider; use errors::AnyhowResult; use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob; +use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update; +use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token_with_executor::{mark_generic_inference_job_successfully_done_by_token_with_executor, MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs}; use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder; -use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token; use seedance2pro_client::requests::poll_orders::poll_orders::OrderStatus; -use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err; +use crate::alert_on_error::alert_pager_and_return_err; use crate::job_dependencies::JobDependencies; +use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal; const PREFIX: &str = "artcraft_"; const SUFFIX: &str = ".mp4"; /// Download the completed video, upload to bucket, create media file record, and mark job done. -pub async fn process_successful_job( +pub async fn process_successful_video_job( deps: &JobDependencies, job: &PendingSeedance2ProJob, order: &OrderStatus, @@ -57,7 +59,7 @@ pub async fn process_successful_job( error!("Error reading video bytes for order {}: {:?}", order.order_id, err); return alert_pager_and_return_err( &deps.pager, - "Seedance2Pro video download failed", + "Kinovi video download failed", anyhow!("error reading video bytes: {:?}", err), Some(job), ); @@ -67,7 +69,7 @@ pub async fn process_successful_job( error!("Error downloading video for order {}: {:?}", order.order_id, err); return alert_pager_and_return_err( &deps.pager, - "Seedance2Pro video download failed", + "Kinovi video download failed", anyhow!("reqwest error downloading video: {:?}", err), Some(job), ); @@ -104,7 +106,7 @@ pub async fn process_successful_job( error!("Error uploading video for order {}: {:?}", order.order_id, err); return alert_pager_and_return_err( &deps.pager, - "Seedance2Pro bucket upload failed", + "Kinovi bucket upload failed", anyhow!("error uploading video to bucket: {:?}", err), Some(job), ); @@ -116,8 +118,9 @@ pub async fn process_successful_job( ); // Optionally extract frame dimensions from the order results. - let maybe_frame_width = order.results.first().map(|r| r.width); - let maybe_frame_height = order.results.first().map(|r| r.height); + // Persist the integer dimensions when the API provides them, NULL otherwise. + let maybe_frame_width = order.results.first().and_then(|r| r.maybe_width); + let maybe_frame_height = order.results.first().and_then(|r| r.maybe_height); // Insert media file record. let media_file_result = MediaFileInsertBuilder::new() @@ -147,7 +150,7 @@ pub async fn process_successful_job( error!("Error inserting media file record for order {}: {:?}", order.order_id, err); return alert_pager_and_return_err( &deps.pager, - "Seedance2Pro media file insert failed", + "Kinovi media file insert failed", anyhow!("error inserting media file record: {:?}", err), Some(job), ); @@ -155,30 +158,89 @@ pub async fn process_successful_job( }; info!( - "Created media file {} for order {}. Marking job {} complete.", + "Created media file {} for order {}. Finalizing job {}.", media_file_token.as_str(), order.order_id, job.job_token.as_str() ); - // Mark inference job as successfully completed. - if let Err(err) = mark_generic_inference_job_successfully_done_by_token( - &deps.mysql_pool, - &job.job_token, - Some(InferenceResultType::MediaFile), - Some(media_file_token.as_str()), - None, - None, + // Finalize inside a transaction: re-check the job is still pending under a row + // lock, then mark it complete. This guards against a concurrent finalizer + // (another poll, a web cancel) having already settled the job between our + // pre-check and now. If it has, the freshly-uploaded media file is left + // orphaned (a rare, harmless cost). + finalize_success(deps, job, order, media_file_token.as_str()).await +} + +async fn finalize_success( + deps: &JobDependencies, + job: &PendingSeedance2ProJob, + order: &OrderStatus, + media_file_token: &str, +) -> AnyhowResult<()> { + let mut transaction = deps.mysql_pool.begin().await.map_err(|err| { + anyhow!("error beginning finalize transaction for job {}: {:?}", job.job_token.as_str(), err) + })?; + + let maybe_status = select_inference_job_status_for_update(&mut *transaction, &job.job_token) + .await + .map_err(|err| anyhow!("error locking job {} for finalize: {:?}", job.job_token.as_str(), err))?; + + // ── Terminal-state guard (do NOT remove) ── + // + // Bail unless the job is still pending. A concurrent finalizer (another poll, + // a web cancel) may have settled it between the processing loop's pre-check + // and this locked re-read. If we don't stop here we'd re-mark a finished job + // and leak the just-uploaded media file. This is the single most important + // check in this function, so it's a discrete, early-returning step. + + let status = match maybe_status { + Some(status) => status, + None => { + let _ = transaction.rollback().await; + return Err(anyhow!( + "Job {} vanished before finalize (order {})", + job.job_token.as_str(), order.order_id, + )); + } + }; + + if is_job_status_terminal(status) { + warn!( + "Job {} is already terminal ({:?}); skipping mark-done (order {}). \ + Media file {} may be orphaned.", + job.job_token.as_str(), status, order.order_id, media_file_token, + ); + let _ = transaction.rollback().await; + return Ok(()); + } + + // Still pending — mark it done within the locked transaction. + + if let Err(err) = mark_generic_inference_job_successfully_done_by_token_with_executor( + MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs { + executor: &mut *transaction, + token: &job.job_token, + maybe_entity_type: Some(InferenceResultType::MediaFile), + maybe_entity_token: Some(media_file_token), + total_job_duration: None, + inference_duration: None, + }, ).await { + let _ = transaction.rollback().await; error!("Error marking job {} done: {:?}", job.job_token.as_str(), err); return alert_pager_and_return_err( &deps.pager, - "Seedance2Pro job completion update failed", + "Kinovi job completion update failed", anyhow!("error marking job done: {:?}", err), Some(job), ); } + transaction.commit().await.map_err(|err| { + anyhow!("error committing finalize transaction for job {}: {:?}", job.job_token.as_str(), err) + })?; + info!("Job {} completed successfully.", job.job_token.as_str()); Ok(()) diff --git a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/mod.rs b/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/mod.rs deleted file mode 100644 index ead0b85788..0000000000 --- a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod alert_on_error; -pub mod video_polling_main_loop; -pub mod process_job; -pub mod process_orders_batch; diff --git a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/mod.rs b/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/mod.rs deleted file mode 100644 index b2de8e165f..0000000000 --- a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod process_failed_job; -pub mod process_successful_image_job; -pub mod process_successful_job; diff --git a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_orders_batch.rs b/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_orders_batch.rs deleted file mode 100644 index 045620dd0b..0000000000 --- a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_orders_batch.rs +++ /dev/null @@ -1,127 +0,0 @@ -use std::collections::HashMap; - -use log::{info, warn}; -use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob; -use seedance2pro_client::requests::poll_orders::poll_orders::{OrderMediaType, OrderStatus, TaskStatus}; - -use crate::job_dependencies::JobDependencies; -use crate::jobs::video_polling_job::process_job::process_failed_job::process_failed_job; -use crate::jobs::video_polling_job::process_job::process_successful_image_job::process_successful_image_job; -use crate::jobs::video_polling_job::process_job::process_successful_job::process_successful_job; - -/// Process a batch of polled orders against the pending jobs map. -/// -/// Removes matched order_ids from `job_by_order_id` so they are not -/// processed again in subsequent batches. -pub async fn process_orders_batch( - deps: &JobDependencies, - kinovi_orders: &[OrderStatus], - job_by_order_id: &mut HashMap, - pages_in_current_batch: u32, -) { - log_batch_summary(&kinovi_orders, pages_in_current_batch); - - let pending_job_count = job_by_order_id.len(); - - let mut batch_succeeded = 0u32; - let mut batch_failed = 0u32; - let mut batch_in_progress = 0u32; - let mut batch_matched = 0u32; - - for order in kinovi_orders { - let job = match job_by_order_id.remove(&order.order_id) { - Some(j) => j, - None => continue, // Not one of our pending jobs. - }; - - batch_matched += 1; - - match &order.task_status { - TaskStatus::Completed => { - info!( - "Order {} completed (media_type={:?}), processing job {}", - order.order_id, - order.media_type, - job.job_token.as_str() - ); - - // Dispatch on media type: Midjourney image orders return ~4 PNGs - // and need different download/insert handling than the video - // path. `None` (older response shapes that pre-date the - // `mediaType` field) and `Some(Video)` both fall to the video - // handler for back-compat with the seedance video flow. - let result = match &order.media_type { - Some(OrderMediaType::Image) => process_successful_image_job(deps, &job, order).await, - Some(OrderMediaType::Video) | None => process_successful_job(deps, &job, order).await, - Some(OrderMediaType::Unknown(other)) => { - warn!( - "Order {} has unrecognised media_type {:?}; treating as video", - order.order_id, other, - ); - process_successful_job(deps, &job, order).await - } - }; - - if let Err(err) = result { - warn!( - "Error processing completed order {}: {:?}", - order.order_id, err - ); - let _ = deps.job_stats.increment_failure_count(); - } else { - let _ = deps.job_stats.increment_success_count(); - batch_succeeded += 1; - } - } - TaskStatus::Failed => { - process_failed_job(deps, &job, order).await; - batch_failed += 1; - } - TaskStatus::Pending | TaskStatus::Processing => { - // Still in progress — check again next poll. - batch_in_progress += 1; - } - TaskStatus::Unknown(unknown_status) => { - warn!("Unknown order status: {:?}", unknown_status); - batch_in_progress += 1; - } - } - } - - info!( - "Batch processing done: \ - {} kinovi orders in polled batch, \ - {} pending db jobs in batch at start, \ - {} kinovi orders in batch matched pending jobs \ - (orders succeeded={}, orders failed={}, orders in_progress={}), \ - {} pending db jobs in batch remaining", - kinovi_orders.len(), - pending_job_count, - batch_matched, - batch_succeeded, - batch_failed, - batch_in_progress, - job_by_order_id.len(), - ); -} - -fn log_batch_summary(orders: &[OrderStatus], pages_in_batch: u32) { - let mut succeeded = 0u32; - let mut failed = 0u32; - let mut in_progress = 0u32; - let mut unknown = 0u32; - - for order in orders { - match &order.task_status { - TaskStatus::Completed => succeeded += 1, - TaskStatus::Failed => failed += 1, - TaskStatus::Pending | TaskStatus::Processing => in_progress += 1, - TaskStatus::Unknown(_) => unknown += 1, - } - } - - info!( - "Processing batch of {} Kinovi order pages, {} total orders (succeeded={}, failed={}, in_progress={}, unknown={})", - pages_in_batch, orders.len(), succeeded, failed, in_progress, unknown - ); -} diff --git a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/video_polling_main_loop.rs b/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/video_polling_main_loop.rs deleted file mode 100644 index 7c621a24f7..0000000000 --- a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/video_polling_main_loop.rs +++ /dev/null @@ -1,287 +0,0 @@ -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -use chrono::Utc; -use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty; -use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType; -use log::{error, info, warn}; -use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::{list_pending_seedance2pro_video_jobs, PendingSeedance2ProJob}; -use pager::notification::notification_details_builder::NotificationDetailsBuilder; -use pager::notification::notification_urgency::NotificationUrgency; -use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, PollOrdersResponse, TaskStatus}; - -use crate::job_dependencies::JobDependencies; -use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err; -use crate::jobs::video_polling_job::process_orders_batch::process_orders_batch; -use crate::kinovi_version::KinoviVersion; - -const POLL_ALERT_THRESHOLD: Duration = Duration::from_mins(6); - -pub async fn video_polling_main_loop(job_dependencies: JobDependencies) { - while !job_dependencies.application_shutdown.get() { - let start = Instant::now(); - - // - // Run a single polling iteration and alert if it takes too long - // - - let result = run_poll_iteration(&job_dependencies).await; - - let elapsed = start.elapsed(); - - if let Err(err) = result { - error!("Error in poll iteration: {:?}", err); - let _ = alert_pager_and_return_err::<()>(&job_dependencies.pager, "Kinovi poll iteration error", err, None); - let _ = job_dependencies.job_stats.increment_failure_count(); - } - - if elapsed > POLL_ALERT_THRESHOLD { - warn!("Poll iteration took {:.1}s (threshold: {}s)", elapsed.as_secs_f64(), POLL_ALERT_THRESHOLD.as_secs()); - - let notification = NotificationDetailsBuilder::from_title( - "Kinovi poll iteration slow".to_string()) - .set_description(Some(format!( - "Poll iteration took {:.1} seconds, exceeding the 10-minute threshold.", - elapsed.as_secs_f64(), - ))) - .set_urgency(Some(NotificationUrgency::Medium)) - .build(); - - if let Err(pager_err) = job_dependencies.pager.enqueue_page(notification) { - error!("Failed to enqueue slow iteration alert: {:?}", pager_err); - } - } - - tokio::select! { - _ = tokio::time::sleep(Duration::from_millis(job_dependencies.poll_interval_millis)) => {} - _ = job_dependencies.shutdown_notify.notified() => {} - } - } - - warn!("Kinovi job runner main loop is shut down."); -} - -async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> { - info!("Querying database jobs for type: {:?}", deps.kinovi_version); - - let ( - job_type, - third_party, - ) = match deps.kinovi_version { - KinoviVersion::Volcengine => ( - InferenceJobType::Seedance2ProQueue, - InferenceJobExternalThirdParty::Seedance2Pro, - ), - KinoviVersion::BytePlus => ( - InferenceJobType::Seedance2ProAltQueue, - InferenceJobExternalThirdParty::Seedance2ProAlt, - ), - KinoviVersion::BytePlusUltra => ( - InferenceJobType::Seedance2ProBytePlusUltraQueue, - InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra, - ), - }; - - // 1. Query all (limit 25,000) non-terminal Seedance2Pro jobs from DB. - // This is all non-(complete_success, complete_failure) jobs. - let pending_jobs = match list_pending_seedance2pro_video_jobs(&deps.mysql_pool, third_party, job_type).await { - Ok(jobs) => jobs, - Err(err) => { - error!("Failed to list pending database jobs: {:?}", err); - return alert_pager_and_return_err(&deps.pager, "Jobs DB query failed", err.into(), None); - } - }; - - let total_pending_jobs = pending_jobs.len(); - - if pending_jobs.is_empty() { - info!("No pending database jobs found in the database."); - return Ok(()); - } - - info!("Found {} pending database job(s).", pending_jobs.len()); - - let result = website_polling_loop(&deps, pending_jobs).await?; - - info!( - "Database + Kinovi poll iteration complete: \ - {} total database jobs, \ - {} total Kinovi pages, \ - {} total Kinovi orders seen.", - total_pending_jobs, - result.total_pages_seen, - result.total_orders_seen - ); - - Ok(()) -} - -struct WebsitePollingResult { - total_pages_seen: u32, - total_orders_seen: u32, -} - -async fn website_polling_loop( - deps: &&JobDependencies, - pending_jobs: Vec -) -> anyhow::Result { - - // Build a lookup: order_id -> job. This is mutated as batches are processed - // so that each order is handled at most once. - let mut job_by_order_id: HashMap = pending_jobs - .into_iter() - .map(|job| (job.order_id.clone(), job)) - .collect(); - - // 2. Poll orders from Kinovi API, optionally processing in chunks. - let mut cursor: Option = None; - let mut total_pages_seen: u32 = 0; - let mut total_orders_seen: u32 = 0; - - // Accumulates orders for the current batch (when chunking is enabled) - // or all orders (when chunking is disabled). - let mut batch_orders: Vec = Vec::new(); - let mut pages_in_current_batch: u32 = 0; - - loop { - if deps.application_shutdown.get() { - info!("Shutdown requested during pagination. Stopping early."); - break; - } - - info!( - "Beginning request for Kinovi page {}... (cursor: {:?}, total orders seen thus far: {})", - total_pages_seen, cursor, total_orders_seen - ); - - let response = poll_orders_with_retry(deps, cursor).await?; - - let page_orders_count = response.orders.len() as u32; - - total_pages_seen += 1; - total_orders_seen += page_orders_count; - - pages_in_current_batch += 1; - - let maybe_last_order_created_at = response.orders - .iter() - .filter(|order| order.created_at_utc.is_some()) - .last() - .and_then(|order| order.created_at_utc); - - info!( - "Done polling Kinovi orders page {}. \ - Got {} orders on this page (oldest on page created at {:?}).", - total_pages_seen, - page_orders_count, - maybe_last_order_created_at, - ); - - // Check if the last (oldest) order in this page exceeds the max age threshold. - // Orders are returned newest-first, so the last order is the oldest. - let mut exceeded_max_age = false; - - if let Some(ref max_age) = deps.maybe_max_job_age { - if let Some(last_order_created_at) = maybe_last_order_created_at { - let order_age = Utc::now() - last_order_created_at; - let too_old = order_age > *max_age; - if too_old { - info!( - "Last order on Kinovi order page {} is {} hours old \ - (staleness threshold: {} hours). Stopping Kinovi pagination.", - total_pages_seen, - order_age.num_hours(), - max_age.num_hours() - ); - exceeded_max_age = true; - } - } - }; - - batch_orders.extend(response.orders); - - cursor = response.next_cursor; - - let reached_end = cursor.is_none() || exceeded_max_age; - - let batch_is_full = deps.maybe_pages_per_batch - .map(|batch_page_limit| pages_in_current_batch >= batch_page_limit) - .unwrap_or(false); - - // Determine if we should process the current batch now. - let should_process_batch_now = reached_end || batch_is_full; - - if should_process_batch_now && !batch_orders.is_empty() { - process_orders_batch( - deps, - &batch_orders, - &mut job_by_order_id, - pages_in_current_batch - ).await; - - batch_orders.clear(); - pages_in_current_batch = 0; - - // If all pending jobs have been matched, no need to keep paging. - if job_by_order_id.is_empty() { - info!("All pending database jobs have been matched to Kinovi orders and processed. Stopping pagination early."); - break; - } - } - - if reached_end { - break; - } - } - - Ok(WebsitePollingResult { - total_pages_seen, - total_orders_seen - }) -} - -/// Poll orders from Kinovi with retries. On transient failures, waits with -/// increasing delay (attempt × 2s, capped at `poll_retry_max_delay_millis`). -/// After exhausting retries, alerts the pager and returns an error. -async fn poll_orders_with_retry( - deps: &JobDependencies, - cursor: Option, -) -> anyhow::Result { - let max_retries = deps.poll_max_retries; - - for attempt in 1..=max_retries { - match poll_orders(PollOrdersArgs { - session: &deps.seedance2pro_session, - cursor, - host_override: None, - }).await { - Ok(response) => return Ok(response), - Err(err) => { - warn!( - "Error polling Kinovi orders (attempt {}/{}): {:?}", - attempt, max_retries, err - ); - - if attempt >= max_retries { - return alert_pager_and_return_err( - &deps.pager, - "Kinovi API polling failed after retries", - anyhow::anyhow!("poll_orders failed after {} attempts: {:?}", attempt, err), - None, - ); - } - - let delay_millis = (attempt as u64 * 2_000).min(deps.poll_retry_max_delay_millis); - tokio::time::sleep(Duration::from_millis(delay_millis)).await; - } - } - } - - alert_pager_and_return_err( - &deps.pager, - "Kinovi API polling failed after max retries", - anyhow::anyhow!("poll_orders failed after {} attempts", max_retries), - None, - ) -} - diff --git a/crates/service/job/seedance2_pro_job/src/main.rs b/crates/service/job/seedance2_pro_job/src/main.rs index d9374a0c8d..df196e60a3 100644 --- a/crates/service/job/seedance2_pro_job/src/main.rs +++ b/crates/service/job/seedance2_pro_job/src/main.rs @@ -18,7 +18,7 @@ use sqlx::mysql::MySqlPoolOptions; use tokio::sync::Notify; use bootstrap::bootstrap::{bootstrap, BootstrapArgs}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use shared_env_var_config::logging::DEFAULT_RUST_LOG; use errors::AnyhowResult; @@ -30,15 +30,19 @@ use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default; use crate::http_server::run_http_server::{launch_http_server, CreateServerArgs}; use crate::jobs::character_polling_job::character_polling_main_loop::character_polling_main_loop; use crate::jobs::credits_checking_job::credits_checking_main_loop::credits_checking_main_loop; -use crate::jobs::video_polling_job::video_polling_main_loop::video_polling_main_loop; +use crate::jobs::order_polling_job::order_polling_main_loop::order_polling_main_loop; +use crate::jobs::order_processing_job::order_processing_main_loop::order_processing_main_loop; use crate::job_dependencies::JobDependencies; +use crate::order_reconciler::OrderReconciler; use crate::startup::build_pager::build_pager; use crate::startup::kinovi_setup::{get_kinovi_session, get_kinovi_version}; +pub mod alert_on_error; pub mod http_server; pub mod job_dependencies; pub mod jobs; pub mod kinovi_version; +pub mod order_reconciler; pub mod startup; // Bucket config @@ -93,7 +97,7 @@ async fn main() -> AnyhowResult<()> { Duration::from_secs(60 * 5), ); - let public_bucket_client = BucketClient::create( + let public_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, @@ -112,12 +116,6 @@ async fn main() -> AnyhowResult<()> { 5_000, )?; - let maybe_pages_per_batch: Option = easyenv::try_get_env_num_optional("BATCH_PAGE_COUNT")?; - - if let Some(count) = maybe_pages_per_batch { - info!("Batch page count: {}", count); - } - let maybe_max_job_age = easyenv::try_get_env_num_optional::(ENV_MAX_JOB_AGE_THRESHOLD_HOURS)? .and_then(chrono::Duration::try_hours); @@ -160,6 +158,9 @@ async fn main() -> AnyhowResult<()> { let shutdown_notify = Arc::new(Notify::new()); let job_stats = JobStats::new(); + // Shared hand-off between the polling loop (producer) and processing loop (consumer). + let order_reconciler = OrderReconciler::new(); + let pager_for_shutdown = pager.clone(); let create_server_args = CreateServerArgs { @@ -176,7 +177,6 @@ async fn main() -> AnyhowResult<()> { server_environment, job_stats, poll_interval_millis, - maybe_pages_per_batch, maybe_max_job_age, poll_max_retries, poll_retry_max_delay_millis, @@ -184,6 +184,7 @@ async fn main() -> AnyhowResult<()> { application_shutdown: application_shutdown.clone(), shutdown_notify: shutdown_notify.clone(), pager, + order_reconciler, }; // HTTP server runs on a separate OS thread with its own actix System. @@ -214,12 +215,18 @@ async fn main() -> AnyhowResult<()> { } }); - // Spawn all polling loops as concurrent tasks. - let video_deps = job_dependencies.clone(); + // Spawn all loops as concurrent tasks. Order polling (producer) and order + // processing (consumer) run independently and hand off via the reconciler. + let polling_deps = job_dependencies.clone(); + let processing_deps = job_dependencies.clone(); let credits_deps = job_dependencies.clone(); - let video_handle = tokio::spawn(async move { - video_polling_main_loop(video_deps).await; + let polling_handle = tokio::spawn(async move { + order_polling_main_loop(polling_deps).await; + }); + + let processing_handle = tokio::spawn(async move { + order_processing_main_loop(processing_deps).await; }); let credits_handle = tokio::spawn(async move { @@ -231,11 +238,11 @@ async fn main() -> AnyhowResult<()> { let character_handle = tokio::spawn(async move { character_polling_main_loop(character_deps).await; }); - let _ = tokio::join!(video_handle, character_handle, credits_handle); + let _ = tokio::join!(polling_handle, processing_handle, character_handle, credits_handle); } else { // skip character polling entirely. info!("Alternate mode: character polling is disabled."); - let _ = tokio::join!(video_handle, credits_handle); + let _ = tokio::join!(polling_handle, processing_handle, credits_handle); } info!("Shutting down pager worker..."); diff --git a/crates/service/job/seedance2_pro_job/src/order_reconciler.rs b/crates/service/job/seedance2_pro_job/src/order_reconciler.rs new file mode 100644 index 0000000000..0bd1cc368f --- /dev/null +++ b/crates/service/job/seedance2_pro_job/src/order_reconciler.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use rand::Rng; + +use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob; +use seedance2pro_client::requests::poll_orders::poll_orders::OrderStatus; + +/// Kinovi's `order_id`. Used as the key into the reconciler. +pub type OrderId = String; + +/// A finished external order (Kinovi reports it `Completed` or `Failed`) paired +/// with our database job record, staged for reconciliation into our own system. +#[derive(Clone)] +pub struct OrderDetails { + /// The order payload fetched from Kinovi (success or failure details). + pub kinovi_record: OrderStatus, + /// Our database job that this order fulfills. + pub database_record: PendingSeedance2ProJob, +} + +/// Hand-off point between the polling loop and the processing loop. +/// +/// Metaphor: reconciliation. Kinovi is an external ledger of orders; once an +/// order is *finished* there (success or failure), our system still has to be +/// brought into agreement — download/upload the result, write the media file, +/// settle the job row, refund on failure. The polling loop discovers finished +/// orders and stages them here; the processing loop drains them and reconciles +/// each one into our database. +/// +/// Cheaply [`Clone`]able: clones share the same underlying map. +#[derive(Clone)] +pub struct OrderReconciler { + // NB: A std (sync) RwLock is intentional. All critical sections are tiny, + // in-memory map operations with no `.await` held across the guard, so there + // is no risk of blocking the async runtime. + inner: Arc>>, +} + +impl OrderReconciler { + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Stage a finished order for reconciliation, but only if it isn't already + /// staged. Returns `true` if it was newly inserted, `false` if an entry for + /// this `order_id` was already present (so the poller doesn't re-enqueue an + /// order the processor is mid-way through, or one it already staged). + pub fn push_order( + &self, + order_id: OrderId, + kinovi_order: OrderStatus, + database_record: PendingSeedance2ProJob, + ) -> bool { + let mut map = self.inner.write().expect("order reconciler lock poisoned"); + + if map.contains_key(&order_id) { + return false; + } + + map.insert(order_id, OrderDetails { kinovi_record: kinovi_order, database_record }); + true + } + + /// Clone out one *randomly chosen* staged order without removing it, so the + /// caller can consult the database before committing to (and popping) it. + /// Returns `None` when nothing is staged. + /// + /// Selection is random on purpose: if we always handed back the same + /// (deterministically ordered) entry, a single order that keeps failing to + /// process — and keeps getting re-staged by the poller — could monopolize the + /// consumer and starve everything behind it. Random pick spreads attempts + /// across the whole backlog so one poison order can't wedge the queue. + pub fn peek_random(&self) -> Option { + let map = self.inner.read().expect("order reconciler lock poisoned"); + + if map.is_empty() { + return None; + } + + let index = rand::rng().random_range(0..map.len()); + map.values().nth(index).cloned() + } + + /// Remove and return a staged order by id. + pub fn remove(&self, order_id: &str) -> Option { + let mut map = self.inner.write().expect("order reconciler lock poisoned"); + map.remove(order_id) + } + + pub fn contains(&self, order_id: &str) -> bool { + let map = self.inner.read().expect("order reconciler lock poisoned"); + map.contains_key(order_id) + } + + pub fn len(&self) -> usize { + let map = self.inner.read().expect("order reconciler lock poisoned"); + map.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl Default for OrderReconciler { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/service/job/video_thumbnail_job/src/job_dependencies.rs b/crates/service/job/video_thumbnail_job/src/job_dependencies.rs index f7895bf446..760d07c3ea 100644 --- a/crates/service/job/video_thumbnail_job/src/job_dependencies.rs +++ b/crates/service/job/video_thumbnail_job/src/job_dependencies.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use jobs_common::job_stats::JobStats; use pager::client::pager::Pager; @@ -17,7 +17,7 @@ pub struct JobDependencies { pub mysql_pool: MySqlPool, /// Public GCS/S3 bucket for downloading source videos and uploading generated thumbnails. - pub public_bucket_client: BucketClient, + pub public_bucket_client: LegacyBucketClient, pub server_environment: ServerEnvironment, diff --git a/crates/service/job/video_thumbnail_job/src/main.rs b/crates/service/job/video_thumbnail_job/src/main.rs index 3ee0d9a9a0..dec94a2b0c 100644 --- a/crates/service/job/video_thumbnail_job/src/main.rs +++ b/crates/service/job/video_thumbnail_job/src/main.rs @@ -17,7 +17,7 @@ use log::{info, warn}; use sqlx::mysql::MySqlPoolOptions; use bootstrap::bootstrap::{bootstrap, BootstrapArgs}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use shared_env_var_config::logging::DEFAULT_RUST_LOG; use errors::AnyhowResult; @@ -85,7 +85,7 @@ async fn main() -> AnyhowResult<()> { Duration::from_secs(60 * 5), ); - let public_bucket_client = BucketClient::create( + let public_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, diff --git a/crates/service/job/worldlabs_job/src/job_dependencies.rs b/crates/service/job/worldlabs_job/src/job_dependencies.rs index 0978614964..f6deb0d33b 100644 --- a/crates/service/job/worldlabs_job/src/job_dependencies.rs +++ b/crates/service/job/worldlabs_job/src/job_dependencies.rs @@ -1,4 +1,4 @@ -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use jobs_common::job_stats::JobStats; use server_environment::ServerEnvironment; @@ -9,7 +9,7 @@ pub struct JobDependencies { pub mysql_pool: MySqlPool, /// Public GCS/S3 bucket for storing generated splats. - pub public_bucket_client: BucketClient, + pub public_bucket_client: LegacyBucketClient, /// API credentials for World Labs. pub worldlabs_creds: WorldLabsApiCreds, diff --git a/crates/service/job/worldlabs_job/src/main.rs b/crates/service/job/worldlabs_job/src/main.rs index bd52bc7a56..89bfed6590 100644 --- a/crates/service/job/worldlabs_job/src/main.rs +++ b/crates/service/job/worldlabs_job/src/main.rs @@ -16,7 +16,7 @@ use log::{info, warn}; use sqlx::mysql::MySqlPoolOptions; use bootstrap::bootstrap::{bootstrap, BootstrapArgs}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use concurrency::relaxed_atomic_bool::RelaxedAtomicBool; use shared_env_var_config::logging::DEFAULT_RUST_LOG; use errors::AnyhowResult; @@ -85,7 +85,7 @@ async fn main() -> AnyhowResult<()> { Duration::from_secs(60 * 5), ); - let public_bucket_client = BucketClient::create( + let public_bucket_client = LegacyBucketClient::create( &access_key, &secret_key, ®ion_name, diff --git a/crates/service/web/storyteller_web/CLAUDE.md b/crates/service/web/storyteller_web/CLAUDE.md index 9fc47c4e87..93bfbb00d5 100644 --- a/crates/service/web/storyteller_web/CLAUDE.md +++ b/crates/service/web/storyteller_web/CLAUDE.md @@ -28,8 +28,21 @@ deprecated or only used when setting other HTTP headers, like cookies. Prefer `CommonWebError` for new handlers unless you need custom error variants. -When a handler uses `require_user_session`, prefer `require_user_session_using_connection` -which reuses an existing DB connection. +The session helpers (`require_user_session`, `require_user_session_extended`, `require_moderator` +in `http_server/web_utils/user_session/`) take a generic `mysql_executor` argument. Pass an +already-open connection (`&mut *conn`) so the helper reuses it; do NOT pass +`&server_state.mysql_pool` when the handler already holds a connection, since that self-acquires a +second one and adds pool pressure (this crate has had pool-timeout incidents). There is no +`require_user_session_using_connection` / `require_moderator_using_connection` — the +reuse-vs-acquire behavior is determined by the argument. + +## Database Access + +Do NOT write SQLx queries in this crate. `storyteller-web` contains **zero** direct SQLx queries — +all MySQL access goes through the `mysql_queries` crate. Add a function there (or reuse an existing +one) and call it from the handler; never embed `sqlx::query!` / `sqlx::query()` in a handler or +helper here. See `crates/schema/database/mysql_queries/CLAUDE.md` for the query-writing conventions +(compile-time `query!` / `query_as!` macros, offline cache, the `Args`/`Executor` pattern, etc.). ## api_doc.rs diff --git a/crates/service/web/storyteller_web/Cargo.toml b/crates/service/web/storyteller_web/Cargo.toml index 0cda8305f8..dc5f806e8a 100644 --- a/crates/service/web/storyteller_web/Cargo.toml +++ b/crates/service/web/storyteller_web/Cargo.toml @@ -36,8 +36,12 @@ actix_artcraft.workspace = true actix_cors_configs = { path = "../../../lib/actix_cors_configs" } actix_helpers.workspace = true artcraft_api_defs.workspace = true +artcraft_api_keys.workspace = true artcraft_router.workspace = true +sha1_hash.workspace = true +video_info.workspace = true bootstrap.workspace = true +bucket_client.workspace = true bucket_paths.workspace = true cloud_storage.workspace = true collections.workspace = true diff --git a/crates/service/web/storyteller_web/src/billing/internal_session_cache_purge_impl.rs b/crates/service/web/storyteller_web/src/billing/internal_session_cache_purge_impl.rs index 0ce99a31cc..bad5300e09 100644 --- a/crates/service/web/storyteller_web/src/billing/internal_session_cache_purge_impl.rs +++ b/crates/service/web/storyteller_web/src/billing/internal_session_cache_purge_impl.rs @@ -3,7 +3,7 @@ use actix_web::HttpRequest; use redis_caching::redis_ttl_cache::RedisTtlCache; use redis_common::redis_cache_keys::RedisCacheKeys; use user_traits_component::traits::internal_session_cache_purge::InternalSessionCachePurge; -use crate::http_server::session::session_checker::SessionChecker; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; pub struct InternalSessionCachePurgeImpl { session_checker: SessionChecker, diff --git a/crates/service/web/storyteller_web/src/billing/stripe_internal_user_lookup_impl.rs b/crates/service/web/storyteller_web/src/billing/stripe_internal_user_lookup_impl.rs index 21b88ece3e..112d38be06 100644 --- a/crates/service/web/storyteller_web/src/billing/stripe_internal_user_lookup_impl.rs +++ b/crates/service/web/storyteller_web/src/billing/stripe_internal_user_lookup_impl.rs @@ -1,4 +1,4 @@ -use crate::http_server::session::session_checker::SessionChecker; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; use actix_web::HttpRequest; use async_trait::async_trait; use chrono::Utc; diff --git a/crates/service/web/storyteller_web/src/configs/app_startup/redis_rate_limiters.rs b/crates/service/web/storyteller_web/src/configs/app_startup/redis_rate_limiters.rs index 21db936680..58793827b6 100644 --- a/crates/service/web/storyteller_web/src/configs/app_startup/redis_rate_limiters.rs +++ b/crates/service/web/storyteller_web/src/configs/app_startup/redis_rate_limiters.rs @@ -116,6 +116,19 @@ pub fn configure_redis_rate_limiters() -> AnyhowResult { RedisRateLimiter::new(limiter, "file_upload_logged_in", limiter_enabled) }; + let video_info_read_only_rate_limiter = { + let limiter_enabled = easyenv::get_env_bool_or_default("LIMITER_VIDEO_INFO_READ_ONLY_ENABLED", true); + let limiter_max_requests = easyenv::get_env_num("LIMITER_VIDEO_INFO_READ_ONLY_MAX_REQUESTS", 1)?; + let limiter_window_seconds = easyenv::get_env_num("LIMITER_VIDEO_INFO_READ_ONLY_WINDOW_SECONDS", 5)?; + + let limiter = Limiter::build(&redis_connection_string) + .limit(limiter_max_requests) + .period(Duration::from_secs(limiter_window_seconds)) + .finish()?; + + RedisRateLimiter::new(limiter, "video_info_read_only", limiter_enabled) + }; + Ok(RedisRateLimiters { logged_out: logged_out_redis_rate_limiter, logged_in: logged_in_redis_rate_limiter, @@ -125,5 +138,6 @@ pub fn configure_redis_rate_limiters() -> AnyhowResult { model_upload: model_upload_rate_limiter, file_upload_logged_out: file_upload_logged_out_redis_rate_limiter, file_upload_logged_in: file_upload_logged_in_redis_rate_limiter, + video_info_read_only: video_info_read_only_rate_limiter, }) } diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs index cb3dad22f8..c229c8d4f5 100644 --- a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs @@ -5,6 +5,7 @@ use enums::common::generation::common_video_model::CommonVideoModel; use enums::common::generation::model_creator::ModelCreator; use enums::common::generation_provider::GenerationProvider; use once_cell::sync::Lazy; +use enums::common::generation::common_bitrate::CommonBitrate; pub const OMNI_GEN_VIDEO_MODELS_AND_PROVIDERS: Lazy = Lazy::new(|| { let models = build_omni_gen_video_models(); @@ -246,6 +247,11 @@ fn build_omni_gen_video_models() -> Vec { CommonResolution::TenEightyP, ]), resolution_default: Some(CommonResolution::SevenTwentyP), + bitrate_options: Some(vec![ + CommonBitrate::Normal, + CommonBitrate::High, + ]), + bitrate_default: Some(CommonBitrate::Normal), batch_size_options: Some(vec![1, 2, 4]), batch_size_default: Some(1), duration_seconds_min: Some(4), @@ -287,6 +293,11 @@ fn build_omni_gen_video_models() -> Vec { CommonResolution::SevenTwentyP, ]), resolution_default: Some(CommonResolution::SevenTwentyP), + bitrate_options: Some(vec![ + CommonBitrate::Normal, + CommonBitrate::High, + ]), + bitrate_default: Some(CommonBitrate::Normal), batch_size_options: Some(vec![1, 2, 4]), batch_size_default: Some(1), duration_seconds_min: Some(4), @@ -329,6 +340,11 @@ fn build_omni_gen_video_models() -> Vec { CommonResolution::TenEightyP, ]), resolution_default: Some(CommonResolution::SevenTwentyP), + bitrate_options: Some(vec![ + CommonBitrate::Normal, + CommonBitrate::High, + ]), + bitrate_default: Some(CommonBitrate::Normal), batch_size_options: Some(vec![1, 2, 4]), batch_size_default: Some(1), duration_seconds_min: Some(4), @@ -370,6 +386,11 @@ fn build_omni_gen_video_models() -> Vec { CommonResolution::SevenTwentyP, ]), resolution_default: Some(CommonResolution::SevenTwentyP), + bitrate_options: Some(vec![ + CommonBitrate::Normal, + CommonBitrate::High, + ]), + bitrate_default: Some(CommonBitrate::Normal), batch_size_options: Some(vec![1, 2, 4]), batch_size_default: Some(1), duration_seconds_min: Some(4), @@ -412,6 +433,11 @@ fn build_omni_gen_video_models() -> Vec { CommonResolution::TenEightyP, ]), resolution_default: Some(CommonResolution::SevenTwentyP), + bitrate_options: Some(vec![ + CommonBitrate::Normal, + CommonBitrate::High, + ]), + bitrate_default: Some(CommonBitrate::Normal), batch_size_options: Some(vec![1, 2, 4]), batch_size_default: Some(1), duration_seconds_min: Some(4), @@ -453,6 +479,10 @@ fn build_omni_gen_video_models() -> Vec { CommonResolution::SevenTwentyP, ]), resolution_default: Some(CommonResolution::SevenTwentyP), + bitrate_options: Some(vec![ + CommonBitrate::Normal, + CommonBitrate::High, + ]), batch_size_options: Some(vec![1, 2, 4]), batch_size_default: Some(1), duration_seconds_min: Some(4), diff --git a/crates/service/web/storyteller_web/src/configs/plans/get_correct_plan_for_session.rs b/crates/service/web/storyteller_web/src/configs/plans/get_correct_plan_for_session.rs index ba270e4e08..53d03d4c8b 100644 --- a/crates/service/web/storyteller_web/src/configs/plans/get_correct_plan_for_session.rs +++ b/crates/service/web/storyteller_web/src/configs/plans/get_correct_plan_for_session.rs @@ -5,7 +5,7 @@ use server_environment::ServerEnvironment; use crate::configs::plans::plan::Plan; use crate::configs::plans::plan_list::{DEVELOPMENT_PREMIUM_PLANS_BY_SLUG, FREE_LOGGED_IN_PLAN, FREE_LOGGED_OUT_PLAN, LOYALTY_PLANS_BY_SLUG, PRODUCTION_PREMIUM_PLANS_BY_SLUG}; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; /// Look up the most appropriate plan for the session. /// This will probably grow to include a lot of factors. @@ -70,7 +70,7 @@ pub mod tests { use crate::configs::plans::get_correct_plan_for_session::get_correct_plan_for_session; use crate::configs::plans::plan_list::{ALL_PLANS_BY_SLUG, FREE_LOGGED_IN_PLAN, FREE_LOGGED_OUT_PLAN}; - use crate::http_server::session::lookup::user_session_extended::{UserSessionExtended, UserSessionSubscriptionPlan}; + use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::{UserSessionExtended, UserSessionSubscriptionPlan}; #[test] fn test_free_logged_out_plan() { diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index f15b9ca09d..d1acbdbf50 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -80,6 +80,9 @@ use crate::http_server::endpoints::media_files::upload::upload_spz_media_file_ha use crate::http_server::endpoints::media_files::upload::upload_studio_shot::upload_studio_shot_media_file_handler::*; use crate::http_server::endpoints::media_files::upload::upload_video_new::upload_new_video_media_file_handler::*; use crate::http_server::endpoints::media_files::upload::upload_video_old::upload_video_media_file_handler::*; +use crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::*; +use crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::*; +use crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::*; use crate::http_server::endpoints::media_files::upsert_upload::write_engine_asset::write_engine_asset_media_file_handler::*; use crate::http_server::endpoints::media_files::upsert_upload::write_error::MediaFileWriteError; use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::*; @@ -265,6 +268,7 @@ use enums::by_table::users::user_feature_flag::UserFeatureFlag; use enums::common::generation::common_model_class::CommonModelClass; use enums::common::generation::common_model_type::CommonModelType; use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; use enums::common::generation::common_generation_mode::CommonGenerationMode; use enums::common::generation::common_resolution::CommonResolution; use enums::common::generation_provider::GenerationProvider; @@ -335,6 +339,8 @@ use artcraft_api_defs::characters::edit_character::*; use artcraft_api_defs::characters::get_character::*; use artcraft_api_defs::characters::list_characters::*; use artcraft_api_defs::moderation::alerts::moderation_send_alert::*; +use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::*; +use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::*; use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::*; use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::*; use artcraft_api_defs::omni_gen::cost_response::omni_gen_image_cost_response::*; @@ -343,6 +349,9 @@ use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_resp use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::*; use artcraft_api_defs::omni_gen::models::omni_gen_image_models::*; use artcraft_api_defs::omni_gen::models::omni_gen_video_models::*; +use artcraft_api_defs::omni_api::omni_upload_audio::*; +use artcraft_api_defs::omni_api::omni_upload_image::*; +use artcraft_api_defs::omni_api::omni_upload_video::*; use artcraft_api_defs::moderation::user::list_subscribing_users_by_signup_date::*; use artcraft_api_defs::moderation::user::list_users_by_signup_date::*; use artcraft_api_defs::moderation::user::user_lookup::*; @@ -358,6 +367,14 @@ use artcraft_api_defs::user_referral_codes::create_referral_code::*; use artcraft_api_defs::user_referral_codes::delete_referral_code::*; use artcraft_api_defs::user_referral_codes::list_referral_codes::*; use artcraft_api_defs::web_referrals::log_web_referral::*; +use artcraft_api_defs::video_info::read_only::*; +use artcraft_api_defs::video_info::upload::*; +use artcraft_api_defs::video_info::notes::*; +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 crate::http_server::endpoints::video_info::video_info_read_info_handler::*; +use crate::http_server::endpoints::video_info::video_info_upload_handler::*; +use crate::http_server::endpoints::video_info::video_info_notes_handler::*; use crate::http_server::endpoints::web_referrals::log_web_referral_handler::*; use crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::*; use crate::http_server::endpoints::voice_conversion::enqueue_seed_vc_inference_handler::*; @@ -550,6 +567,12 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux crate::http_server::endpoints::omni_gen::generate::video::omni_gen_video_generate_handler::omni_gen_video_generate_handler, crate::http_server::endpoints::omni_gen::models::image::omni_gen_image_models_handler::omni_gen_image_models_handler, crate::http_server::endpoints::omni_gen::models::video::omni_gen_video_models_handler::omni_gen_video_models_handler, + // Omni API (API-key authenticated generate) + crate::http_server::endpoints::omni_api::generate::image::omni_api_image_generate_handler::omni_api_image_generate_handler, + crate::http_server::endpoints::omni_api::generate::video::omni_api_video_generate_handler::omni_api_video_generate_handler, + crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::omni_upload_audio_media_file_handler, + crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::omni_upload_image_media_file_handler, + crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler, // Moderation crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler, crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler, @@ -605,6 +628,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux // Subscriptions crate::http_server::endpoints::subscriptions::get_session_subscription_handler::get_session_subscription_handler, // Web Referrals + crate::http_server::endpoints::video_info::video_info_read_info_handler::video_info_read_info_handler, + crate::http_server::endpoints::video_info::video_info_upload_handler::video_info_upload_handler, + crate::http_server::endpoints::video_info::video_info_notes_handler::video_info_notes_handler, crate::http_server::endpoints::web_referrals::log_web_referral_handler::log_web_referral_handler, // User Referral Codes crate::http_server::endpoints::user_referral_codes::create_referral_code_handler::create_referral_code_handler, @@ -645,6 +671,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux CommonModelClass, CommonModelType, CommonAspectRatio, + CommonBitrate, CommonGenerationMode, CommonResolution, PromptContextSemanticType, @@ -799,6 +826,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux GetCharacterResponse, ListCharactersEntry, ListCharactersResponse, + OmniApiImageGenerateRequest, + OmniApiVideoGenerateRequest, OmniGenImageCostAndGenerateRequest, OmniGenImageCostResponse, OmniGenImageGenerateResponse, @@ -1077,6 +1106,12 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux UploadEngineAssetMediaSuccessResponse, UploadImageMediaFileForm, UploadImageMediaFileSuccessResponse, + OmniUploadAudioMediaFileForm, + OmniUploadAudioMediaFileSuccessResponse, + OmniUploadImageMediaFileForm, + OmniUploadImageMediaFileSuccessResponse, + OmniUploadVideoMediaFileForm, + OmniUploadVideoMediaFileSuccessResponse, UserImpersonationRequestResponse, UploadMediaSuccessResponse, UploadNewEngineAssetFileForm, @@ -1231,6 +1266,22 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux Veo3p1FastMultiFunctionVideoGenRequest, Veo3p1FastMultiFunctionVideoGenResponse, + // Video info (provenance detection) + VideoInfoReadOnlyForm, + VideoInfoReadOnlyResponse, + VideoInfoUploadForm, + VideoInfoUploadResponse, + VideoInfoNoteRequest, + VideoInfoNoteResponse, + UploadedVideoNoteReportedModelType, + UploadedVideoNoteReportedWebsite, + VideoProvenanceKind, + SeedanceVideoInfo, + VeoVideoInfo, + SoraVideoInfo, + DreaminaVideoInfo, + KlingVideoInfo, + // Object Multi-Function types Hunyuan3dV3MultiFunctionObjectGenRequest, Hunyuan3dV3MultiFunctionObjectGenResponse, diff --git a/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs b/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs index 30e479da1d..5950132f59 100644 --- a/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs +++ b/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs @@ -1,7 +1,7 @@ use std::fmt::{Display, Formatter}; use std::sync::Arc; -use crate::http_server::session::session_checker_error::SessionCheckerError; +use crate::http_server::user_lookup::user_session::session_utils::session_checker_error::SessionCheckerError; use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_payload_error::AvtCookiePayloadError; use actix_artcraft::sessions::user_sessions::http_user_session_payload_error::HttpUserSessionPayloadError; use actix_http::StatusCode; diff --git a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/animation/enqueue_face_animation.rs b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/animation/enqueue_face_animation.rs index 5ab75450df..4640768fe0 100644 --- a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/animation/enqueue_face_animation.rs +++ b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/animation/enqueue_face_animation.rs @@ -28,7 +28,7 @@ use tokens::tokens::voice_conversion_results::VoiceConversionResultToken; use crate::configs::plans::get_correct_plan_for_session::get_correct_plan_for_session; use crate::http_server::requests::request_headers::get_routing_tag_header::get_routing_tag_header; use crate::http_server::requests::request_headers::has_debug_header::has_debug_header; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::state::server_state::ServerState; diff --git a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/animation/enqueue_rerender_animation.rs b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/animation/enqueue_rerender_animation.rs index 2cf1a7f268..a4aec6e1b7 100644 --- a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/animation/enqueue_rerender_animation.rs +++ b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/animation/enqueue_rerender_animation.rs @@ -10,7 +10,7 @@ use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; use log::{info, warn}; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory; use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType; use enums::by_table::generic_inference_jobs::inference_model_type::InferenceModelType; diff --git a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/media_uploads/list_user_media_uploads_of_type.rs b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/media_uploads/list_user_media_uploads_of_type.rs index 5df5d6218c..33f128c399 100644 --- a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/media_uploads/list_user_media_uploads_of_type.rs +++ b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/media_uploads/list_user_media_uploads_of_type.rs @@ -8,7 +8,7 @@ use actix_web::{web, HttpMessage, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; use log::warn; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use crate::http_server::common_responses::common_web_error::CommonWebError; use enums::by_table::media_uploads::media_upload_type::MediaUploadType; use enums::common::visibility::Visibility; diff --git a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/mocap/enqueue_mocapnet.rs b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/mocap/enqueue_mocapnet.rs index b1ec67e92b..0c1e4ad235 100644 --- a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/mocap/enqueue_mocapnet.rs +++ b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/mocap/enqueue_mocapnet.rs @@ -10,7 +10,7 @@ use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; use log::{error, info, warn}; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory; use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType; use enums::by_table::generic_inference_jobs::inference_model_type::InferenceModelType; diff --git a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_face_fusion_workflow_handler.rs b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_face_fusion_workflow_handler.rs index e069a6fb38..32f7bdf83d 100644 --- a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_face_fusion_workflow_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_face_fusion_workflow_handler.rs @@ -28,7 +28,7 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r use crate::http_server::requests::request_headers::has_debug_header::has_debug_header; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; -use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended; +use crate::http_server::user_lookup::user_session::require_user_session_extended::require_user_session_extended; use crate::state::server_state::ServerState; use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null; diff --git a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_live_portrait_workflow_handler.rs b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_live_portrait_workflow_handler.rs index 18b58ba0fc..0a56d3711e 100644 --- a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_live_portrait_workflow_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_live_portrait_workflow_handler.rs @@ -29,7 +29,7 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r use crate::http_server::requests::request_headers::has_debug_header::has_debug_header; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; -use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended; +use crate::http_server::user_lookup::user_session::require_user_session_extended::require_user_session_extended; use crate::state::server_state::ServerState; use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null; diff --git a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_studio_workflow_handler.rs b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_studio_workflow_handler.rs index 9a08810a9b..1160309f4b 100644 --- a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_studio_workflow_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_studio_workflow_handler.rs @@ -36,7 +36,7 @@ use crate::http_server::deprecated_endpoints::workflows::enqueue::vst_common::vs use crate::http_server::deprecated_endpoints::workflows::enqueue::vst_common::vst_response::VstSuccessResponse; use crate::http_server::requests::request_headers::get_routing_tag_header::get_routing_tag_header; use crate::http_server::requests::request_headers::has_debug_header::has_debug_header; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::state::server_state::ServerState; use crate::util::allowed_video_style_transfer_access::allowed_video_style_transfer_access; diff --git a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_video_style_transfer_workflow_handler.rs b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_video_style_transfer_workflow_handler.rs index 1fa1a56b90..d2cb340ff5 100644 --- a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_video_style_transfer_workflow_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue/enqueue_video_style_transfer_workflow_handler.rs @@ -37,7 +37,7 @@ use crate::http_server::deprecated_endpoints::workflows::enqueue::vst_common::vs use crate::http_server::requests::get_request_domain_branding::{get_request_domain_branding, DomainBranding}; use crate::http_server::requests::request_headers::get_routing_tag_header::get_routing_tag_header; use crate::http_server::requests::request_headers::has_debug_header::has_debug_header; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::state::server_state::ServerState; use crate::util::allowed_video_style_transfer_access::allowed_video_style_transfer_access; diff --git a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue_comfy_ui_handler.rs b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue_comfy_ui_handler.rs index 60fd6bf04b..bdf2ac943d 100644 --- a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue_comfy_ui_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue_comfy_ui_handler.rs @@ -32,7 +32,7 @@ use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::configs::plans::plan_category::PlanCategory; use crate::http_server::requests::request_headers::get_routing_tag_header::get_routing_tag_header; use crate::http_server::requests::request_headers::has_debug_header::has_debug_header; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::state::server_state::ServerState; use crate::util::allowed_video_style_transfer_access::allowed_video_style_transfer_access; diff --git a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue_video_style_transfer_handler.rs b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue_video_style_transfer_handler.rs index 1eeb8cc71c..eeacbde794 100644 --- a/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue_video_style_transfer_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/deprecated_endpoints/workflows/enqueue_video_style_transfer_handler.rs @@ -33,7 +33,7 @@ use crate::configs::plans::plan_category::PlanCategory; use crate::http_server::deprecated_endpoints::workflows::coordinate_workflow_args::{coordinate_workflow_args, CoordinatedWorkflowArgs}; use crate::http_server::requests::request_headers::get_routing_tag_header::get_routing_tag_header; use crate::http_server::requests::request_headers::has_debug_header::has_debug_header; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::state::server_state::ServerState; use crate::util::allowed_video_style_transfer_access::allowed_video_style_transfer_access; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/log_browser_session_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/log_browser_session_handler.rs index f7e6d91307..29f3460d58 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/log_browser_session_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/log_browser_session_handler.rs @@ -2,6 +2,7 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; use log::warn; @@ -87,9 +88,9 @@ impl fmt::Display for LogBrowserSessionError { )] pub async fn log_browser_session_handler( http_request: HttpRequest, - request: web::Json, + request: Json, server_state: web::Data>, -) -> Result +) -> Result, LogBrowserSessionError> { let mut mysql_connection = server_state.mysql_pool .acquire() @@ -131,11 +132,6 @@ pub async fn log_browser_session_handler( log_token: token, }; - let body = serde_json::to_string(&response) - .map_err(|_e| LogBrowserSessionError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs index 4625a6a2bd..17ef253968 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs @@ -10,7 +10,8 @@ use http_server_common::request::get_request_ip::get_request_ip; use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs}; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; use crate::util::generate_api_key::generate_api_key; @@ -29,6 +30,7 @@ const MAX_DESCRIPTION_LEN: usize = 512; (status = 200, body = CreateApiKeySuccessResponse), (status = 400, body = CommonWebError), (status = 401, body = CommonWebError), + (status = 403, body = CommonWebError), (status = 500, body = CommonWebError), ), )] @@ -44,6 +46,14 @@ pub async fn create_api_key_handler( let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?; + // API key creation is gated behind the `api_key` feature flag. + let feature_flags = UserSessionFeatureFlags::new(user_session.maybe_feature_flags.as_deref()); + + if !feature_flags.can_create_api_key() { + warn!("User {:?} attempted to create an API key without the api_key feature flag", user_session.user_token.as_str()); + return Err(CommonWebError::Forbidden); + } + let name = request.name.trim().to_string(); let maybe_description = request.maybe_description.as_ref().map(|s| s.trim().to_string()); @@ -86,6 +96,8 @@ pub async fn create_api_key_handler( Ok(Json(CreateApiKeySuccessResponse { success: true, api_key_token: token, - api_key, + // Return the full secret value (never the redacted Debug/Display form). This is the only + // time it is ever exposed. + api_key: api_key.to_string_be_careful(), // NB: This is okay in this case })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/delete_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/delete_api_key_handler.rs index 8d00af94ef..062048ee80 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/delete_api_key_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/delete_api_key_handler.rs @@ -14,7 +14,7 @@ use mysql_queries::queries::api_keys::get_api_key_by_token::{ use tokens::tokens::api_keys::ApiKeyToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; /// Soft-delete an API key by `token`, scoped to the logged-in user. diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/get_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/get_api_key_handler.rs index 7f08ebd985..3f22317a08 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/get_api_key_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/get_api_key_handler.rs @@ -14,7 +14,7 @@ use tokens::tokens::api_keys::ApiKeyToken; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoints::api_keys::api_key_info_conversion::api_key_row_to_info; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; /// Fetch a single API key by its `token`, scoped to the logged-in user. diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/list_api_keys_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/list_api_keys_handler.rs index d3d9d41401..52b58d5110 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/list_api_keys_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/list_api_keys_handler.rs @@ -12,7 +12,7 @@ use mysql_queries::queries::api_keys::list_api_keys_for_user::{ use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoints::api_keys::api_key_info_conversion::api_key_row_to_info; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const DEFAULT_LIMIT: u32 = 100; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/update_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/update_api_key_handler.rs index 30c2ff7c67..f8a035d2da 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/update_api_key_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/update_api_key_handler.rs @@ -15,7 +15,7 @@ use mysql_queries::queries::api_keys::update_api_key::{update_api_key, UpdateApi use tokens::tokens::api_keys::ApiKeyToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const MAX_DESCRIPTION_LEN: usize = 512; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_permissions.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_permissions.rs index 03576c44a7..46acef0c38 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_permissions.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_permissions.rs @@ -1,4 +1,4 @@ -use crate::http_server::session::lookup::user_session_extended::{UserSessionExtended, UserSessionRoleAndPermissions}; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::{UserSessionExtended, UserSessionRoleAndPermissions}; use enums::by_table::users::user_feature_flag::UserFeatureFlag; use std::collections::BTreeSet; use utoipa::ToSchema; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_premium_info.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_premium_info.rs index e1e0bf5b73..e7e1dec145 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_premium_info.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_premium_info.rs @@ -1,4 +1,4 @@ -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use utoipa::ToSchema; #[derive(Serialize, ToSchema)] diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_user_info.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_user_info.rs index fcc5d8d322..6c4ed495a7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_user_info.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_user_info.rs @@ -1,5 +1,5 @@ use crate::http_server::common_responses::user_details_lite::UserDefaultAvatarInfo; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use tokens::tokens::users::UserToken; use utoipa::ToSchema; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/create_beta_keys_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/create_beta_keys_handler.rs index c80cf8bfc5..9d4bfaebfb 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/create_beta_keys_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/create_beta_keys_handler.rs @@ -2,8 +2,9 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::warn; use utoipa::ToSchema; @@ -14,7 +15,7 @@ use mysql_queries::queries::users::user_profiles::get_user_profile_by_username:: use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; const MAXIMUM_KEYS : u32 = 100; @@ -56,9 +57,9 @@ pub struct CreateBetaKeysSuccessResponse { )] pub async fn create_beta_keys_handler( http_request: HttpRequest, - request: web::Json, + request: Json, server_state: web::Data>, -) -> Result +) -> Result, CommonWebError> { let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?; @@ -106,11 +107,6 @@ pub async fn create_beta_keys_handler( beta_keys, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/edit_beta_key_distributed_flag_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/edit_beta_key_distributed_flag_handler.rs index eae701228a..d72ecf4a95 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/edit_beta_key_distributed_flag_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/edit_beta_key_distributed_flag_handler.rs @@ -2,9 +2,10 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::warn; use utoipa::ToSchema; @@ -14,7 +15,7 @@ use tokens::tokens::w2l_results::W2lResultToken; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// For the URL PathInfo @@ -53,10 +54,10 @@ pub struct EditBetaKeyDistributedFlagSuccessResponse { )] pub async fn edit_beta_key_distributed_flag_handler( http_request: HttpRequest, - request: web::Json, + request: Json, path: Path, server_state: web::Data>, -) -> Result +) -> Result, CommonWebError> { let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?; @@ -71,10 +72,5 @@ pub async fn edit_beta_key_distributed_flag_handler( success: true, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/edit_beta_key_note_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/edit_beta_key_note_handler.rs index 378d2daa12..44d107479d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/edit_beta_key_note_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/edit_beta_key_note_handler.rs @@ -2,9 +2,10 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::warn; use utoipa::ToSchema; @@ -13,7 +14,7 @@ use tokens::tokens::beta_keys::BetaKeyToken; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// For the URL PathInfo @@ -52,10 +53,10 @@ pub struct EditBetaKeyNoteSuccessResponse { )] pub async fn edit_beta_key_note_handler( http_request: HttpRequest, - request: web::Json, + request: Json, path: Path, server_state: web::Data>, -) -> Result +) -> Result, CommonWebError> { let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?; @@ -75,10 +76,5 @@ pub async fn edit_beta_key_note_handler( success: true, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/list_beta_keys_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/list_beta_keys_handler.rs index 89cb21b91f..419696610b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/list_beta_keys_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/list_beta_keys_handler.rs @@ -1,9 +1,10 @@ use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Query; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; use utoipa::{IntoParams, ToSchema}; @@ -17,7 +18,7 @@ use tokens::tokens::beta_keys::BetaKeyToken; use crate::http_server::common_responses::pagination_page::PaginationPage; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::common_responses::user_details_lite::UserDetailsLight; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; #[derive(Copy, Clone, Deserialize, ToSchema)] @@ -107,7 +108,7 @@ pub async fn list_beta_keys_handler( http_request: HttpRequest, query: Query, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool) .await?; @@ -212,10 +213,5 @@ pub async fn list_beta_keys_handler( } }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/redeem_beta_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/redeem_beta_key_handler.rs index f23c536825..43a1562ffd 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/redeem_beta_key_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/beta_keys/redeem_beta_key_handler.rs @@ -1,11 +1,12 @@ use std::sync::Arc; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; +use actix_web::web::Json; use log::warn; use utoipa::ToSchema; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; use enums::by_table::beta_keys::beta_key_product::BetaKeyProduct; use enums::by_table::users::user_feature_flag::UserFeatureFlag; use http_server_common::request::get_request_ip::get_request_ip; @@ -16,7 +17,7 @@ use mysql_queries::queries::users::user::update::set_user_feature_flags_transact use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord; use crate::http_server::web_utils::try_delete_session_cache::try_delete_session_cache; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; #[derive(Deserialize, ToSchema)] @@ -48,9 +49,9 @@ pub struct RedeemBetaKeySuccessResponse { )] pub async fn redeem_beta_key_handler( http_request: HttpRequest, - request: web::Json, + request: Json, server_state: web::Data>, -) -> Result +) -> Result, CommonWebError> { let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?; @@ -90,12 +91,7 @@ pub async fn redeem_beta_key_handler( success: true, }; - // `?` via the `From` impl on CommonWebError. - let body = serde_json::to_string(&response)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } async fn enroll_in_studio( diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs index 5f83e61416..28ae40e930 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs @@ -10,7 +10,7 @@ use mysql_queries::queries::characters::delete_character::delete_character; use mysql_queries::queries::characters::get_character_by_token_including_deleted::get_character_by_token_including_deleted; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; /// Delete a character. diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs index 3160c7967d..5cbfb8dca3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs @@ -11,7 +11,7 @@ use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; use seedance2pro_client::requests::update_character::update_character::{update_character, UpdateCharacterArgs}; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; /// Edit a character's name or description. diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/list_characters_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/list_characters_handler.rs index 89c21f12e9..d65b245f47 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/list_characters_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/list_characters_handler.rs @@ -16,7 +16,7 @@ use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::common_responses::media::media_domain::MediaDomain; use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder; use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; /// List characters for the current session. diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/comments/create_comment_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/comments/create_comment_handler.rs index 33cdf9b035..a603571280 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/comments/create_comment_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/comments/create_comment_handler.rs @@ -7,6 +7,7 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; use log::warn; @@ -95,9 +96,9 @@ impl fmt::Display for CreateCommentError { )] pub async fn create_comment_handler( http_request: HttpRequest, - request: web::Json, + request: Json, server_state: web::Data>, -) -> Result +) -> Result, CreateCommentError> { // NB(bt,2023-12-14): Kasisnu found that we're getting entity type mismatches in production. Apart from // querying the database for entity existence, this is the next best way to prevent incorrect comment @@ -188,10 +189,5 @@ pub async fn create_comment_handler( comment_token, }; - let body = serde_json::to_string(&response) - .map_err(|_e| CreateCommentError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/comments/delete_comment_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/comments/delete_comment_handler.rs index 931c5d86eb..fe0b4bed73 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/comments/delete_comment_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/comments/delete_comment_handler.rs @@ -7,6 +7,7 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; use actix_web::{web, HttpRequest, HttpResponse}; @@ -19,7 +20,6 @@ use tokens::tokens::comments::CommentToken; use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::state::server_state::ServerState; /// For the URL PathInfo @@ -90,9 +90,9 @@ impl fmt::Display for DeleteCommentError { pub async fn delete_comment_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result { +) -> Result, DeleteCommentError> { let mut mysql_connection = server_state.mysql_pool .acquire() .await @@ -166,5 +166,5 @@ pub async fn delete_comment_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/comments/list_comments_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/comments/list_comments_handler.rs index 0488150846..5f9055212f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/comments/list_comments_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/comments/list_comments_handler.rs @@ -7,6 +7,7 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; use actix_web::{web, HttpRequest, HttpResponse}; @@ -129,7 +130,7 @@ pub async fn list_comments_handler( _http_request: HttpRequest, path: Path, server_state: web::Data> -) -> Result +) -> Result, ListCommentsError> { let entity_token = CommentEntityToken::from_entity_type_and_token( path.entity_type, &path.entity_token); @@ -174,10 +175,5 @@ pub async fn list_comments_handler( .collect(), }; - let body = serde_json::to_string(&response) - .map_err(|_e| ListCommentsError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/download_job/enqueue_generic_download.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/download_job/enqueue_generic_download.rs index 87404114df..cb2e5db237 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/download_job/enqueue_generic_download.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/download_job/enqueue_generic_download.rs @@ -2,6 +2,7 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; use log::{info, warn}; @@ -69,8 +70,8 @@ impl fmt::Display for EnqueueGenericDownloadError { pub async fn enqueue_generic_download_handler( http_request: HttpRequest, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, EnqueueGenericDownloadError> { if let Err(_err) = server_state.redis_rate_limiters.model_upload.rate_limit_request(&http_request).await { return Err(EnqueueGenericDownloadError::RateLimited); @@ -150,10 +151,5 @@ pub async fn enqueue_generic_download_handler( job_token: job_token.to_string(), }; - let body = serde_json::to_string(&response) - .map_err(|e| EnqueueGenericDownloadError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/download_job/get_generic_upload_job_status.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/download_job/get_generic_upload_job_status.rs index f3fb03978d..ac941555b1 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/download_job/get_generic_upload_job_status.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/download_job/get_generic_upload_job_status.rs @@ -2,6 +2,7 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; use actix_web::{web, HttpMessage, HttpRequest, HttpResponse}; @@ -87,7 +88,7 @@ impl fmt::Display for GetGenericDownloadJobStatusError { pub async fn get_generic_download_job_status_handler( http_request: HttpRequest, path: Path, - server_state: web::Data>) -> Result + server_state: web::Data>) -> Result, GetGenericDownloadJobStatusError> { let maybe_job_status = get_generic_download_job_status( &path.token, @@ -142,10 +143,5 @@ pub async fn get_generic_download_job_status_handler( state: model_for_response, }; - let body = serde_json::to_string(&response) - .map_err(|e| GetGenericDownloadJobStatusError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/create_featured_item_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/create_featured_item_handler.rs index f631e36c77..bd1ad809f2 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/create_featured_item_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/create_featured_item_handler.rs @@ -2,8 +2,9 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::{error, warn}; use sqlx::Acquire; use utoipa::ToSchema; @@ -50,9 +51,9 @@ pub struct CreateFeaturedItemSuccessResponse { )] pub async fn create_featured_item_handler( http_request: HttpRequest, - request: web::Json, + request: Json, server_state: web::Data>, -) -> Result +) -> Result, CommonWebError> { // NB(bt,2023-12-14): Kasisnu found that we're getting entity type mismatches in production. Apart from // querying the database for entity existence, this is the next best way to prevent incorrect comment @@ -163,10 +164,5 @@ pub async fn create_featured_item_handler( success: true, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/delete_featured_item_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/delete_featured_item_handler.rs index 4ed891afb0..3ee1a21e66 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/delete_featured_item_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/delete_featured_item_handler.rs @@ -2,8 +2,9 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::{error, warn}; use sqlx::Acquire; use utoipa::ToSchema; @@ -22,7 +23,6 @@ use tokens::tokens::users::UserToken; use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::state::server_state::ServerState; #[derive(Deserialize, ToSchema)] @@ -47,9 +47,9 @@ pub struct DeleteFeaturedItemRequest { )] pub async fn delete_featured_item_handler( http_request: HttpRequest, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { // NB(bt,2023-12-14): Kasisnu found that we're getting entity type mismatches in production. Apart from // querying the database for entity existence, this is the next best way to prevent incorrect comment @@ -138,5 +138,5 @@ pub async fn delete_featured_item_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/color_code_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/color_code_folder_handler.rs index 74082d579b..d2cdd9422f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/color_code_folder_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/color_code_folder_handler.rs @@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_color_code::{ use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const MAX_COLOR_CODE_LEN: usize = 16; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/cover_image_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/cover_image_folder_handler.rs index d08c5d9d3a..48841acd6e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/cover_image_folder_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/cover_image_folder_handler.rs @@ -23,7 +23,7 @@ use tokens::tokens::folders::FolderToken; use tokens::tokens::media_files::MediaFileToken; use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; /// Set or clear the custom cover image on a folder. diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs index c0f21cfac0..903fb286dc 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs @@ -17,7 +17,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ build_folder_thumbnails_lookup, folder_row_to_info, }; use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const MAX_NAME_LEN: usize = 255; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/delete_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/delete_folder_handler.rs index dcc099d654..ae25633a58 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/delete_folder_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/delete_folder_handler.rs @@ -12,7 +12,7 @@ use mysql_queries::queries::folders::folder::soft_delete_folder::{ use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; /// Soft-delete a folder. Children retain their parent pointer and become diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs index e065f67b2c..bcca1a4238 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs @@ -16,7 +16,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ build_folder_thumbnails_lookup, folder_row_to_info, }; use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; /// Fetch a single folder owned by the logged-in user. diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs index 4016e06d5d..eb9bfed464 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs @@ -17,7 +17,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ build_folder_thumbnails_lookup, folder_row_to_info, }; use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const CURSOR_NAME: &str = "folders_all"; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/rename_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/rename_folder_handler.rs index 97a785637c..132b3088b5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/rename_folder_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/rename_folder_handler.rs @@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_name::{ use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const MAX_NAME_LEN: usize = 255; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/star_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/star_folder_handler.rs index a393890e5a..232b490326 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/star_folder_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/star_folder_handler.rs @@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_has_star::{ use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; #[utoipa::path( diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_add_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_add_folder_media_files_handler.rs index 0bd375f7f7..8d3c732b9a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_add_folder_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_add_folder_media_files_handler.rs @@ -27,7 +27,7 @@ use mysql_queries::queries::folders::media_files::recompute_folder_last_media_fi use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const MAX_BULK: usize = 500; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_move_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_move_folder_media_files_handler.rs index d14ee13773..57be27b8dd 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_move_folder_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_move_folder_media_files_handler.rs @@ -30,7 +30,7 @@ use mysql_queries::queries::folders::media_files::recompute_folder_last_media_fi use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const MAX_BULK: usize = 500; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_remove_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_remove_folder_media_files_handler.rs index d002b0d050..be67b82dae 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_remove_folder_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_remove_folder_media_files_handler.rs @@ -24,7 +24,7 @@ use tokens::tokens::folders::FolderToken; use tokens::tokens::media_files::MediaFileToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const MAX_BULK: usize = 500; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs index 568cb69d62..549ee2de7d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs @@ -32,7 +32,7 @@ use crate::http_server::common_responses::media::media_domain::MediaDomain; use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails; use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder; use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const CURSOR_NAME: &str = "folder_mf"; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_add_subfolders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_add_subfolders_handler.rs index c9f6ec1051..0d5ff0f033 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_add_subfolders_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_add_subfolders_handler.rs @@ -25,7 +25,7 @@ use mysql_queries::queries::folders::subfolder::filter_existing_owned_folder_tok use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const MAX_BULK: usize = 500; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_remove_subfolders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_remove_subfolders_handler.rs index 88eb6aecc6..8a0eab5640 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_remove_subfolders_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_remove_subfolders_handler.rs @@ -14,7 +14,7 @@ use mysql_queries::queries::folders::subfolder::bulk_clear_parent_folder::{ use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const MAX_BULK: usize = 500; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs index e3fae72cbf..50f1a73e3b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs @@ -21,7 +21,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ build_folder_thumbnails_lookup, folder_row_to_info, }; use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; const CURSOR_NAME: &str = "subfolders"; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/video/estimate_video_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/video/estimate_video_cost_handler.rs index 8a2964f0ec..0e56a68bb3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/video/estimate_video_cost_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/video/estimate_video_cost_handler.rs @@ -51,6 +51,7 @@ pub async fn estimate_video_cost_handler( reference_character_tokens: None, resolution: router_resolution, aspect_ratio: router_aspect_ratio, + bitrate: None, duration_seconds: request.duration_seconds, video_batch_count: request.video_batch_count, generate_audio: request.generate_audio, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/flux_2_lora_edit_image_angle_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/flux_2_lora_edit_image_angle_handler.rs index b01fda2ede..bd6c210bab 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/flux_2_lora_edit_image_angle_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/flux_2_lora_edit_image_angle_handler.rs @@ -172,6 +172,7 @@ pub async fn flux_2_lora_edit_image_angle_handler( })?; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle_handler.rs index d990043449..0b6a8495ad 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle_handler.rs @@ -173,6 +173,7 @@ pub async fn qwen_edit_2511_edit_image_angle_handler( })?; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/flux_pro_kontext_max_edit_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/flux_pro_kontext_max_edit_image_handler.rs index 7064ef556b..0798ec7eb1 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/flux_pro_kontext_max_edit_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/flux_pro_kontext_max_edit_image_handler.rs @@ -220,6 +220,7 @@ pub async fn flux_pro_kontext_max_edit_image_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/gemini_25_flash_edit_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/gemini_25_flash_edit_image_handler.rs index 57d73ff52c..7656b2c67e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/gemini_25_flash_edit_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/gemini_25_flash_edit_image_handler.rs @@ -208,6 +208,7 @@ pub async fn gemini_25_flash_edit_image_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/gpt_image_1_edit_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/gpt_image_1_edit_image_handler.rs index 43b1735838..cde1953c65 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/gpt_image_1_edit_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/gpt_image_1_edit_image_handler.rs @@ -240,6 +240,7 @@ pub async fn gpt_image_1_edit_image_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/qwen_edit_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/qwen_edit_image_handler.rs index 033ae26385..d2e082093d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/qwen_edit_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/qwen_edit_image_handler.rs @@ -213,6 +213,7 @@ pub async fn qwen_edit_image_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/seededit_3_edit_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/seededit_3_edit_image_handler.rs index eeca7406b3..3ab5ef89c7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/seededit_3_edit_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/edit/seededit_3_edit_image_handler.rs @@ -173,6 +173,7 @@ pub async fn seededit_3_edit_image_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/inpaint/flux_dev_juggernaut_inpaint_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/inpaint/flux_dev_juggernaut_inpaint_handler.rs index 52d525c7f2..dcb711f72a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/inpaint/flux_dev_juggernaut_inpaint_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/inpaint/flux_dev_juggernaut_inpaint_handler.rs @@ -209,6 +209,7 @@ pub async fn flux_dev_juggernaut_inpaint_image_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/inpaint/flux_pro_1_inpaint_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/inpaint/flux_pro_1_inpaint_handler.rs index ceb0f38eba..9e93cdac19 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/inpaint/flux_pro_1_inpaint_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/inpaint/flux_pro_1_inpaint_handler.rs @@ -209,6 +209,7 @@ pub async fn flux_pro_1_inpaint_image_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_5_lite_multi_function_image_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_5_lite_multi_function_image_gen_handler.rs index 31c7a33d94..81780c1bf3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_5_lite_multi_function_image_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_5_lite_multi_function_image_gen_handler.rs @@ -261,6 +261,7 @@ pub async fn bytedance_seedream_5_lite_multi_function_image_gen_handler( }); let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_v4_multi_function_image_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_v4_multi_function_image_gen_handler.rs index dbaec4fdd6..4535c9a9f1 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_v4_multi_function_image_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_v4_multi_function_image_gen_handler.rs @@ -281,6 +281,7 @@ pub async fn bytedance_seedream_v4_multi_function_image_gen_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_v4p5_multi_function_image_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_v4p5_multi_function_image_gen_handler.rs index 2c11202ba2..415f454142 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_v4p5_multi_function_image_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/bytedance_seedream_v4p5_multi_function_image_gen_handler.rs @@ -278,6 +278,7 @@ pub async fn bytedance_seedream_v4p5_multi_function_image_gen_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/gpt_image_1p5_multi_function_image_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/gpt_image_1p5_multi_function_image_gen_handler.rs index 8b4b184a4b..26051fad36 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/gpt_image_1p5_multi_function_image_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/gpt_image_1p5_multi_function_image_gen_handler.rs @@ -329,6 +329,7 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_2_multi_function_image_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_2_multi_function_image_gen_handler.rs index 9c161cd095..4f992ac9c6 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_2_multi_function_image_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_2_multi_function_image_gen_handler.rs @@ -291,6 +291,7 @@ pub async fn nano_banana_2_multi_function_image_gen_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_multi_function_image_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_multi_function_image_gen_handler.rs index 64a688c0a7..22fb4f97b5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_multi_function_image_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_multi_function_image_gen_handler.rs @@ -278,6 +278,7 @@ pub async fn nano_banana_multi_function_image_gen_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_pro_multi_function_image_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_pro_multi_function_image_gen_handler.rs index 089452c061..b1a3e53188 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_pro_multi_function_image_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/multi_function/nano_banana_pro_multi_function_image_gen_handler.rs @@ -299,6 +299,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_1_dev_text_to_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_1_dev_text_to_image_handler.rs index adac8bf8a4..094892b4e6 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_1_dev_text_to_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_1_dev_text_to_image_handler.rs @@ -211,6 +211,7 @@ pub async fn generate_flux_1_dev_text_to_image_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session @@ -301,6 +302,7 @@ async fn insert_mock_failure_job( })?; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_1_schnell_text_to_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_1_schnell_text_to_image_handler.rs index 135dc54185..dc8c087ca9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_1_schnell_text_to_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_1_schnell_text_to_image_handler.rs @@ -191,6 +191,7 @@ pub async fn generate_flux_1_schnell_text_to_image_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_pro_11_text_to_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_pro_11_text_to_image_handler.rs index 918266cd21..31b733112e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_pro_11_text_to_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_pro_11_text_to_image_handler.rs @@ -178,6 +178,7 @@ pub async fn generate_flux_pro_11_text_to_image_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_pro_11_ultra_text_to_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_pro_11_ultra_text_to_image_handler.rs index cce54f9c79..11f280d617 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_pro_11_ultra_text_to_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_flux_pro_11_ultra_text_to_image_handler.rs @@ -186,6 +186,7 @@ pub async fn generate_flux_pro_11_ultra_text_to_image_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_gpt_image_1_text_to_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_gpt_image_1_text_to_image_handler.rs index cba66782be..0e03b83364 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_gpt_image_1_text_to_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/image/text/generate_gpt_image_1_text_to_image_handler.rs @@ -192,6 +192,7 @@ pub async fn generate_gpt_image_1_text_to_image_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(&user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/generate_hunyuan_2_0_image_to_3d_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/generate_hunyuan_2_0_image_to_3d_handler.rs index 94038b1bca..53cfee9060 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/generate_hunyuan_2_0_image_to_3d_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/generate_hunyuan_2_0_image_to_3d_handler.rs @@ -164,6 +164,7 @@ pub async fn generate_hunyuan_2_0_image_to_3d_handler( // Insert prompt record if we have a prompt let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/generate_hunyuan_2_1_image_to_3d_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/generate_hunyuan_2_1_image_to_3d_handler.rs index 16374a6c8b..cf0088391a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/generate_hunyuan_2_1_image_to_3d_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/generate_hunyuan_2_1_image_to_3d_handler.rs @@ -163,6 +163,7 @@ pub async fn generate_hunyuan_2_1_image_to_3d_handler( // Insert prompt record if we have a prompt let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/multi_function/generate_hunyuan3d_v3_multi_function_object_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/multi_function/generate_hunyuan3d_v3_multi_function_object_handler.rs index 1cb77dd7d0..2011b33186 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/multi_function/generate_hunyuan3d_v3_multi_function_object_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/object/multi_function/generate_hunyuan3d_v3_multi_function_object_handler.rs @@ -309,6 +309,7 @@ pub async fn generate_hunyuan3d_v3_multi_function_object_handler( // Insert prompt record if we have a prompt let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_mini_splat_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_mini_splat_handler.rs index 5d7e87a924..f530ecfdef 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_mini_splat_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_mini_splat_handler.rs @@ -175,6 +175,7 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler( })?; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_plus_splat_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_plus_splat_handler.rs index 4152922739..8c0ff010e0 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_plus_splat_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_plus_splat_handler.rs @@ -175,6 +175,7 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler( })?; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/edit/beeble_switchx_edit_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/edit/beeble_switchx_edit_video_gen_handler.rs index 670a950552..9a449228c5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/edit/beeble_switchx_edit_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/edit/beeble_switchx_edit_video_gen_handler.rs @@ -253,6 +253,7 @@ pub async fn beeble_switchx_edit_video_gen_handler( // -- Prompt -- let prompt_token = match insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_1_6_pro_video_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_1_6_pro_video_handler.rs index 7118a34d13..88db2f81ea 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_1_6_pro_video_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_1_6_pro_video_handler.rs @@ -216,6 +216,7 @@ pub async fn generate_kling_1_6_pro_video_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_2_1_master_video_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_2_1_master_video_handler.rs index 1e77c13385..ae5468aad3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_2_1_master_video_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_2_1_master_video_handler.rs @@ -205,6 +205,7 @@ pub async fn generate_kling_2_1_master_video_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_2_1_pro_video_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_2_1_pro_video_handler.rs index 774033dd8d..ca0a470c92 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_2_1_pro_video_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_kling_2_1_pro_video_handler.rs @@ -221,6 +221,7 @@ pub async fn generate_kling_2_1_pro_video_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_seedance_1_0_lite_image_to_video_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_seedance_1_0_lite_image_to_video_handler.rs index 5d9f0cb2a8..996cb7b726 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_seedance_1_0_lite_image_to_video_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_seedance_1_0_lite_image_to_video_handler.rs @@ -235,6 +235,7 @@ pub async fn generate_seedance_1_0_lite_image_to_video_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_seedance_1_0_pro_image_to_video_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_seedance_1_0_pro_image_to_video_handler.rs index bb44423a77..0e15c363c9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_seedance_1_0_pro_image_to_video_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_seedance_1_0_pro_image_to_video_handler.rs @@ -209,6 +209,7 @@ pub async fn generate_seedance_1_0_pro_image_to_video_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_2_image_to_video_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_2_image_to_video_handler.rs index 4a05ff8286..b761617ce2 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_2_image_to_video_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_2_image_to_video_handler.rs @@ -210,6 +210,7 @@ pub async fn generate_veo_2_image_to_video_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_fast_image_to_video_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_fast_image_to_video_handler.rs index 8095403e3a..5a96ce79f7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_fast_image_to_video_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_fast_image_to_video_handler.rs @@ -214,6 +214,7 @@ pub async fn generate_veo_3_fast_image_to_video_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_image_to_video_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_image_to_video_handler.rs index b02bebe83d..e6adb23849 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_image_to_video_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_image_to_video_handler.rs @@ -218,6 +218,7 @@ pub async fn generate_veo_3_image_to_video_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_2p5_turbo_pro_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_2p5_turbo_pro_multi_function_video_gen_handler.rs index d16ad60f53..314c97a6cd 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_2p5_turbo_pro_multi_function_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_2p5_turbo_pro_multi_function_video_gen_handler.rs @@ -267,6 +267,7 @@ pub async fn kling_2p5_turbo_pro_multi_function_video_gen_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_2p6_pro_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_2p6_pro_multi_function_video_gen_handler.rs index 11bae0a30a..7a77e0883c 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_2p6_pro_multi_function_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_2p6_pro_multi_function_video_gen_handler.rs @@ -264,6 +264,7 @@ pub async fn kling_2p6_pro_multi_function_video_gen_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_3p0_pro_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_3p0_pro_multi_function_video_gen_handler.rs index 8db444aee2..697ae9d162 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_3p0_pro_multi_function_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_3p0_pro_multi_function_video_gen_handler.rs @@ -258,6 +258,7 @@ pub async fn kling_3p0_pro_multi_function_video_gen_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_3p0_standard_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_3p0_standard_multi_function_video_gen_handler.rs index 64d2626777..da67fb6141 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_3p0_standard_multi_function_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/kling_3p0_standard_multi_function_video_gen_handler.rs @@ -258,6 +258,7 @@ pub async fn kling_3p0_standard_multi_function_video_gen_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_1p5_pro_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_1p5_pro_multi_function_video_gen_handler.rs index e5f6bae569..410eb0a671 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_1p5_pro_multi_function_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_1p5_pro_multi_function_video_gen_handler.rs @@ -245,6 +245,7 @@ pub async fn seedance_1p5_pro_multi_function_video_gen_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_2p0_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_2p0_multi_function_video_gen_handler.rs index 2fd1b5f71b..8eb8958b3a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_2p0_multi_function_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_2p0_multi_function_video_gen_handler.rs @@ -6,7 +6,7 @@ use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure; use crate::http_server::endpoints::generate::common::map_seedance2pro_web_errors::map_seedance2pro_error_to_web_error; use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type; use crate::state::server_state::ServerState; @@ -317,6 +317,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler( // NB: Don't fail the job if the prompt insert fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen_handler.rs index 995a219580..fa771cdf78 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen_handler.rs @@ -284,6 +284,7 @@ pub async fn sora_2_multi_function_video_gen_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen_handler.rs index 0993679c11..274d82e17b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen_handler.rs @@ -286,6 +286,7 @@ pub async fn sora_2_pro_multi_function_video_gen_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/veo_3p1_fast_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/veo_3p1_fast_multi_function_video_gen_handler.rs index 1a15abe114..fb48b2f8a8 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/veo_3p1_fast_multi_function_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/veo_3p1_fast_multi_function_video_gen_handler.rs @@ -352,6 +352,7 @@ pub async fn veo_3p1_fast_multi_function_video_gen_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/veo_3p1_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/veo_3p1_multi_function_video_gen_handler.rs index 64144aae25..e5f3697d4e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/veo_3p1_multi_function_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/veo_3p1_multi_function_video_gen_handler.rs @@ -352,6 +352,7 @@ pub async fn veo_3p1_multi_function_video_gen_handler( // NB: Don't fail the job if the query fails. let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/update_gpt_image_job_status_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/update_gpt_image_job_status_handler.rs index eda6a50c60..3a008e0d35 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/update_gpt_image_job_status_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/update_gpt_image_job_status_handler.rs @@ -11,7 +11,7 @@ use actix_web::{web, HttpMessage, HttpRequest, HttpResponse}; use base64::prelude::BASE64_STANDARD; use base64::Engine; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use enums::by_table::media_files::media_file_class::MediaFileClass; use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory; use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory; @@ -163,7 +163,7 @@ async fn upload_and_save_image( base64_image: &str, inference_job: &GenericInferenceJobStatus, mysql_pool: &MySqlPool, - public_bucket_client: &BucketClient, + public_bucket_client: &LegacyBucketClient, ) -> AnyhowResult { let image_bytes = BASE64_STANDARD.decode(base64_image)?; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/admin/kill_inference_jobs_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/admin/kill_inference_jobs_handler.rs index fe57f09637..07c8f2afa2 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/admin/kill_inference_jobs_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/admin/kill_inference_jobs_handler.rs @@ -3,8 +3,9 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::warn; use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory; @@ -52,8 +53,8 @@ pub struct KillInferenceJobsResponse { pub async fn kill_generic_inference_jobs_handler( http_request: HttpRequest, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -106,10 +107,5 @@ pub async fn kill_generic_inference_jobs_handler( success: true, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/delete/dismiss_finished_session_jobs_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/delete/dismiss_finished_session_jobs_handler.rs index 5a1a055194..864d655b2e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/delete/dismiss_finished_session_jobs_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/delete/dismiss_finished_session_jobs_handler.rs @@ -8,7 +8,7 @@ use utoipa::ToSchema; use mysql_queries::queries::generic_inference::web::dismiss_finished_jobs_for_user::dismiss_finished_jobs_for_user; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; #[derive(Serialize, ToSchema)] diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/delete/terminate_inference_job_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/delete/terminate_inference_job_handler.rs index 0e230ebca9..e8776603a7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/delete/terminate_inference_job_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/inference_job/delete/terminate_inference_job_handler.rs @@ -2,9 +2,10 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpMessage, HttpRequest, HttpResponse}; +use actix_web::{web, HttpMessage, HttpRequest}; use log::{error, warn}; use utoipa::ToSchema; @@ -47,7 +48,7 @@ pub struct TerminateInferenceJobSuccessResponse { pub async fn terminate_inference_job_handler( http_request: HttpRequest, path: Path, - server_state: web::Data>) -> Result + server_state: web::Data>) -> Result, CommonWebError> { let mut mysql_connection = server_state.mysql_pool.acquire() .await @@ -105,13 +106,5 @@ pub async fn terminate_inference_job_handler( success: true, }; - let body = serde_json::to_string(&response) - .map_err(|e| { - error!("error returning response: {:?}", e); - CommonWebError::from_error(e) - })?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_animation_type_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_animation_type_handler.rs index ffc66e7e5e..8c528829a9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_animation_type_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_animation_type_handler.rs @@ -17,7 +17,7 @@ use mysql_queries::queries::media_files::get::get_media_file::get_media_file; use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::common_responses::simple_response::SimpleResponse; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; #[derive(Deserialize, ToSchema)] diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_engine_category_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_engine_category_handler.rs index 6ecda0dfc5..23f0863a30 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_engine_category_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_engine_category_handler.rs @@ -16,7 +16,7 @@ use mysql_queries::queries::media_files::get::get_media_file::get_media_file; use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::common_responses::simple_response::SimpleResponse; -use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; use crate::state::server_state::ServerState; #[derive(Deserialize, ToSchema)] diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_visibility_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_visibility_handler.rs index 1f9dfd3f92..5dae20013f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_visibility_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/change_media_file_visibility_handler.rs @@ -1,17 +1,14 @@ -use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::warn; use utoipa::ToSchema; use enums::common::visibility::Visibility; use http_server_common::request::get_request_ip::get_request_ip; -use http_server_common::response::response_success_helpers::{simple_json_success, SimpleGenericJsonSuccess}; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess; use mysql_queries::queries::media_files::edit::update_media_file_visibility::{update_media_file_visibility, UpdateMediaFileArgs}; use mysql_queries::queries::media_files::get::get_media_file::get_media_file; @@ -47,8 +44,8 @@ pub struct ChangeMediaFileVisibilityRequest { pub async fn change_media_file_visibility_handler( http_request: HttpRequest, path: Path, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -127,5 +124,5 @@ pub async fn change_media_file_visibility_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/rename_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/rename_media_file_handler.rs index 422d7eef62..4f10688ab4 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/rename_media_file_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/rename_media_file_handler.rs @@ -1,19 +1,15 @@ -use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::warn; use utoipa::ToSchema; use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo; use crate::http_server::common_responses::common_web_error::CommonWebError; use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::state::server_state::ServerState; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use mysql_queries::queries::media_files::edit::rename_media_file::rename_media_file; use mysql_queries::queries::media_files::get::get_media_file::get_media_file; @@ -47,9 +43,9 @@ pub struct RenameMediaFileRequest { pub async fn rename_media_file_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result{ +) -> Result, CommonWebError>{ let maybe_user_session = server_state .session_checker .maybe_get_user_session(&http_request, &server_state.mysql_pool) @@ -104,5 +100,5 @@ pub async fn rename_media_file_handler( CommonWebError::from_anyhow_error(err) })?; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/set_media_file_cover_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/set_media_file_cover_image_handler.rs index b2101d9196..8a8c75774a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/set_media_file_cover_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/set_media_file_cover_image_handler.rs @@ -1,15 +1,12 @@ -use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::{info, warn}; use utoipa::ToSchema; use enums::by_table::media_files::media_file_type::MediaFileType; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use mysql_queries::queries::media_files::edit::set_media_file_cover_image::{set_media_file_cover_image, UpdateArgs}; use mysql_queries::queries::media_files::get::get_media_file::get_media_file; use tokens::tokens::media_files::MediaFileToken; @@ -17,7 +14,6 @@ use tokens::tokens::media_files::MediaFileToken; use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo; use crate::http_server::common_responses::common_web_error::CommonWebError; use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::state::server_state::ServerState; #[derive(Deserialize, ToSchema)] @@ -53,9 +49,9 @@ pub struct SetMediaFileCoverImageRequest { pub async fn set_media_file_cover_image_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result{ +) -> Result, CommonWebError>{ let maybe_user_session = server_state .session_checker .maybe_get_user_session(&http_request, &server_state.mysql_pool) @@ -157,5 +153,5 @@ pub async fn set_media_file_cover_image_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/update_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/update_media_file_handler.rs index affd7a55b2..e4054e8361 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/update_media_file_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/edit/update_media_file_handler.rs @@ -1,16 +1,12 @@ -use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::warn; use enums::common::visibility::Visibility; use http_server_common::request::get_request_ip::get_request_ip; -use http_server_common::response::response_success_helpers::simple_json_success; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use mysql_queries::queries::media_files::edit::update_media_file_visibility::{update_media_file_visibility, UpdateMediaFileArgs}; use mysql_queries::queries::media_files::get::get_media_file::get_media_file; use tokens::tokens::media_files::MediaFileToken; @@ -41,8 +37,8 @@ pub struct UpdateMediaFilePathInfo { pub async fn update_media_file_handler( http_request: HttpRequest, path: Path, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -121,5 +117,5 @@ pub async fn update_media_file_handler( } }; - Ok(simple_json_success()) + Ok(Json(UpdateMediaFileResponse { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_pmx/extract_and_upload_pmx_files.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_pmx/extract_and_upload_pmx_files.rs index 08338f9433..b11d24564f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_pmx/extract_and_upload_pmx_files.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_pmx/extract_and_upload_pmx_files.rs @@ -7,7 +7,7 @@ use once_cell::sync::Lazy; use zip::ZipArchive; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use filesys::path_to_string::path_to_string; use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes; @@ -40,7 +40,7 @@ pub struct PmxDetails { pub async fn extract_and_upload_pmx_files( zip_container_file_bytes: &[u8], - bucket_client: &BucketClient, + bucket_client: &LegacyBucketClient, prefix: Option<&str>, suffix: Option<&str> ) -> Result { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_pmx/upload_pmx_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_pmx/upload_pmx_media_file_handler.rs index a83faabc9a..652182e390 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_pmx/upload_pmx_media_file_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_pmx/upload_pmx_media_file_handler.rs @@ -24,7 +24,7 @@ use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileU use crate::http_server::endpoints::media_files::upload::upload_pmx::extract_and_upload_pmx_files::{extract_and_upload_pmx_files, PmxError}; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// Form-multipart request fields. diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_studio_shot/upload_studio_shot_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_studio_shot/upload_studio_shot_media_file_handler.rs index 4ec61b108b..654c0313e8 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_studio_shot/upload_studio_shot_media_file_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_studio_shot/upload_studio_shot_media_file_handler.rs @@ -25,7 +25,7 @@ use crate::http_server::endpoints::media_files::upload::upload_studio_shot::extr use crate::http_server::endpoints::media_files::upload::upload_studio_shot::ffmpeg_frames_to_mp4::ffmpeg_frames_to_mp4; use crate::http_server::requests::request_headers::has_debug_header::has_debug_header; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; -use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended; +use crate::http_server::user_lookup::user_session::require_user_session_extended::require_user_session_extended; use crate::state::server_state::ServerState; const DEFAULT_FPS : u8 = 12; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs index 4d593ba4d7..9108c35d06 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs @@ -17,6 +17,7 @@ pub mod media_files; pub mod misc; pub mod model_download; pub mod moderation; +pub mod omni_api; pub mod omni_gen; pub mod prompts; pub mod service; @@ -31,6 +32,7 @@ pub mod user_ratings; pub mod user_referral_codes; pub mod user_wallets; pub mod users; +pub mod video_info; pub mod voice_clone_requests; pub mod voice_conversion; pub mod voice_designer; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/model_download/enqueue_gptsovits_model_download_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/model_download/enqueue_gptsovits_model_download_handler.rs index 0e6a8c85df..9db8b015c3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/model_download/enqueue_gptsovits_model_download_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/model_download/enqueue_gptsovits_model_download_handler.rs @@ -28,7 +28,7 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r use crate::http_server::requests::request_headers::has_debug_header::has_debug_header; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::http_server::validations::validate_model_title::validate_model_title; -use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended; +use crate::http_server::user_lookup::user_session::require_user_session_extended::require_user_session_extended; use crate::state::server_state::ServerState; #[derive(Deserialize, ToSchema)] diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/alerts/moderation_send_alert_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/alerts/moderation_send_alert_handler.rs index c7f8cc01e4..b11515ddb8 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/alerts/moderation_send_alert_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/alerts/moderation_send_alert_handler.rs @@ -14,7 +14,7 @@ use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{ use pager::notification::notification_details_builder::NotificationDetailsBuilder; use pager::notification::notification_urgency::NotificationUrgency; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// Send a test alert via the pager system. Moderators only. diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/debug_logs/moderation_list_debug_logs_for_token_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/debug_logs/moderation_list_debug_logs_for_token_handler.rs index 04e854b8b6..eb2402049f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/debug_logs/moderation_list_debug_logs_for_token_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/debug_logs/moderation_list_debug_logs_for_token_handler.rs @@ -15,7 +15,7 @@ use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; // ── Path params ── diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/info/moderator_token_info_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/info/moderator_token_info_handler.rs index ec7ab084bc..598a81afbf 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/info/moderator_token_info_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/info/moderator_token_info_handler.rs @@ -2,9 +2,10 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::warn; use serde::Serialize; use sqlx::MySqlPool; @@ -25,7 +26,7 @@ use tokens::tokens::users::UserToken; use crate::http_server::web_utils::serialize_as_json_error::serialize_as_json_error; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// For the URL PathInfo @@ -58,7 +59,7 @@ pub async fn moderator_get_token_info_handler( path: Path, http_request: HttpRequest, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?; @@ -75,12 +76,7 @@ pub async fn moderator_get_token_info_handler( maybe_payload: maybe_result, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } const LEGACY_USER_TOKEN_PREFIX : &str = "U:"; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/add_ip_ban.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/add_ip_ban.rs index aaf371dce9..777f33fac4 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/add_ip_ban.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/add_ip_ban.rs @@ -2,16 +2,17 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::{info, warn}; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use mysql_queries::queries::ip_bans::upsert_ip_ban::{upsert_ip_ban, UpsertIpBanArgs}; use user_input_common::validate_user_provided_ip_address::validate_user_provided_ip_address; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::state::server_state::ServerState; #[derive(Deserialize)] @@ -23,9 +24,9 @@ pub struct AddIpBanRequest { // NB: Not using derive_more::Display since Clion doesn't understand it. pub async fn add_ip_ban_handler( http_request: HttpRequest, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -70,5 +71,5 @@ pub async fn add_ip_ban_handler( CommonWebError::from_anyhow_error(err) })?; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/delete_ip_ban.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/delete_ip_ban.rs index 5cbe146b6a..7b64f235bb 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/delete_ip_ban.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/delete_ip_ban.rs @@ -1,17 +1,18 @@ use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use derive_more::Display; use log::{info, warn}; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use mysql_queries::queries::ip_bans::toggle_ip_ban::{toggle_ip_ban, IpBanToggleState}; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::state::server_state::ServerState; /// For the URL PathInfo @@ -27,9 +28,9 @@ pub struct DeleteIpBanRequest { pub async fn delete_ip_ban_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -77,5 +78,5 @@ pub async fn delete_ip_ban_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/get_ip_ban.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/get_ip_ban.rs index 260edd35b3..c188fca429 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/get_ip_ban.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/get_ip_ban.rs @@ -1,9 +1,10 @@ use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use derive_more::Display; use log::{error, warn}; @@ -42,7 +43,7 @@ pub async fn get_ip_ban_handler( http_request: HttpRequest, path: Path, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -97,10 +98,5 @@ pub async fn get_ip_ban_handler( ip_address_ban: result, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/list_ip_bans.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/list_ip_bans.rs index 29fa9bccf6..b50f259970 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/list_ip_bans.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/ip_bans/list_ip_bans.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::{error, warn}; @@ -34,7 +35,7 @@ pub struct IpBanRecordForList { pub async fn list_ip_bans_handler( http_request: HttpRequest, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -85,10 +86,5 @@ pub async fn list_ip_bans_handler( ip_address_bans: results, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/get_tts_inference_queue_count.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/get_tts_inference_queue_count.rs index 753082dc5c..0a3665ecf9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/get_tts_inference_queue_count.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/get_tts_inference_queue_count.rs @@ -2,8 +2,9 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::warn; use mysql_queries::queries::tts::tts_inference_jobs::get_pending_tts_inference_job_detailed_stats::{get_pending_tts_inference_job_detailed_stats, PendingCountResult}; @@ -34,7 +35,7 @@ pub struct GetTtsInferenceQueueCountResponse { pub async fn get_tts_inference_queue_count_handler( http_request: HttpRequest, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -85,10 +86,5 @@ pub async fn get_tts_inference_queue_count_handler( attempt_failed_count: result.attempt_failed_count, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/get_w2l_inference_queue_count.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/get_w2l_inference_queue_count.rs index b76efeaf17..52b49308a8 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/get_w2l_inference_queue_count.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/get_w2l_inference_queue_count.rs @@ -47,7 +47,7 @@ impl fmt::Display for GetW2lInferenceQueueCountError { pub async fn get_w2l_inference_queue_count_handler( http_request: HttpRequest, server_state: web::Data> -) -> Result { +) -> Result, GetW2lInferenceQueueCountError> { let maybe_user_session = server_state .session_checker @@ -92,10 +92,5 @@ pub async fn get_w2l_inference_queue_count_handler( seconds_since_first: result.seconds_since_first, }; - let body = serde_json::to_string(&response) - .map_err(|e| GetW2lInferenceQueueCountError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/kill_tts_inference_jobs.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/kill_tts_inference_jobs.rs index bca96c4f9c..e33073c950 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/kill_tts_inference_jobs.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/kill_tts_inference_jobs.rs @@ -2,8 +2,9 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::{error, warn}; use http_server_common::response::serialize_as_json_error::serialize_as_json_error; @@ -45,8 +46,8 @@ pub struct KillTtsInferenceJobsResponse { pub async fn kill_tts_inference_jobs_handler( http_request: HttpRequest, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -88,10 +89,5 @@ pub async fn kill_tts_inference_jobs_handler( success: true, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/moderation_get_job_by_token_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/moderation_get_job_by_token_handler.rs index 40813847ee..ca9d4e533f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/moderation_get_job_by_token_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/moderation_get_job_by_token_handler.rs @@ -16,7 +16,7 @@ use tokens::tokens::prompts::PromptToken; use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; // ── Path params ── diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/user/list_user_jobs_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/user/list_user_jobs_handler.rs index 56256256c8..cb3c16c86b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/user/list_user_jobs_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/jobs/user/list_user_jobs_handler.rs @@ -13,7 +13,7 @@ use mysql_queries::queries::generic_inference::web::list_user_jobs_for_moderatio use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// List jobs for a user (moderation) diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/moderator_list_staff_audit_logs_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/moderator_list_staff_audit_logs_handler.rs index 98719e5e80..764f948e0e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/moderator_list_staff_audit_logs_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/moderator_list_staff_audit_logs_handler.rs @@ -13,7 +13,7 @@ use tokens::tokens::staff_audit_logs::StaffAuditLogToken; use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; const CURSOR_NAME: &str = "modstfaud"; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_list_subscribing_users_by_signup_date.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_list_subscribing_users_by_signup_date.rs index d51dc46dc6..afde1db281 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_list_subscribing_users_by_signup_date.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_list_subscribing_users_by_signup_date.rs @@ -15,7 +15,7 @@ use mysql_queries::queries::users::user::list::list_subscribing_users_by_signup_ }; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// List subscribing users by signup date (descending), for moderators. diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_list_users_by_signup_date.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_list_users_by_signup_date.rs index 711be69d67..9016480907 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_list_users_by_signup_date.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_list_users_by_signup_date.rs @@ -15,7 +15,7 @@ use mysql_queries::queries::users::user::list::list_users_by_signup_date_for_mod }; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// List users by signup date (descending), for moderators. diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_user_lookup_by_stripe_customer_id_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_user_lookup_by_stripe_customer_id_handler.rs index 3ffb961167..46b7f76dc3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_user_lookup_by_stripe_customer_id_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_user_lookup_by_stripe_customer_id_handler.rs @@ -12,7 +12,7 @@ use artcraft_api_defs::moderation::user::user_lookup_by_stripe_customer_id::{ use mysql_queries::queries::users::user_subscriptions::lookup_users_by_stripe_customer_id::lookup_users_by_stripe_customer_id; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// Moderator User Lookup by Stripe Customer ID diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_user_lookup_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_user_lookup_handler.rs index feb99cbe5e..a168539c4c 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_user_lookup_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_user_lookup_handler.rs @@ -17,7 +17,7 @@ use mysql_queries::queries::users::user::get::lookup_user_for_moderation::{ use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; const LEGACY_USER_TOKEN_PREFIX: &str = "U:"; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/moderation_ban_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/moderation_ban_user_handler.rs index 5949ecd571..d8399d89c7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/moderation_ban_user_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/moderation_ban_user_handler.rs @@ -18,7 +18,7 @@ use mysql_queries::queries::users::user::update::set_user_ban_status::{ use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; // --- Request --- diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_change_user_email_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_change_user_email_handler.rs index 77dedaa0a0..73489d7689 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_change_user_email_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_change_user_email_handler.rs @@ -24,7 +24,7 @@ use users::email::email_to_gravatar_hash::email_to_gravatar_hash; use users::email::validate_email_address_format::validate_email_address_format; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; // --- Request --- diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_list_email_address_changes_for_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_list_email_address_changes_for_user_handler.rs index ccb4b84a9e..b47fce3bac 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_list_email_address_changes_for_user_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_list_email_address_changes_for_user_handler.rs @@ -15,7 +15,7 @@ use mysql_queries::queries::users::user_email_changes::list_user_email_changes_f use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; const CURSOR_NAME: &str = "modemchg"; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_edit_user_feature_flags_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_edit_user_feature_flags_handler.rs index 6f5ee84308..d0c60b0929 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_edit_user_feature_flags_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_edit_user_feature_flags_handler.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use actix_web::error::ResponseError; use actix_web::http::StatusCode; -use actix_web::web::{Data, Json, Path}; -use actix_web::{HttpRequest, HttpResponse}; +use actix_web::web::{self, Data, Json, Path}; +use actix_web::HttpRequest; use log::warn; use redis::{Client, Commands}; use sqlx::Acquire; @@ -23,11 +23,10 @@ use mysql_queries::queries::users::user::update::set_user_feature_flags::{set_us use mysql_queries::queries::users::user_profiles::get_user_profile_by_token::get_user_profile_by_token; use tokens::tokens::users::UserToken; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; @@ -85,7 +84,7 @@ pub async fn moderator_edit_user_feature_flags_handler( request: Json, server_state: Data>, redis_pool: Data>, -) -> Result { +) -> Result, CommonWebError> { let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?; @@ -190,5 +189,5 @@ pub async fn moderator_edit_user_feature_flags_handler( let _r: Result, _> = redis.del(&cache_key); } - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_list_all_available_user_feature_flags_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_list_all_available_user_feature_flags_handler.rs index 279ce42797..230d4d2213 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_list_all_available_user_feature_flags_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_list_all_available_user_feature_flags_handler.rs @@ -8,7 +8,7 @@ use utoipa::ToSchema; use enums::by_table::users::user_feature_flag::UserFeatureFlag; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; // ── Response ── diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_list_user_feature_flags_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_list_user_feature_flags_handler.rs index 7f5becf2a4..5c7b5442d4 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_list_user_feature_flags_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_feature_flags/moderator_list_user_feature_flags_handler.rs @@ -11,8 +11,8 @@ use mysql_queries::queries::users::user_profiles::get_user_profile_by_token::get use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; // ── Request ── diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_global_user_referrals_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_global_user_referrals_handler.rs index 24b5909236..7602df67bf 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_global_user_referrals_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_global_user_referrals_handler.rs @@ -13,7 +13,7 @@ use mysql_queries::queries::user_referrals::list_global_user_referrals::{ }; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; const CURSOR_NAME: &str = "modusrref"; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_user_referrals_for_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_user_referrals_for_user_handler.rs index dcfc2da2b1..6b22e3a18a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_user_referrals_for_user_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_user_referrals_for_user_handler.rs @@ -16,7 +16,7 @@ use mysql_queries::queries::user_referrals::list_user_referrals_for_user::{ }; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; const CURSOR_NAME: &str = "modusrrefu"; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_for_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_for_user_handler.rs index 725483aa8d..71ef7f3225 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_for_user_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_for_user_handler.rs @@ -12,7 +12,7 @@ use mysql_queries::queries::user_impersonation_requests::list_user_impersonation use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; const CURSOR_NAME: &str = "moduserimp"; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_handler.rs index 9b6634fdfe..0ddbd5395d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_handler.rs @@ -12,7 +12,7 @@ use mysql_queries::queries::user_impersonation_requests::list_user_impersonation use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; const CURSOR_NAME: &str = "modallimp"; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_user_session_impersonation_request_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_user_session_impersonation_request_handler.rs index 9800d28adb..a34086d2d9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_user_session_impersonation_request_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_user_session_impersonation_request_handler.rs @@ -25,7 +25,7 @@ use mysql_queries::queries::users::user::get::lookup_user_for_moderation::{ }; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; // --- Request --- diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_stripe_data/moderator_get_user_stripe_customer_ids_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_stripe_data/moderator_get_user_stripe_customer_ids_handler.rs index 99d390b8bb..485cdbd1a2 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_stripe_data/moderator_get_user_stripe_customer_ids_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_stripe_data/moderator_get_user_stripe_customer_ids_handler.rs @@ -16,7 +16,7 @@ use mysql_queries::queries::users::user_subscriptions::list_stripe_customer_ids_ use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// Get all stripe customer ids on file for a user (moderation) diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallet_ledger_entries/list_wallet_ledger_entries_by_wallet_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallet_ledger_entries/list_wallet_ledger_entries_by_wallet_handler.rs index 8cbd49e9f4..4f9d9b96d3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallet_ledger_entries/list_wallet_ledger_entries_by_wallet_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallet_ledger_entries/list_wallet_ledger_entries_by_wallet_handler.rs @@ -13,7 +13,7 @@ use mysql_queries::queries::wallet_ledger_entries::list_wallet_ledger_entries_by use tokens::tokens::wallets::WalletToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// List wallet ledger entries by wallet token (moderation) diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallet_ledger_entries/moderator_get_wallet_ledger_entry_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallet_ledger_entries/moderator_get_wallet_ledger_entry_handler.rs index a54aa586b7..7cc5c95fc6 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallet_ledger_entries/moderator_get_wallet_ledger_entry_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallet_ledger_entries/moderator_get_wallet_ledger_entry_handler.rs @@ -13,7 +13,7 @@ use mysql_queries::queries::wallet_ledger_entries::get_wallet_ledger_entry_for_m use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// Get a single wallet ledger entry by token (moderation) diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/list_user_wallets_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/list_user_wallets_handler.rs index 25d09d08c3..4403e06cdb 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/list_user_wallets_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/list_user_wallets_handler.rs @@ -13,7 +13,7 @@ use mysql_queries::queries::wallets::list_user_wallets_for_moderation::list_user use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// List wallets for a user (moderation) diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_add_banked_balance_to_wallet_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_add_banked_balance_to_wallet_handler.rs index 1a502c8598..a1939c58b7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_add_banked_balance_to_wallet_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_add_banked_balance_to_wallet_handler.rs @@ -21,7 +21,7 @@ use mysql_queries::queries::wallets::add_durable_banked_balance_to_wallet::add_d use tokens::tokens::wallets::WalletToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// Add banked credits to a wallet (moderation) diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_create_wallet_for_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_create_wallet_for_user_handler.rs index 48123baab8..d790753ddd 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_create_wallet_for_user_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_create_wallet_for_user_handler.rs @@ -15,7 +15,7 @@ use mysql_queries::queries::wallets::create_new_wallet_for_owner_user::create_ne use mysql_queries::queries::wallets::find_primary_wallet_token_for_owner::find_primary_wallet_token_for_owner_using_connection; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// Create a wallet for a user (moderation) diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_get_wallet_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_get_wallet_handler.rs index 216a7884ef..0f6b2a718c 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_get_wallet_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/wallets/moderator_get_wallet_handler.rs @@ -13,7 +13,7 @@ use mysql_queries::queries::wallets::get_wallet_for_moderation::get_wallet_for_m use tokens::tokens::wallets::WalletToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_moderator::require_moderator; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; use crate::state::server_state::ServerState; /// Get a single wallet by token (moderation) diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/check_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/check_request.rs new file mode 100644 index 0000000000..d601f3f0cb --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/check_request.rs @@ -0,0 +1,21 @@ +use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::OmniApiImageGenerateRequest; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::omni_api::shared_utils::check_url_inputs::{check_mutually_exclusive, check_url_format}; + +/// Validate the URL/media-token preconditions before any billable or +/// DB-mutating work: +/// 1. A media-token field and its paired URL field are mutually exclusive. +/// 2. Every supplied URL starts with `http://` or `https://`. +pub fn check_request(request: &OmniApiImageGenerateRequest) -> Result<(), CommonWebError> { + check_mutually_exclusive( + "image_media_tokens", + request.image_media_tokens.is_some(), + "image_urls", + request.image_urls.is_some(), + )?; + + check_url_format(request.image_urls.as_deref())?; + + Ok(()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/hydrate_to_router_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/hydrate_to_router_request.rs new file mode 100644 index 0000000000..2e7f133ee4 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/hydrate_to_router_request.rs @@ -0,0 +1,100 @@ +use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::OmniApiImageGenerateRequest; +use artcraft_router::api::router_aspect_ratio::RouterAspectRatio; +use artcraft_router::api::router_image_model::RouterImageModel; +use artcraft_router::api::router_quality::RouterQuality; +use artcraft_router::api::router_resolution::RouterResolution; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; +use enums::common::generation::common_quality::CommonQuality as CommonQualityEnum; +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +pub fn hydrate_to_router_request( + request: &OmniApiImageGenerateRequest, +) -> Result { + let api_model = request.model.as_ref() + .ok_or_else(|| CommonWebError::BadInputWithSimpleMessage( + "model is required".to_string(), + ))?; + + let model = convert_model(api_model)?; + + let aspect_ratio = request.aspect_ratio.as_ref() + .map(convert_aspect_ratio) + .transpose()?; + + let resolution = request.resolution.as_ref() + .map(convert_resolution) + .transpose()?; + + let quality = request.quality.as_ref() + .map(convert_quality) + .transpose()?; + + Ok(GenerateImageRequestBuilder { + model, + provider: RouterProvider::Artcraft, + prompt: request.prompt.clone(), + image_inputs: request.image_media_tokens.clone() + .map(ImageListRef::MediaFileTokens), + resolution, + aspect_ratio, + quality, + image_batch_count: request.image_batch_count, + horizontal_angle: request.adjust_horizontal_angle, + vertical_angle: request.adjust_vertical_angle, + zoom: request.adjust_zoom, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + generation_mode_mismatch_strategy: None, + idempotency_token: request.idempotency_token.clone(), + }) +} + +fn convert_model( + model: &CommonImageModelEnum, +) -> Result { + let json = serde_json::to_string(model)?; + serde_json::from_str(&json).map_err(|e| { + CommonWebError::BadInputWithSimpleMessage( + format!("Unsupported image model: {}", e), + ) + }) +} + +fn convert_aspect_ratio( + ar: &CommonAspectRatioEnum, +) -> Result { + let json = serde_json::to_string(ar)?; + serde_json::from_str(&json).map_err(|e| { + CommonWebError::BadInputWithSimpleMessage( + format!("Unsupported aspect ratio: {}", e), + ) + }) +} + +fn convert_resolution( + res: &CommonResolutionEnum, +) -> Result { + let json = serde_json::to_string(res)?; + serde_json::from_str(&json).map_err(|e| { + CommonWebError::BadInputWithSimpleMessage( + format!("Unsupported resolution: {}", e), + ) + }) +} + +fn convert_quality( + quality: &CommonQualityEnum, +) -> Result { + let json = serde_json::to_string(quality)?; + serde_json::from_str(&json).map_err(|e| { + CommonWebError::BadInputWithSimpleMessage( + format!("Unsupported quality: {}", e), + ) + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/ingest_url_inputs.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/ingest_url_inputs.rs new file mode 100644 index 0000000000..93b25a0818 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/ingest_url_inputs.rs @@ -0,0 +1,31 @@ +use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::OmniApiImageGenerateRequest; +use tokens::tokens::users::UserToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::omni_api::shared_utils::media_uploads::upload_url_to_media_file::{upload_url_to_media_file, MediaUploadKind}; +use crate::state::server_state::ServerState; + +/// Download any URL image inputs, store them as media files owned by the API +/// user, and fold the resulting tokens into the request's media-token fields so +/// the rest of the handler can treat them as ordinary media tokens. +/// +/// `check_request` has already guaranteed the URL and media-token fields are +/// mutually exclusive, so populating the token field here is always safe. +pub async fn ingest_url_inputs( + request: &mut OmniApiImageGenerateRequest, + server_state: &ServerState, + user_token: &UserToken, + ip_address: &str, +) -> Result<(), CommonWebError> { + if let Some(urls) = request.image_urls.take() { + let mut tokens = Vec::with_capacity(urls.len()); + for url in &urls { + tokens.push( + upload_url_to_media_file(server_state, user_token, ip_address, url, MediaUploadKind::Image).await?, + ); + } + request.image_media_tokens = Some(tokens); + } + + Ok(()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/insert_fal_job.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/insert_fal_job.rs new file mode 100644 index 0000000000..cf27586134 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/insert_fal_job.rs @@ -0,0 +1,61 @@ +use log::warn; + +use enums::common::visibility::Visibility; +use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue::FalCategory; +use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{ + insert_generic_inference_job_for_fal_queue_with_apriori_job_token, + InsertGenericInferenceForFalWithAprioriJobTokenArgs, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +use super::shared_job_args::SharedJobArgs; + +pub struct InsertFalJobArgs<'a, 'tx> { + pub external_job_id: &'a str, + pub shared: SharedJobArgs<'a, 'tx>, +} + +/// Insert a single Fal-queue inference job row for an image generation. +/// Mirrors `omni_gen::generate::video::insert_db_job::insert_fal_job::insert_fal_job` +/// but tags the row with `FalCategory::ImageGeneration` so the worker +/// routes it to the image-generation queue. +pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result { + let InsertFalJobArgs { + external_job_id, + shared, + } = args; + + let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token( + InsertGenericInferenceForFalWithAprioriJobTokenArgs { + apriori_job_token: shared.apriori_job_token, + uuid_idempotency_token: shared.idempotency_token, + maybe_external_third_party_id: external_job_id, + fal_category: FalCategory::ImageGeneration, + maybe_model_type: shared.maybe_model_type, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + creator_set_visibility: Visibility::Public, + maybe_platform_type: shared.maybe_platform_type, + maybe_cost_estimates: None, + maybe_debug_log_event_token: shared.maybe_debug_log_event_token, + mysql_executor: &mut **shared.transaction, + maybe_inference_args: None, + starting_job_status_override: None, + maybe_frontend_failure_category: None, + maybe_failure_reason: None, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => Ok(token), + Err(err) => { + warn!("Error inserting fal inference job (image): {:?}", err); + Err(CommonWebError::from_error(err)) + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/insert_seedance2pro_jobs.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/insert_seedance2pro_jobs.rs new file mode 100644 index 0000000000..05a87d6415 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/insert_seedance2pro_jobs.rs @@ -0,0 +1,117 @@ +use log::{error, warn}; + +use enums::common::visibility::Visibility; +use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{ + insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token, + InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs, KinoviVersion, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; +use uuid_utils::uuid::generate_random_uuid; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +use super::shared_job_args::SharedJobArgs; + +pub struct InsertSeedance2proJobsArgs<'a, 'tx> { + /// The order_id for the primary job (always inserted first). + pub primary_order_id: &'a str, + + /// Additional order_ids returned by Kinovi when batch_count > 1. Each + /// becomes its own inference job row. + pub maybe_additional_order_ids: Option<&'a [String]>, + + /// Wallet ledger entry from the up-front deduction. Stamped on each + /// inserted row so the worker can issue a refund on failure. + pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>, + + /// Which Kinovi account fulfilled the request — Volcengine, BytePlus, + /// or BytePlusUltra. For Midjourney image generation today this is + /// always Volcengine, but the helper stays version-agnostic so a future + /// account-picking layer can use it unchanged. + pub kinovi_version: KinoviVersion, + + pub shared: SharedJobArgs<'a, 'tx>, +} + +pub struct InsertSeedance2proJobsResult { + pub primary_job_token: InferenceJobToken, + pub all_job_tokens: Vec, +} + +/// Insert one Seedance2Pro/Kinovi inference job per returned order_id. +/// Mirrors the video-side helper at +/// `omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs`. +pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result { + let InsertSeedance2proJobsArgs { + primary_order_id, + maybe_additional_order_ids, + maybe_wallet_ledger_entry_token, + kinovi_version, + mut shared, + } = args; + + // Build a deduplicated list with primary_order_id first. + let mut all_order_ids = vec![primary_order_id.to_string()]; + + if let Some(additional) = maybe_additional_order_ids { + for id in additional { + if id != primary_order_id { + all_order_ids.push(id.clone()); + } + } + } + + let mut all_job_tokens: Vec = Vec::with_capacity(all_order_ids.len()); + + for (i, order_id) in all_order_ids.iter().enumerate() { + let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() }; + + // The idempotency_token column has a max length too tight for the + // `{idempotency}-batch-{i}` suffix pattern used elsewhere; mint a + // fresh UUID for each additional row instead. The primary row still + // wants a deterministic value, but historic behavior on the video + // side has been to mint UUIDs for all rows, which we mirror here. + let idempotency_str = generate_random_uuid(); + + let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token( + InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs { + kinovi_version, + apriori_job_token: &job_token, + uuid_idempotency_token: &idempotency_str, + maybe_external_third_party_id: order_id, + maybe_model_type: shared.maybe_model_type, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_wallet_ledger_entry_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + creator_set_visibility: Visibility::Public, + maybe_platform_type: shared.maybe_platform_type, + maybe_cost_estimates: None, + maybe_debug_log_event_token: shared.maybe_debug_log_event_token, + mysql_executor: &mut **shared.transaction, + maybe_inference_args: None, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => all_job_tokens.push(token), + Err(err) => { + warn!("Error inserting seedance2pro inference job (image, order_id={}): {:?}", order_id, err); + if i == 0 { return Err(CommonWebError::from_error(err)); } + } + } + } + + let primary_job_token = all_job_tokens.first().cloned().ok_or_else(|| { + error!("No inference job token was created"); + CommonWebError::server_error_with_message("No inference job token was created") + })?; + + Ok(InsertSeedance2proJobsResult { + primary_job_token, + all_job_tokens, + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/mod.rs new file mode 100644 index 0000000000..751cbf87e7 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/mod.rs @@ -0,0 +1,3 @@ +pub mod insert_fal_job; +pub mod insert_seedance2pro_jobs; +pub mod shared_job_args; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/shared_job_args.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/shared_job_args.rs new file mode 100644 index 0000000000..e83ff66c7d --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/insert_db_job/shared_job_args.rs @@ -0,0 +1,22 @@ +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::platform_type::PlatformType; +use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; +use tokens::tokens::prompts::PromptToken; +use tokens::tokens::users::UserToken; + +/// Fields every image-side `insert_*_job` helper needs. +/// Parallels `omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs`. +pub struct SharedJobArgs<'a, 'tx> { + pub apriori_job_token: &'a InferenceJobToken, + pub idempotency_token: &'a str, + pub user_token: &'a UserToken, + pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>, + pub maybe_prompt_token: Option<&'a PromptToken>, + pub maybe_debug_log_event_token: Option<&'a DebugLogEventToken>, + pub maybe_model_type: Option, + pub maybe_platform_type: Option, + pub ip_address: &'a str, + pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>, +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/mod.rs new file mode 100644 index 0000000000..fbfa589a55 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/mod.rs @@ -0,0 +1,7 @@ +pub mod omni_api_image_generate_handler; +pub(crate) mod check_request; +pub(crate) mod hydrate_to_router_request; +pub(crate) mod ingest_url_inputs; +pub(crate) mod insert_db_job; +pub(crate) mod pipeline_result; +pub mod pipeline_v2; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/omni_api_image_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/omni_api_image_generate_handler.rs new file mode 100644 index 0000000000..5ae8c48e43 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/omni_api_image_generate_handler.rs @@ -0,0 +1,328 @@ +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::{error, info, warn}; +use sqlx::Acquire; + +use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::OmniApiImageGenerateRequest; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse; +use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse; +use enums::by_table::debug_logs::debug_log_type::DebugLogType; +use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType; +use enums::by_table::prompts::prompt_type::PromptType; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation_provider::GenerationProvider; +use enums::common::platform_type::PlatformType; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs}; +use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::KinoviVersion; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{ + insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem, +}; +use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test; +use crate::http_server::endpoints::omni_api::generate::image::check_request::check_request; +use crate::http_server::endpoints::omni_api::generate::image::hydrate_to_router_request::hydrate_to_router_request; +use crate::http_server::endpoints::omni_api::generate::image::ingest_url_inputs::ingest_url_inputs; +use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs}; +use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs}; +use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::shared_job_args::SharedJobArgs; +use crate::http_server::endpoints::omni_api::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args}; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; +use crate::state::server_state::ServerState; +use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map; + +/// Generate an image using the omni-api unified endpoint (API-key authenticated). +#[utoipa::path( + post, + tag = "Omni API", + path = "/v1/omni_api/generate/image", + request_body = OmniApiImageGenerateRequest, + responses( + (status = 200, description = "Success", body = OmniGenImageGenerateResponse), + (status = 400, description = "Bad input"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Payment required"), + (status = 500, description = "Server error"), + ), +)] +pub async fn omni_api_image_generate_handler( + http_request: HttpRequest, + mut request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + + info!("request: {:?}", request); + + // Validate URL/media-token preconditions before any billable or DB-mutating work. + check_request(&request)?; + + payments_error_test(&request.prompt.as_deref().unwrap_or(""))?; + + let debug_log_event_token = DebugLogEventToken::generate(); + + let maybe_prompt_model_type: Option = request.model + .as_ref() + .map(|m| m.to_common_model_type()); + + // ==================== API KEY USER ==================== // + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + // API-key authentication (Authorization header) instead of a session cookie. Never cached. + let api_session = require_api_key_user(&http_request, &mut *mysql_connection).await?; + + let user_token = &api_session.user_token; + + // ==================== MODEL ACCESS CHECK ==================== // + + // API-key users have no per-user feature flags today. + let user_feature_flags = UserSessionFeatureFlags::new(None); + + // ==================== IDEMPOTENCY ==================== // + + let idempotency_token = request.idempotency_token.as_deref() + .unwrap_or("") + .to_string(); + + if let Err(reason) = validate_idempotency_token_format(&idempotency_token) { + return Err(CommonWebError::BadInputWithSimpleMessage(reason)); + } + + insert_idempotency_token(&idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string()) + })?; + + let ip_address = get_request_ip(&http_request); + + // ==================== INGEST URL INPUTS ==================== // + + // Download any URL image inputs into media files owned by this user, then + // treat them as media tokens. Release the pooled connection first so we don't + // hold a pool slot during the (network) downloads. + if request.image_urls.is_some() { + drop(mysql_connection); + ingest_url_inputs(&mut request, &server_state, user_token, &ip_address).await?; + mysql_connection = server_state.mysql_pool.acquire().await?; + } + + // ==================== RESOLVE MEDIA TOKENS ==================== // + + // Look up media file tokens BEFORE distilling. Pipeline execution should not do I/O. + let resolved_media = lookup_media_files_as_cdn_url_list_and_map( + &http_request, + &mut mysql_connection, + server_state.server_environment, + request.image_media_tokens.as_deref().unwrap_or(&[]), + ).await?; + + // ==================== HYDRATE ROUTER REQUEST ==================== // + + let router_builder = hydrate_to_router_request(&request)?; + + // ==================== DEBUG LOG: HTTP REQUEST ==================== // + + if let Err(err) = insert_debug_log(InsertDebugLogArgs { + apriori_debug_log_event_token: Some(&debug_log_event_token), + maybe_creator_user_token: Some(user_token), + debug_log_type: DebugLogType::HttpRequest, + message: &serde_json::to_string(&*request).unwrap_or_default(), + mysql_executor: &mut *mysql_connection, + phantom: Default::default(), + }).await { + warn!("Failed to insert HTTP request debug log: {:?}", err); + } + + // ==================== PIPELINE ==================== // + + // NB: Release the pooled DB connection before the (slow, external) generation call so we don't + // hold a pool slot idle while waiting on the provider — that's what starves the pool and causes + // PoolTimedOut on unrelated endpoints. We re-acquire below to write the result. + drop(mysql_connection); + + let pipeline_result = run_pipeline_v2(RunPipelineV2Args { + router_builder: &router_builder, + server_state: &server_state, + user_token, + resolved_media: &resolved_media, + }).await?; + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + // ==================== DEBUG LOG: FAL REQUEST ==================== // + + if let GenerateImageResponse::Fal(ref fal_payload) = pipeline_result.response { + if let Some(ref outbound_request) = fal_payload.maybe_outbound_request { + if let Err(err) = insert_debug_log(InsertDebugLogArgs { + apriori_debug_log_event_token: Some(&debug_log_event_token), + maybe_creator_user_token: Some(user_token), + debug_log_type: DebugLogType::FalRequest, + message: &format!("{:#?}", outbound_request), + mysql_executor: &mut *mysql_connection, + phantom: Default::default(), + }).await { + warn!("Failed to insert Fal request debug log: {:?}", err); + } + } + } + + // ==================== WRITE RESULT ==================== // + + // Omni API requests are always API-key authenticated; hardcode the platform type. + let maybe_platform_type = Some(PlatformType::ApiKey); + + let mut transaction = mysql_connection + .begin() + .await + .map_err(|err| { + error!("Error starting MySQL transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + + // -- Prompt -- + + let generation_mode = if request.image_media_tokens.is_some() { + CommonGenerationMode::Edit + } else { + CommonGenerationMode::Text + }; + + let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, + maybe_apriori_prompt_token: None, + prompt_type: PromptType::ArtcraftApp, + maybe_creator_user_token: Some(user_token), + maybe_model_type: maybe_prompt_model_type, + maybe_generation_provider: Some(GenerationProvider::Artcraft), + maybe_positive_prompt: request.prompt.as_deref(), + maybe_negative_prompt: None, + maybe_other_args: None, + maybe_generation_mode: Some(generation_mode), + maybe_aspect_ratio: request.aspect_ratio, // TODO: should be saved from router's decision as it could have changed + maybe_resolution: request.resolution,// TODO: should be saved from router's decision as it could have changed + maybe_batch_count: request.image_batch_count.map(|c| c as u8), + maybe_generate_audio: None, // NB: Images, not video + maybe_duration_seconds: None, // NB: Images, not video + creator_ip_address: &ip_address, + mysql_executor: &mut *transaction, + phantom: Default::default(), + }).await; + + let prompt_token = match prompt_result { + Ok(token) => Some(token), + Err(err) => { + warn!("Error inserting prompt: {:?}", err); + None + } + }; + + // -- Prompt context items -- + + if let Some(token) = prompt_token.as_ref() { + let mut context_items = Vec::new(); + + if let Some(ref_tokens) = &request.image_media_tokens { + for media_token in ref_tokens { + context_items.push(PromptContextItem { + media_token: media_token.clone(), + context_semantic_type: PromptContextSemanticType::Imgref, + }); + } + } + + if !context_items.is_empty() { + if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs { + prompt_token: token.clone(), + items: context_items, + transaction: &mut transaction, + }).await { + warn!("Error inserting batch prompt context items: {:?}", err); + } + } + } + + // -- Inference job -- + // + // Each provider has its own queue / worker. Branch by response variant + // so the row lands on the correct queue (the Seedance2Pro/Kinovi worker + // for Midjourney; the Fal worker for everything else). + + let job_token: InferenceJobToken = match &pipeline_result.response { + GenerateImageResponse::Seedance2Pro(payload) => { + info!("Inserting seedance2pro image job(s) with token: {:?}", pipeline_result.apriori_job_token); + + // The image-side omni pipeline always dispatches Midjourney via the + // Volcengine Kinovi account today. If we ever route to BytePlus / + // BytePlus Ultra here, mirror the video-side `kinovi_account` knob. + let kinovi_version = KinoviVersion::Volcengine; + + let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs { + primary_order_id: &payload.order_id, + maybe_additional_order_ids: payload.maybe_order_ids.as_deref(), + maybe_wallet_ledger_entry_token: pipeline_result.maybe_wallet_ledger_entry_token.as_ref(), + kinovi_version, + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, + maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none. + maybe_model_type: request.model.map(|v| v.to_common_model_type()), + maybe_prompt_token: prompt_token.as_ref(), + maybe_debug_log_event_token: Some(&debug_log_event_token), + maybe_platform_type, + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await?; + result.primary_job_token + } + GenerateImageResponse::Fal(payload) => { + info!("Inserting fal image job with token: {:?}", pipeline_result.apriori_job_token); + let external_job_id = payload.request_id.clone().unwrap_or_default(); + insert_fal_job(InsertFalJobArgs { + external_job_id: &external_job_id, + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, + maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none. + maybe_model_type: request.model.map(|v| v.to_common_model_type()), + maybe_prompt_token: prompt_token.as_ref(), + maybe_debug_log_event_token: Some(&debug_log_event_token), + maybe_platform_type, + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await? + } + GenerateImageResponse::Artcraft(payload) => { + // The omni image pipeline never dispatches via the Artcraft provider + // itself today (everything routes to Fal or Kinovi), but the response + // variant exists so we cover it defensively — Artcraft jobs come back + // already inserted server-side, so just propagate the token. + payload.inference_job_token.clone() + } + }; + + transaction.commit().await.map_err(|err| { + error!("Error committing transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + + Ok(Json(OmniGenImageGenerateResponse { + success: true, + inference_job_token: job_token, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/pipeline_result.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/pipeline_result.rs new file mode 100644 index 0000000000..4d4bd9120d --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/pipeline_result.rs @@ -0,0 +1,15 @@ +use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; + +/// Both image pipelines produce this, then the shared handler suffix writes DB rows. +pub struct ImagePipelineResult { + pub apriori_job_token: InferenceJobToken, + pub response: GenerateImageResponse, + + /// The wallet ledger entry created when the user was charged for this + /// generation, if any (None when the generation is free). Used as a + /// foreign key on the inference job row so the worker can refund on + /// failure. + pub maybe_wallet_ledger_entry_token: Option, +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/pipeline_v2/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/pipeline_v2/mod.rs new file mode 100644 index 0000000000..f207b21293 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/pipeline_v2/mod.rs @@ -0,0 +1 @@ +pub mod run_pipeline_v2; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/pipeline_v2/run_pipeline_v2.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/pipeline_v2/run_pipeline_v2.rs new file mode 100644 index 0000000000..eb9dd59ed5 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/image/pipeline_v2/run_pipeline_v2.rs @@ -0,0 +1,208 @@ +use log::{info, warn}; + +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::router_image_model::RouterImageModel; +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::client::router_client::RouterClient; +use artcraft_router::client::router_fal_client::RouterFalClient; +use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient; +use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse; +use artcraft_router::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; +use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use artcraft_router::generate::generate_image::image_generation_request::ImageGenerationRequest; +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::users::UserToken; + +use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error; +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::omni_api::generate::image::pipeline_result::ImagePipelineResult; +use crate::http_server::endpoints::omni_api::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error; +use crate::state::server_state::ServerState; +use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesAsCdnUrlListAndMap; + +pub struct RunPipelineV2Args<'a> { + pub router_builder: &'a GenerateImageRequestBuilder, + pub server_state: &'a ServerState, + pub user_token: &'a UserToken, + pub resolved_media: &'a MediaFilesAsCdnUrlListAndMap, +} + +// NB: This pipeline does an external generation call (`finalize_and_generate`) that can take many +// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires a +// short-lived connection only for the wallet deduction. Holding a pooled connection across the +// external call is what starves the pool and causes `PoolTimedOut`. +pub async fn run_pipeline_v2( + args: RunPipelineV2Args<'_>, +) -> Result { + let RunPipelineV2Args { + router_builder, + server_state, + user_token, + resolved_media, + } = args; + + let hydrated_builder = apply_hydrated_media_inputs( + router_builder, + resolved_media, + ); + + let cost = estimate_cost_in_credits(&router_builder)?; + + let draft_or_request = build_execution_request(&hydrated_builder)?; + + info!("Charging wallet: {} credits", cost); + + let apriori_job_token = InferenceJobToken::generate(); + + let maybe_wallet_ledger_entry_token = if cost > 0 { + // NB: Short-lived connection — released before the external call below. + let mut billing_connection = server_state.mysql_pool.acquire().await + .map_err(|err| { + warn!("Failed to acquire MySQL connection for image billing: {:?}", err); + CommonWebError::from_error(err) + })?; + let deduction = attempt_wallet_deduction_else_common_web_error( + user_token, + Some(apriori_job_token.as_str()), + cost, + &mut billing_connection, + ).await?; + Some(deduction.ledger_entry_token) + } else { + None + }; + + // NB: No pooled DB connection is held across this external generation call. + let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?; + + Ok(ImagePipelineResult { + apriori_job_token, + response, + maybe_wallet_ledger_entry_token, + }) +} + +fn build_execution_request( + router_builder: &GenerateImageRequestBuilder, +) -> Result { + let mut execution_builder = router_builder.clone(); + execution_builder.provider = provider_for_model(execution_builder.model); + + execution_builder.build2().map_err(|e| { + warn!("Failed to build2 for image v2 pipeline: {}", e); + CommonWebError::from_error(e) + }) +} + +/// Route each image model to the provider that fulfils it. Midjourney is +/// served via the Seedance2Pro/Kinovi (Volcengine) backend; everything else +/// flows through Fal. +fn provider_for_model(model: RouterImageModel) -> RouterProvider { + match model { + RouterImageModel::Midjourney7 + | RouterImageModel::Midjourney7Niji + | RouterImageModel::Midjourney8 => RouterProvider::Seedance2Pro, + _ => RouterProvider::Fal, + } +} + +fn apply_hydrated_media_inputs( + router_builder: &GenerateImageRequestBuilder, + resolved_media: &MediaFilesAsCdnUrlListAndMap, +) -> GenerateImageRequestBuilder { + let mut hydrated_builder = router_builder.clone(); + + match hydrated_builder.image_inputs.as_ref() { + Some(ImageListRef::MediaFileTokens(tokens)) if !tokens.is_empty() => { + hydrated_builder.image_inputs = Some(ImageListRef::Urls( + resolved_media.ordered_url_list.clone(), + )); + }, + _ => {}, + } + + hydrated_builder +} + +fn estimate_cost_in_credits( + router_builder: &GenerateImageRequestBuilder, +) -> Result { + let mut cost_builder = router_builder.clone(); + cost_builder.provider = RouterProvider::Artcraft; + + let request = cost_builder.build2().map_err(|e| { + warn!("Failed to build image cost request: {}", e); + CommonWebError::from_error(e) + })?; + + let cost = request.estimate_cost().map_err(|e| { + warn!("Failed to estimate image cost: {}", e); + CommonWebError::from_error(e) + })?; + + Ok(cost.cost_in_credits.unwrap_or(0)) +} + +async fn finalize_and_generate( + draft_or_request: ImageGenerationDraftOrRequest, + server_state: &ServerState, + resolved_media: &MediaFilesAsCdnUrlListAndMap, +) -> Result { + let provider = draft_or_request.get_provider(); + let client = build_router_client(provider, server_state)?; + + // Inline the draft finalization so the draft context can borrow `client`. + let request: ImageGenerationRequest = match draft_or_request { + ImageGenerationDraftOrRequest::Request(request) => request, + ImageGenerationDraftOrRequest::Draft(draft) => { + let draft_context = ImageGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: Some(&resolved_media.token_to_url_map), + }; + draft.finalize(draft_context) + .await + .map_err(|err| { + warn!("Failed to finalize image v2 draft: {:?}", err); + map_router_error_to_web_error(err) + })? + } + }; + + request.send_request(&client) + .await + .map_err(|err| { + warn!("v2 image generation failed: {:?}", err); + map_router_error_to_web_error(err) + }) +} + +fn build_router_client( + provider: RouterProvider, + server_state: &ServerState, +) -> Result { + match provider { + RouterProvider::Fal => { + let fal_client = RouterFalClient::new_with_webhook( + server_state.inference_providers.fal.api_key.clone(), + server_state.inference_providers.fal.webhook_url.clone(), + ); + Ok(RouterClient::Fal(fal_client)) + }, + RouterProvider::Seedance2Pro => { + // Midjourney image generation always uses the Volcengine account. + // Per-request account selection (like the video flow's + // `KinoviAccount` enum) isn't needed here yet — if it ever is, plumb + // a parallel knob through `RunPipelineV2Args`. + let cookies = server_state.inference_providers.seedance2pro.cookies_volcengine.clone(); + let session = Seedance2ProSession::from_cookies_string(cookies); + Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))) + }, + other => { + Err(CommonWebError::server_error_with_message( + &format!("Unsupported provider for image v2 generation: {:?}", other), + )) + }, + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/mod.rs new file mode 100644 index 0000000000..c10a6ca15e --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/mod.rs @@ -0,0 +1,2 @@ +pub mod image; +pub mod video; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/check_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/check_request.rs new file mode 100644 index 0000000000..88d51dccfd --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/check_request.rs @@ -0,0 +1,53 @@ +use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::omni_api::shared_utils::check_url_inputs::{check_mutually_exclusive, check_single_url_format, check_url_format}; + +/// Validate the URL/media-token preconditions before any billable or +/// DB-mutating work: +/// 1. Each media-token field and its paired URL field are mutually exclusive. +/// 2. Every supplied URL starts with `http://` or `https://`. +pub fn check_request(request: &OmniApiVideoGenerateRequest) -> Result<(), CommonWebError> { + check_mutually_exclusive( + "start_frame_image_media_token", + request.start_frame_image_media_token.is_some(), + "start_frame_image_url", + request.start_frame_image_url.is_some(), + )?; + check_mutually_exclusive( + "end_frame_image_media_token", + request.end_frame_image_media_token.is_some(), + "end_frame_image_url", + request.end_frame_image_url.is_some(), + )?; + check_mutually_exclusive( + "reference_image_media_tokens", + request.reference_image_media_tokens.is_some(), + "reference_image_urls", + request.reference_image_urls.is_some(), + )?; + check_mutually_exclusive( + "reference_video_media_tokens", + request.reference_video_media_tokens.is_some(), + "reference_video_urls", + request.reference_video_urls.is_some(), + )?; + check_mutually_exclusive( + "reference_audio_media_tokens", + request.reference_audio_media_tokens.is_some(), + "reference_audio_urls", + request.reference_audio_urls.is_some(), + )?; + + if let Some(url) = request.start_frame_image_url.as_deref() { + check_single_url_format(url)?; + } + if let Some(url) = request.end_frame_image_url.as_deref() { + check_single_url_format(url)?; + } + check_url_format(request.reference_image_urls.as_deref())?; + check_url_format(request.reference_video_urls.as_deref())?; + check_url_format(request.reference_audio_urls.as_deref())?; + + Ok(()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/bill_wallet.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/bill_wallet.rs new file mode 100644 index 0000000000..4529d92d71 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/bill_wallet.rs @@ -0,0 +1,44 @@ +//! Shared wallet billing logic for both video generation pipelines. + +use log::info; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::users::UserToken; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; + +use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error; +use crate::http_server::common_responses::common_web_error::CommonWebError; + +pub struct BillWalletResult { + pub apriori_job_token: InferenceJobToken, + pub maybe_wallet_ledger_entry_token: Option, +} + +/// Generate an apriori job token and bill the user's wallet for the given cost. +/// +/// If cost is 0, no wallet deduction is made but the apriori token is still generated. +pub async fn bill_wallet( + user_token: &UserToken, + cost: u64, + mysql_connection: &mut sqlx::pool::PoolConnection, +) -> Result { + let apriori_job_token = InferenceJobToken::generate(); + + info!("Charging wallet: {} credits", cost); + + let maybe_wallet_ledger_entry_token = if cost > 0 { + let deduction_result = attempt_wallet_deduction_else_common_web_error( + user_token, + Some(apriori_job_token.as_str()), + cost, + mysql_connection, + ).await?; + Some(deduction_result.ledger_entry_token) + } else { + None + }; + + Ok(BillWalletResult { + apriori_job_token, + maybe_wallet_ledger_entry_token, + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/build_router_client.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/build_router_client.rs new file mode 100644 index 0000000000..0e153c7cb8 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/build_router_client.rs @@ -0,0 +1,61 @@ +//! Build a RouterClient for the given provider from server state. + +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::client::router_client::RouterClient; +use artcraft_router::client::router_fal_client::RouterFalClient; +use artcraft_router::client::router_gmicloud_client::RouterGmiCloudClient; +use artcraft_router::client::router_grok_api_client::RouterGrokApiClient; +use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient; +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount; +use crate::state::server_state::ServerState; + +pub fn build_router_client( + provider: RouterProvider, + server_state: &ServerState, + kinovi_account: KinoviAccount, +) -> Result { + match provider { + RouterProvider::Seedance2Pro => { + kinovi_provider(server_state, kinovi_account) + } + RouterProvider::Fal => { + let fal_client = RouterFalClient::new_with_webhook( + server_state.inference_providers.fal.api_key.clone(), + server_state.inference_providers.fal.webhook_url.clone(), + ); + Ok(RouterClient::Fal(fal_client)) + } + RouterProvider::GmiCloud => { + Ok(RouterClient::GmiCloud(RouterGmiCloudClient::new( + server_state.inference_providers.gmicloud.api_key.clone(), + ))) + } + RouterProvider::GrokApi => { + Ok(RouterClient::GrokApi(RouterGrokApiClient::new( + server_state.inference_providers.grok_api.api_key.clone(), + ))) + } + other => { + Err(CommonWebError::server_error_with_message( + &format!("Unsupported provider for video generation: {:?}", other), + )) + } + } +} + +fn kinovi_provider(server_state: &ServerState, kinovi_account: KinoviAccount) -> Result { + let seedance2pro = &server_state.inference_providers.seedance2pro; + + let cookies = match kinovi_account { + KinoviAccount::Volcengine => seedance2pro.cookies_volcengine.clone(), + KinoviAccount::BytePlus => seedance2pro.cookies_byteplus.clone(), + KinoviAccount::BytePlusUltra => seedance2pro.cookies_byteplus_ultra.clone(), + }; + + let session = Seedance2ProSession::from_cookies_string(cookies); + + Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/hydrate_router_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/hydrate_router_request.rs new file mode 100644 index 0000000000..a14388aad6 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/hydrate_router_request.rs @@ -0,0 +1,178 @@ +use crate::http_server::common_responses::common_web_error::CommonWebError; +use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest; +use artcraft_router::api::audio_list_ref::AudioListRef; +use artcraft_router::api::character_list_ref::CharacterListRef; +use artcraft_router::api::router_aspect_ratio::RouterAspectRatio; +use artcraft_router::api::router_bitrate::RouterBitrate; +use artcraft_router::api::router_resolution::RouterResolution; +use artcraft_router::api::router_video_model::RouterVideoModel; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::image_ref::ImageRef; +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::api::video_list_ref::VideoListRef; +use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; +use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +pub fn hydrate_to_router_request( + request: &OmniApiVideoGenerateRequest, +) -> Result { + let api_model = request.model + .as_ref() + .ok_or_else(|| CommonWebError::BadInputWithSimpleMessage( + "model is required".to_string(), + ))?; + + let model = convert_model(api_model)?; + + let aspect_ratio = request.aspect_ratio + .as_ref() + .map(convert_aspect_ratio) + .transpose()?; + + let resolution = request.resolution + .as_ref() + .map(convert_resolution) + .transpose()?; + + let bitrate = request.bitrate + .as_ref() + .map(convert_bitrate) + .transpose()?; + + Ok(GenerateVideoRequestBuilder { + model, + provider: RouterProvider::Artcraft, + prompt: request.prompt.clone(), + negative_prompt: request.negative_prompt.clone(), + start_frame: request.start_frame_image_media_token.clone() + .map(ImageRef::MediaFileToken), + end_frame: request.end_frame_image_media_token.clone() + .map(ImageRef::MediaFileToken), + reference_images: request.reference_image_media_tokens.clone() + .map(ImageListRef::MediaFileTokens), + reference_videos: request.reference_video_media_tokens.clone() + .map(VideoListRef::MediaFileTokens), + reference_audio: request.reference_audio_media_tokens.clone() + .map(AudioListRef::MediaFileTokens), + reference_character_tokens: request.reference_character_tokens.clone() + .map(CharacterListRef::CharacterTokens), + resolution, + aspect_ratio, + bitrate, + duration_seconds: request.duration_seconds, + video_batch_count: request.video_batch_count, + generate_audio: request.generate_audio, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + idempotency_token: request.idempotency_token.clone(), + }) +} + +fn convert_model( + model: &CommonVideoModelEnum, +) -> Result { + let json = serde_json::to_string(model)?; + serde_json::from_str(&json).map_err(|e| { + CommonWebError::BadInputWithSimpleMessage( + format!("Unsupported video model: {}", e), + ) + }) +} + +fn convert_aspect_ratio( + ar: &CommonAspectRatioEnum, +) -> Result { + let json = serde_json::to_string(ar)?; + serde_json::from_str(&json).map_err(|e| { + CommonWebError::BadInputWithSimpleMessage( + format!("Unsupported aspect ratio: {}", e), + ) + }) +} + +fn convert_resolution( + res: &CommonResolutionEnum, +) -> Result { + let json = serde_json::to_string(res)?; + serde_json::from_str(&json).map_err(|e| { + CommonWebError::BadInputWithSimpleMessage( + format!("Unsupported resolution: {}", e), + ) + }) +} + +fn convert_bitrate( + bitrate: &CommonBitrateEnum, +) -> Result { + let json = serde_json::to_string(bitrate)?; + serde_json::from_str(&json).map_err(|e| { + CommonWebError::BadInputWithSimpleMessage( + format!("Unsupported bitrate: {}", e), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod bitrate_hydration { + use super::*; + + #[test] + fn high_is_hydrated() { + let request = OmniApiVideoGenerateRequest { + bitrate: Some(CommonBitrateEnum::High), + ..base_request() + }; + let builder = hydrate_to_router_request(&request).expect("hydrate should succeed"); + assert_eq!(builder.bitrate, Some(RouterBitrate::High)); + } + + #[test] + fn normal_is_hydrated() { + let request = OmniApiVideoGenerateRequest { + bitrate: Some(CommonBitrateEnum::Normal), + ..base_request() + }; + let builder = hydrate_to_router_request(&request).expect("hydrate should succeed"); + assert_eq!(builder.bitrate, Some(RouterBitrate::Normal)); + } + + #[test] + fn none_stays_none() { + let builder = hydrate_to_router_request(&base_request()).expect("hydrate should succeed"); + assert!(builder.bitrate.is_none()); + } + } + + fn base_request() -> OmniApiVideoGenerateRequest { + OmniApiVideoGenerateRequest { + idempotency_token: None, + model: Some(CommonVideoModelEnum::Seedance2p0), + prompt: None, + negative_prompt: None, + start_frame_image_media_token: None, + start_frame_image_url: None, + end_frame_image_media_token: None, + end_frame_image_url: None, + reference_image_media_tokens: None, + reference_image_urls: None, + reference_video_media_tokens: None, + reference_video_urls: None, + reference_audio_media_tokens: None, + reference_audio_urls: None, + reference_character_tokens: None, + resolution: None, + aspect_ratio: None, + bitrate: None, + quality: None, + duration_seconds: None, + video_batch_count: None, + generate_audio: None, + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/mod.rs new file mode 100644 index 0000000000..7108f22a3b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/mod.rs @@ -0,0 +1,6 @@ +pub mod bill_wallet; +pub mod build_router_client; +pub(crate) mod hydrate_router_request; +pub mod pipeline_result; +pub mod resolve_kinovi_character_ids; +pub mod resolve_media_tokens_to_urls; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/pipeline_result.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/pipeline_result.rs new file mode 100644 index 0000000000..cb0648f34c --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/pipeline_result.rs @@ -0,0 +1,14 @@ +use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse; +use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates; + +use crate::http_server::endpoints::omni_api::generate::video::helpers::bill_wallet::BillWalletResult; + +/// Both pipelines produce this, then the shared suffix handles DB writes. +pub struct PipelineResult { + pub billing: BillWalletResult, + pub response: GenerateVideoResponse, + + /// System (user-facing) and provider-side cost estimates, written onto the + /// generic_inference_jobs rows. + pub cost_estimates: JobCostEstimates, +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/resolve_kinovi_character_ids.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/resolve_kinovi_character_ids.rs new file mode 100644 index 0000000000..25b0f8aeac --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/resolve_kinovi_character_ids.rs @@ -0,0 +1,51 @@ +//! Resolve character tokens to Kinovi character IDs for prompting. + +use std::collections::HashMap; + +use log::warn; +use sqlx::MySql; + +use mysql_queries::queries::characters::batch_lookup_characters_by_token_for_prompting::batch_lookup_characters_by_token_for_prompting; +use tokens::tokens::characters::CharacterToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +/// Resolve character tokens to a map of CharacterToken → Kinovi character ID. +/// +/// Looks up the characters, filters to active ones with kinovi IDs, and warns about +/// any that are missing or inactive (but doesn't fail the request). +pub async fn resolve_kinovi_character_ids( + maybe_tokens: Option<&[CharacterToken]>, + connection: &mut sqlx::pool::PoolConnection, +) -> Result>, CommonWebError> { + let tokens = match maybe_tokens { + None => return Ok(None), + Some(tokens) if tokens.is_empty() => return Ok(None), + Some(tokens) => tokens, + }; + + let characters = batch_lookup_characters_by_token_for_prompting(tokens, connection) + .await?; + + if characters.len() != tokens.len() { + warn!( + "Not all character tokens were found: requested {}, found {}", + tokens.len(), characters.len(), + ); + } + + for character in &characters { + if !character.is_active { + warn!("Character {} is not yet active, skipping", character.token); + } + } + + let map: HashMap = characters.iter() + .filter(|c| c.is_active) + .filter_map(|c| { + c.kinovi_character_id.as_ref().map(|kid| (c.token.clone(), kid.clone())) + }) + .collect(); + + if map.is_empty() { Ok(None) } else { Ok(Some(map)) } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/resolve_media_tokens_to_urls.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/resolve_media_tokens_to_urls.rs new file mode 100644 index 0000000000..8ee7d7f175 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/helpers/resolve_media_tokens_to_urls.rs @@ -0,0 +1,64 @@ +use std::collections::HashMap; + +use artcraft_router::api::audio_list_ref::AudioListRef; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::image_ref::ImageRef; +use artcraft_router::api::video_list_ref::VideoListRef; +use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use tokens::tokens::media_files::MediaFileToken; + +/// For providers that take URLs directly (GmiCloud, Fal), swap +/// `ImageRef::MediaFileToken` → `ImageRef::Url` using the resolved map. +pub fn resolve_media_tokens_to_urls( + builder: &mut GenerateVideoRequestBuilder, + url_map: Option<&HashMap>, +) { + let map = match url_map { + Some(m) => m, + None => return, + }; + + // start_frame + if let Some(ImageRef::MediaFileToken(ref token)) = builder.start_frame { + if let Some(url) = map.get(token) { + builder.start_frame = Some(ImageRef::Url(url.clone())); + } + } + + // end_frame + if let Some(ImageRef::MediaFileToken(ref token)) = builder.end_frame { + if let Some(url) = map.get(token) { + builder.end_frame = Some(ImageRef::Url(url.clone())); + } + } + + // reference_images + if let Some(ImageListRef::MediaFileTokens(ref tokens)) = builder.reference_images { + let urls: Vec = tokens.iter() + .filter_map(|t| map.get(t).cloned()) + .collect(); + if !urls.is_empty() { + builder.reference_images = Some(ImageListRef::Urls(urls)); + } + } + + // reference_videos + if let Some(VideoListRef::MediaFileTokens(ref tokens)) = builder.reference_videos { + let urls: Vec = tokens.iter() + .filter_map(|t| map.get(t).cloned()) + .collect(); + if !urls.is_empty() { + builder.reference_videos = Some(VideoListRef::Urls(urls)); + } + } + + // reference_audio + if let Some(AudioListRef::MediaFileTokens(ref tokens)) = builder.reference_audio { + let urls: Vec = tokens.iter() + .filter_map(|t| map.get(t).cloned()) + .collect(); + if !urls.is_empty() { + builder.reference_audio = Some(AudioListRef::Urls(urls)); + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/ingest_url_inputs.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/ingest_url_inputs.rs new file mode 100644 index 0000000000..e9922925a3 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/ingest_url_inputs.rs @@ -0,0 +1,63 @@ +use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::omni_api::shared_utils::media_uploads::upload_url_to_media_file::{upload_url_to_media_file, MediaUploadKind}; +use crate::state::server_state::ServerState; + +/// Download any URL media inputs, store them as media files owned by the API +/// user, and fold the resulting tokens into the request's media-token fields so +/// the rest of the handler can treat them as ordinary media tokens. +/// +/// `check_request` has already guaranteed each URL and media-token field is +/// mutually exclusive, so populating the token field here is always safe. +pub async fn ingest_url_inputs( + request: &mut OmniApiVideoGenerateRequest, + server_state: &ServerState, + user_token: &UserToken, + ip_address: &str, +) -> Result<(), CommonWebError> { + if let Some(url) = request.start_frame_image_url.take() { + request.start_frame_image_media_token = Some( + upload_url_to_media_file(server_state, user_token, ip_address, &url, MediaUploadKind::Image).await?, + ); + } + + if let Some(url) = request.end_frame_image_url.take() { + request.end_frame_image_media_token = Some( + upload_url_to_media_file(server_state, user_token, ip_address, &url, MediaUploadKind::Image).await?, + ); + } + + if let Some(urls) = request.reference_image_urls.take() { + request.reference_image_media_tokens = + Some(upload_all(server_state, user_token, ip_address, &urls, MediaUploadKind::Image).await?); + } + + if let Some(urls) = request.reference_video_urls.take() { + request.reference_video_media_tokens = + Some(upload_all(server_state, user_token, ip_address, &urls, MediaUploadKind::Video).await?); + } + + if let Some(urls) = request.reference_audio_urls.take() { + request.reference_audio_media_tokens = + Some(upload_all(server_state, user_token, ip_address, &urls, MediaUploadKind::Audio).await?); + } + + Ok(()) +} + +async fn upload_all( + server_state: &ServerState, + user_token: &UserToken, + ip_address: &str, + urls: &[String], + kind: MediaUploadKind, +) -> Result, CommonWebError> { + let mut tokens = Vec::with_capacity(urls.len()); + for url in urls { + tokens.push(upload_url_to_media_file(server_state, user_token, ip_address, url, kind).await?); + } + Ok(tokens) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_fal_job.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_fal_job.rs new file mode 100644 index 0000000000..b71ca9b184 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_fal_job.rs @@ -0,0 +1,57 @@ +use log::warn; + +use enums::common::visibility::Visibility; +use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue::FalCategory; +use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{ + insert_generic_inference_job_for_fal_queue_with_apriori_job_token, + InsertGenericInferenceForFalWithAprioriJobTokenArgs, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +use super::shared_job_args::SharedJobArgs; + +pub struct InsertFalJobArgs<'a, 'tx> { + pub external_job_id: &'a str, + pub shared: SharedJobArgs<'a, 'tx>, +} + +pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result { + let InsertFalJobArgs { + external_job_id, + shared, + } = args; + + let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token( + InsertGenericInferenceForFalWithAprioriJobTokenArgs { + apriori_job_token: shared.apriori_job_token, + uuid_idempotency_token: shared.idempotency_token, + maybe_external_third_party_id: external_job_id, + fal_category: FalCategory::VideoGeneration, + maybe_model_type: shared.maybe_model_type, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + creator_set_visibility: Visibility::Public, + maybe_platform_type: shared.maybe_platform_type, + maybe_cost_estimates: shared.maybe_cost_estimates, + maybe_debug_log_event_token: shared.maybe_debug_log_event_token, + mysql_executor: &mut **shared.transaction, + maybe_inference_args: None, + starting_job_status_override: None, + maybe_frontend_failure_category: None, + maybe_failure_reason: None, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => Ok(token), + Err(err) => { + warn!("Error inserting fal inference job: {:?}", err); + Err(CommonWebError::from_error(err)) + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_gmicloud_job.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_gmicloud_job.rs new file mode 100644 index 0000000000..776318f419 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_gmicloud_job.rs @@ -0,0 +1,48 @@ +use log::warn; + +use mysql_queries::queries::generic_inference::api_providers::gmicloud::insert_generic_inference_job_for_gmicloud::{ + insert_generic_inference_job_for_gmicloud, InsertGmiCloudInferenceJobArgs, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +use super::shared_job_args::SharedJobArgs; + +pub struct InsertGmiCloudJobArgs<'a, 'tx> { + pub external_request_id: &'a str, + pub shared: SharedJobArgs<'a, 'tx>, +} + +pub async fn insert_gmicloud_job(args: InsertGmiCloudJobArgs<'_, '_>) -> Result { + let InsertGmiCloudJobArgs { + external_request_id, + shared, + } = args; + + let db_result = insert_generic_inference_job_for_gmicloud( + InsertGmiCloudInferenceJobArgs { + apriori_job_token: shared.apriori_job_token, + uuid_idempotency_token: shared.idempotency_token, + external_request_id, + maybe_model_type: shared.maybe_model_type, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + maybe_platform_type: shared.maybe_platform_type, + maybe_cost_estimates: shared.maybe_cost_estimates, + maybe_debug_log_event_token: shared.maybe_debug_log_event_token, + mysql_executor: &mut **shared.transaction, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => Ok(token), + Err(err) => { + warn!("Error inserting GmiCloud inference job: {:?}", err); + Err(CommonWebError::from_error(err)) + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_grok_api_job.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_grok_api_job.rs new file mode 100644 index 0000000000..76e4b70d00 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_grok_api_job.rs @@ -0,0 +1,48 @@ +use log::warn; + +use mysql_queries::queries::generic_inference::api_providers::grok_api::insert_generic_inference_job_for_grok_api::{ + insert_generic_inference_job_for_grok_api, InsertGrokApiInferenceJobArgs, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +use super::shared_job_args::SharedJobArgs; + +pub struct InsertGrokApiJobArgs<'a, 'tx> { + pub external_request_id: &'a str, + pub shared: SharedJobArgs<'a, 'tx>, +} + +pub async fn insert_grok_api_job(args: InsertGrokApiJobArgs<'_, '_>) -> Result { + let InsertGrokApiJobArgs { + external_request_id, + shared, + } = args; + + let db_result = insert_generic_inference_job_for_grok_api( + InsertGrokApiInferenceJobArgs { + apriori_job_token: shared.apriori_job_token, + uuid_idempotency_token: shared.idempotency_token, + external_request_id, + maybe_model_type: shared.maybe_model_type, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + maybe_platform_type: shared.maybe_platform_type, + maybe_cost_estimates: shared.maybe_cost_estimates, + maybe_debug_log_event_token: shared.maybe_debug_log_event_token, + mysql_executor: &mut **shared.transaction, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => Ok(token), + Err(err) => { + warn!("Error inserting Grok (xAI) API inference job: {:?}", err); + Err(CommonWebError::from_error(err)) + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_seedance2pro_jobs.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_seedance2pro_jobs.rs new file mode 100644 index 0000000000..5da1d5e915 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/insert_seedance2pro_jobs.rs @@ -0,0 +1,95 @@ +use log::{error, warn}; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use enums::common::visibility::Visibility; +use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token, InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs, KinoviVersion}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; +use uuid_utils::uuid::generate_random_uuid; + +use super::shared_job_args::SharedJobArgs; + +pub struct InsertSeedance2proJobsArgs<'a, 'tx> { + pub primary_order_id: &'a str, + pub maybe_additional_order_ids: Option<&'a [String]>, + pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>, + pub kinovi_version: KinoviVersion, + pub shared: SharedJobArgs<'a, 'tx>, +} + +pub struct InsertSeedance2proJobsResult { + pub primary_job_token: InferenceJobToken, + pub all_job_tokens: Vec, +} + +pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result { + let InsertSeedance2proJobsArgs { + primary_order_id, + maybe_additional_order_ids, + maybe_wallet_ledger_entry_token, + kinovi_version, + mut shared, + } = args; + + // Build a deduplicated list with primary_order_id first. + let mut all_order_ids = vec![primary_order_id.to_string()]; + + if let Some(additional) = maybe_additional_order_ids { + for id in additional { + if id != primary_order_id { + all_order_ids.push(id.clone()); + } + } + } + + let mut all_job_tokens: Vec = Vec::with_capacity(all_order_ids.len()); + + for (i, order_id) in all_order_ids.iter().enumerate() { + let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() }; + + // TODO: These strings are too long for the database: + //let idempotency_str = if i == 0 { shared.idempotency_token.to_string() } else { format!("{}-batch-{}", shared.idempotency_token, i) }; + + let idempotency_str = generate_random_uuid(); + + let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token( + InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs { + kinovi_version, + apriori_job_token: &job_token, + uuid_idempotency_token: &idempotency_str, + maybe_external_third_party_id: order_id, + maybe_model_type: shared.maybe_model_type, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_wallet_ledger_entry_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + creator_set_visibility: Visibility::Public, + maybe_platform_type: shared.maybe_platform_type, + maybe_cost_estimates: shared.maybe_cost_estimates, + maybe_debug_log_event_token: shared.maybe_debug_log_event_token, + mysql_executor: &mut **shared.transaction, + maybe_inference_args: None, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => all_job_tokens.push(token), + Err(err) => { + warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err); + if i == 0 { return Err(CommonWebError::from_error(err)); } + } + } + } + + let primary_job_token = all_job_tokens.first().cloned().ok_or_else(|| { + error!("No inference job token was created"); + CommonWebError::server_error_with_message("No inference job token was created") + })?; + + Ok(InsertSeedance2proJobsResult { + primary_job_token, + all_job_tokens, + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/mod.rs new file mode 100644 index 0000000000..4591de2a46 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/mod.rs @@ -0,0 +1,5 @@ +pub mod insert_fal_job; +pub mod insert_gmicloud_job; +pub mod insert_grok_api_job; +pub mod insert_seedance2pro_jobs; +pub mod shared_job_args; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/shared_job_args.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/shared_job_args.rs new file mode 100644 index 0000000000..69215d9ce0 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/insert_db_job/shared_job_args.rs @@ -0,0 +1,22 @@ +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::platform_type::PlatformType; +use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates; +use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; +use tokens::tokens::prompts::PromptToken; +use tokens::tokens::users::UserToken; + +pub struct SharedJobArgs<'a, 'tx> { + pub apriori_job_token: &'a InferenceJobToken, + pub idempotency_token: &'a str, + pub user_token: &'a UserToken, + pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>, + pub maybe_prompt_token: Option<&'a PromptToken>, + pub maybe_debug_log_event_token: Option<&'a DebugLogEventToken>, + pub maybe_model_type: Option, + pub maybe_platform_type: Option, + pub maybe_cost_estimates: Option, + pub ip_address: &'a str, + pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>, +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/kinovi_account.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/kinovi_account.rs new file mode 100644 index 0000000000..17277f6934 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/kinovi_account.rs @@ -0,0 +1,7 @@ + +#[derive(Debug, Clone, Copy)] +pub enum KinoviAccount { + Volcengine, + BytePlus, + BytePlusUltra, +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/mod.rs new file mode 100644 index 0000000000..65f5fa3973 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/mod.rs @@ -0,0 +1,7 @@ +pub mod omni_api_video_generate_handler; +pub mod check_request; +pub mod helpers; +pub mod ingest_url_inputs; +pub mod pipeline_v2; +pub mod insert_db_job; +pub mod kinovi_account; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/omni_api_video_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/omni_api_video_generate_handler.rs new file mode 100644 index 0000000000..29365d5218 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/omni_api_video_generate_handler.rs @@ -0,0 +1,498 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::{error, info, warn}; +use sqlx::Acquire; + +use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse; +use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse; +use enums::by_table::debug_logs::debug_log_type::DebugLogType; +use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType; +use enums::by_table::prompts::prompt_type::PromptType; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation::common_video_model::CommonVideoModel; +use enums::common::generation_provider::GenerationProvider; +use enums::common::platform_type::PlatformType; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs}; +use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::KinoviVersion; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{ + insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem, +}; +use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs}; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test; +use crate::http_server::endpoints::omni_api::generate::video::check_request::check_request; +use crate::http_server::endpoints::omni_api::generate::video::ingest_url_inputs::ingest_url_inputs; +use crate::http_server::endpoints::omni_api::generate::video::helpers::hydrate_router_request::hydrate_to_router_request; +use crate::http_server::endpoints::omni_api::generate::video::helpers::resolve_kinovi_character_ids::resolve_kinovi_character_ids; +use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs}; +use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_gmicloud_job::{insert_gmicloud_job, InsertGmiCloudJobArgs}; +use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs}; +use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs}; +use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::shared_job_args::SharedJobArgs; +use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount; +use crate::http_server::endpoints::omni_api::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args}; +use crate::http_server::endpoints::omni_api::shared_utils::video::validate_video_request::validate_video_request; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; +use crate::state::server_state::ServerState; +use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map; + +/// Generate a video using the omni-api unified endpoint (API-key authenticated). +#[utoipa::path( + post, + tag = "Omni API", + path = "/v1/omni_api/generate/video", + request_body = OmniApiVideoGenerateRequest, + responses( + (status = 200, description = "Success", body = OmniGenVideoGenerateResponse), + (status = 400, description = "Bad input"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Payment required"), + (status = 500, description = "Server error"), + ), +)] +pub async fn omni_api_video_generate_handler( + http_request: HttpRequest, + mut request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + + info!("request: {:?}", request); + + // Validate URL/media-token preconditions before any billable or DB-mutating work. + check_request(&request)?; + + // Reject doomed combos (e.g. grok_imagine_video_1p5 without an image) + // before any billable or DB-mutating work — see helper for the rules. + validate_video_request(&request)?; + + payments_error_test(&request.prompt.as_deref().unwrap_or(""))?; + + let debug_log_event_token = DebugLogEventToken::generate(); + + let maybe_prompt_model_type: Option = request.model + .as_ref() + .map(|m| m.to_common_model_type()); + + // ==================== API KEY USER ==================== // + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + // API-key authentication (Authorization header) instead of a session cookie. Never cached. + let api_session = require_api_key_user(&http_request, &mut *mysql_connection).await?; + + let user_token = &api_session.user_token; + + // ==================== MODEL ACCESS CHECK ==================== // + + // API-key users have no per-user feature flags today. + let user_feature_flags = UserSessionFeatureFlags::new(None); + + // ==================== IDEMPOTENCY ==================== // + + let idempotency_token = request.idempotency_token.as_deref() + .unwrap_or("") + .to_string(); + + if let Err(reason) = validate_idempotency_token_format(&idempotency_token) { + return Err(CommonWebError::BadInputWithSimpleMessage(reason)); + } + + insert_idempotency_token(&idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string()) + })?; + + let ip_address = get_request_ip(&http_request); + + // ==================== INGEST URL INPUTS ==================== // + + // Download any URL media inputs into media files owned by this user, then + // treat them as media tokens. Release the pooled connection first so we don't + // hold a pool slot during the (network) downloads. + let has_url_inputs = request.start_frame_image_url.is_some() + || request.end_frame_image_url.is_some() + || request.reference_image_urls.is_some() + || request.reference_video_urls.is_some() + || request.reference_audio_urls.is_some(); + + if has_url_inputs { + drop(mysql_connection); + ingest_url_inputs(&mut request, &server_state, user_token, &ip_address).await?; + mysql_connection = server_state.mysql_pool.acquire().await?; + } + + // ==================== RESOLVE MEDIA TOKENS ==================== // + + let mut all_tokens: Vec = Vec::new(); + + if let Some(token) = &request.start_frame_image_media_token { + all_tokens.push(token.clone()); + } + if let Some(token) = &request.end_frame_image_media_token { + all_tokens.push(token.clone()); + } + if let Some(tokens) = &request.reference_image_media_tokens { + all_tokens.extend(tokens.iter().cloned()); + } + if let Some(tokens) = &request.reference_video_media_tokens { + all_tokens.extend(tokens.iter().cloned()); + } + if let Some(tokens) = &request.reference_audio_media_tokens { + all_tokens.extend(tokens.iter().cloned()); + } + + let media_file_to_url_map: Option> = if all_tokens.is_empty() { + None + } else { + info!("Resolving {} media file tokens to CDN URLs", all_tokens.len()); + let resolved = lookup_media_files_as_cdn_url_list_and_map( + &http_request, + &mut mysql_connection, + server_state.server_environment, + &all_tokens, + ).await?; + Some(resolved.token_to_url_map) + }; + + // ==================== RESOLVE CHARACTERS ==================== // + + let kinovi_character_id_map: Option> = + resolve_kinovi_character_ids( + request.reference_character_tokens.as_deref(), + &mut mysql_connection, + ).await?; + + // ==================== HYDRATE ROUTER REQUEST ==================== // + + let router_builder = hydrate_to_router_request(&request)?; + + // ==================== PIPELINE DISPATCH ==================== // + + let kinovi_account = match request.model { + // BytePlus Ultra + Some(CommonVideoModel::Seedance2p0BytePlusUltra) => KinoviAccount::BytePlusUltra, + Some(CommonVideoModel::Seedance2p0BytePlusUltraFast) => KinoviAccount::BytePlusUltra, + // BytePlus + Some(CommonVideoModel::Seedance2p0BytePlus) => KinoviAccount::BytePlus, + Some(CommonVideoModel::Seedance2p0BytePlusFast) => KinoviAccount::BytePlus, + Some(CommonVideoModel::PreviewModel) => KinoviAccount::BytePlus, + Some(CommonVideoModel::PreviewModelFast) => KinoviAccount::BytePlus, + // Everything else goes through Volcengine + _ => KinoviAccount::Volcengine, + }; + + // ==================== DEBUG LOG: HTTP REQUEST ==================== // + + if let Err(err) = insert_debug_log(InsertDebugLogArgs { + apriori_debug_log_event_token: Some(&debug_log_event_token), + maybe_creator_user_token: Some(user_token), + debug_log_type: DebugLogType::HttpRequest, + message: &serde_json::to_string(&*request).unwrap_or_default(), + mysql_executor: &mut *mysql_connection, + phantom: Default::default(), + }).await { + warn!("Failed to insert HTTP request debug log: {:?}", err); + } + + // NB: Release the pooled DB connection before the (slow, external) generation call so we don't + // hold a pool slot idle while waiting on the provider — that's what starves the pool and causes + // PoolTimedOut on unrelated endpoints. We re-acquire below to write the result. + drop(mysql_connection); + + let pipeline_result = run_pipeline_v2(RunPipelineV2Args { + router_builder: &router_builder, + server_state: &server_state, + user_token, + media_file_to_url_map: &media_file_to_url_map, + kinovi_character_id_map: &kinovi_character_id_map, + kinovi_account, + }).await?; + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + // ==================== DEBUG LOG: FAL REQUEST ==================== // + + if let GenerateVideoResponse::Fal(ref fal_payload) = pipeline_result.response { + if let Some(ref outbound_request) = fal_payload.maybe_outbound_request { + if let Err(err) = insert_debug_log(InsertDebugLogArgs { + apriori_debug_log_event_token: Some(&debug_log_event_token), + maybe_creator_user_token: Some(user_token), + debug_log_type: DebugLogType::FalRequest, + message: &format!("{:#?}", outbound_request), + mysql_executor: &mut *mysql_connection, + phantom: Default::default(), + }).await { + warn!("Failed to insert Fal request debug log: {:?}", err); + } + } + } + + // ==================== DEBUG LOG: GROK API REQUEST ==================== // + + if let GenerateVideoResponse::Grok(ref grok_payload) = pipeline_result.response { + if let Some(ref outbound_request) = grok_payload.maybe_outbound_request { + if let Err(err) = insert_debug_log(InsertDebugLogArgs { + apriori_debug_log_event_token: Some(&debug_log_event_token), + maybe_creator_user_token: Some(user_token), + debug_log_type: DebugLogType::GrokApiRequest, + message: &format!("{:#?}", outbound_request), + mysql_executor: &mut *mysql_connection, + phantom: Default::default(), + }).await { + warn!("Failed to insert Grok API request debug log: {:?}", err); + } + } + } + + // ==================== WRITE RESULT ==================== // + + // Omni API requests are always API-key authenticated; hardcode the platform type. + let maybe_platform_type = Some(PlatformType::ApiKey); + + let mut transaction = mysql_connection.begin().await.map_err(|err| { + error!("Error starting MySQL transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + + // -- Prompt -- + + let prompt_token = match insert_prompt(InsertPromptArgs { + maybe_apriori_prompt_token: None, + prompt_type: PromptType::ArtcraftApp, + maybe_creator_user_token: Some(user_token), + maybe_model_type: maybe_prompt_model_type, + maybe_generation_provider: Some(GenerationProvider::Artcraft), + maybe_positive_prompt: request.prompt.as_deref(), + maybe_negative_prompt: request.negative_prompt.as_deref(), + maybe_other_args: None, + maybe_generation_mode: Some(determine_generation_mode(&request)), + maybe_aspect_ratio: request.aspect_ratio, + maybe_resolution: request.resolution, + maybe_bitrate: request.bitrate, + maybe_batch_count: request.video_batch_count.map(|c| c as u8), + maybe_generate_audio: request.generate_audio, + maybe_duration_seconds: request.duration_seconds.map(|d| d as u32), + creator_ip_address: &ip_address, + mysql_executor: &mut *transaction, + phantom: Default::default(), + }).await { + Ok(token) => Some(token), + Err(err) => { + warn!("Error inserting prompt: {:?}", err); + None + } + }; + + // -- Prompt context items -- + + if let Some(token) = prompt_token.as_ref() { + let mut context_items = Vec::new(); + + if let Some(media_token) = &request.start_frame_image_media_token { + context_items.push(PromptContextItem { + media_token: media_token.clone(), + context_semantic_type: PromptContextSemanticType::VidStartFrame, + }); + } + if let Some(media_token) = &request.end_frame_image_media_token { + context_items.push(PromptContextItem { + media_token: media_token.clone(), + context_semantic_type: PromptContextSemanticType::VidEndFrame, + }); + } + if let Some(ref_tokens) = &request.reference_image_media_tokens { + for media_token in ref_tokens { + context_items.push(PromptContextItem { + media_token: media_token.clone(), + context_semantic_type: PromptContextSemanticType::Imgref, + }); + } + } + if let Some(ref_tokens) = &request.reference_video_media_tokens { + for media_token in ref_tokens { + context_items.push(PromptContextItem { + media_token: media_token.clone(), + context_semantic_type: PromptContextSemanticType::VidRef, + }); + } + } + + if !context_items.is_empty() { + if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs { + prompt_token: token.clone(), + items: context_items, + transaction: &mut transaction, + }).await { + warn!("Error inserting batch prompt context items: {:?}", err); + } + } + } + + // -- Inference job -- + + let (primary_job_token, all_job_tokens) = match &pipeline_result.response { + GenerateVideoResponse::Seedance2Pro(payload) => { + info!("Inserting seedance2pro job(s) with token: {:?}", pipeline_result.billing.apriori_job_token); + + let kinovi_version = match kinovi_account { + KinoviAccount::Volcengine => KinoviVersion::Volcengine, + KinoviAccount::BytePlus => KinoviVersion::BytePlus, + KinoviAccount::BytePlusUltra => KinoviVersion::BytePlusUltra, + }; + + let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs { + primary_order_id: &payload.order_id, + maybe_additional_order_ids: payload.maybe_order_ids.as_deref(), + maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(), + kinovi_version, + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.billing.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, + maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none. + maybe_model_type: request.model.map(|v| v.to_common_model_type()), + maybe_prompt_token: prompt_token.as_ref(), + maybe_debug_log_event_token: Some(&debug_log_event_token), + maybe_platform_type, + maybe_cost_estimates: Some(pipeline_result.cost_estimates), + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await?; + (result.primary_job_token, result.all_job_tokens) + } + GenerateVideoResponse::Fal(payload) => { + let external_id = payload.request_id.as_deref().ok_or_else(|| { + error!("Fal generation response missing request_id"); + CommonWebError::server_error_with_message("Fal generation response missing request_id") + })?; + info!("Inserting fal job with token: {:?}", pipeline_result.billing.apriori_job_token); + let token = insert_fal_job(InsertFalJobArgs { + external_job_id: external_id, + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.billing.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, + maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none. + maybe_model_type: request.model.map(|v| v.to_common_model_type()), + maybe_prompt_token: prompt_token.as_ref(), + maybe_debug_log_event_token: Some(&debug_log_event_token), + maybe_platform_type, + maybe_cost_estimates: Some(pipeline_result.cost_estimates), + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await?; + + ( + token.clone(), + vec![token], + ) + } + GenerateVideoResponse::Artcraft(payload) => { + ( + payload.inference_job_token.clone(), + vec![payload.inference_job_token.clone()], + ) + } + GenerateVideoResponse::GmiCloud(payload) => { + info!("Inserting GmiCloud job with token: {:?}", pipeline_result.billing.apriori_job_token); + let token = insert_gmicloud_job(InsertGmiCloudJobArgs { + external_request_id: &payload.request_id, + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.billing.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, + maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none. + maybe_model_type: request.model.map(|v| v.to_common_model_type()), + maybe_prompt_token: prompt_token.as_ref(), + maybe_debug_log_event_token: Some(&debug_log_event_token), + maybe_platform_type, + maybe_cost_estimates: Some(pipeline_result.cost_estimates), + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await?; + + ( + token.clone(), + vec![token], + ) + } + GenerateVideoResponse::Grok(payload) => { + info!("Inserting Grok (xAI) API job with token: {:?}", pipeline_result.billing.apriori_job_token); + let token = insert_grok_api_job(InsertGrokApiJobArgs { + external_request_id: &payload.request_id, + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.billing.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, + maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none. + maybe_model_type: request.model.map(|v| v.to_common_model_type()), + maybe_prompt_token: prompt_token.as_ref(), + maybe_debug_log_event_token: Some(&debug_log_event_token), + maybe_platform_type, + maybe_cost_estimates: Some(pipeline_result.cost_estimates), + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await?; + + ( + token.clone(), + vec![token], + ) + } + other => { + error!("Unexpected generation response variant: {:?}", other); + return Err(CommonWebError::server_error_with_message("Unexpected generation response")); + } + }; + + transaction.commit().await.map_err(|err| { + error!("Error committing transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + + Ok(Json(OmniGenVideoGenerateResponse { + success: true, + inference_job_token: primary_job_token, + all_job_tokens, + })) +} + +fn determine_generation_mode(request: &OmniApiVideoGenerateRequest) -> CommonGenerationMode { + let has_keyframe = request.start_frame_image_media_token.is_some() + || request.end_frame_image_media_token.is_some(); + + if has_keyframe { + return CommonGenerationMode::Keyframe; + } + + let has_reference = request.reference_image_media_tokens.as_ref().is_some_and(|t| !t.is_empty()) + || request.reference_video_media_tokens.as_ref().is_some_and(|t| !t.is_empty()) + || request.reference_audio_media_tokens.as_ref().is_some_and(|t| !t.is_empty()) + || request.reference_character_tokens.as_ref().is_some_and(|t| !t.is_empty()); + + if has_reference { + return CommonGenerationMode::Reference; + } + + CommonGenerationMode::Text +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/pipeline_v2/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/pipeline_v2/mod.rs new file mode 100644 index 0000000000..f207b21293 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/pipeline_v2/mod.rs @@ -0,0 +1 @@ +pub mod run_pipeline_v2; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/pipeline_v2/run_pipeline_v2.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/pipeline_v2/run_pipeline_v2.rs new file mode 100644 index 0000000000..19288349dd --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/pipeline_v2/run_pipeline_v2.rs @@ -0,0 +1,227 @@ +use std::convert::TryFrom; + +use std::collections::HashMap; + +use log::{error, info, warn}; +use artcraft_router::api::router_video_model::RouterVideoModel; +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse; +use artcraft_router::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext; +use artcraft_router::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure; +use crate::http_server::endpoints::omni_api::generate::video::helpers::bill_wallet::bill_wallet; +use crate::http_server::endpoints::omni_api::generate::video::helpers::build_router_client::build_router_client; +use crate::http_server::endpoints::omni_api::generate::video::helpers::pipeline_result::PipelineResult; +use crate::http_server::endpoints::omni_api::generate::video::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls; +use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates; +use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount; +use crate::http_server::endpoints::omni_api::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error; +use crate::state::server_state::ServerState; + +pub struct RunPipelineV2Args<'a> { + pub router_builder: &'a GenerateVideoRequestBuilder, + pub server_state: &'a ServerState, + pub user_token: &'a UserToken, + pub media_file_to_url_map: &'a Option>, + pub kinovi_character_id_map: &'a Option>, + pub kinovi_account: KinoviAccount, +} + +// NB: This pipeline does an external generation call (`upload_and_generate`) that can take many +// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires +// short-lived connections only for the billing and (on failure) refund writes. Holding a pooled +// connection across the external call is what starves the pool and causes `PoolTimedOut`. +pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result { + let RunPipelineV2Args { + router_builder, + server_state, + user_token, + media_file_to_url_map, + kinovi_character_id_map, + kinovi_account, + } = args; + + let mut router_builder = router_builder.clone(); + + match router_builder.model { + RouterVideoModel::PreviewModel | + RouterVideoModel::Seedance2p0BytePlus | + RouterVideoModel::Seedance2p0BytePlusUltra => { + router_builder.model = RouterVideoModel::Seedance2p0; + }, + RouterVideoModel::PreviewModelFast | + RouterVideoModel::Seedance2p0BytePlusFast | + RouterVideoModel::Seedance2p0BytePlusUltraFast => { + router_builder.model = RouterVideoModel::Seedance2p0Fast; + }, + _ => {}, // Fall-through + } + + let provider = match router_builder.model { + RouterVideoModel::HappyHorse1p0 => RouterProvider::Seedance2Pro, + RouterVideoModel::Seedance2p0 => RouterProvider::Seedance2Pro, + RouterVideoModel::Seedance2p0Fast => RouterProvider::Seedance2Pro, + //RouterVideoModel::Seedance2p0Ultra => RouterProvider::GmiCloud, + //RouterVideoModel::Seedance2p0UltraFast => RouterProvider::GmiCloud, + RouterVideoModel::GrokImagineVideo => RouterProvider::GrokApi, + RouterVideoModel::GrokImagineVideo1p5 => RouterProvider::GrokApi, + _ => RouterProvider::Fal, + }; + + // 1. Build execution request + let mut exec_builder = router_builder.clone(); + exec_builder.provider = provider; + + // Fal, GmiCloud, and Grok (xAI) take image URLs directly, not media file tokens. + // Resolve tokens to URLs before building. + if matches!(provider, RouterProvider::Fal | RouterProvider::GmiCloud | RouterProvider::GrokApi) { + resolve_media_tokens_to_urls(&mut exec_builder, media_file_to_url_map.as_ref()); + } + + let draft_or_request = exec_builder.build2() + .map_err(|e| { + warn!("Failed to build2 for v2 pipeline: {}", e); + CommonWebError::from_error(e) + })?; + + // 2. Calculate cost. + // For Artcraft-billable models, swap provider to Artcraft so credits = cents. + // For GmiCloud, use the execution request's cost directly (no Artcraft equivalent). + let system_cost_estimate = { + let mut cost_builder = router_builder.clone(); + cost_builder.provider = RouterProvider::Artcraft; + + cost_builder.build2() + .map_err(|e| { + warn!("Failed to build2 cost estimate for v2: {}", e); + CommonWebError::from_error(e) + })? + .estimate_cost() + .map_err(|e| { + warn!("Failed to estimate cost for v2: {}", e); + CommonWebError::from_error(e) + })? + }; + + let cost = system_cost_estimate.cost_in_credits.unwrap_or(0); + + // Provider-side estimate (what the fulfilling provider charges us). The + // router defers to the underlying provider crates (seedance2pro_client, + // gmicloud_client, grok_api_client, the fal pricing modules, etc.) per + // request variant. Bookkeeping only — failures must not block generation. + let maybe_provider_cost_estimate = match draft_or_request.estimate_cost() { + Ok(estimate) => Some(estimate), + Err(err) => { + warn!("Failed to estimate provider cost for v2 video: {}", err); + None + } + }; + + let cost_estimates = JobCostEstimates { + maybe_external_third_party_cost_credits: maybe_provider_cost_estimate.as_ref() + .and_then(|e| e.cost_in_credits) + .and_then(|v| u32::try_from(v).ok()), + maybe_external_third_party_cost_usd_cents: maybe_provider_cost_estimate.as_ref() + .and_then(|e| e.cost_in_usd_cents) + .and_then(|v| u32::try_from(v).ok()), + maybe_system_cost_credits: system_cost_estimate.cost_in_credits + .and_then(|v| u32::try_from(v).ok()), + maybe_system_cost_usd_cents: system_cost_estimate.cost_in_usd_cents + .and_then(|v| u32::try_from(v).ok()), + }; + + info!("v2 estimated cost: {} credits (estimates: {:?})", cost, cost_estimates); + + // 3. Bill wallet (short-lived connection — released before the external call below). + let billing = { + let mut billing_connection = server_state.mysql_pool.acquire().await + .map_err(|err| { + error!("Failed to acquire MySQL connection for billing: {:?}", err); + CommonWebError::from_error(err) + })?; + bill_wallet(user_token, cost, &mut billing_connection).await? + }; + + // 4. Upload media (if draft) and generate video. + // The entire block is wrapped so Kinovi failures trigger a refund. + // NB: No pooled DB connection is held across this call. + let result = upload_and_generate( + draft_or_request, + server_state, + media_file_to_url_map.as_ref(), + kinovi_character_id_map.as_ref(), + kinovi_account, + ).await; + + // 5. On failure, refund wallet for Kinovi requests. + if let Err(ref err) = result { + if matches!(provider, RouterProvider::Seedance2Pro) { + if let Some(ledger_entry_token) = billing.maybe_wallet_ledger_entry_token.as_ref() { + warn!("Kinovi v2 generation failed, issuing refund for {}: {:?}", ledger_entry_token.as_str(), err); + + match server_state.mysql_pool.acquire().await { + Ok(mut refund_connection) => { + if let Err(refund_err) = refund_wallet_after_api_failure(ledger_entry_token, &mut refund_connection).await { + error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err); + } + } + Err(acquire_err) => { + error!("Failed to acquire MySQL connection to refund wallet after Kinovi v2 failure: {:?}", acquire_err); + } + } + } + } + } + + let response = result?; + + info!("v2 generation response: {:?}", response); + + Ok(PipelineResult { billing, response, cost_estimates }) +} + +/// Finalize the draft (uploading media if needed), then send the generation request. +/// +/// This is the block that gets refunded on failure for Kinovi providers. +async fn upload_and_generate( + draft_or_request: VideoGenerationDraftOrRequest, + server_state: &ServerState, + media_file_urls_by_token: Option<&HashMap>, + kinovi_character_ids: Option<&HashMap>, + kinovi_account: KinoviAccount, +) -> Result { + + let provider = draft_or_request.get_provider(); + let client = build_router_client(provider, server_state, kinovi_account)?; + + let video_request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(request) => request, + VideoGenerationDraftOrRequest::Draft(draft) => { + let draft_context = VideoGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: media_file_urls_by_token, + character_token_to_kinovi_id_map: kinovi_character_ids, + }; + + draft.finalize(draft_context) + .await + .map_err(|err| { + warn!("Failed to finalize v2 draft: {:?}", err); + map_router_error_to_web_error(err) + })? + } + }; + + video_request.send_request(&client) + .await + .map_err(|err| { + warn!("v2 video generation failed: {:?}", err); + map_router_error_to_web_error(err) + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/mod.rs new file mode 100644 index 0000000000..46c5a37d41 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/mod.rs @@ -0,0 +1,3 @@ +pub mod generate; +pub mod shared_utils; +pub mod upload; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/check_url_inputs.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/check_url_inputs.rs new file mode 100644 index 0000000000..1d20458bc5 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/check_url_inputs.rs @@ -0,0 +1,37 @@ +use crate::http_server::common_responses::common_web_error::CommonWebError; + +/// Reject a request where a media-token field and its paired URL field are both +/// set. Exactly one of the two may be provided. +pub fn check_mutually_exclusive( + media_token_field_name: &str, + media_token_is_set: bool, + url_field_name: &str, + url_is_set: bool, +) -> Result<(), CommonWebError> { + if media_token_is_set && url_is_set { + return Err(CommonWebError::BadInputWithSimpleMessage(format!( + "Either {} or {} must be set, not both", + media_token_field_name, url_field_name, + ))); + } + Ok(()) +} + +/// Reject any URL that does not start with `http://` or `https://`. +pub fn check_url_format(urls: Option<&[String]>) -> Result<(), CommonWebError> { + for url in urls.unwrap_or(&[]) { + check_single_url_format(url)?; + } + Ok(()) +} + +/// Single-URL variant of [`check_url_format`] for non-list fields. +pub fn check_single_url_format(url: &str) -> Result<(), CommonWebError> { + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(CommonWebError::BadInputWithSimpleMessage(format!( + "URL must start with http:// or https://, bad URL: {}", + url, + ))); + } + Ok(()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/map_seedance2pro_router_error.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/map_seedance2pro_router_error.rs new file mode 100644 index 0000000000..6815fa3869 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/map_seedance2pro_router_error.rs @@ -0,0 +1,90 @@ +//! Maps artcraft router errors to web errors for the omni_gen pipelines. +//! +//! Known Kinovi (Seedance2Pro) bad requests (content violations, over-long +//! prompts, too many urls) become 400s with a user-facing message. Everything +//! else is unanticipated and stays a 500. + +use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError; +use artcraft_router::errors::provider_error::ProviderError; +use seedance2pro_client::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError; +use seedance2pro_client::error::seedance2pro_error::Seedance2ProError; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +/// Map an artcraft router error to a web error, unwrapping Kinovi bad requests. +pub fn map_router_error_to_web_error(error: ArtcraftRouterError) -> CommonWebError { + match error { + ArtcraftRouterError::Provider( + ProviderError::Seedance2Pro(Seedance2ProError::ApiBadRequest(bad_request)) + ) => { + CommonWebError::BadInputWithSimpleMessage(user_facing_bad_request_message(&bad_request)) + } + other => CommonWebError::from_error(other), + } +} + +fn user_facing_bad_request_message(error: &Seedance2ProBadRequestApiError) -> String { + match error { + Seedance2ProBadRequestApiError::VideoGenerationViolation { .. } => { + "The generation request was flagged as a content violation. \ + Please adjust your prompt or input media and try again.".to_string() + } + Seedance2ProBadRequestApiError::PromptIsTooLong { .. } => { + "The prompt is too long. Please shorten it below 10,000 characters and try again.".to_string() + } + Seedance2ProBadRequestApiError::TooManyUrls { .. } => { + "Too many input files were attached. \ + Please remove some and try again.".to_string() + } + } +} + +#[cfg(test)] +mod tests { + use actix_web::ResponseError; + + use super::*; + + #[test] + fn kinovi_bad_requests_become_400s_with_user_facing_messages() { + let cases = [ + ( + Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: "{}".to_string() }, + "content violation", + ), + ( + Seedance2ProBadRequestApiError::PromptIsTooLong { raw_body: "{}".to_string() }, + "prompt is too long", + ), + ( + Seedance2ProBadRequestApiError::TooManyUrls { raw_body: "{}".to_string() }, + "Too many input files", + ), + ]; + + for (bad_request, expected_message_fragment) in cases { + let error = map_router_error_to_web_error( + ArtcraftRouterError::Provider( + ProviderError::Seedance2Pro( + Seedance2ProError::ApiBadRequest(bad_request)))); + + assert_eq!(error.status_code().as_u16(), 400); + match error { + CommonWebError::BadInputWithSimpleMessage(message) => { + assert!( + message.contains(expected_message_fragment), + "message {:?} should contain {:?}", message, expected_message_fragment); + } + other => panic!("Expected BadInputWithSimpleMessage, got: {:?}", other), + } + } + } + + #[test] + fn other_router_errors_stay_500s() { + let error = map_router_error_to_web_error( + ArtcraftRouterError::UnsupportedModel("some_model".to_string())); + + assert_eq!(error.status_code().as_u16(), 500); + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/media_uploads/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/media_uploads/mod.rs new file mode 100644 index 0000000000..3e68a13969 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/media_uploads/mod.rs @@ -0,0 +1 @@ +pub mod upload_url_to_media_file; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/media_uploads/upload_url_to_media_file.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/media_uploads/upload_url_to_media_file.rs new file mode 100644 index 0000000000..3578bfa7c2 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/media_uploads/upload_url_to_media_file.rs @@ -0,0 +1,216 @@ +use std::collections::HashSet; + +use log::{info, warn}; +use once_cell::sync::Lazy; + +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; +use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes; +use mimetypes::mimetype_to_extension::mimetype_to_extension; +use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType}; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::state::server_state::ServerState; +use crate::util::http_download_url_to_bytes::http_download_url_to_bytes; + +/// The kind of media we expect at a given URL. Determines the permitted mime +/// types, the bucket path prefix, and how the `media_files` row is classified. +#[derive(Clone, Copy, Debug)] +pub enum MediaUploadKind { + Image, + Video, + Audio, +} + +/// Download a user-supplied URL, store its bytes in the public R2 bucket, and +/// insert a `media_files` record owned by the API user. Returns the new +/// `MediaFileToken` so the caller can treat the URL input as a media token from +/// here on. +/// +/// NB: this performs a network download followed by an R2 upload — do NOT call +/// it while holding a pooled MySQL connection (the insert acquires its own). +pub async fn upload_url_to_media_file( + server_state: &ServerState, + user_token: &UserToken, + ip_address: &str, + url: &str, + kind: MediaUploadKind, +) -> Result { + // ==================== DOWNLOAD ==================== // + + let file_bytes = http_download_url_to_bytes(url) + .await + .map_err(|err| { + warn!("Failed to download media URL {}: {:?}", url, err); + CommonWebError::BadInputWithSimpleMessage(format!("Failed to download URL: {}", url)) + })?; + + // ==================== MIME VALIDATION ==================== // + + let mimetype = get_mimetype_for_bytes(file_bytes.as_ref()) + .map(|mimetype| mimetype.to_string()) + .ok_or_else(|| { + warn!("Could not determine mimetype for URL: {}", url); + CommonWebError::BadInputWithSimpleMessage( + format!("Could not determine file type for URL: {}", url)) + })?; + + if !kind.allowed_mime_types().contains(mimetype.as_str()) { + // NB: Don't let our error message echo back malicious strings. + let filtered_mimetype = mimetype + .chars() + .filter(|c| c.is_ascii()) + .filter(|c| c.is_alphanumeric() || *c == '/') + .collect::(); + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("Unpermitted mime type for URL {}: {}", url, filtered_mimetype))); + } + + // ==================== OTHER FILE METADATA ==================== // + + let extension = mimetype_to_extension(&mimetype) + .ok_or_else(|| { + warn!("Could not determine file extension for mimetype: {}", &mimetype); + CommonWebError::server_error_with_message("Could not determine file extension") + })?; + let extension = format!(".{extension}"); // NB: needs dot prefix + + let file_size_bytes = file_bytes.len() as u64; + + let hash = sha256_hash_bytes(file_bytes.as_ref()) + .map_err(|err| { + warn!("Problem hashing bytes for URL {}: {:?}", url, err); + CommonWebError::server_error_with_message("Could not hash downloaded file") + })?; + + // ==================== UPLOAD TO R2 ==================== // + + let prefix = kind.bucket_prefix(); + let public_upload_path = MediaFileBucketPath::generate_new(Some(prefix), Some(&extension)); + + info!("Uploading URL media to bucket path: {}", public_upload_path.get_full_object_path_str()); + + server_state.public_bucket_client.upload_file_with_content_type( + public_upload_path.get_full_object_path_str(), + file_bytes.as_ref(), + &mimetype, + ) + .await + .map_err(|err| { + warn!("Upload URL media bytes to bucket error: {:?}", err); + CommonWebError::server_error_with_message("Could not store downloaded file") + })?; + + // ==================== INSERT MEDIA FILE ==================== // + + let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs { + maybe_media_class: Some(kind.media_file_class()), + media_file_type: kind.media_file_type(&mimetype), + maybe_creator_user_token: Some(user_token), + // NB: AVT (anonymous visitor) tokens are a web-session concept; API-key callers have none. + maybe_creator_anonymous_visitor_token: None, + creator_ip_address: ip_address, + creator_set_visibility: Visibility::default(), + upload_type: UploadType::Filesystem, + maybe_engine_category: None, + maybe_animation_type: None, + maybe_mime_type: Some(&mimetype), + maybe_prompt_token: None, + maybe_batch_token: None, + file_size_bytes, + // NB: Duration would require probing the file on disk; URL inputs skip it. + maybe_duration_millis: None, + sha256_checksum: &hash, + maybe_title: None, + maybe_scene_source_media_file_token: None, + is_intermediate_system_file: false, + maybe_generation_provider: None, + public_bucket_directory_hash: public_upload_path.get_object_hash(), + maybe_public_bucket_prefix: Some(prefix), + maybe_public_bucket_extension: Some(&extension), + pool: &server_state.mysql_pool, + }) + .await + .map_err(|err| { + warn!("Failed to insert media file from URL {}: {:?}", url, err); + CommonWebError::server_error_with_message("Could not save downloaded file") + })?; + + info!("New media file from URL id: {} token: {:?}", record_id, &token); + + Ok(token) +} + +static IMAGE_MIME_TYPES: Lazy> = Lazy::new(|| { + HashSet::from([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ]) +}); + +static VIDEO_MIME_TYPES: Lazy> = Lazy::new(|| { + HashSet::from([ + "video/mp4", + ]) +}); + +static AUDIO_MIME_TYPES: Lazy> = Lazy::new(|| { + HashSet::from([ + "audio/aac", + "audio/m4a", + "audio/mpeg", + "audio/ogg", + "audio/opus", + "audio/x-flac", + "audio/x-wav", + "audio/mp4", + "video/mp4", + "video/webm", + ]) +}); + +impl MediaUploadKind { + fn allowed_mime_types(&self) -> &'static HashSet<&'static str> { + match self { + MediaUploadKind::Image => &IMAGE_MIME_TYPES, + MediaUploadKind::Video => &VIDEO_MIME_TYPES, + MediaUploadKind::Audio => &AUDIO_MIME_TYPES, + } + } + + fn bucket_prefix(&self) -> &'static str { + match self { + MediaUploadKind::Image => "image_", + MediaUploadKind::Video => "video_", + MediaUploadKind::Audio => "aud_", + } + } + + fn media_file_class(&self) -> MediaFileClass { + match self { + MediaUploadKind::Image => MediaFileClass::Image, + MediaUploadKind::Video => MediaFileClass::Video, + MediaUploadKind::Audio => MediaFileClass::Audio, + } + } + + fn media_file_type(&self, mimetype: &str) -> MediaFileType { + match self { + MediaUploadKind::Image => match mimetype { + "image/jpeg" => MediaFileType::Jpg, + "image/png" => MediaFileType::Png, + "image/gif" => MediaFileType::Gif, + _ => MediaFileType::Image, + }, + MediaUploadKind::Video => MediaFileType::Video, + MediaUploadKind::Audio => MediaFileType::Audio, + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/mod.rs new file mode 100644 index 0000000000..91f31a387b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/mod.rs @@ -0,0 +1,4 @@ +pub mod check_url_inputs; +pub mod map_seedance2pro_router_error; +pub mod media_uploads; +pub mod video; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/video/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/video/mod.rs new file mode 100644 index 0000000000..bfdbcc2afa --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/video/mod.rs @@ -0,0 +1,2 @@ +pub mod validate_video_request; +mod validate_when_image_required; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/video/validate_video_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/video/validate_video_request.rs new file mode 100644 index 0000000000..346684f58a --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/video/validate_video_request.rs @@ -0,0 +1,11 @@ +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::omni_api::shared_utils::video::validate_when_image_required::validate_when_image_required; +use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest; + +/// Validate requests before they incur user costs or send API requests +pub fn validate_video_request( + request: &OmniApiVideoGenerateRequest, +) -> Result<(), CommonWebError> { + validate_when_image_required(request)?; + Ok(()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/video/validate_when_image_required.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/video/validate_when_image_required.rs new file mode 100644 index 0000000000..2eb99dbe46 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/shared_utils/video/validate_when_image_required.rs @@ -0,0 +1,171 @@ +use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest; +use enums::common::generation::common_video_model::CommonVideoModel; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +/// xAI's `grok-imagine-video-1.5-preview` model rejects text-to-video at +/// the upstream API. Reject the request at the handler layer so callers see +/// a clean 400 with an actionable message and we don't charge their wallet +/// for an inference job we know will fail. +pub (super) fn validate_when_image_required( + request: &OmniApiVideoGenerateRequest, +) -> Result<(), CommonWebError> { + match request.model { + Some(CommonVideoModel::GrokImagineVideo1p5) => {}, // Fall-through + _ => return Ok(()), + } + + let requires_image = matches!(request.model, Some(CommonVideoModel::GrokImagineVideo1p5)); + + if !requires_image { + return Ok(()); + } + + // NB: A start frame / reference image may be supplied either as a media token + // or as a URL (URLs are converted to media files later in the handler), so + // both satisfy the image requirement here. + let has_start_frame = request.start_frame_image_media_token.is_some() + || request.start_frame_image_url.is_some(); + + let has_reference_images = request + .reference_image_media_tokens + .as_ref() + .is_some_and(|v| !v.is_empty()) + || request + .reference_image_urls + .as_ref() + .is_some_and(|v| !v.is_empty()); + + if has_start_frame || has_reference_images { + return Ok(()); + } + + Err(CommonWebError::BadInputWithSimpleMessage( + "Image is required".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokens::tokens::media_files::MediaFileToken; + + fn base_request() -> OmniApiVideoGenerateRequest { + OmniApiVideoGenerateRequest { + idempotency_token: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()), + model: None, + prompt: Some("test".to_string()), + negative_prompt: None, + start_frame_image_media_token: None, + start_frame_image_url: None, + end_frame_image_media_token: None, + end_frame_image_url: None, + reference_image_media_tokens: None, + reference_image_urls: None, + reference_video_media_tokens: None, + reference_video_urls: None, + reference_audio_media_tokens: None, + reference_audio_urls: None, + reference_character_tokens: None, + resolution: None, + aspect_ratio: None, + bitrate: None, + quality: None, + duration_seconds: Some(5), + video_batch_count: Some(1), + generate_audio: None, + } + } + + fn token(id: &str) -> MediaFileToken { + MediaFileToken::new(id.to_string()) + } + + #[test] + fn passes_when_model_is_none() { + let req = base_request(); + assert!(validate_when_image_required(&req).is_ok()); + } + + #[test] + fn passes_for_other_models_without_image() { + // Only v1.5 needs the gate; other models can do text-to-video. + let req = OmniApiVideoGenerateRequest { + model: Some(CommonVideoModel::Seedance2p0), + ..base_request() + }; + assert!(validate_when_image_required(&req).is_ok()); + } + + #[test] + fn rejects_v1p5_with_no_image_inputs() { + let req = OmniApiVideoGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + ..base_request() + }; + let err = validate_when_image_required(&req).expect_err("should reject"); + match err { + CommonWebError::BadInputWithSimpleMessage(msg) => { + assert_eq!(msg, "Image is required"); + } + other => panic!("expected BadInputWithSimpleMessage, got {:?}", other), + } + } + + #[test] + fn rejects_v1p5_with_empty_reference_list_and_no_start_frame() { + let req = OmniApiVideoGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + reference_image_media_tokens: Some(vec![]), + ..base_request() + }; + assert!(matches!( + validate_when_image_required(&req), + Err(CommonWebError::BadInputWithSimpleMessage(_)) + )); + } + + #[test] + fn accepts_v1p5_with_start_frame_only() { + let req = OmniApiVideoGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + start_frame_image_media_token: Some(token("mf_start")), + ..base_request() + }; + assert!(validate_when_image_required(&req).is_ok()); + } + + #[test] + fn accepts_v1p5_with_reference_images_only() { + let req = OmniApiVideoGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + reference_image_media_tokens: Some(vec![token("mf_a"), token("mf_b")]), + ..base_request() + }; + assert!(validate_when_image_required(&req).is_ok()); + } + + #[test] + fn accepts_v1p5_with_both_start_frame_and_reference_images() { + let req = OmniApiVideoGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + start_frame_image_media_token: Some(token("mf_start")), + reference_image_media_tokens: Some(vec![token("mf_a")]), + ..base_request() + }; + assert!(validate_when_image_required(&req).is_ok()); + } + + #[test] + fn end_frame_alone_does_not_count_as_image_input() { + // end_frame_image_media_token is technically an image, but xAI's v1.5 + // wire shape only uses start_frame / reference_images. end_frame + // shouldn't satisfy the gate. + let req = OmniApiVideoGenerateRequest { + model: Some(CommonVideoModel::GrokImagineVideo1p5), + end_frame_image_media_token: Some(token("mf_end")), + ..base_request() + }; + assert!(validate_when_image_required(&req).is_err()); + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/mod.rs new file mode 100644 index 0000000000..84dfcb12f9 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/mod.rs @@ -0,0 +1,3 @@ +pub mod omni_upload_audio_media_file_handler; +pub mod omni_upload_image_media_file_handler; +pub mod omni_upload_video_media_file_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/omni_upload_audio_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/omni_upload_audio_media_file_handler.rs new file mode 100644 index 0000000000..d2055f62b7 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/omni_upload_audio_media_file_handler.rs @@ -0,0 +1,327 @@ +use std::collections::HashSet; +use std::io::Read; +use std::path::PathBuf; +use std::sync::Arc; + +use actix_multipart::form::tempfile::TempFile; +use actix_multipart::form::text::Text; +use actix_multipart::form::MultipartForm; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use utoipa::ToSchema; + +use artcraft_api_defs::omni_api::omni_upload_audio::OmniUploadAudioMediaFileSuccessResponse; +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; +use http_server_common::request::get_request_ip::get_request_ip; +use media::decode_basic_audio_info::decode_basic_audio_bytes_info; +use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes; +use mimetypes::mimetype_to_extension::mimetype_to_extension; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType}; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError; +use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider; +use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; +use crate::state::server_state::ServerState; + +/// Form-multipart request fields. +/// +/// IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadAudioMediaFileForm` (Under "Schema") FOR DETAILS ON FIELDS AND NULLABILITY. +#[derive(MultipartForm, ToSchema)] +#[multipart(duplicate_field = "deny")] +pub struct OmniUploadAudioMediaFileForm { + /// UUID for request idempotency + #[multipart(limit = "2 KiB")] + #[schema(value_type = String, format = Binary)] + uuid_idempotency_token: Text, + + /// The uploaded file + #[multipart(limit = "512 MiB")] + #[schema(value_type = Vec, format = Binary)] + file: TempFile, + + /// Optional: Title (name) of the scene + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_title: Option>, + + /// Optional: Visibility of the scene + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_visibility: Option>, + + /// Optional: The third-party generation provider (e.g. "fal", "replicate"). + /// If set, `is_user_upload` will be false and `is_intermediate_system_file` will be forced false. + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_generation_provider: Option>, +} + +static ALLOWED_MIME_TYPES : Lazy> = Lazy::new(|| { + HashSet::from([ + // Audio + "audio/aac", + "audio/m4a", + "audio/mpeg", + "audio/ogg", + "audio/opus", + "audio/x-flac", + "audio/x-wav", + // Mixed + "audio/mp4", // iPhone seems to upload these as audio + // Video + "video/mp4", + "video/webm", + ]) +}); + +/// API-key authenticated audio upload (Omni API). Identity is read from the `Authorization` header +/// API key rather than a session cookie. +#[utoipa::path( + post, + tag = "Omni API", + path = "/v1/omni_api/upload/audio", + responses( + (status = 200, description = "Success Update", body = OmniUploadAudioMediaFileSuccessResponse), + (status = 400, description = "Bad input", body = MediaFileUploadError), + (status = 401, description = "Not authorized", body = MediaFileUploadError), + (status = 429, description = "Too many requests", body = MediaFileUploadError), + (status = 500, description = "Server error", body = MediaFileUploadError), + ), + params( + ( + "request" = OmniUploadAudioMediaFileForm, + description = "IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadAudioMediaFileForm` (Under 'Schema') FOR DETAILS ON FIELDS AND NULLABILITY." + ), + ) +)] +pub async fn omni_upload_audio_media_file_handler( + http_request: HttpRequest, + server_state: web::Data>, + MultipartForm(mut form): MultipartForm, +) -> Result, MediaFileUploadError> { + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + error!("MySql pool error: {:?}", err); + MediaFileUploadError::ServerError + })?; + + // ==================== API KEY USER ==================== // + + // API-key authentication (Authorization header) instead of a session cookie. Never cached, and a + // banned owner is rejected inside `require_api_key_user`. + let api_session = require_api_key_user(&http_request, &mut *mysql_connection) + .await + .map_err(map_api_key_auth_error)?; + + let maybe_user_token = Some(&api_session.user_token); + + // ==================== RATE LIMIT ==================== // + + let rate_limiter = &server_state.redis_rate_limiters.file_upload_logged_in; + + if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await { + return Err(MediaFileUploadError::RateLimited); + } + + // ==================== HANDLE IDEMPOTENCY ==================== // + + let uuid_idempotency_token = form.uuid_idempotency_token.as_ref(); + + if let Err(reason) = validate_idempotency_token_format(uuid_idempotency_token) { + return Err(MediaFileUploadError::BadInput(reason)); + } + + insert_idempotency_token(uuid_idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + MediaFileUploadError::BadInput("invalid idempotency token".to_string()) + })?; + + // ==================== UPLOAD METADATA ==================== // + + let maybe_title = form.maybe_title + .map(|title| title.trim().to_string()) + .filter(|title| !title.is_empty()); + + let creator_set_visibility = form.maybe_visibility + .map(|visibility| visibility.0) + .unwrap_or(Visibility::default()); + + // ==================== USER DATA ==================== // + + let ip_address = get_request_ip(&http_request); + + // ==================== FILE VALIDATION ==================== // + + let mut file_bytes = Vec::new(); + form.file.file.read_to_end(&mut file_bytes) + .map_err(|e| { + error!("Problem reading file: {:?}", e); + MediaFileUploadError::ServerError + })?; + + let mimetype = get_mimetype_for_bytes(file_bytes.as_ref()) + .map(|mimetype| mimetype.to_string()) + .ok_or_else(|| { + warn!("Could not determine mimetype for file"); + MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string()) + })?; + + if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) { + // NB: Don't let our error message inject malicious strings + let filtered_mimetype = mimetype + .chars() + .filter(|c| c.is_ascii()) + .filter(|c| c.is_alphanumeric() || *c == '/') + .collect::(); + return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype))); + } + + // ==================== DURATION DETECTION ==================== // + + let do_audio_decode = match mimetype.as_ref() { + // TODO: Revisit when Safari can send us this metadata consistently + "audio/mp4" | "video/mp4" => false, + // Symphonia is currently broken for Firefox's opus; skip decoding it. + "audio/opus" => false, + // Also, don't decode images + "image/jpeg" => false, + "image/png" => false, + "image/webp" => false, + _ => true, + }; + + let mut maybe_duration_millis = None; + + if do_audio_decode { + let basic_info = decode_basic_audio_bytes_info( + file_bytes.as_ref(), + Some(&mimetype), + None + ).map_err(|e| { + warn!("file decoding error: {:?}", e); + MediaFileUploadError::BadInput("could not decode file".to_string()) + })?; + + maybe_duration_millis = basic_info.duration_millis; + } + + // ==================== OTHER FILE METADATA ==================== // + + let maybe_filename = form.file.file_name.as_deref() + .as_deref() + .map(|filename| PathBuf::from(filename)); + + let extension = mimetype_to_extension(&mimetype) + .or_else(|| { + maybe_filename + .as_ref() + .and_then(|filename| filename.extension()) + .and_then(|ext| ext.to_str()) + }) + .ok_or_else(|| { + warn!("Could not determine file extension for mimetype: {}", &mimetype); + MediaFileUploadError::ServerError + })?; + + let extension = format!(".{extension}"); // NB: needs dot prefix + + let file_size_bytes = file_bytes.len(); + + let hash = sha256_hash_bytes(&file_bytes) + .map_err(|io_error| { + error!("Problem hashing bytes: {:?}", io_error); + MediaFileUploadError::ServerError + })?; + + // ==================== UPLOAD AND SAVE ==================== // + + const PREFIX : Option<&str> = Some("aud_"); + + let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension)); + + info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str()); + + server_state.public_bucket_client.upload_file_with_content_type( + public_upload_path.get_full_object_path_str(), + file_bytes.as_ref(), + &mimetype) + .await + .map_err(|e| { + warn!("Upload media bytes to bucket error: {:?}", e); + MediaFileUploadError::ServerError + })?; + + let maybe_generation_provider = form.maybe_generation_provider + .as_ref() + .and_then(|text| try_parse_generation_provider(text.as_ref())); + + let upload_type = if maybe_generation_provider.is_some() { + UploadType::ThirdPartyInference + } else { + UploadType::Filesystem + }; + + let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs { + maybe_media_class: Some(MediaFileClass::Audio), + media_file_type: MediaFileType::Audio, + maybe_creator_user_token: maybe_user_token, + // NB: AVT (anonymous visitor) tokens are a web-session concept; API-key callers have none. + maybe_creator_anonymous_visitor_token: None, + creator_ip_address: &ip_address, + creator_set_visibility, + upload_type, + maybe_engine_category: None, + maybe_animation_type: None, + maybe_prompt_token: None, + maybe_batch_token: None, + maybe_mime_type: Some(&mimetype), + file_size_bytes: file_size_bytes as u64, + maybe_duration_millis, + sha256_checksum: &hash, + maybe_title: maybe_title.as_deref(), + maybe_scene_source_media_file_token: None, + is_intermediate_system_file: false, // NB: is_user_upload = true + maybe_generation_provider, + public_bucket_directory_hash: public_upload_path.get_object_hash(), + maybe_public_bucket_prefix: PREFIX, + maybe_public_bucket_extension: Some(&extension), + pool: &server_state.mysql_pool, + }) + .await + .map_err(|err| { + warn!("New file creation DB error: {:?}", err); + MediaFileUploadError::ServerError + })?; + + info!("new media file id: {} token: {:?}", record_id, &token); + + Ok(Json(OmniUploadAudioMediaFileSuccessResponse { + success: true, + media_file_token: token, + })) +} + +/// Map an API-key authentication failure onto this endpoint's error type. A genuine 401 stays a +/// 401; anything else (e.g. a DB error during lookup) becomes a 500. +fn map_api_key_auth_error(err: CommonWebError) -> MediaFileUploadError { + match err { + CommonWebError::NotAuthorized => { + MediaFileUploadError::NotAuthorizedVerbose("invalid or missing API key".to_string()) + } + _ => MediaFileUploadError::ServerError, + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/omni_upload_image_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/omni_upload_image_media_file_handler.rs new file mode 100644 index 0000000000..a7efced045 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/omni_upload_image_media_file_handler.rs @@ -0,0 +1,338 @@ +use std::collections::HashSet; +use std::io::Read; +use std::path::PathBuf; +use std::sync::Arc; + +use actix_multipart::form::tempfile::TempFile; +use actix_multipart::form::text::Text; +use actix_multipart::form::MultipartForm; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use utoipa::ToSchema; + +use artcraft_api_defs::omni_api::omni_upload_image::OmniUploadImageMediaFileSuccessResponse; +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; +use http_server_common::request::get_request_ip::get_request_ip; +use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes; +use mimetypes::mimetype_to_extension::mimetype_to_extension; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType}; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::prompts::PromptToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError; +use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider; +use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; +use crate::state::server_state::ServerState; + +/// Form-multipart request fields. +/// +/// IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadImageMediaFileForm` (Under "Schema") FOR DETAILS ON FIELDS AND NULLABILITY. +#[derive(MultipartForm, ToSchema)] +#[multipart(duplicate_field = "deny")] +pub struct OmniUploadImageMediaFileForm { + /// UUID for request idempotency + #[multipart(limit = "2 KiB")] + #[schema(value_type = String, format = Binary)] + uuid_idempotency_token: Text, + + /// The uploaded file + #[multipart(limit = "512 MiB")] + #[schema(value_type = Vec, format = Binary)] + file: TempFile, + + /// Optional: Title (name) of the scene + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_title: Option>, + + /// Optional: Visibility of the scene + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_visibility: Option>, + + /// Optional: Prompt associated with this image + /// NOTE: Cannot set `is_intermediate_system_file = true` if this is set. + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_prompt_token: Option>, + + /// Optional: Batch associated with this image + /// NOTE: Cannot set `is_intermediate_system_file = true` if this is set. + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_batch_token: Option>, + + /// Optional: Whether this is a system file (e.g. cover files we should hide) + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + is_intermediate_system_file: Option>, + + /// Optional: The third-party generation provider (e.g. "fal", "replicate"). + /// If set, `is_user_upload` will be false and `is_intermediate_system_file` will be forced false. + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_generation_provider: Option>, +} + +static ALLOWED_MIME_TYPES : Lazy> = Lazy::new(|| { + HashSet::from([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ]) +}); + +/// API-key authenticated image upload (Omni API). Identity is read from the `Authorization` header +/// API key rather than a session cookie. +#[utoipa::path( + post, + tag = "Omni API", + path = "/v1/omni_api/upload/image", + responses( + (status = 200, description = "Success Update", body = OmniUploadImageMediaFileSuccessResponse), + (status = 400, description = "Bad input", body = MediaFileUploadError), + (status = 401, description = "Not authorized", body = MediaFileUploadError), + (status = 429, description = "Too many requests", body = MediaFileUploadError), + (status = 500, description = "Server error", body = MediaFileUploadError), + ), + params( + ( + "request" = OmniUploadImageMediaFileForm, + description = "IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadImageMediaFileForm` (Under 'Schema') FOR DETAILS ON FIELDS AND NULLABILITY." + ), + ) +)] +pub async fn omni_upload_image_media_file_handler( + http_request: HttpRequest, + server_state: web::Data>, + MultipartForm(mut form): MultipartForm, +) -> Result, MediaFileUploadError> { + + validate_form(&form)?; + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + error!("MySql pool error: {:?}", err); + MediaFileUploadError::ServerError + })?; + + // ==================== API KEY USER ==================== // + + // API-key authentication (Authorization header) instead of a session cookie. Never cached, and a + // banned owner is rejected inside `require_api_key_user`. + let api_session = require_api_key_user(&http_request, &mut *mysql_connection) + .await + .map_err(map_api_key_auth_error)?; + + let maybe_user_token = Some(&api_session.user_token); + + // ==================== HANDLE IDEMPOTENCY ==================== // + + let uuid_idempotency_token = form.uuid_idempotency_token.as_ref(); + + if let Err(reason) = validate_idempotency_token_format(uuid_idempotency_token) { + return Err(MediaFileUploadError::BadInput(reason)); + } + + insert_idempotency_token(uuid_idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + MediaFileUploadError::BadInput("invalid idempotency token".to_string()) + })?; + + // ==================== UPLOAD METADATA ==================== // + + let maybe_title = form.maybe_title.map(|title| title.to_string()); + + let creator_set_visibility = form.maybe_visibility + .map(|visibility| visibility.0) + .unwrap_or(Visibility::default()); + + // ==================== USER DATA ==================== // + + let ip_address = get_request_ip(&http_request); + + // ==================== FILE VALIDATION ==================== // + + let mut file_bytes = Vec::new(); + form.file.file.read_to_end(&mut file_bytes) + .map_err(|e| { + error!("Problem reading file: {:?}", e); + MediaFileUploadError::ServerError + })?; + + let mimetype = get_mimetype_for_bytes(file_bytes.as_ref()) + .map(|mimetype| mimetype.to_string()) + .ok_or_else(|| { + warn!("Could not determine mimetype for file"); + MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string()) + })?; + + if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) { + // NB: Don't let our error message inject malicious strings + let filtered_mimetype = mimetype + .chars() + .filter(|c| c.is_ascii()) + .filter(|c| c.is_alphanumeric() || *c == '/') + .collect::(); + return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype))); + } + + // ==================== OTHER FILE METADATA ==================== // + + let maybe_filename = form.file.file_name.as_deref() + .as_deref() + .map(|filename| PathBuf::from(filename)); + + let extension = mimetype_to_extension(&mimetype) + .or_else(|| { + maybe_filename + .as_ref() + .and_then(|filename| filename.extension()) + .and_then(|ext| ext.to_str()) + }) + .ok_or_else(|| { + warn!("Could not determine file extension for mimetype: {}", &mimetype); + MediaFileUploadError::ServerError + })?; + + let extension = format!(".{extension}"); // NB: needs dot prefix + + let file_size_bytes = file_bytes.len(); + + let hash = sha256_hash_bytes(&file_bytes) + .map_err(|io_error| { + error!("Problem hashing bytes: {:?}", io_error); + MediaFileUploadError::ServerError + })?; + + // ==================== UPLOAD AND SAVE ==================== // + + const PREFIX : Option<&str> = Some("image_"); + + let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension)); + + info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str()); + + server_state.public_bucket_client.upload_file_with_content_type( + public_upload_path.get_full_object_path_str(), + file_bytes.as_ref(), + &mimetype) + .await + .map_err(|e| { + warn!("Upload media bytes to bucket error: {:?}", e); + MediaFileUploadError::ServerError + })?; + + let is_intermediate_system_file = form.is_intermediate_system_file + .map(|b| b.0) + .unwrap_or(false); + + let maybe_prompt_token = form.maybe_prompt_token + .as_ref() + .map(|token| token.0.clone()); + + let maybe_batch_token = form.maybe_batch_token + .as_ref() + .map(|token| token.0.clone()); + + let maybe_generation_provider = form.maybe_generation_provider + .as_ref() + .and_then(|text| try_parse_generation_provider(text.as_ref())); + + let upload_type = if maybe_generation_provider.is_some() { + UploadType::ThirdPartyInference + } else { + UploadType::Filesystem + }; + + let media_file_type = match mimetype.as_str() { + "image/jpeg" => MediaFileType::Jpg, + "image/png" => MediaFileType::Png, + "image/gif" => MediaFileType::Gif, + "image/webp" => MediaFileType::Image, // Fallback + _ => MediaFileType::Image, // fallback + }; + + let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs { + maybe_media_class: Some(MediaFileClass::Image), + media_file_type, + maybe_creator_user_token: maybe_user_token, + // NB: AVT (anonymous visitor) tokens are a web-session concept; API-key callers have none. + maybe_creator_anonymous_visitor_token: None, + creator_ip_address: &ip_address, + creator_set_visibility, + upload_type, + maybe_engine_category: None, + maybe_animation_type: None, + maybe_mime_type: Some(&mimetype), + maybe_prompt_token: maybe_prompt_token.as_ref(), + maybe_batch_token: maybe_batch_token.as_ref(), + file_size_bytes: file_size_bytes as u64, + maybe_duration_millis: None, + sha256_checksum: &hash, + maybe_title: maybe_title.as_deref(), + maybe_scene_source_media_file_token: None, + is_intermediate_system_file, + maybe_generation_provider, + public_bucket_directory_hash: public_upload_path.get_object_hash(), + maybe_public_bucket_prefix: PREFIX, + maybe_public_bucket_extension: Some(&extension), + pool: &server_state.mysql_pool, + }) + .await + .map_err(|err| { + warn!("New file creation DB error: {:?}", err); + MediaFileUploadError::ServerError + })?; + + info!("new media file id: {} token: {:?}", record_id, &token); + + Ok(Json(OmniUploadImageMediaFileSuccessResponse { + success: true, + media_file_token: token, + })) +} + +fn validate_form(form: &OmniUploadImageMediaFileForm) -> Result<(), MediaFileUploadError> { + if form.uuid_idempotency_token.is_empty() { + return Err(MediaFileUploadError::BadInput("Idempotency token is required".to_string())); + } + + let is_intermediate_system_file = form.is_intermediate_system_file + .as_ref() + .map(|b| b.0) + .unwrap_or(false); + + if is_intermediate_system_file && form.maybe_prompt_token.is_some() { + return Err(MediaFileUploadError::BadInput( + "Cannot set `is_intermediate_system_file` to true if `maybe_prompt_token` is provided." + .to_string())); + } + + Ok(()) +} + +/// Map an API-key authentication failure onto this endpoint's error type. A genuine 401 stays a +/// 401; anything else (e.g. a DB error during lookup) becomes a 500. +fn map_api_key_auth_error(err: CommonWebError) -> MediaFileUploadError { + match err { + CommonWebError::NotAuthorized => { + MediaFileUploadError::NotAuthorizedVerbose("invalid or missing API key".to_string()) + } + _ => MediaFileUploadError::ServerError, + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/omni_upload_video_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/omni_upload_video_media_file_handler.rs new file mode 100644 index 0000000000..340a7f9010 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/upload/omni_upload_video_media_file_handler.rs @@ -0,0 +1,557 @@ +use std::collections::HashSet; +use std::io::Read; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use actix_multipart::form::tempfile::TempFile; +use actix_multipart::form::text::Text; +use actix_multipart::form::MultipartForm; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::{debug, error, info, warn}; +use once_cell::sync::Lazy; +use utoipa::ToSchema; + +use artcraft_api_defs::omni_api::omni_upload_video::OmniUploadVideoMediaFileSuccessResponse; +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use ffmpeg_utils::ffmpeg::ffmpeg_transcode_to_mp4::{ffmpeg_transcode_to_mp4, FfmpegTranscodeToMp4Args}; +use ffmpeg_utils::ffmpeg::ffmpeg_trim_and_resample::{ffmpeg_trim_and_resample, Args}; +use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info; +use filesys::file_read_bytes::file_read_bytes; +use filesys::path_to_string::path_to_string; +use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; +use http_server_common::request::get_request_ip::get_request_ip; +use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes; +use mimetypes::mimetype_to_extension::mimetype_to_extension; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType}; +use thumbnail_generator::task_client::thumbnail_task::{ThumbnailTaskBuilder, ThumbnailTaskInputMimeType}; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError; +use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider; +use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; +use crate::state::server_state::ServerState; + +/// Form-multipart request fields. +/// +/// IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadVideoMediaFileForm` (Under "Schema") FOR DETAILS ON FIELDS AND NULLABILITY. +#[derive(MultipartForm, ToSchema)] +#[multipart(duplicate_field = "deny")] +pub struct OmniUploadVideoMediaFileForm { + /// UUID for request idempotency + #[multipart(limit = "2 KiB")] + #[schema(value_type = String, format = Binary)] + uuid_idempotency_token: Text, + + /// The uploaded file + #[multipart(limit = "512 MiB")] + #[schema(value_type = Vec, format = Binary)] + file: TempFile, + + /// Optional: Title (name) of the scene + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_title: Option>, + + /// Optional: Visibility of the scene + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_visibility: Option>, + + /// Optional: Prompt associated with this image + /// NOTE: Cannot set `is_intermediate_system_file = true` if this is set. + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_prompt_token: Option>, + + /// Optional: If an engine scene was used to generate this video, provide it here to create a link. + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_scene_source_media_file_token: Option>, + + /// Optional: Whether this is a system file (eg. cover files we should hide) + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + is_intermediate_system_file: Option>, + + /// Optional: Trim start offset in milliseconds. + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_trim_start_millis: Option>, + + /// Optional: Trim end offset in milliseconds. + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_trim_end_millis: Option>, + + /// Optional: Resample the video to this FPS. + /// Only certain values equal or under 24fps are allowed. + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_resample_fps: Option>, + + /// Optional: The third-party generation provider (e.g. "fal", "replicate"). + /// If set, `is_user_upload` will be false and `is_intermediate_system_file` will be forced false. + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_generation_provider: Option>, +} + +static ALLOWED_MIME_TYPES : Lazy> = Lazy::new(|| { + HashSet::from([ + // Video + "video/mp4", // NB: Only mp4 for now. + ]) +}); + +static TRANSCODE_MIME_TYPES : Lazy> = Lazy::new(|| { + HashSet::from([ + // Video + "video/quicktime", // .qt quicktime files + ]) +}); + +/// API-key authenticated video upload (Omni API). Identity is read from the `Authorization` header +/// API key rather than a session cookie. +#[utoipa::path( + post, + tag = "Omni API", + path = "/v1/omni_api/upload/video", + responses( + (status = 200, description = "Success Update", body = OmniUploadVideoMediaFileSuccessResponse), + (status = 400, description = "Bad input", body = MediaFileUploadError), + (status = 401, description = "Not authorized", body = MediaFileUploadError), + (status = 429, description = "Too many requests", body = MediaFileUploadError), + (status = 500, description = "Server error", body = MediaFileUploadError), + ), + params( + ( + "request" = OmniUploadVideoMediaFileForm, + description = "IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadVideoMediaFileForm` (Under 'Schema') FOR DETAILS ON FIELDS AND NULLABILITY." + ), + ) +)] +pub async fn omni_upload_video_media_file_handler( + http_request: HttpRequest, + server_state: web::Data>, + MultipartForm(mut form): MultipartForm, +) -> Result, MediaFileUploadError> { + + fast_form_validations(&form)?; + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + error!("MySql pool error: {:?}", err); + MediaFileUploadError::ServerError + })?; + + // ==================== API KEY USER ==================== // + + // API-key authentication (Authorization header) instead of a session cookie. Never cached, and a + // banned owner is rejected inside `require_api_key_user`. + let api_session = require_api_key_user(&http_request, &mut *mysql_connection) + .await + .map_err(map_api_key_auth_error)?; + + let maybe_user_token = Some(&api_session.user_token); + + // ==================== RATE LIMIT ==================== // + + let rate_limiter = &server_state.redis_rate_limiters.file_upload_logged_in; + + if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await { + return Err(MediaFileUploadError::RateLimited); + } + + // ==================== HANDLE IDEMPOTENCY ==================== // + + let uuid_idempotency_token = form.uuid_idempotency_token.as_ref(); + + if let Err(reason) = validate_idempotency_token_format(uuid_idempotency_token) { + return Err(MediaFileUploadError::BadInput(reason)); + } + + insert_idempotency_token(uuid_idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + MediaFileUploadError::BadInput("invalid idempotency token".to_string()) + })?; + + // ==================== UPLOAD METADATA ==================== // + + let maybe_title = form.maybe_title + .map(|title| title.trim().to_string()) + .filter(|title| !title.is_empty()); + + let creator_set_visibility = form.maybe_visibility + .map(|visibility| visibility.0) + .unwrap_or(Visibility::default()); + + // ==================== USER DATA ==================== // + + let ip_address = get_request_ip(&http_request); + + // ==================== FILE VALIDATION ==================== // + + let mut file_bytes = Vec::new(); + form.file.file.read_to_end(&mut file_bytes) + .map_err(|e| { + error!("Problem reading file: {:?}", e); + MediaFileUploadError::ServerError + })?; + + let mut mimetype = get_mimetype_for_bytes(file_bytes.as_ref()) + .map(|mimetype| mimetype.to_string()) + .ok_or_else(|| { + warn!("Could not determine mimetype for file"); + MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string()) + })?; + + let needs_transcode = TRANSCODE_MIME_TYPES.contains(mimetype.as_str()); + + if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) && !needs_transcode { + // NB: Don't let our error message inject malicious strings + let filtered_mimetype = mimetype + .chars() + .filter(|c| c.is_ascii()) + .filter(|c| c.is_alphanumeric() || *c == '/') + .collect::(); + return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype))); + } + + // ==================== ORIGINAL FILE BOOKKEEPING ==================== // + // Hold on to the user's original file (path + mimetype + extension) so we + // can upload it as a `_original.{ext}` sibling later if we end up + // transcoding and/or resampling it. + + let original_file_path: PathBuf = form.file.file.path().to_path_buf(); + let original_mimetype: String = mimetype.clone(); + let original_filename_extension: Option = form.file.file_name.as_deref() + .and_then(|filename| std::path::Path::new(filename).extension()) + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_string()); + let original_extension: String = mimetype_to_extension(&original_mimetype) + .map(|ext| ext.to_string()) + .or(original_filename_extension) + .ok_or_else(|| { + warn!("Could not determine file extension for original mimetype: {}", &original_mimetype); + MediaFileUploadError::ServerError + })?; + + // ==================== OPTIONAL TRANSCODE TO MP4 ==================== // + // Some accepted source mime types (e.g. QuickTime .mov) are not the canonical + // distribution format we want for the media file record. Transcode them to + // mp4 in a tempdir so we can resample / upload mp4 instead. + + let mut transcode_tempdir_ref = None; + let mut resample_tempdir_ref = None; + + let mut final_upload_file_path = form.file.file.path().to_path_buf(); + + if needs_transcode { + let transcode_tempdir = server_state.temp_dir_creator.new_tempdir("ffmpeg_transcode") + .map_err(|err| { + error!("Problem creating transcode temp dir: {:?}", err); + MediaFileUploadError::ServerError + })?; + + let transcoded_path = transcode_tempdir.path().join("transcoded.mp4"); + + info!("Transcoding {} → mp4", &original_mimetype); + ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args { + video_input_path: form.file.file.path(), + video_output_path: &transcoded_path, + }).map_err(|err| { + error!("Problem transcoding video to mp4: {:?}", err); + MediaFileUploadError::ServerError + })?; + + file_bytes = file_read_bytes(&transcoded_path) + .map_err(|e| { + error!("Problem reading transcoded mp4: {:?}", e); + MediaFileUploadError::ServerError + })?; + mimetype = "video/mp4".to_string(); + final_upload_file_path = transcoded_path; + transcode_tempdir_ref = Some(transcode_tempdir); + } + + // ==================== OPTIONAL VIDEO RESAMPLE ==================== // + + let should_resample = form.maybe_resample_fps.is_some() + || form.maybe_trim_start_millis.is_some() + || form.maybe_trim_end_millis.is_some(); + + if should_resample { + let frame_temp_dir = server_state.temp_dir_creator.new_tempdir("ffmpeg") + .map_err(|err| { + error!("Problem creating temp dir: {:?}", err); + MediaFileUploadError::ServerError + })?; + + let video_output_path = frame_temp_dir.path().join("output.mp4"); + + let maybe_new_frame_rate = form.maybe_resample_fps.map(|fps| fps.0); + let maybe_start_offset = form.maybe_trim_start_millis.map(|millis| Duration::from_millis(millis.0)); + let maybe_end_offset = form.maybe_trim_end_millis.map(|millis| Duration::from_millis(millis.0)); + + ffmpeg_trim_and_resample(Args { + video_input_path: &final_upload_file_path, + video_output_path: &video_output_path, + maybe_new_frame_rate, + maybe_start_offset, + maybe_end_offset, + }).map_err(|err| { + error!("Problem resampling video: {:?}", err); + MediaFileUploadError::ServerError + })?; + + file_bytes = file_read_bytes(&video_output_path) + .map_err(|e| { + error!("Problem reading file: {:?}", e); + MediaFileUploadError::ServerError + })?; + + mimetype = get_mimetype_for_bytes(file_bytes.as_ref()) + .map(|mimetype| mimetype.to_string()) + .ok_or_else(|| { + warn!("Could not determine mimetype for file"); + MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string()) + })?; + + final_upload_file_path = video_output_path; + resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope + } + + // True if we touched the user's file in any way (transcoded, resampled, or both). + let should_upload_original = needs_transcode || should_resample; + + // ==================== OTHER FILE METADATA ==================== // + + let mut maybe_duration_millis = None; + + match ffprobe_get_info(&final_upload_file_path) { + Ok(video_info) => { + maybe_duration_millis = video_info.duration + .map(|duration| duration.millis as u64); + } + Err(error) => { + warn!("Error reading video dimensions with ffprobe: {:?}", error); + } + } + + let maybe_filename = form.file.file_name.as_deref() + .as_deref() + .map(|filename| PathBuf::from(filename)); + + let extension = mimetype_to_extension(&mimetype) + .or_else(|| { + maybe_filename + .as_ref() + .and_then(|filename| filename.extension()) + .and_then(|ext| ext.to_str()) + }) + .ok_or_else(|| { + warn!("Could not determine file extension for mimetype: {}", &mimetype); + MediaFileUploadError::ServerError + })?; + + let extension = format!(".{extension}"); // NB: needs dot prefix + + let file_size_bytes = file_bytes.len(); + + let hash = sha256_hash_bytes(&file_bytes) + .map_err(|io_error| { + error!("Problem hashing bytes: {:?}", io_error); + MediaFileUploadError::ServerError + })?; + + // ==================== UPLOAD AND SAVE ==================== // + + const PREFIX : Option<&str> = Some("video_"); + + let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension)); + + info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str()); + + server_state.public_bucket_client.upload_file_with_content_type( + public_upload_path.get_full_object_path_str(), + file_bytes.as_ref(), + &mimetype) + .await + .map_err(|e| { + warn!("Upload media bytes to bucket error: {:?}", e); + MediaFileUploadError::ServerError + })?; + + // ==================== UPLOAD ORIGINAL SIBLING ==================== // + // If we transcoded and/or resampled the user's upload, also upload the + // unmodified original alongside the canonical file as `_original.{ext}`. + + if should_upload_original { + let original_suffix = format!("_original.{}", original_extension); + let original_bucket_path = MediaFileBucketPath::from_object_hash( + public_upload_path.get_object_hash(), + PREFIX, + Some(&original_suffix), + ); + + info!( + "Uploading original sibling to bucket path: {}", + original_bucket_path.get_full_object_path_str(), + ); + + let original_bytes = file_read_bytes(&original_file_path) + .map_err(|e| { + error!("Problem reading original file for sibling upload: {:?}", e); + MediaFileUploadError::ServerError + })?; + + server_state.public_bucket_client.upload_file_with_content_type( + original_bucket_path.get_full_object_path_str(), + original_bytes.as_ref(), + &original_mimetype, + ) + .await + .map_err(|e| { + // NB: Sibling failure is non-fatal — the canonical upload already + // succeeded and the user can still use the media. Log loudly. + warn!("Upload original sibling to bucket error: {:?}", e); + }) + .ok(); + } + + let maybe_scene_source_media_file_token = form.maybe_scene_source_media_file_token + .as_ref() + .map(|token| &token.0); + + let maybe_prompt_token = form.maybe_prompt_token + .as_ref() + .map(|token| token.0.clone()); + + // NB: If we're uploading a video file that references an engine scene, then this is an engine + // render video, and we should mark it as a system (hidden) file. + let is_intermediate_system_file = + maybe_scene_source_media_file_token.is_some() || + form.is_intermediate_system_file.map(|b| b.0).unwrap_or(false); + + let maybe_generation_provider = form.maybe_generation_provider + .as_ref() + .and_then(|text| try_parse_generation_provider(text.as_ref())); + + let upload_type = if maybe_generation_provider.is_some() { + UploadType::ThirdPartyInference + } else { + UploadType::Filesystem + }; + + let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs { + maybe_media_class: Some(MediaFileClass::Video), + media_file_type: MediaFileType::Video, + maybe_creator_user_token: maybe_user_token, + // NB: AVT (anonymous visitor) tokens are a web-session concept; API-key callers have none. + maybe_creator_anonymous_visitor_token: None, + creator_ip_address: &ip_address, + creator_set_visibility, + upload_type, + maybe_engine_category: None, + maybe_animation_type: None, + maybe_prompt_token: maybe_prompt_token.as_ref(), + maybe_batch_token: None, + maybe_mime_type: Some(&mimetype), + file_size_bytes: file_size_bytes as u64, + maybe_duration_millis, + sha256_checksum: &hash, + maybe_title: maybe_title.as_deref(), + maybe_scene_source_media_file_token, + is_intermediate_system_file, // NB: is_user_upload = TRUE + maybe_generation_provider, + public_bucket_directory_hash: public_upload_path.get_object_hash(), + maybe_public_bucket_prefix: PREFIX, + maybe_public_bucket_extension: Some(&extension), + pool: &server_state.mysql_pool, + }) + .await + .map_err(|err| { + warn!("New file creation DB error: {:?}", err); + MediaFileUploadError::ServerError + })?; + + info!("new media file id: {} token: {:?}", record_id, &token); + + let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4) + .with_bucket(server_state.public_bucket_client.bucket_name().as_str()) + .with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf())) + .with_output_suffix("thumb") + .with_event_id(&token.to_string()) + .send_all() + .await; + + match thumbnail_task_result { + Ok(thumbnail_task) => { + debug!("Thumbnail tasks sent: {:?}", thumbnail_task); + }, + Err(e) => { + error!("Failed to create some/all thumbnail tasks: {:?}", e); + } + } + + Ok(Json(OmniUploadVideoMediaFileSuccessResponse { + success: true, + media_file_token: token, + })) +} + +fn fast_form_validations(form: &OmniUploadVideoMediaFileForm) -> Result<(), MediaFileUploadError> { + if let Some(resample_fps) = form.maybe_resample_fps.as_ref() { + if **resample_fps > 24 { + return Err(MediaFileUploadError::BadInput("Resample FPS must be 24 or lower".to_string())); + } else if **resample_fps == 0 { + return Err(MediaFileUploadError::BadInput("Resample FPS must be greater than 0".to_string())); + } + } + + form.maybe_trim_start_millis.as_ref().zip(form.maybe_trim_end_millis.as_ref()) + .map(|(start, end)| { + if **start >= **end { + return Err(MediaFileUploadError::BadInput("Trim start must be less than trim end".to_string())); + } + Ok(()) + }) + .transpose()?; + + let is_intermediate_system_file = form.is_intermediate_system_file + .as_ref() + .map(|b| b.0) + .unwrap_or(false); + + if is_intermediate_system_file && form.maybe_prompt_token.is_some() { + return Err(MediaFileUploadError::BadInput( + "Cannot set `is_intermediate_system_file` to true if `maybe_prompt_token` is provided." + .to_string())); + } + + Ok(()) +} + +/// Map an API-key authentication failure onto this endpoint's error type. A genuine 401 stays a +/// 401; anything else (e.g. a DB error during lookup) becomes a 500. +fn map_api_key_auth_error(err: CommonWebError) -> MediaFileUploadError { + match err { + CommonWebError::NotAuthorized => { + MediaFileUploadError::NotAuthorizedVerbose("invalid or missing API key".to_string()) + } + _ => MediaFileUploadError::ServerError, + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs index c874a4fb71..361c7250c0 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs @@ -32,7 +32,7 @@ use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::ins use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs}; use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::shared_job_args::SharedJobArgs; use crate::http_server::endpoints::omni_gen::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args}; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type; use crate::state::server_state::ServerState; @@ -196,6 +196,7 @@ pub async fn omni_gen_image_generate_handler( }; let prompt_result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: Some(user_token), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/hydrate_router_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/hydrate_router_request.rs index 604697dc0e..0adac62f00 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/hydrate_router_request.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/hydrate_router_request.rs @@ -3,6 +3,7 @@ use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost use artcraft_router::api::audio_list_ref::AudioListRef; use artcraft_router::api::character_list_ref::CharacterListRef; use artcraft_router::api::router_aspect_ratio::RouterAspectRatio; +use artcraft_router::api::router_bitrate::RouterBitrate; use artcraft_router::api::router_resolution::RouterResolution; use artcraft_router::api::router_video_model::RouterVideoModel; use artcraft_router::api::image_list_ref::ImageListRef; @@ -12,6 +13,7 @@ use artcraft_router::api::video_list_ref::VideoListRef; use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; +use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; @@ -36,6 +38,11 @@ pub fn hydrate_to_router_request( .map(convert_resolution) .transpose()?; + let bitrate = request.bitrate + .as_ref() + .map(convert_bitrate) + .transpose()?; + Ok(GenerateVideoRequestBuilder { model, provider: RouterProvider::Artcraft, @@ -55,6 +62,7 @@ pub fn hydrate_to_router_request( .map(CharacterListRef::CharacterTokens), resolution, aspect_ratio, + bitrate, duration_seconds: request.duration_seconds, video_batch_count: request.video_batch_count, generate_audio: request.generate_audio, @@ -95,3 +103,71 @@ fn convert_resolution( ) }) } + +fn convert_bitrate( + bitrate: &CommonBitrateEnum, +) -> Result { + let json = serde_json::to_string(bitrate)?; + serde_json::from_str(&json).map_err(|e| { + CommonWebError::BadInputWithSimpleMessage( + format!("Unsupported bitrate: {}", e), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod bitrate_hydration { + use super::*; + + #[test] + fn high_is_hydrated() { + let request = OmniGenVideoCostAndGenerateRequest { + bitrate: Some(CommonBitrateEnum::High), + ..base_request() + }; + let builder = hydrate_to_router_request(&request).expect("hydrate should succeed"); + assert_eq!(builder.bitrate, Some(RouterBitrate::High)); + } + + #[test] + fn normal_is_hydrated() { + let request = OmniGenVideoCostAndGenerateRequest { + bitrate: Some(CommonBitrateEnum::Normal), + ..base_request() + }; + let builder = hydrate_to_router_request(&request).expect("hydrate should succeed"); + assert_eq!(builder.bitrate, Some(RouterBitrate::Normal)); + } + + #[test] + fn none_stays_none() { + let builder = hydrate_to_router_request(&base_request()).expect("hydrate should succeed"); + assert!(builder.bitrate.is_none()); + } + } + + fn base_request() -> OmniGenVideoCostAndGenerateRequest { + OmniGenVideoCostAndGenerateRequest { + idempotency_token: None, + model: Some(CommonVideoModelEnum::Seedance2p0), + prompt: None, + negative_prompt: None, + start_frame_image_media_token: None, + end_frame_image_media_token: None, + reference_image_media_tokens: None, + reference_video_media_tokens: None, + reference_audio_media_tokens: None, + reference_character_tokens: None, + resolution: None, + aspect_ratio: None, + bitrate: None, + quality: None, + duration_seconds: None, + video_batch_count: None, + generate_audio: None, + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs index 9824680a02..3b0e752fc4 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs @@ -41,7 +41,7 @@ use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::sha use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount; use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args}; use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_video_request::validate_video_request; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type; use crate::state::server_state::ServerState; @@ -274,6 +274,7 @@ pub async fn omni_gen_video_generate_handler( maybe_generation_mode: Some(determine_generation_mode(&request)), maybe_aspect_ratio: request.aspect_ratio, maybe_resolution: request.resolution, + maybe_bitrate: request.bitrate, maybe_batch_count: request.video_batch_count.map(|c| c as u8), maybe_generate_audio: request.generate_audio, maybe_duration_seconds: request.duration_seconds.map(|d| d as u32), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/video/omni_gen_video_models_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/video/omni_gen_video_models_handler.rs index 9769eb23af..d50f5f7c48 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/video/omni_gen_video_models_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/video/omni_gen_video_models_handler.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use crate::configs::omni_gen::video_models::OMNI_GEN_VIDEO_MODELS_AND_PROVIDERS; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::state::server_state::ServerState; use actix_web::web::{Json, Query}; use actix_web::{web, HttpRequest}; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_when_image_required.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_when_image_required.rs index c94faa6e48..3b6a84b709 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_when_image_required.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_when_image_required.rs @@ -56,6 +56,7 @@ mod tests { reference_character_tokens: None, resolution: None, aspect_ratio: None, + bitrate: None, quality: None, duration_seconds: Some(5), video_batch_count: Some(1), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/batch_get_prompts_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/batch_get_prompts_handler.rs index a129f690d4..12f5a903c5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/batch_get_prompts_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/batch_get_prompts_handler.rs @@ -139,6 +139,7 @@ pub async fn batch_get_prompts_handler( maybe_generation_mode: result.maybe_generation_mode, maybe_aspect_ratio: result.maybe_aspect_ratio, maybe_resolution: result.maybe_resolution, + maybe_bitrate: result.maybe_bitrate, maybe_batch_count: result.maybe_batch_count, maybe_generate_audio: result.maybe_generate_audio, maybe_duration_seconds: result.maybe_duration_seconds, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/create_prompt_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/create_prompt_handler.rs index 15b04fff9a..5fca2cd828 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/create_prompt_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/create_prompt_handler.rs @@ -70,6 +70,7 @@ pub async fn create_prompt_handler( .map(|prompt| prompt.trim()); let result = insert_prompt(InsertPromptArgs { + maybe_bitrate: None, maybe_apriori_prompt_token: None, prompt_type: PromptType::ArtcraftApp, maybe_creator_user_token: maybe_user_session diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/get_prompt_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/get_prompt_handler.rs index bbd5ee65ce..16a38b39d9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/get_prompt_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/prompts/get_prompt_handler.rs @@ -159,6 +159,7 @@ pub async fn get_prompt_handler( maybe_generation_mode: result.maybe_generation_mode, maybe_aspect_ratio: result.maybe_aspect_ratio, maybe_resolution: result.maybe_resolution, + maybe_bitrate: result.maybe_bitrate, maybe_batch_count: result.maybe_batch_count, maybe_generate_audio: result.maybe_generate_audio, maybe_duration_seconds: result.maybe_duration_seconds, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/studio_gen2/enqueue_studio_gen2_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/studio_gen2/enqueue_studio_gen2_handler.rs index d36e35375a..5c976c6a51 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/studio_gen2/enqueue_studio_gen2_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/studio_gen2/enqueue_studio_gen2_handler.rs @@ -30,7 +30,7 @@ use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::configs::plans::plan_category::PlanCategory; use crate::http_server::requests::request_headers::get_routing_tag_header::get_routing_tag_header; use crate::http_server::requests::request_headers::has_debug_header::has_debug_header; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::state::server_state::ServerState; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/subscriptions/unsubscribe_reason.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/subscriptions/unsubscribe_reason.rs index 91649d02bd..a93decf00d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/subscriptions/unsubscribe_reason.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/subscriptions/unsubscribe_reason.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse, ResponseError}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::{error, info}; use http_server_common::request::get_request_ip::get_request_ip; @@ -31,8 +32,8 @@ pub struct SetUnsubscribeReasonResponse { pub async fn set_unsubscribe_reason_handler( http_request: HttpRequest, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let mut mysql_connection = server_state.mysql_pool.acquire() .await @@ -73,10 +74,5 @@ pub async fn set_unsubscribe_reason_handler( success: true, }; - let body = serde_json::to_string(&response) - .map_err(|_e| CommonWebError::from_error(_e))?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/set_tags_for_entity_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/set_tags_for_entity_handler.rs index f181e7efc4..4fe1e910d5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/set_tags_for_entity_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/set_tags_for_entity_handler.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use crate::http_server::common_responses::tag_info::TagInfo; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; -use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended; +use crate::http_server::user_lookup::user_session::require_user_session_extended::require_user_session_extended; use crate::state::server_state::ServerState; use actix_web::error::ResponseError; use actix_web::http::StatusCode; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/trending/list_trending_tts_models.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/trending/list_trending_tts_models.rs index c96031fc9f..2f10fdef17 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/trending/list_trending_tts_models.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/trending/list_trending_tts_models.rs @@ -12,8 +12,9 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::error; use enums::by_table::trending_model_analytics::window_name::WindowName; @@ -51,7 +52,7 @@ pub type WindowTrends = HashMap>; pub async fn list_trending_tts_models_handler( _http_request: HttpRequest, - server_state: web::Data>) -> Result + server_state: web::Data>) -> Result, CommonWebError> { // TODO: Cache the outputs! @@ -112,10 +113,5 @@ pub async fn list_trending_tts_models_handler( top_trending_by_language_code, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/delete_tts_model.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/delete_tts_model.rs index 45a594e4b3..11d8cd0b18 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/delete_tts_model.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/delete_tts_model.rs @@ -2,11 +2,13 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::warn; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use http_server_common::request::get_request_ip::get_request_ip; use mysql_queries::queries::tts::tts_models::delete_tts_model_various_scopes::delete_tts_model_as_mod; use mysql_queries::queries::tts::tts_models::delete_tts_model_various_scopes::delete_tts_model_as_user; @@ -16,7 +18,6 @@ use mysql_queries::queries::tts::tts_models::get_tts_model::get_tts_model_by_tok use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::state::server_state::ServerState; use crate::util::delete_role_disambiguation::delete_role_disambiguation; use crate::util::delete_role_disambiguation::DeleteRole; @@ -37,9 +38,9 @@ pub struct DeleteTtsModelRequest { pub async fn delete_tts_model_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { // NB: Disable if we've migrated to model_weights if server_state.flags.switch_tts_to_model_weights { warn!("Migration to model_weights for tts. Cannot delete old model."); @@ -154,5 +155,5 @@ pub async fn delete_tts_model_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/delete_tts_result.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/delete_tts_result.rs index abad0907bf..b78f281059 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/delete_tts_result.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/delete_tts_result.rs @@ -2,11 +2,13 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::warn; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use mysql_queries::queries::tts::tts_results::delete_tts_result_various_scopes::delete_tts_inference_result_as_mod; use mysql_queries::queries::tts::tts_results::delete_tts_result_various_scopes::delete_tts_inference_result_as_user; use mysql_queries::queries::tts::tts_results::delete_tts_result_various_scopes::undelete_tts_inference_result_as_mod; @@ -15,7 +17,6 @@ use mysql_queries::queries::tts::tts_results::query_tts_result::select_tts_resul use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::state::server_state::ServerState; use crate::util::delete_role_disambiguation::delete_role_disambiguation; use crate::util::delete_role_disambiguation::DeleteRole; @@ -36,9 +37,9 @@ pub struct DeleteTtsInferenceResultRequest { pub async fn delete_tts_inference_result_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker .maybe_get_user_session(&http_request, &server_state.mysql_pool) @@ -141,5 +142,5 @@ pub async fn delete_tts_inference_result_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/edit_tts_model.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/edit_tts_model.rs index 3f26e6f4cd..556d8787a2 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/edit_tts_model.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/edit_tts_model.rs @@ -6,15 +6,15 @@ use std::sync::Arc; use actix_web::web::{Json, Path}; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use language_tags::LanguageTag; use log::{error, info, warn}; use sqlx::MySqlPool; use crate::configs::supported_languages_for_models::get_canonicalized_language_tag_for_model; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::state::server_state::ServerState; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use enums::common::visibility::Visibility; use http_server_common::request::get_request_ip::get_request_ip; use markdown::simple_markdown_to_html::simple_markdown_to_html; @@ -92,7 +92,7 @@ pub async fn edit_tts_model_handler( http_request: HttpRequest, path: Path, request: Json, - server_state: web::Data>) -> Result + server_state: web::Data>) -> Result, CommonWebError> { // NB: Disable if we've migrated to model_weights if server_state.flags.switch_tts_to_model_weights { @@ -300,7 +300,7 @@ pub async fn edit_tts_model_handler( let _r = redis_ttl_cache.delete_from_cache(&cache_key).ok(); } - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } async fn update_mod_details( diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/edit_tts_result.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/edit_tts_result.rs index b82629cd56..63c8cd46d7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/edit_tts_result.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/edit_tts_result.rs @@ -6,16 +6,17 @@ use std::sync::Arc; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::{error, warn}; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use enums::common::visibility::Visibility; use http_server_common::request::get_request_ip::get_request_ip; use mysql_queries::queries::tts::tts_results::edit_tts_result::{edit_tts_result, CreatorOrModFields, EditTtsResultArgs}; use mysql_queries::queries::tts::tts_results::query_tts_result::select_tts_result_by_token; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::state::server_state::ServerState; /// For the URL PathInfo @@ -33,8 +34,8 @@ pub struct EditTtsResultRequest { pub async fn edit_tts_inference_result_handler( http_request: HttpRequest, path: Path, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -120,5 +121,5 @@ pub async fn edit_tts_inference_result_handler( CommonWebError::from_anyhow_error(err) })?; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_f5_tts_handler/enqueue_infer_f5_tts_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_f5_tts_handler/enqueue_infer_f5_tts_handler.rs index db07766eed..925aa3002e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_f5_tts_handler/enqueue_infer_f5_tts_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_f5_tts_handler/enqueue_infer_f5_tts_handler.rs @@ -37,7 +37,7 @@ use crate::configs::app_startup::username_set::UsernameSet; use crate::configs::plans::get_correct_plan_for_session::get_correct_plan_for_session; use crate::configs::plans::plan::Plan; use crate::http_server::deprecated_endpoints::investor_demo::demo_cookie::request_has_demo_cookie; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::state::server_state::ServerState; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_tts_handler/enqueue_infer_tts_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_tts_handler/enqueue_infer_tts_handler.rs index ac186f5ef4..17ff4aa849 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_tts_handler/enqueue_infer_tts_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_tts_handler/enqueue_infer_tts_handler.rs @@ -38,7 +38,7 @@ use crate::configs::plans::get_correct_plan_for_session::get_correct_plan_for_se use crate::configs::plans::plan::Plan; use crate::http_server::deprecated_endpoints::investor_demo::demo_cookie::request_has_demo_cookie; use crate::http_server::endpoints::tts::enqueue_infer_tts_handler::get_tts_model_with_caching::get_tts_model_with_caching; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::state::server_state::ServerState; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_upload_tts_model.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_upload_tts_model.rs index 3434a89b6c..5fea2804ba 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_upload_tts_model.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/enqueue_upload_tts_model.rs @@ -5,7 +5,8 @@ use std::sync::Arc; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; +use actix_web::web::Json; use log::warn; use crate::http_server::validations::validate_model_title::validate_model_title; @@ -69,10 +70,10 @@ pub struct UploadTtsModelSuccessResponse { // NB: Not using derive_more::Display since Clion doesn't understand it. pub async fn upload_tts_model_handler( http_request: HttpRequest, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result { - +) -> Result, CommonWebError> { + if server_state.flags.disable_tts { return Err(CommonWebError::TooManyRequests); } @@ -186,18 +187,11 @@ pub async fn upload_tts_model_handler( CommonWebError::from_anyhow_error(e) })?; - let response = UploadTtsModelSuccessResponse { + Ok(Json(UploadTtsModelSuccessResponse { success: true, job_token: job_token.to_string(), job_type: download_job_type, - }; - - let body = serde_json::to_string(&response) - ?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + })) } fn validate_idempotency_token(token: &str) -> Result<(), String> { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_inference_job_status.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_inference_job_status.rs index b6a2260fa3..2010564d67 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_inference_job_status.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_inference_job_status.rs @@ -2,9 +2,10 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpMessage, HttpRequest, HttpResponse}; +use actix_web::{web, HttpMessage, HttpRequest}; use chrono::{DateTime, Utc}; use log::error; use redis::{Commands, RedisResult}; @@ -61,7 +62,7 @@ pub struct GetTtsInferenceStatusSuccessResponse { pub async fn get_tts_inference_job_status_handler( http_request: HttpRequest, path: Path, - server_state: web::Data>) -> Result + server_state: web::Data>) -> Result, CommonWebError> { let job_token = path.into_inner().token; @@ -80,20 +81,10 @@ pub async fn get_tts_inference_job_status_handler( legacy_lookup(&job_token, &server_state).await? }; - let response = GetTtsInferenceStatusSuccessResponse { + Ok(Json(GetTtsInferenceStatusSuccessResponse { success: true, state: record_for_response, - }; - - let body = serde_json::to_string(&response) - .map_err(|e| { - error!("error returning response: {:?}", e); - CommonWebError::from_error(e) - })?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + })) } async fn legacy_lookup(job_token: &str, server_state: &ServerState) diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_model.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_model.rs index 00824be2c0..ba6650738f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_model.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_model.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; @@ -152,7 +153,7 @@ pub struct TtsModelModeratorFieldInfo { pub async fn get_tts_model_handler( http_request: HttpRequest, path: Path, - server_state: web::Data>) -> Result + server_state: web::Data>) -> Result, CommonWebError> { let mut mysql_connection = server_state.mysql_pool.acquire() .await @@ -327,10 +328,5 @@ pub async fn get_tts_model_handler( }, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_model_use_count.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_model_use_count.rs index eefae36524..faf0929b8a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_model_use_count.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_model_use_count.rs @@ -1,9 +1,10 @@ use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpMessage, HttpRequest, HttpResponse}; +use actix_web::{web, HttpMessage, HttpRequest}; use derive_more::Display; use log::warn; use redis::Commands; @@ -28,7 +29,7 @@ pub struct GetTtsModelUseCountSuccessResponse { pub async fn get_tts_model_use_count_handler( http_request: HttpRequest, path: Path, - server_state: web::Data>) -> Result + server_state: web::Data>) -> Result, CommonWebError> { let mut redis = server_state.redis_pool .get() @@ -45,15 +46,8 @@ pub async fn get_tts_model_use_count_handler( CommonWebError::from_error(e) })?; - let response = GetTtsModelUseCountSuccessResponse { + Ok(Json(GetTtsModelUseCountSuccessResponse { success: true, count, - }; - - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_result.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_result.rs index bd7a0283ad..d598327b61 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_result.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_result.rs @@ -2,9 +2,10 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpMessage, HttpRequest, HttpResponse}; +use actix_web::{web, HttpMessage, HttpRequest}; use log::warn; use mysql_queries::queries::tts::tts_results::query_tts_result::select_tts_result_by_token; @@ -29,7 +30,7 @@ pub struct GetTtsResultSuccessResponse { pub async fn get_tts_inference_result_handler( http_request: HttpRequest, path: Path, - server_state: web::Data>) -> Result + server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -80,15 +81,8 @@ pub async fn get_tts_inference_result_handler( inference_result.maybe_moderator_fields = None; } - let response = GetTtsResultSuccessResponse { + Ok(Json(GetTtsResultSuccessResponse { success: true, result: inference_result, - }; - - let body = serde_json::to_string(&response) - .map_err(|e| CommonWebError::from_error(e))?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_upload_model_job_status.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_upload_model_job_status.rs index a2dd7ddf90..5e5cd83829 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_upload_model_job_status.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/get_tts_upload_model_job_status.rs @@ -1,9 +1,10 @@ use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpMessage, HttpRequest, HttpResponse}; +use actix_web::{web, HttpMessage, HttpRequest}; use chrono::{DateTime, Utc}; use derive_more::Display; use log::{error, warn}; @@ -49,7 +50,7 @@ pub struct GetTtsUploadModelStatusSuccessResponse { pub async fn get_tts_upload_model_job_status_handler( http_request: HttpRequest, path: Path, - server_state: web::Data>) -> Result + server_state: web::Data>) -> Result, CommonWebError> { // NB: Since this is publicly exposed, we don't query sensitive data. let record = get_tts_model_upload_job_status(&path.token, &server_state.mysql_pool) @@ -92,15 +93,8 @@ pub async fn get_tts_upload_model_job_status_handler( updated_at: record.updated_at, }; - let response = GetTtsUploadModelStatusSuccessResponse { + Ok(Json(GetTtsUploadModelStatusSuccessResponse { success: true, state: model_for_response, - }; - - let body = serde_json::to_string(&response) - .map_err(|e| CommonWebError::from_error(e))?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/search_tts_models_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/search_tts_models_handler.rs index 93878e9a6e..aaf853a07f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tts/search_tts_models_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tts/search_tts_models_handler.rs @@ -6,7 +6,8 @@ use std::collections::HashSet; use std::sync::Arc; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; +use actix_web::web::Json; use chrono::{DateTime, Utc}; use log::error; @@ -66,8 +67,8 @@ pub struct SearchTtsModelsSuccessResponse { // NB: Not using derive_more::Display since Clion doesn't understand it. pub async fn search_tts_models_handler( _http_request: HttpRequest, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let results = search_tts_models( &server_state.elasticsearch, @@ -109,16 +110,9 @@ pub async fn search_tts_models_handler( new_results.push(result); } - let response = SearchTtsModelsSuccessResponse { + Ok(Json(SearchTtsModelsSuccessResponse { success: true, models: new_results, - }; - - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/create_user_bookmark_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/create_user_bookmark_handler.rs index a697a8ca44..c672ae0df3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/create_user_bookmark_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/create_user_bookmark_handler.rs @@ -7,6 +7,7 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; use log::{error, warn}; @@ -95,9 +96,9 @@ impl fmt::Display for CreateUserBookmarkError { )] pub async fn create_user_bookmark_handler( http_request: HttpRequest, - request: web::Json, + request: Json, server_state: web::Data>, -) -> Result +) -> Result, CreateUserBookmarkError> { // NB(bt,2023-12-14): Kasisnu found that we're getting entity type mismatches in production. Apart from // querying the database for entity existence, this is the next best way to prevent incorrect comment @@ -226,10 +227,5 @@ pub async fn create_user_bookmark_handler( new_bookmark_count_for_entity: count.total_count, }; - let body = serde_json::to_string(&response) - .map_err(|_e| CreateUserBookmarkError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/delete_user_bookmark_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/delete_user_bookmark_handler.rs index b86ba95adc..b2452ab74c 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/delete_user_bookmark_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/delete_user_bookmark_handler.rs @@ -7,6 +7,7 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; use actix_web::{web, HttpRequest, HttpResponse}; @@ -22,7 +23,6 @@ use tokens::tokens::user_bookmarks::UserBookmarkToken; use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::state::server_state::ServerState; /// For the URL PathInfo @@ -92,9 +92,9 @@ impl fmt::Display for DeleteUserBookmarkError { pub async fn delete_user_bookmark_handler( http_request: HttpRequest, path: Path, - _request: web::Json, + _request: Json, server_state: web::Data> -) -> Result { +) -> Result, DeleteUserBookmarkError> { let mut mysql_connection = server_state.mysql_pool .acquire() .await @@ -181,5 +181,5 @@ pub async fn delete_user_bookmark_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/list_user_bookmarks_for_entity_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/list_user_bookmarks_for_entity_handler.rs index d99c9ac5fe..bc1cd339b1 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/list_user_bookmarks_for_entity_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/list_user_bookmarks_for_entity_handler.rs @@ -7,6 +7,7 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; use actix_web::{web, HttpRequest, HttpResponse}; @@ -92,7 +93,7 @@ pub async fn list_user_bookmarks_for_entity_handler( _http_request: HttpRequest, path: Path, server_state: web::Data> -) -> Result +) -> Result, ListUserBookmarksForEntityError> { let entity_token = UserBookmarkEntityToken::from_entity_type_and_token( path.entity_type, &path.entity_token); @@ -128,10 +129,5 @@ pub async fn list_user_bookmarks_for_entity_handler( .collect(), }; - let body = serde_json::to_string(&response) - .map_err(|_e| ListUserBookmarksForEntityError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/list_user_bookmarks_for_session_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/list_user_bookmarks_for_session_handler.rs index b427cdbcee..6fb456b4a5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/list_user_bookmarks_for_session_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/list_user_bookmarks_for_session_handler.rs @@ -2,6 +2,7 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Query; use actix_web::{web, HttpRequest, HttpResponse}; @@ -84,7 +85,7 @@ pub async fn list_user_bookmarks_for_session_handler( http_request: HttpRequest, query: Query, server_state: web::Data> -) -> Result +) -> Result, ListUserBookmarksError> { let user_session = server_state.session_checker .maybe_get_user_session(&http_request, &server_state.mysql_pool) @@ -108,10 +109,5 @@ pub async fn list_user_bookmarks_for_session_handler( user_bookmarks: Vec::new() }; - let body = serde_json::to_string(&response) - .map_err(|_e| ListUserBookmarksError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_ratings/get_user_rating_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_ratings/get_user_rating_handler.rs index 39d5c5852b..99c7a05aa2 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/user_ratings/get_user_rating_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_ratings/get_user_rating_handler.rs @@ -1,13 +1,12 @@ use std::sync::Arc; -use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse, ResponseError}; +use actix_web::{web, HttpRequest}; +use actix_web::web::Json; use log::{error, info}; use utoipa::ToSchema; use enums::by_table::user_ratings::entity_type::UserRatingEntityType; use enums::by_table::user_ratings::rating_value::UserRatingValue; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use mysql_queries::composite_keys::by_table::user_ratings::user_rating_entity::UserRatingEntity; use mysql_queries::queries::users::user_ratings::get_user_rating::{get_user_rating, Args}; use tokens::tokens::media_files::MediaFileToken; @@ -64,7 +63,7 @@ pub struct GetUserRatingResponse { pub async fn get_user_rating_handler( http_request: HttpRequest, path: web::Path, - server_state: web::Data>) -> Result + server_state: web::Data>) -> Result, CommonWebError> { let mut mysql_connection = server_state.mysql_pool.acquire() .await @@ -121,10 +120,5 @@ pub async fn get_user_rating_handler( maybe_rating_value: maybe_rating.map(|rating| rating.rating_value), }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_ratings/set_user_rating_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_ratings/set_user_rating_handler.rs index 5d3fc88b9b..22eb512319 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/user_ratings/set_user_rating_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_ratings/set_user_rating_handler.rs @@ -1,7 +1,7 @@ use std::sync::Arc; -use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse, ResponseError}; +use actix_web::{web, HttpRequest}; +use actix_web::web::Json; use log::{error, info, warn}; use sqlx::Acquire; use utoipa::ToSchema; @@ -9,7 +9,6 @@ use utoipa::ToSchema; use enums::by_table::user_ratings::entity_type::UserRatingEntityType; use enums::by_table::user_ratings::rating_value::UserRatingValue; use http_server_common::request::get_request_ip::get_request_ip; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use mysql_queries::composite_keys::by_table::user_ratings::user_rating_entity::UserRatingEntity; use mysql_queries::queries::entity_stats::stats_entity_token::StatsEntityToken; use mysql_queries::queries::entity_stats::upsert_entity_stats_on_ratings_event::{upsert_entity_stats_on_ratings_event, RatingsAction, UpsertEntityStatsArgs}; @@ -69,8 +68,8 @@ pub struct SetUserRatingResponse { )] pub async fn set_user_rating_handler( http_request: HttpRequest, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { // NB(bt,2023-12-14): Kasisnu found that we're getting entity type mismatches in production. Apart from // querying the database for entity existence, this is the next best way to prevent incorrect comment @@ -239,10 +238,5 @@ pub async fn set_user_rating_handler( new_positive_rating_count_for_entity: count.positive_count, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/create_referral_code_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/create_referral_code_handler.rs index 1ec2a043a2..14d464da1d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/create_referral_code_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/create_referral_code_handler.rs @@ -7,7 +7,7 @@ use utoipa::ToSchema; use artcraft_api_defs::user_referral_codes::create_referral_code::{CreateReferralCodeRequest, CreateReferralCodeResponse}; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::state::server_state::ServerState; use mysql_queries::errors::database_insert_error::DatabaseInsertError; use mysql_queries::queries::user_referral_codes::create_referral_code::{create_referral_code, CreateReferralCodeArgs}; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/change_password_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/change_password_handler.rs index 7f503c932b..7c5b42ec58 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/change_password_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/change_password_handler.rs @@ -11,7 +11,7 @@ use log::warn; use artcraft_api_defs::users::change_password::{ChangePasswordRequest, ChangePasswordResponse}; use crate::http_server::validations::validate_passwords::validate_passwords; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended; +use crate::http_server::user_lookup::user_session::require_user_session_extended::require_user_session_extended; use crate::state::server_state::ServerState; use http_server_common::request::get_request_ip::get_request_ip; use mysql_queries::queries::users::user::update::update_password::{update_password, UpdatePasswordArgs}; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_email_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_email_handler.rs index 09886986ab..2b3d84f018 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_email_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_email_handler.rs @@ -10,7 +10,7 @@ use actix_web::{web, HttpRequest}; use log::warn; use artcraft_api_defs::users::edit_email::{EditEmailRequest, EditEmailResponse}; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended; +use crate::http_server::user_lookup::user_session::require_user_session_extended::require_user_session_extended; use crate::state::server_state::ServerState; use http_server_common::request::get_request_ip::get_request_ip; use mysql_queries::queries::users::user::update::update_email::{update_email, UpdateEmailArgs, UpdateEmailError}; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_profile_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_profile_handler.rs index 58cbd7447f..4cb136a50d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_profile_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_profile_handler.rs @@ -5,7 +5,8 @@ use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::warn; use sqlx::MySqlPool; @@ -20,7 +21,7 @@ use redis_caching::redis_ttl_cache::RedisTtlCache; use redis_common::redis_cache_keys::RedisCacheKeys; use user_input_common::check_for_slurs::contains_slurs; -use crate::http_server::session::session_checker::SessionChecker; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::validations::validate_profile_cashapp_username::{normalize_cashapp_username_for_storage, validate_profile_cashapp_username}; use crate::http_server::validations::validate_profile_discord_username::validate_profile_discord_username; @@ -61,11 +62,11 @@ pub struct EditProfileSuccessResponse { pub async fn edit_profile_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, mysql_pool: web::Data, redis_ttl_cache: web::Data, session_checker: web::Data, -) -> Result +) -> Result, CommonWebError> { let maybe_user_session = session_checker .maybe_get_user_session(&http_request, &mysql_pool) @@ -285,10 +286,5 @@ pub async fn edit_profile_handler( success: true, }; - let body = serde_json::to_string(&response) - .map_err(|_e| CommonWebError::BadInputWithSimpleMessage("".to_string()))?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_username_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_username_handler.rs index 23cc23b90e..1b8980b3a5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_username_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_username_handler.rs @@ -12,7 +12,7 @@ use artcraft_api_defs::users::edit_username::{EditUsernameRequest, EditUsernameR use crate::http_server::validations::is_reserved_username::is_reserved_username; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::validations::validate_username::validate_username; -use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended; +use crate::http_server::user_lookup::user_session::require_user_session_extended::require_user_session_extended; use crate::state::server_state::ServerState; use http_server_common::request::get_request_ip::get_request_ip; use mysql_queries::queries::users::user::update::update_username::{update_username, UpdateUsernameArgs, UpdateUsernameError}; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/get_profile_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/get_profile_handler.rs index 0e97ffedd8..b9b263b046 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/get_profile_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/get_profile_handler.rs @@ -27,8 +27,8 @@ use crate::http_server::common_responses::user_avatars::default_avatar_color_fro use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::common_responses::user_avatars::default_avatar_from_username::default_avatar_from_username; use crate::http_server::common_responses::user_details_lite::UserDetailsLight; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; -use crate::http_server::session::session_checker::SessionChecker; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; // TODO: This is duplicated in query_user_profile diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs index bce507df70..e099b62b55 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs @@ -1,6 +1,6 @@ use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoints::users::google_sso::handle_new_sso_account::NewSsoAccountInfo; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; use actix_artcraft::requests::get_request_signup_source_enum::get_request_signup_source_enum; use actix_web::HttpRequest; use enums::by_table::users::user_feature_flag::UserFeatureFlag; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs index 64fb92182b..07138b04eb 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs @@ -33,7 +33,7 @@ use utoipa::ToSchema; use artcraft_api_defs::users::login::{LoginErrorType, LoginRequest, LoginSuccessResponse}; use password::errors::password_confirm_error::PasswordConfirmError; use tokens::tokens::users::UserToken; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::util::enroll_in_studio::enroll_in_studio; #[derive(Serialize, Debug, ToSchema)] diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_info_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_info_handler.rs index c3ca810b8a..e34ac72f37 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_info_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_info_handler.rs @@ -13,8 +13,8 @@ use enums::by_table::users::user_feature_flag::UserFeatureFlag; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::common_responses::user_details_lite_builder::UserDetailsLightBuilder; use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; -use crate::http_server::session::session_checker::SessionChecker; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; #[utoipa::path( get, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_token_info_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_token_info_handler.rs index 3b40181675..2925297e7c 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_token_info_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_token_info_handler.rs @@ -10,7 +10,7 @@ use utoipa::ToSchema; use http_server_common::response::response_error_helpers::to_simple_json_error; -use crate::http_server::session::session_checker::SessionChecker; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::state::server_state::ServerState; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/mod.rs new file mode 100644 index 0000000000..6d2a09d51f --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/mod.rs @@ -0,0 +1,4 @@ +pub mod provenance; +pub mod video_info_notes_handler; +pub mod video_info_read_info_handler; +pub mod video_info_upload_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/provenance.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/provenance.rs new file mode 100644 index 0000000000..3122cb4769 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/provenance.rs @@ -0,0 +1,241 @@ +//! Shared helpers for the video-info endpoints: mapping the internal +//! `video_info` types into the public API types, and deriving the database +//! `detected_model_type` / `detected_model_family` enums from a parse result. + +use artcraft_api_defs::video_info::read_only::{ + DreaminaVideoInfo, KlingVideoInfo, SeedanceVideoInfo, SoraVideoInfo, VeoVideoInfo, + VideoProvenanceKind, +}; +use enums::by_table::uploaded_videos::uploaded_video_detected_model_family::UploadedVideoDetectedModelFamily; +use enums::by_table::uploaded_videos::uploaded_video_detected_model_type::UploadedVideoDetectedModelType; +use video_info::dreamina_info::DreaminaInfo; +use video_info::error::VideoInfoError; +use video_info::kling_info::KlingInfo; +use video_info::seedance_info::{SeedanceInfo, SeedancePlatform}; +use video_info::sora_info::SoraInfo; +use video_info::veo_info::VeoInfo; +use video_info::VideoInfo; + +/// The public-facing provenance for a parsed video — the shared body of both the +/// read-only and upload responses. +pub struct Provenance { + pub kind: VideoProvenanceKind, + pub maybe_encoder: Option, + pub maybe_seedance: Option, + pub maybe_veo: Option, + pub maybe_sora: Option, + pub maybe_dreamina: Option, + pub maybe_kling: Option, +} + +/// Map a `video_info` parse result into the public [`Provenance`]. Any parse +/// error (unrecognized or malformed) collapses to `Unrecognized` — internal +/// error types are never surfaced. +pub fn to_provenance( + result: &Result, + maybe_encoder: Option, +) -> Provenance { + let mut provenance = Provenance { + kind: VideoProvenanceKind::Unrecognized, + maybe_encoder, + maybe_seedance: None, + maybe_veo: None, + maybe_sora: None, + maybe_dreamina: None, + maybe_kling: None, + }; + + match result { + Ok(VideoInfo::Seedance(info)) => { + provenance.kind = VideoProvenanceKind::Seedance; + provenance.maybe_seedance = Some(to_seedance(info)); + } + Ok(VideoInfo::Veo(info)) => { + provenance.kind = VideoProvenanceKind::Veo; + provenance.maybe_veo = Some(to_veo(info)); + } + Ok(VideoInfo::Sora(info)) => { + provenance.kind = VideoProvenanceKind::Sora; + provenance.maybe_sora = Some(to_sora(info)); + } + Ok(VideoInfo::Dreamina(info)) => { + provenance.kind = VideoProvenanceKind::Dreamina; + provenance.maybe_dreamina = Some(to_dreamina(info)); + } + Ok(VideoInfo::Kling(info)) => { + provenance.kind = VideoProvenanceKind::Kling; + provenance.maybe_kling = Some(to_kling(info)); + } + Err(_) => {} + } + + provenance +} + +// ── Model detection (for the `uploaded_videos` columns) ── + +/// Best-effort specific model type. Only Seedance carries enough metadata to pin +/// an exact variant; other families return `None` (we can't honestly claim a +/// version we didn't observe). +pub fn detect_model_type( + result: &Result, +) -> Option { + match result.as_ref().ok()? { + VideoInfo::Seedance(info) => seedance_model_type(info), + _ => None, + } +} + +/// Map a known model type to its family. The requested +/// "family based on the model type" helper. +pub fn model_family_for_type( + model_type: UploadedVideoDetectedModelType, +) -> UploadedVideoDetectedModelFamily { + use UploadedVideoDetectedModelFamily as Family; + use UploadedVideoDetectedModelType as Type; + match model_type { + Type::GrokVideo | Type::GrokImagineVideo | Type::GrokImagineVideo1p5 => Family::Grok, + Type::Kling16Pro + | Type::Kling21Pro + | Type::Kling21Master + | Type::Kling2p5TurboPro + | Type::Kling2p6Pro + | Type::Kling3p0Standard + | Type::Kling3p0Pro => Family::Kling, + Type::HappyHorse1p0 => Family::HappyHorse, + Type::Seedance10Lite + | Type::Seedance1p5Pro + | Type::Seedance2p0 + | Type::Seedance2p0Fast + | Type::Seedance2p0BytePlus + | Type::Seedance2p0BytePlusFast + | Type::Seedance2p0Ultra + | Type::Seedance2p0UltraFast + | Type::Seedance2p0BytePlusUltra + | Type::Seedance2p0BytePlusUltraFast => Family::Seedance, + Type::Sora2 | Type::Sora2Pro => Family::Sora, + Type::Veo2 | Type::Veo3 | Type::Veo3Fast | Type::Veo3p1 | Type::Veo3p1Fast => Family::Veo, + } +} + +/// Family derived directly from the parsed variant. Used as a fallback when an +/// exact model type couldn't be pinned (e.g. Veo/Sora/Kling, whose version isn't +/// in the metadata). Dreamina is ByteDance's Seedance consumer app, so it maps +/// to the Seedance family. +pub fn detect_model_family( + result: &Result, +) -> Option { + use UploadedVideoDetectedModelFamily as Family; + Some(match result.as_ref().ok()? { + VideoInfo::Seedance(_) | VideoInfo::Dreamina(_) => Family::Seedance, + VideoInfo::Veo(_) => Family::Veo, + VideoInfo::Sora(_) => Family::Sora, + VideoInfo::Kling(_) => Family::Kling, + }) +} + +fn seedance_model_type(info: &SeedanceInfo) -> Option { + use UploadedVideoDetectedModelType as Type; + let is_byteplus = matches!(info.platform, SeedancePlatform::BytePlus); + match (info.model_version.as_deref(), info.is_fast, info.is_lite, is_byteplus) { + (Some("1.0"), _, true, _) => Some(Type::Seedance10Lite), + (Some("1.5"), _, _, _) => Some(Type::Seedance1p5Pro), + (Some("2.0"), false, _, false) => Some(Type::Seedance2p0), + (Some("2.0"), true, _, false) => Some(Type::Seedance2p0Fast), + (Some("2.0"), false, _, true) => Some(Type::Seedance2p0BytePlus), + (Some("2.0"), true, _, true) => Some(Type::Seedance2p0BytePlusFast), + _ => None, + } +} + +// ── Internal `video_info` → public API type converters ── + +fn to_seedance(info: &SeedanceInfo) -> SeedanceVideoInfo { + SeedanceVideoInfo { + platform: info.platform.as_str().to_string(), + software_agent: info.software_agent.clone(), + maybe_software_agent_version: info.software_agent_version.clone(), + model_name: info.model_name.clone(), + maybe_model_brand: info.model_brand.clone(), + maybe_model_version: info.model_version.clone(), + is_fast: info.is_fast, + is_lite: info.is_lite, + generated_at: info.generated_at.clone(), + maybe_generated_at_utc: info.generated_at_utc.map(|dt| dt.to_rfc3339()), + maybe_log_id: info.log_id.clone(), + maybe_log_id_decoded_hex: info.log_id_decoded_hex.clone(), + maybe_digital_source_type: info.digital_source_type.clone(), + maybe_claim_generator: info.claim_generator.clone(), + maybe_claim_generator_version: info.claim_generator_version.clone(), + maybe_manifest_id: info.manifest_id.clone(), + maybe_instance_id: info.instance_id.clone(), + maybe_signer_email: info.signer_email.clone(), + maybe_signer_org_id: info.signer_org_id.clone(), + maybe_signer_country: info.signer_country.clone(), + maybe_cert_serial: info.cert_serial.clone(), + } +} + +fn to_veo(info: &VeoInfo) -> VeoVideoInfo { + VeoVideoInfo { + producer: info.producer.clone(), + has_c2pa_manifest: info.has_c2pa_manifest, + maybe_encoder: info.encoder.clone(), + maybe_created_description: info.created_description.clone(), + has_synthid_watermark: info.has_synthid_watermark, + maybe_synthid_description: info.synthid_description.clone(), + maybe_digital_source_type: info.digital_source_type.clone(), + maybe_claim_generator: info.claim_generator.clone(), + maybe_claim_generator_version: info.claim_generator_version.clone(), + maybe_manifest_id: info.manifest_id.clone(), + maybe_instance_id: info.instance_id.clone(), + maybe_cert_serial: info.cert_serial.clone(), + maybe_signer_ca: info.signer_ca.clone(), + is_timestamped: info.is_timestamped, + maybe_timestamp_authority: info.timestamp_authority.clone(), + } +} + +fn to_sora(info: &SoraInfo) -> SoraVideoInfo { + SoraVideoInfo { + producer: info.producer.clone(), + maybe_model_name: info.model_name.clone(), + maybe_created_description: info.created_description.clone(), + maybe_digital_source_type: info.digital_source_type.clone(), + maybe_claim_generator: info.claim_generator.clone(), + maybe_manifest_id: info.manifest_id.clone(), + maybe_instance_id: info.instance_id.clone(), + maybe_cert_serial: info.cert_serial.clone(), + } +} + +fn to_dreamina(info: &DreaminaInfo) -> DreaminaVideoInfo { + DreaminaVideoInfo { + product: info.product.clone(), + maybe_export_type: info.export_type.clone(), + maybe_os: info.os.clone(), + maybe_source_info: info.source_info.clone(), + maybe_aigc_label_type: info.aigc_label_type, + maybe_video_id: info.video_id.clone(), + has_c2pa: info.has_c2pa, + maybe_signer_org_id: info.signer_org_id.clone(), + maybe_signer_country: info.signer_country.clone(), + maybe_cert_serial: info.cert_serial.clone(), + } +} + +fn to_kling(info: &KlingInfo) -> KlingVideoInfo { + KlingVideoInfo { + maybe_model_version: info.model_version.clone(), + is_ai_generated: info.is_ai_generated, + maybe_label: info.label.clone(), + maybe_content_producer: info.content_producer.clone(), + maybe_produce_id: info.produce_id.clone(), + maybe_content_propagator: info.content_propagator.clone(), + maybe_propagate_id: info.propagate_id.clone(), + maybe_reserved_code_1: info.reserved_code_1.clone(), + maybe_reserved_code_2: info.reserved_code_2.clone(), + has_stream_watermark: info.has_stream_watermark, + maybe_watermark_uuid: info.watermark_uuid.clone(), + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/video_info_notes_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/video_info_notes_handler.rs new file mode 100644 index 0000000000..528d698151 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/video_info_notes_handler.rs @@ -0,0 +1,119 @@ +//! `POST /v1/video_info/notes` — attach (or update) a user-submitted note about +//! an uploaded video. +//! +//! Always tied to an `UploadedVideoToken`. If the request carries an +//! `UploadVideoNoteToken`, the existing note is updated; otherwise a new note is +//! created. The IP address is stamped on the create or update column accordingly. + +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; + +use artcraft_api_defs::video_info::notes::{VideoInfoNoteRequest, VideoInfoNoteResponse}; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::uploaded_video_notes::insert_uploaded_video_note::{ + insert_uploaded_video_note, InsertUploadedVideoNoteArgs, +}; +use mysql_queries::queries::uploaded_video_notes::update_uploaded_video_note::{ + update_uploaded_video_note, UpdateUploadedVideoNoteArgs, +}; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::redis_rate_limiter::RateLimiterError; +use crate::state::server_state::ServerState; + +/// Create or update a note about an uploaded video. +#[utoipa::path( + post, + tag = "Video Info", + path = "/v1/video_info/notes", + request_body = VideoInfoNoteRequest, + responses( + (status = 200, description = "Note created or updated", body = VideoInfoNoteResponse), + (status = 400, description = "Bad input", body = CommonWebError), + (status = 500, description = "Server error", body = CommonWebError), + ) +)] +pub async fn video_info_notes_handler( + http_request: HttpRequest, + server_state: web::Data>, + Json(request): Json, +) -> Result, CommonWebError> { + // IP-based rate limit (shared with the other video-info endpoints). Fails open. + match server_state.redis_rate_limiters.video_info_read_only.rate_limit_request(&http_request).await { + Ok(()) => {} + Err(RateLimiterError::RateLimitExceededError) => return Err(CommonWebError::TooManyRequests), + Err(RateLimiterError::ClientError) => {} // fail open + } + + let ip_address = get_request_ip(&http_request); + + // Free-form text fields are trimmed; empty becomes absent. + let maybe_filename = request.maybe_filename.as_deref(); + let maybe_reported_model_name = normalize_optional(&request.maybe_reported_model_name); + let maybe_other_website = normalize_optional(&request.maybe_other_website); + let maybe_comments = normalize_optional(&request.maybe_comments); + let maybe_email_address = normalize_optional(&request.maybe_email_address); + let can_share_report = request.can_share_report.unwrap_or(false); + let was_scammed = request.was_scammed.unwrap_or(false); + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + let uploaded_video_note_token = match &request.maybe_uploaded_video_note_token { + // Update an existing note. + Some(note_token) => { + update_uploaded_video_note(UpdateUploadedVideoNoteArgs { + token: note_token, + maybe_filename, + maybe_reported_model_type: request.maybe_reported_model_type, + maybe_reported_model_name: maybe_reported_model_name.as_deref(), + maybe_website: request.maybe_website, + maybe_other_website: maybe_other_website.as_deref(), + maybe_comments: maybe_comments.as_deref(), + maybe_email_address: maybe_email_address.as_deref(), + can_share_report, + was_scammed, + maybe_comment_update_ip_address: Some(ip_address.as_str()), + mysql_executor: &mut *mysql_connection, + phantom: PhantomData, + }) + .await?; + note_token.clone() + } + // Create a new note. + None => { + insert_uploaded_video_note(InsertUploadedVideoNoteArgs { + uploaded_video_token: &request.uploaded_video_token, + maybe_filename, + maybe_reported_model_type: request.maybe_reported_model_type, + maybe_reported_model_name: maybe_reported_model_name.as_deref(), + maybe_website: request.maybe_website, + maybe_other_website: maybe_other_website.as_deref(), + maybe_comments: maybe_comments.as_deref(), + maybe_email_address: maybe_email_address.as_deref(), + can_share_report, + was_scammed, + comment_create_ip_address: &ip_address, + mysql_executor: &mut *mysql_connection, + phantom: PhantomData, + }) + .await? + } + }; + + Ok(Json(VideoInfoNoteResponse { + success: true, + uploaded_video_note_token, + })) +} + +/// Trim a free-form text field; a missing or whitespace-only value becomes `None`. +fn normalize_optional(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|trimmed| !trimmed.is_empty()) + .map(str::to_string) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/video_info_read_info_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/video_info_read_info_handler.rs new file mode 100644 index 0000000000..3341340dc6 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/video_info_read_info_handler.rs @@ -0,0 +1,86 @@ +//! `POST /v1/video_info/read_only` — inspect an uploaded video's embedded +//! AI-generation provenance and return what was detected. +//! +//! Stateless and read-only: the bytes are parsed in memory by the `video_info` +//! crate and nothing is persisted. The internal `video_info` types are mapped +//! into the public [`artcraft_api_defs`] wire types (see [`super::provenance`]) +//! so the parsing library can evolve independently of the HTTP contract. + +use std::io::Read; +use std::sync::Arc; + +use actix_multipart::form::tempfile::TempFile; +use actix_multipart::form::MultipartForm; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::error; +use utoipa::ToSchema; + +use artcraft_api_defs::video_info::read_only::VideoInfoReadOnlyResponse; +use video_info::VideoInfo; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::video_info::provenance::to_provenance; +use crate::http_server::web_utils::redis_rate_limiter::RateLimiterError; +use crate::state::server_state::ServerState; + +/// Form-multipart request: a single video file under the `file` field. +#[derive(MultipartForm, ToSchema)] +#[multipart(duplicate_field = "deny")] +pub struct VideoInfoReadOnlyForm { + /// The video file to inspect. + #[multipart(limit = "256 MiB")] + #[schema(value_type = Vec, format = Binary)] + file: TempFile, +} + +/// Detect the AI-generation provenance of an uploaded video. +#[utoipa::path( + post, + tag = "Video Info", + path = "/v1/video_info/read_only", + responses( + (status = 200, description = "Detected provenance (or `unrecognized`)", body = VideoInfoReadOnlyResponse), + (status = 400, description = "Bad input", body = CommonWebError), + (status = 500, description = "Server error", body = CommonWebError), + ), + params( + ("request" = VideoInfoReadOnlyForm, description = "Multipart form with a single `file` video upload."), + ) +)] +pub async fn video_info_read_info_handler( + http_request: HttpRequest, + server_state: web::Data>, + MultipartForm(mut form): MultipartForm, +) -> Result, CommonWebError> { + // IP-based rate limit (1 req / 5s by default, configurable). Fails open. + match server_state.redis_rate_limiters.video_info_read_only.rate_limit_request(&http_request).await { + Ok(()) => {} + Err(RateLimiterError::RateLimitExceededError) => return Err(CommonWebError::TooManyRequests), + Err(RateLimiterError::ClientError) => {} // fail open + } + + let mut bytes = Vec::new(); + form.file.file.read_to_end(&mut bytes).map_err(|err| { + error!("video_info: problem reading uploaded file: {:?}", err); + CommonWebError::server_error_with_message("could not read uploaded file") + })?; + + if bytes.is_empty() { + return Err(CommonWebError::BadInputWithSimpleMessage("empty file".to_string())); + } + + let maybe_encoder = video_info::encoder_tag(&bytes); + let provenance = to_provenance(&VideoInfo::from_bytes(&bytes), maybe_encoder); + + Ok(Json(VideoInfoReadOnlyResponse { + success: true, + kind: provenance.kind, + maybe_encoder: provenance.maybe_encoder, + maybe_seedance: provenance.maybe_seedance, + maybe_veo: provenance.maybe_veo, + maybe_sora: provenance.maybe_sora, + maybe_dreamina: provenance.maybe_dreamina, + maybe_kling: provenance.maybe_kling, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/video_info_upload_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/video_info_upload_handler.rs new file mode 100644 index 0000000000..00c4ecb9ad --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/video_info/video_info_upload_handler.rs @@ -0,0 +1,373 @@ +//! `POST /v1/video_info/upload` — like `read_only`, but persists the result. +//! +//! Algorithm: read bytes → SHA-1 (Crockford) → run `video_info` provenance +//! detection → probe MP4 dimensions via ffprobe → upsert an `uploaded_videos` +//! row keyed by the SHA-1 checksum → return the same provenance the read-only +//! endpoint returns, plus the persisted `UploadedVideoToken`. + +use std::io::{Read, Write}; +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_multipart::form::tempfile::TempFile; +use actix_multipart::form::text::Text; +use actix_multipart::form::MultipartForm; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::{error, warn}; +use utoipa::ToSchema; + +use artcraft_api_defs::video_info::upload::VideoInfoUploadResponse; +use bucket_client::UploadFileBytesArgs; +use ffmpeg_utils::ffprobe::ffprobe_get_dimensions::ffprobe_get_dimensions; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::uploaded_videos::get_uploaded_video_by_sha1_checksum::{ + get_uploaded_video_by_sha1_checksum, GetUploadedVideoBySha1ChecksumArgs, +}; +use mysql_queries::queries::uploaded_videos::insert_uploaded_video::{ + insert_uploaded_video, InsertUploadedVideoArgs, +}; +use mysql_queries::queries::uploaded_videos::update_uploaded_video::{ + update_uploaded_video, UpdateUploadedVideoArgs, +}; +use sha1_hash::sha1_hash_bytes_as_crockford; +use video_info::VideoInfo; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::video_info::provenance::{ + detect_model_family, detect_model_type, model_family_for_type, to_provenance, +}; +use crate::http_server::web_utils::redis_rate_limiter::RateLimiterError; +use crate::state::server_state::ServerState; + +/// Form-multipart request: a single video file under the `file` field. +#[derive(MultipartForm, ToSchema)] +#[multipart(duplicate_field = "deny")] +pub struct VideoInfoUploadForm { + /// The video file to inspect and store. + #[multipart(limit = "256 MiB")] + #[schema(value_type = Vec, format = Binary)] + file: TempFile, + + /// Optional filename to record. Falls back to the uploaded file's own name. + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_filename: Option>, +} + +/// Detect a video's AI-generation provenance and persist the result. +#[utoipa::path( + post, + tag = "Video Info", + path = "/v1/video_info/upload", + responses( + (status = 200, description = "Detected provenance + persisted token", body = VideoInfoUploadResponse), + (status = 400, description = "Bad input", body = CommonWebError), + (status = 500, description = "Server error", body = CommonWebError), + ), + params( + ("request" = VideoInfoUploadForm, description = "Multipart form with a single `file` video upload."), + ) +)] +pub async fn video_info_upload_handler( + http_request: HttpRequest, + server_state: web::Data>, + MultipartForm(mut form): MultipartForm, +) -> Result, CommonWebError> { + // IP-based rate limit (shared with the read-only endpoint). Fails open. + match server_state.redis_rate_limiters.video_info_read_only.rate_limit_request(&http_request).await { + Ok(()) => {} + Err(RateLimiterError::RateLimitExceededError) => return Err(CommonWebError::TooManyRequests), + Err(RateLimiterError::ClientError) => {} // fail open + } + + let mut bytes = Vec::new(); + form.file.file.read_to_end(&mut bytes).map_err(|err| { + error!("video_info upload: problem reading uploaded file: {:?}", err); + CommonWebError::server_error_with_message("could not read uploaded file") + })?; + + if bytes.is_empty() { + return Err(CommonWebError::BadInputWithSimpleMessage("empty file".to_string())); + } + + // ── In-memory analysis (no DB connection held during any of this) ── + + let filesize_bytes = bytes.len().min(u32::MAX as usize) as u32; + let ip_address = get_request_ip(&http_request); + + // Prefer an explicit `maybe_filename` field; otherwise use the uploaded file's + // own name. Trimmed; empty becomes absent. + let maybe_filename = form + .maybe_filename + .as_ref() + .map(|filename| filename.as_str().to_string()) + .or_else(|| form.file.file_name.clone()) + .map(|filename| filename.trim().to_string()) + .filter(|filename| !filename.is_empty()); + + let sha1_checksum = + sha1_hash_bytes_as_crockford(&bytes).map_err(CommonWebError::from_error)?; + + let parse_result = VideoInfo::from_bytes(&bytes); + let maybe_encoder = video_info::encoder_tag(&bytes); + + // The full debug dump of the parse result becomes the stored report. + let maybe_report = parse_result.as_ref().ok().map(|info| format!("{info:#?}")); + + let maybe_detected_model_type = detect_model_type(&parse_result); + let maybe_detected_model_family = maybe_detected_model_type + .map(model_family_for_type) + .or_else(|| detect_model_family(&parse_result)); + + let provenance = to_provenance(&parse_result, maybe_encoder); + + // Archive the original bytes to the Seedance video bucket, if configured. + // Done before moving `bytes` into the ffprobe task, and before acquiring a DB + // connection. Fail-soft: a storage error must not fail provenance detection. + if let Some(bucket) = server_state.seedance_video_bucket.as_ref() { + let object_name = seedance_video_object_name(&sha1_checksum); + if let Err(err) = bucket + .upload_file_bytes(UploadFileBytesArgs { + object_name: object_name.as_str(), + bytes: &bytes, + content_type: Some("video/mp4"), + }) + .await + { + warn!("seedance video bucket upload failed for {}: {:?}", object_name, err); + } + } + + // ffprobe is a blocking subprocess — run it off the async executor, and BEFORE + // acquiring a DB connection (never hold a pooled connection across this). + // + // Fail open: ffprobe being missing, slow, panicking, or erroring must never + // fail the upload — we just store the record without dimensions. + let maybe_dimensions = match web::block(move || probe_dimensions(&bytes)).await { + Ok(dimensions) => dimensions, + Err(err) => { + warn!("video_info upload: ffprobe task failed; storing without dimensions: {:?}", err); + None + } + }; + let maybe_width = maybe_dimensions.map(|(width, _)| width); + let maybe_height = maybe_dimensions.map(|(_, height)| height); + let maybe_resolution = maybe_dimensions + .map(|(width, height)| classify_resolution(width, height).as_str().to_string()); + + // ── Persist (upsert by SHA-1) ── + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + let maybe_existing = get_uploaded_video_by_sha1_checksum(GetUploadedVideoBySha1ChecksumArgs { + sha1_checksum: &sha1_checksum, + mysql_executor: &mut *mysql_connection, + phantom: PhantomData, + }) + .await?; + + let uploaded_video_token = match maybe_existing { + Some(existing) => { + update_uploaded_video(UpdateUploadedVideoArgs { + token: &existing.token, + maybe_filename: maybe_filename.as_deref(), + maybe_width, + maybe_height, + maybe_resolution: maybe_resolution.as_deref(), + maybe_detected_model_family, + maybe_detected_model_type, + maybe_report: maybe_report.as_deref(), + maybe_updated_ip_address: Some(ip_address.as_str()), + mysql_executor: &mut *mysql_connection, + phantom: PhantomData, + }) + .await?; + existing.token + } + None => { + insert_uploaded_video(InsertUploadedVideoArgs { + sha1_checksum: &sha1_checksum, + filesize_bytes, + maybe_filename: maybe_filename.as_deref(), + maybe_width, + maybe_height, + maybe_resolution: maybe_resolution.as_deref(), + maybe_detected_model_family, + maybe_detected_model_type, + maybe_report: maybe_report.as_deref(), + upload_ip_address: &ip_address, + mysql_executor: &mut *mysql_connection, + phantom: PhantomData, + }) + .await? + } + }; + + Ok(Json(VideoInfoUploadResponse { + success: true, + uploaded_video_token, + kind: provenance.kind, + maybe_encoder: provenance.maybe_encoder, + maybe_seedance: provenance.maybe_seedance, + maybe_veo: provenance.maybe_veo, + maybe_sora: provenance.maybe_sora, + maybe_dreamina: provenance.maybe_dreamina, + maybe_kling: provenance.maybe_kling, + })) +} + +/// Object key for archiving an uploaded video, sharded into four directory +/// levels by the first four characters of the checksum: +/// `uploads/{c0}/{c1}/{c2}/{c3}/{checksum}.mp4` +/// (e.g. checksum `f0a2cda0…` → `uploads/f/0/a/2/f0a2cda0….mp4`). +fn seedance_video_object_name(sha1_checksum: &str) -> String { + let shard: String = sha1_checksum + .chars() + .take(4) + .map(|character| format!("{character}/")) + .collect(); + format!("uploads/{shard}{sha1_checksum}.mp4") +} + +/// A coarse resolution bucket, classified by **total pixel area** so that +/// orientation (portrait/landscape/square) doesn't matter — `1080×1920` and +/// `1920×1080` bucket the same. Stored in `maybe_resolution`. +/// +/// Thresholds are placed between the standard tiers' pixel areas: +/// - 0.5K ≈ 512×512 (0.26 MP) +/// - 1K ≈ 720p / 1280×720 (0.92 MP) +/// - 2K ≈ 1080p–1440p (2.07–3.69 MP) +/// - 3K ≈ ~4.5–5 MP +/// - 4K ≈ 2160p / 3840×2160 (8.3 MP) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VideoResolutionClass { + /// Below ~0.5K (under ~0.13 MP). + Tiny, + /// ~0.5K — e.g. 512×512, VGA. + HalfK, + /// ~1K — e.g. 720p (1280×720), 1024×1024, 1080×1080. + OneK, + /// ~2K — e.g. 1080p (1920×1080), 1440×1080, 2048×2048, 2560×1600. + TwoK, + /// ~3K — e.g. 2160×2160 (~4.7 MP). + ThreeK, + /// ~4K — e.g. 3840×2160 / 4096×2160 (~8.3–8.8 MP). + FourK, + /// Beyond 4K — e.g. 5K, 8K. + Huge, +} + +impl VideoResolutionClass { + fn as_str(self) -> &'static str { + match self { + Self::Tiny => "tiny", + Self::HalfK => "half_k", + Self::OneK => "one_k", + Self::TwoK => "two_k", + Self::ThreeK => "three_k", + Self::FourK => "four_k", + Self::Huge => "huge", + } + } +} + +/// Bucket a video's resolution by total pixel area (`width × height`). Boundaries +/// sit roughly at the geometric midpoints between adjacent standard tiers. +fn classify_resolution(width: u32, height: u32) -> VideoResolutionClass { + let area = width as u64 * height as u64; + match area { + 0..=129_999 => VideoResolutionClass::Tiny, + 130_000..=499_999 => VideoResolutionClass::HalfK, + 500_000..=1_399_999 => VideoResolutionClass::OneK, + 1_400_000..=4_399_999 => VideoResolutionClass::TwoK, + 4_400_000..=6_299_999 => VideoResolutionClass::ThreeK, + 6_300_000..=10_999_999 => VideoResolutionClass::FourK, + _ => VideoResolutionClass::Huge, + } +} + +/// Write the bytes to a temp file and ffprobe its video dimensions. Fail-soft: +/// returns `None` on any failure (not a probeable video, ffprobe missing, I/O +/// error) so the caller can proceed without dimensions. Never panics. +fn probe_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + let mut temp_file = match tempfile::NamedTempFile::new() { + Ok(temp_file) => temp_file, + Err(err) => { + warn!("video_info upload: could not create temp file for ffprobe: {:?}", err); + return None; + } + }; + + if let Err(err) = temp_file.write_all(bytes) { + warn!("video_info upload: could not write temp file for ffprobe: {:?}", err); + return None; + } + let _ = temp_file.flush(); + + match ffprobe_get_dimensions(temp_file.path()) { + Ok(Some(dimensions)) => Some((dimensions.width as u32, dimensions.height as u32)), + // Not a probeable video (no video stream / no duration) — not an error. + Ok(None) => None, + Err(err) => { + warn!("video_info upload: ffprobe failed; storing without dimensions: {:?}", err); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::{classify_resolution, seedance_video_object_name}; + + #[test] + fn shards_object_name_by_first_four_checksum_chars() { + assert_eq!( + seedance_video_object_name("f0a2cda0deadbeef"), + "uploads/f/0/a/2/f0a2cda0deadbeef.mp4" + ); + } + + #[test] + fn classifies_resolution_by_area() { + let class = |w, h| classify_resolution(w, h).as_str(); + + // Tiny + assert_eq!(class(320, 240), "tiny"); + // HalfK + assert_eq!(class(512, 512), "half_k"); + assert_eq!(class(640, 480), "half_k"); + // OneK (incl. square) + assert_eq!(class(1280, 720), "one_k"); + assert_eq!(class(1024, 1024), "one_k"); + assert_eq!(class(1080, 1080), "one_k"); + // TwoK — tall, wide, and square all by area + assert_eq!(class(1440, 1080), "two_k"); + assert_eq!(class(1920, 1200), "two_k"); + assert_eq!(class(2048, 1080), "two_k"); + assert_eq!(class(2160, 1440), "two_k"); + assert_eq!(class(2560, 1600), "two_k"); + assert_eq!(class(2048, 2048), "two_k"); + // ThreeK + assert_eq!(class(2160, 2160), "three_k"); + // FourK (UHD + DCI) + assert_eq!(class(3840, 2160), "four_k"); + assert_eq!(class(4096, 2160), "four_k"); + // Huge (5K / 8K) + assert_eq!(class(5120, 2880), "huge"); + assert_eq!(class(7680, 4320), "huge"); + } + + /// Flipping a video (swapping width/height) must not change its class, and the + /// same holds for square videos — they're all classified purely by area. + #[test] + fn resolution_class_is_orientation_independent() { + for (w, h) in [(1920, 1080), (2048, 1080), (3840, 2160), (1280, 720)] { + assert_eq!( + classify_resolution(w, h), + classify_resolution(h, w), + "{w}x{h} should match its flip {h}x{w}", + ); + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_clone_requests/check_if_voice_clone_request_submitted.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_clone_requests/check_if_voice_clone_request_submitted.rs index a952160e10..e95b451d42 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_clone_requests/check_if_voice_clone_request_submitted.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_clone_requests/check_if_voice_clone_request_submitted.rs @@ -2,8 +2,9 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::warn; use http_server_common::response::serialize_as_json_error::serialize_as_json_error; @@ -35,8 +36,8 @@ pub struct CheckIfVoiceRequestSubmittedResponse { pub async fn check_if_voice_clone_request_submitted_handler( http_request: HttpRequest, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -83,10 +84,5 @@ pub async fn check_if_voice_clone_request_submitted_handler( has_submitted, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_clone_requests/create_voice_clone_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_clone_requests/create_voice_clone_request.rs index 648076d9fe..7ccca23953 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_clone_requests/create_voice_clone_request.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_clone_requests/create_voice_clone_request.rs @@ -2,8 +2,9 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use log::warn; use http_server_common::request::get_request_ip::get_request_ip; @@ -64,8 +65,8 @@ pub struct CreateVoiceRequestResponse { pub async fn create_voice_clone_request_handler( http_request: HttpRequest, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -113,10 +114,5 @@ pub async fn create_voice_clone_request_handler( success: true, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_conversion/enqueue_seed_vc_inference_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_conversion/enqueue_seed_vc_inference_handler.rs index 34659709da..6059dee028 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_conversion/enqueue_seed_vc_inference_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_conversion/enqueue_seed_vc_inference_handler.rs @@ -30,7 +30,7 @@ use crate::configs::app_startup::username_set::UsernameSet; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::configs::plans::get_correct_plan_for_session::get_correct_plan_for_session; use crate::http_server::deprecated_endpoints::investor_demo::demo_cookie::request_has_demo_cookie; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; use crate::state::server_state::ServerState; // TODO: Temporary for investor demo diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/inference/enqueue_tts_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/inference/enqueue_tts_request.rs index f4892bb727..9418e37c1b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/inference/enqueue_tts_request.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/inference/enqueue_tts_request.rs @@ -4,7 +4,8 @@ use std::sync::Arc; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; +use actix_web::web::Json; use log::warn; use serde::Deserialize; use serde::Serialize; @@ -76,10 +77,10 @@ pub struct EnqueueTTSRequestSuccessResponse { )] pub async fn enqueue_tts_request( http_request: HttpRequest, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result { - +) -> Result, CommonWebError> { + let mut maybe_user_token: Option = None; let mut mysql_connection = server_state.mysql_pool.acquire().await.map_err(|err| { @@ -194,8 +195,5 @@ pub async fn enqueue_tts_request( inference_job_token: job_token, }; - let body = serde_json::to_string(&response)?; - - // Error handling 101 rust result type returned like so. - Ok(HttpResponse::Ok().content_type("application/json").body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_dataset_samples/delete_sample.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_dataset_samples/delete_sample.rs index 6cedc383ef..3e874933ab 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_dataset_samples/delete_sample.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_dataset_samples/delete_sample.rs @@ -1,12 +1,11 @@ use std::sync::Arc; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse, ResponseError}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::warn; -use http_server_common::response::response_error_helpers::to_simple_json_error; -use http_server_common::response::response_success_helpers::simple_json_success; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use mysql_queries::queries::voice_designer::voice_samples::delete_sample::{delete_sample_as_mod, delete_sample_as_user, undelete_sample_as_mod, undelete_sample_as_user}; use mysql_queries::queries::voice_designer::voice_samples::get_dataset_sample::get_dataset_sample_by_token; use tokens::tokens::zs_voice_dataset_samples::ZsVoiceDatasetSampleToken; @@ -33,9 +32,9 @@ pub struct DeleteSampleRequest { pub async fn delete_sample_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -122,5 +121,5 @@ pub async fn delete_sample_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_dataset_samples/list_samples_by_dataset.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_dataset_samples/list_samples_by_dataset.rs index c34747aa0b..e7307f16ec 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_dataset_samples/list_samples_by_dataset.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_dataset_samples/list_samples_by_dataset.rs @@ -1,8 +1,7 @@ use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; +use actix_web::web::Json; use chrono::{DateTime, Utc}; use log::warn; @@ -47,7 +46,7 @@ pub async fn list_samples_by_dataset_handler( http_request: HttpRequest, path: web::Path, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state.session_checker.maybe_get_user_session( &http_request, &server_state.mysql_pool @@ -108,10 +107,5 @@ pub async fn list_samples_by_dataset_handler( samples }; - let body = serde_json::to_string(&response).map_err(|e| { - warn!("json serialization error: {:?}", e); - CommonWebError::from_error(e) - })?; - - Ok(HttpResponse::Ok().content_type("application/json").body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/create_dataset.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/create_dataset.rs index 33c5afbc61..2b35708f54 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/create_dataset.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/create_dataset.rs @@ -1,14 +1,12 @@ use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; +use actix_web::web::Json; use log::{error, warn}; use enums::common::visibility::Visibility; use http_server_common::request::get_request_ip::get_request_ip; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use mysql_queries::queries::voice_designer::datasets::create_dataset::{create_dataset, CreateDatasetArgs}; use tokens::tokens::zs_voice_datasets::ZsVoiceDatasetToken; @@ -34,7 +32,7 @@ pub struct CreateDatasetResponse { // NB: Not using DeriveMore since Clion doesn't understand it. // =============== Handler =============== -pub async fn create_dataset_handler(http_request: HttpRequest, request: web::Json, server_state: web::Data>) -> Result { +pub async fn create_dataset_handler(http_request: HttpRequest, request: Json, server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state.session_checker.maybe_get_user_session(&http_request, &server_state.mysql_pool).await.map_err(|e| { error!("Error getting user session: {:?}", e); CommonWebError::from_error(e) @@ -78,10 +76,5 @@ pub async fn create_dataset_handler(http_request: HttpRequest, request: web::Jso }; - let body = serde_json::to_string(&response) - .map_err(|e| CommonWebError::from_error(e))?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/delete_dataset.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/delete_dataset.rs index 02680799f6..8801086155 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/delete_dataset.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/delete_dataset.rs @@ -1,18 +1,16 @@ use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::warn; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use mysql_queries::queries::voice_designer::datasets::delete_dataset::{delete_dataset_as_mod, delete_dataset_as_user, undelete_dataset_as_mod, undelete_dataset_as_user}; use mysql_queries::queries::voice_designer::datasets::get_dataset::get_dataset_by_token; use tokens::tokens::zs_voice_datasets::ZsVoiceDatasetToken; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::state::server_state::ServerState; use crate::util::delete_role_disambiguation::{delete_role_disambiguation, DeleteRole}; @@ -37,9 +35,9 @@ pub struct DeleteDatasetPathInfo { pub async fn delete_dataset_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result{ +) -> Result, CommonWebError>{ let maybe_user_session = server_state .session_checker .maybe_get_user_session(&http_request, &server_state.mysql_pool) @@ -140,6 +138,6 @@ pub async fn delete_dataset_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/get_dataset.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/get_dataset.rs index 216eb7c368..6fb674b6bf 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/get_dataset.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/get_dataset.rs @@ -1,10 +1,9 @@ use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; @@ -42,7 +41,7 @@ pub async fn get_dataset_handler( http_request: HttpRequest, path: Path, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -109,10 +108,5 @@ pub async fn get_dataset_handler( updated_at: dataset.updated_at, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/list_datasets_by_session.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/list_datasets_by_session.rs index 88b789bf2b..353a41b139 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/list_datasets_by_session.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/list_datasets_by_session.rs @@ -2,8 +2,9 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; @@ -61,7 +62,7 @@ impl ResponseError for ListDatasetsByUserError { pub async fn list_datasets_by_session_handler( http_request: HttpRequest, server_state: web::Data> -) -> Result { +) -> Result, ListDatasetsByUserError> { let user_session = server_state .session_checker @@ -122,10 +123,5 @@ pub async fn list_datasets_by_session_handler( datasets, }; - let body = serde_json::to_string(&response) - .map_err(|e| ListDatasetsByUserError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/list_datasets_by_user.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/list_datasets_by_user.rs index c7d92c08dd..f2c1352bab 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/list_datasets_by_user.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/list_datasets_by_user.rs @@ -2,9 +2,10 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; use utoipa::ToSchema; @@ -82,7 +83,7 @@ pub async fn list_datasets_by_user_handler( http_request: HttpRequest, path: Path, server_state: web::Data> -) -> Result { +) -> Result, ListDatasetsByUserError> { let maybe_user_session = server_state .session_checker @@ -148,10 +149,5 @@ pub async fn list_datasets_by_user_handler( datasets, }; - let body = serde_json::to_string(&response) - .map_err(|e| ListDatasetsByUserError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/update_dataset.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/update_dataset.rs index 37a437fb80..bd533c7745 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/update_dataset.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voice_datasets/update_dataset.rs @@ -1,16 +1,14 @@ use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::{error, warn}; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use enums::common::visibility::Visibility; use http_server_common::request::get_request_ip::get_request_ip; -use http_server_common::response::response_success_helpers::simple_json_success; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use mysql_queries::queries::voice_designer::datasets::get_dataset::get_dataset_by_token; use mysql_queries::queries::voice_designer::datasets::update_dataset::{update_dataset, UpdateDatasetArgs}; use tokens::tokens::zs_voice_datasets::ZsVoiceDatasetToken; @@ -48,8 +46,8 @@ pub struct UpdateDatasetPathInfo { pub async fn update_dataset_handler( http_request: HttpRequest, path: Path, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -169,5 +167,5 @@ pub async fn update_dataset_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/create_voice.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/create_voice.rs index b26dd52eb7..c5fc935ead 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/create_voice.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/create_voice.rs @@ -5,9 +5,8 @@ use std::fmt::Debug; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; +use actix_web::web::Json; use log::{info, warn}; use serde::Deserialize; use serde::Serialize; @@ -33,7 +32,6 @@ use tokens::tokens::users::UserToken; use crate::configs::plans::get_correct_plan_for_session::get_correct_plan_for_session; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::state::server_state::ServerState; #[derive(Deserialize)] @@ -58,9 +56,9 @@ const ROUTING_TAG_HEADER_NAME: &str = "routing-tag"; // pub async fn get_dataset_by_token pub async fn create_voice_handler( http_request: HttpRequest, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { // voice data set token ... zsd_wb08asm0m4a4cz42wwqked6tfg6e e.g // need this for the job @@ -184,9 +182,5 @@ pub async fn create_voice_handler( inference_job_token: job_token, }; - let body = serde_json - ::to_string(&response) - ?; - // Error handling 101 rust result type returned like so. - Ok(HttpResponse::Ok().content_type("application/json").body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/delete_voice.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/delete_voice.rs index dbcc9f3256..a0a4da58ee 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/delete_voice.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/delete_voice.rs @@ -1,18 +1,16 @@ use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::warn; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use mysql_queries::queries::voice_designer::voices::delete_voice::{delete_voice_as_mod, delete_voice_as_user, undelete_voice_as_mod, undelete_voice_as_user}; use mysql_queries::queries::voice_designer::voices::get_voice::get_voice_by_token; use tokens::tokens::zs_voices::ZsVoiceToken; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::state::server_state::ServerState; use crate::util::delete_role_disambiguation::{delete_role_disambiguation, DeleteRole}; @@ -37,9 +35,9 @@ pub struct DeleteVoicePathInfo { pub async fn delete_voice_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, server_state: web::Data> -) -> Result{ +) -> Result, CommonWebError>{ let maybe_user_session = server_state .session_checker .maybe_get_user_session(&http_request, &server_state.mysql_pool) @@ -139,5 +137,5 @@ pub async fn delete_voice_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/get_voice.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/get_voice.rs index 1136685112..dc94a6df6a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/get_voice.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/get_voice.rs @@ -1,10 +1,9 @@ use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; @@ -42,7 +41,7 @@ pub async fn get_voice_handler( http_request: HttpRequest, path: Path, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -109,10 +108,5 @@ pub async fn get_voice_handler( updated_at: voice.updated_at, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_available_voices.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_available_voices.rs index 68456e37fc..215fc78622 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_available_voices.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_available_voices.rs @@ -1,8 +1,7 @@ use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; +use actix_web::web::Json; use chrono::{DateTime, Utc}; use log::{info, warn}; @@ -48,7 +47,7 @@ pub async fn list_available_voices_handler( http_request: HttpRequest, query: web::Query, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state.session_checker.maybe_get_user_session( &http_request, @@ -158,12 +157,7 @@ pub async fn list_available_voices_handler( cursor_previous, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_voices_by_session.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_voices_by_session.rs index ef34ff9f6a..fd095da662 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_voices_by_session.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_voices_by_session.rs @@ -2,8 +2,9 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; @@ -60,7 +61,7 @@ impl ResponseError for ListVoicesByUserError { pub async fn list_voices_by_session_handler( http_request: HttpRequest, server_state: web::Data> -) -> Result { +) -> Result, ListVoicesByUserError> { let user_session = server_state .session_checker @@ -121,10 +122,5 @@ pub async fn list_voices_by_session_handler( voices, }; - let body = serde_json::to_string(&response) - .map_err(|e| ListVoicesByUserError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_voices_by_user.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_voices_by_user.rs index 757bbcaec0..7e5188b978 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_voices_by_user.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_voices_by_user.rs @@ -2,9 +2,10 @@ use std::fmt; use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; @@ -68,7 +69,7 @@ pub async fn list_voices_by_user_handler( http_request: HttpRequest, path: Path, server_state: web::Data> -) -> Result { +) -> Result, ListVoicesByUserError> { let maybe_user_session = server_state .session_checker @@ -134,12 +135,7 @@ pub async fn list_voices_by_user_handler( voices, }; - let body = serde_json::to_string(&response) - .map_err(|e| ListVoicesByUserError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/update_voice.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/update_voice.rs index 513df6d9d4..f695bda3c8 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/update_voice.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/update_voice.rs @@ -1,15 +1,13 @@ use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::{error, warn}; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; use enums::common::visibility::Visibility; -use http_server_common::response::response_success_helpers::simple_json_success; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use mysql_queries::queries::voice_designer::voices::get_voice::get_voice_by_token; use mysql_queries::queries::voice_designer::voices::update_voice::{update_voice, UpdateVoiceArgs}; use tokens::tokens::zs_voices::ZsVoiceToken; @@ -47,8 +45,8 @@ pub struct UpdateVoicePathInfo { pub async fn update_voice_handler( http_request: HttpRequest, path: Path, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -166,5 +164,5 @@ pub async fn update_voice_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/delete/delete_weight_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/delete/delete_weight_handler.rs index af62499971..b371a38e73 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/delete/delete_weight_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/delete/delete_weight_handler.rs @@ -1,14 +1,11 @@ -use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::warn; use utoipa::ToSchema; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use mysql_queries::queries::model_weights::delete_weights::{ delete_weights_as_mod, delete_weights_as_user, @@ -18,7 +15,6 @@ use mysql_queries::queries::model_weights::get::get_weight::get_weight_by_token; use tokens::tokens::model_weights::ModelWeightToken; use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; -use crate::http_server::web_utils::response_success_helpers::simple_json_success; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::state::server_state::ServerState; use crate::util::delete_role_disambiguation::{delete_role_disambiguation, DeleteRole}; @@ -55,9 +51,9 @@ pub struct DeleteWeightRequest { pub async fn delete_weight_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, server_state: web::Data> - ) -> Result{ + ) -> Result, CommonWebError>{ let maybe_user_session = server_state .session_checker .maybe_get_user_session(&http_request, &server_state.mysql_pool) @@ -155,5 +151,5 @@ pub async fn delete_weight_handler( } }; - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/get/get_weight_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/get/get_weight_handler.rs index 5013c38322..65e24c473c 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/get/get_weight_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/get/get_weight_handler.rs @@ -1,10 +1,8 @@ -use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; use utoipa::ToSchema; @@ -105,7 +103,7 @@ pub async fn get_weight_handler( http_request: HttpRequest, path: Path, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker .maybe_get_user_session(&http_request, &server_state.mysql_pool) @@ -216,10 +214,5 @@ pub async fn get_weight_handler( updated_at: weight.updated_at, }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_available_weights_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_available_weights_handler.rs index 191e17bfc2..da75a5ece9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_available_weights_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_available_weights_handler.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; use rand::Rng; @@ -142,7 +143,7 @@ pub async fn list_available_weights_handler( http_request: HttpRequest, query: web::Query, server_state: web::Data> -) -> Result { +) -> Result, impl ResponseError> { let maybe_user_session = server_state.session_checker .maybe_get_user_session(&http_request, &server_state.mysql_pool).await .map_err(|e| { @@ -296,8 +297,5 @@ pub async fn list_available_weights_handler( }, }; - let body = serde_json::to_string(&response) - .map_err(|e| ListWeightError::ServerError)?; - - Ok(HttpResponse::Ok().content_type("application/json").body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_featured_weights_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_featured_weights_handler.rs index 7cde33a36f..47b03afc0e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_featured_weights_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_featured_weights_handler.rs @@ -1,9 +1,10 @@ use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; use actix_web::web::Query; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; use utoipa::{IntoParams, ToSchema}; @@ -140,7 +141,7 @@ pub async fn list_featured_weights_handler( http_request: HttpRequest, query: Query, server_state: web::Data> -) -> Result { +) -> Result, impl ResponseError> { let maybe_user_session = server_state .session_checker @@ -280,10 +281,5 @@ pub async fn list_featured_weights_handler( }).collect::>(), }; - let body = serde_json::to_string(&response) - .map_err(|e| ListFeaturedWeightsError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_pinned_weights_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_pinned_weights_handler.rs index ab375608d8..9348cd91d3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_pinned_weights_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_pinned_weights_handler.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use actix_web::error::ResponseError; +use actix_web::web::Json; use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::{debug, error, warn}; use redis::Commands; @@ -108,7 +109,7 @@ impl ResponseError for ListPinnedWeightsError { pub async fn list_pinned_weights_handler( http_request: HttpRequest, server_state: web::Data> -) -> Result { +) -> Result, impl ResponseError> { let mut redis = server_state.redis_pool.get() .map_err(|err| { @@ -201,10 +202,5 @@ pub async fn list_pinned_weights_handler( }).collect::>(), }; - let body = serde_json::to_string(&response) - .map_err(|e| ListPinnedWeightsError::ServerError)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_weights_by_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_weights_by_user_handler.rs index 528eeaf11d..1335d7c9db 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_weights_by_user_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/list/list_weights_by_user_handler.rs @@ -1,10 +1,8 @@ -use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::{Path, Query}; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use chrono::{DateTime, Utc}; use log::warn; use utoipa::{IntoParams, ToSchema}; @@ -124,7 +122,7 @@ pub async fn list_weights_by_user_handler( path: Path, query: Query, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -248,10 +246,5 @@ pub async fn list_weights_by_user_handler( }; - let body = serde_json::to_string(&response) - .map_err(CommonWebError::from_error)?; - - Ok(HttpResponse::Ok() - .content_type("application/json") - .body(body)) + Ok(Json(response)) } \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/update/set_model_weight_cover_image_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/update/set_model_weight_cover_image_handler.rs index 99d410c59e..e0a8e18d38 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/update/set_model_weight_cover_image_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/update/set_model_weight_cover_image_handler.rs @@ -1,17 +1,13 @@ -use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::warn; use utoipa::ToSchema; use enums::by_table::media_files::media_file_type::MediaFileType; use http_server_common::request::get_request_ip::get_request_ip; -use http_server_common::response::response_success_helpers::simple_json_success; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use mysql_queries::queries::media_files::get::get_media_file::get_media_file; use mysql_queries::queries::model_weights::edit::set_model_weight_cover_image::{set_model_weight_cover_image, UpdateArgs}; use mysql_queries::queries::model_weights::get::get_weight::get_weight_by_token; @@ -59,8 +55,8 @@ pub struct SetModelWeightCoverImagePathInfo { pub async fn set_model_weight_cover_image_handler( http_request: HttpRequest, path: Path, - request: web::Json, - server_state: web::Data>) -> Result + request: Json, + server_state: web::Data>) -> Result, CommonWebError> { let maybe_user_session = server_state .session_checker @@ -165,5 +161,5 @@ pub async fn set_model_weight_cover_image_handler( } }; - Ok(simple_json_success()) + Ok(Json(SetModelWeightCoverImageResponse { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/update/update_weight_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/update/update_weight_handler.rs index 04a34c8b20..90a1c89e7b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/weights/update/update_weight_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/weights/update/update_weight_handler.rs @@ -1,23 +1,20 @@ -use std::fmt; use std::sync::Arc; -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; use actix_web::web::Path; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; use log::warn; use sqlx::MySqlPool; use utoipa::ToSchema; use crate::configs::supported_languages_for_models::{get_canonicalized_language_tag_for_model, get_primary_language_subtag}; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended; +use crate::http_server::user_lookup::user_session::require_user_session_extended::require_user_session_extended; use crate::state::server_state::ServerState; use enums::by_table::media_files::media_file_type::MediaFileType; use enums::by_table::model_weights::weights_types::WeightsType; use enums::common::visibility::Visibility; -use http_server_common::response::response_success_helpers::{simple_json_success, SimpleGenericJsonSuccess}; -use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess; use markdown::simple_markdown_to_html::simple_markdown_to_html; use mysql_queries::queries::media_files::get::get_media_file::get_media_file; use mysql_queries::queries::model_weights::edit::update_weight::{update_weights, CoverImageOption, UpdateWeightArgs}; @@ -82,10 +79,10 @@ pub struct UpdateWeightPathInfo { pub async fn update_weight_handler( http_request: HttpRequest, path: Path, - request: web::Json, + request: Json, mysql_pool: web::Data, server_state: web::Data> -) -> Result { +) -> Result, CommonWebError> { let mut mysql_connection = mysql_pool .acquire() .await @@ -222,5 +219,5 @@ pub async fn update_weight_handler( } } - Ok(simple_json_success()) + Ok(Json(SimpleGenericJsonSuccess { success: true })) } diff --git a/crates/service/web/storyteller_web/src/http_server/mod.rs b/crates/service/web/storyteller_web/src/http_server/mod.rs index 82a70137ab..2bc90b5c60 100644 --- a/crates/service/web/storyteller_web/src/http_server/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/mod.rs @@ -6,6 +6,6 @@ pub mod endpoints; pub mod middleware; pub mod requests; pub mod routes; -pub mod session; +pub mod user_lookup; pub mod validations; pub mod web_utils; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs index e3051809a7..1699b07ef3 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs @@ -11,6 +11,7 @@ use crate::http_server::routes::application_routes::generate_routes::add_generat use crate::http_server::routes::application_routes::job_routes::add_job_routes; use crate::http_server::routes::application_routes::media_files_routes::add_media_file_routes; use crate::http_server::routes::application_routes::moderation_routes::add_moderator_routes; +use crate::http_server::routes::application_routes::omni_api_routes::add_omni_api_routes; use crate::http_server::routes::application_routes::omni_gen_routes::add_omni_gen_routes; use crate::http_server::routes::application_routes::prompts_routes::add_prompts_routes; use crate::http_server::routes::application_routes::stripe_artcraft_routes::add_stripe_artcraft_routes; @@ -20,6 +21,7 @@ use crate::http_server::routes::application_routes::user_bookmarks_routes::add_u use crate::http_server::routes::application_routes::user_rating_routes::add_user_rating_routes; use crate::http_server::routes::application_routes::user_referral_code_routes::add_user_referral_code_routes; use crate::http_server::routes::application_routes::user_routes::add_user_routes; +use crate::http_server::routes::application_routes::video_info_routes::add_video_info_routes; use crate::http_server::routes::application_routes::wallet_routes::add_wallet_routes; use crate::http_server::routes::application_routes::web_referrals_routes::add_web_referrals_routes; use crate::http_server::routes::application_routes::webhook_routes::add_webhook_routes; @@ -48,6 +50,7 @@ where // Artcraft surface area app = add_generate_routes(app); // /v1/generate/... app = add_omni_gen_routes(app); // /v1/omni_gen/... + app = add_omni_api_routes(app); // /v1/omni_api/... (API-key authenticated generate) app = add_character_routes(app); // /v1/character/... and /v1/characters/... app = add_webhook_routes(app); // /v1/webhooks/... (fal) @@ -66,6 +69,7 @@ where app = add_job_routes(app); // Other useful tools + app = add_video_info_routes(app); // /v1/video_info/... (video provenance detection) app = add_analytics_routes(app); // /v1/analytics/... app = add_web_referrals_routes(app); // /v1/web_referrals/... app = add_dev_routes(app); // /v1/dev/... — dev-only error/response shape probes diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs index e7f8b9a836..d67c9c160c 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs @@ -11,6 +11,7 @@ mod generate_routes; mod job_routes; mod media_files_routes; mod moderation_routes; +mod omni_api_routes; mod omni_gen_routes; mod prompts_routes; mod stripe_artcraft_routes; @@ -20,6 +21,7 @@ mod user_bookmarks_routes; mod user_rating_routes; mod user_referral_code_routes; mod user_routes; +mod video_info_routes; mod wallet_routes; mod web_referrals_routes; mod webhook_routes; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/omni_api_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/omni_api_routes.rs new file mode 100644 index 0000000000..72a1f15082 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/omni_api_routes.rs @@ -0,0 +1,53 @@ +use actix_http::body::MessageBody; +use actix_service::ServiceFactory; +use actix_web::dev::{ServiceRequest, ServiceResponse}; +use actix_web::{web, App, Error, HttpResponse}; + +use crate::http_server::endpoints::omni_api::generate::image::omni_api_image_generate_handler::omni_api_image_generate_handler; +use crate::http_server::endpoints::omni_api::generate::video::omni_api_video_generate_handler::omni_api_video_generate_handler; +use crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::omni_upload_audio_media_file_handler; +use crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::omni_upload_image_media_file_handler; +use crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler; + +/// API-key authenticated generation endpoints. These mirror `/v1/omni_gen/generate/*` but read the +/// caller's identity from the `Authorization` header API key instead of a session cookie. The +/// `cost` and `models` endpoints are not duplicated here — they need no user and the existing +/// `/v1/omni_gen/{cost,models}/*` routes suffice. +pub fn add_omni_api_routes(app: App) -> App +where + B: MessageBody, + T: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = Error, + InitError = (), + >, +{ + app.service(web::scope("/v1/omni_api") + .service(web::scope("/generate") + .service(web::resource("/video") + .route(web::post().to(omni_api_video_generate_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service(web::resource("/image") + .route(web::post().to(omni_api_image_generate_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + ) + .service(web::scope("/upload") + .service(web::resource("/audio") + .route(web::post().to(omni_upload_audio_media_file_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service(web::resource("/image") + .route(web::post().to(omni_upload_image_media_file_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service(web::resource("/video") + .route(web::post().to(omni_upload_video_media_file_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + ) + ) +} diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/video_info_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/video_info_routes.rs new file mode 100644 index 0000000000..2924847c95 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/video_info_routes.rs @@ -0,0 +1,36 @@ +use actix_http::body::MessageBody; +use actix_service::ServiceFactory; +use actix_web::dev::{ServiceRequest, ServiceResponse}; +use actix_web::error::Error; +use actix_web::{web, App, HttpResponse}; + +use crate::http_server::endpoints::video_info::video_info_notes_handler::video_info_notes_handler; +use crate::http_server::endpoints::video_info::video_info_read_info_handler::video_info_read_info_handler; +use crate::http_server::endpoints::video_info::video_info_upload_handler::video_info_upload_handler; + +pub fn add_video_info_routes (app: App) -> App +where + B: MessageBody, + T: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = Error, + InitError = (), + >, +{ + app.service(web::scope("/v1/video_info") + .service(web::resource("/read_only") + .route(web::post().to(video_info_read_info_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service(web::resource("/upload") + .route(web::post().to(video_info_upload_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service(web::resource("/notes") + .route(web::post().to(video_info_notes_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + ) +} diff --git a/crates/service/web/storyteller_web/src/http_server/user_lookup/api_keys/mod.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_keys/mod.rs new file mode 100644 index 0000000000..c84538ff4f --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_keys/mod.rs @@ -0,0 +1 @@ +pub mod require_api_key_user; diff --git a/crates/service/web/storyteller_web/src/http_server/user_lookup/api_keys/require_api_key_user.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_keys/require_api_key_user.rs new file mode 100644 index 0000000000..e7a867bd34 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_keys/require_api_key_user.rs @@ -0,0 +1,83 @@ +use std::marker::PhantomData; + +use actix_web::HttpRequest; +use log::{info, warn}; +use sqlx::MySqlConnection; + +use mysql_queries::queries::api_keys::get_api_key_user_by_value::{ + get_api_key_user_by_value, GetApiKeyUserByValueArgs, +}; +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::get_authorization_header_api_key::get_authorization_header_api_key; + +/// The authenticated identity behind an API-key request — the API-key analogue of a cookie +/// `SessionUserRecord`, but deliberately minimal (just what the omni-api handlers need). +/// +/// Unlike cookie sessions, this is **never** cached in Redis: every request performs a fresh MySQL +/// lookup. +pub struct UserApiSession { + pub api_key_token: ApiKeyToken, + pub user_token: UserToken, + pub is_banned: bool, +} + +/// Authenticate a request using the API key in its `Authorization` header (see +/// `get_authorization_header_api_key` for the accepted formats). +/// +/// Returns `CommonWebError::NotAuthorized` (401) when the header is missing/unparseable, when the +/// key does not match a live (non-soft-deleted) API key whose owning user still exists, or when +/// that owner is banned. `mysql_executor` can be any sqlx executor — pass `&mut *connection` to +/// reuse a connection the handler already holds. +pub async fn require_api_key_user( + http_request: &HttpRequest, + mysql_connection: &mut MySqlConnection, +) -> Result { + let api_key = match get_authorization_header_api_key(http_request) { + Some(api_key) => api_key, + None => { + warn!("Missing or malformed Authorization header API key"); + return Err(CommonWebError::NotAuthorized); + } + }; + + let maybe_record = get_api_key_user_by_value(GetApiKeyUserByValueArgs { + api_key: &api_key, + mysql_executor: &mut *mysql_connection, + phantom: PhantomData, + }) + .await + .map_err(|err| { + warn!("API key user lookup error: {:?}", err); + CommonWebError::from_error(err) + })?; + + // A missing or soft-deleted key (or a key whose owner no longer exists) is a 401, not a leak of + // which case occurred. + let record = match maybe_record { + Some(record) => record, + None => { + warn!("No live API key user for presented key: {:?}", api_key); + return Err(CommonWebError::NotAuthorized); + } + }; + + if record.is_banned { + warn!("API key owner is banned: {:?}", record.user_token.as_str()); + return Err(CommonWebError::NotAuthorized); + } + + info!( + "API key user authenticated: api_key_token={:?} user_token={:?}", + record.api_key_token.as_str(), + record.user_token.as_str(), + ); + + Ok(UserApiSession { + api_key_token: record.api_key_token, + user_token: record.user_token, + is_banned: record.is_banned, + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/user_lookup/mod.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/mod.rs new file mode 100644 index 0000000000..0043e41ef5 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/mod.rs @@ -0,0 +1,2 @@ +pub mod api_keys; +pub mod user_session; diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/user_session/mod.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/mod.rs similarity index 80% rename from crates/service/web/storyteller_web/src/http_server/web_utils/user_session/mod.rs rename to crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/mod.rs index 446fa123d5..af5848741f 100644 --- a/crates/service/web/storyteller_web/src/http_server/web_utils/user_session/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/mod.rs @@ -1,3 +1,4 @@ pub mod require_moderator; pub mod require_user_session; pub mod require_user_session_extended; +pub mod session_utils; diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/user_session/require_moderator.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/require_moderator.rs similarity index 92% rename from crates/service/web/storyteller_web/src/http_server/web_utils/user_session/require_moderator.rs rename to crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/require_moderator.rs index 69466b4e1a..b9cb4bb7a1 100644 --- a/crates/service/web/storyteller_web/src/http_server/web_utils/user_session/require_moderator.rs +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/require_moderator.rs @@ -5,7 +5,7 @@ use sqlx::{Executor, MySql}; use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::session::session_checker::SessionChecker; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; /// `mysql_executor` can be any sqlx executor — pass `&server_state.mysql_pool` to grab a /// fresh connection, or an in-flight connection/transaction (`&mut *connection`) to reuse diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/user_session/require_user_session.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/require_user_session.rs similarity index 92% rename from crates/service/web/storyteller_web/src/http_server/web_utils/user_session/require_user_session.rs rename to crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/require_user_session.rs index 65cf5d2063..57c48b3bf9 100644 --- a/crates/service/web/storyteller_web/src/http_server/web_utils/user_session/require_user_session.rs +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/require_user_session.rs @@ -5,7 +5,7 @@ use sqlx::{Executor, MySql}; use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::session::session_checker::SessionChecker; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; /// `mysql_executor` can be any sqlx executor — pass `&server_state.mysql_pool` to grab a /// fresh connection, or an in-flight connection/transaction (`&mut *connection`) to reuse diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/user_session/require_user_session_extended.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/require_user_session_extended.rs similarity index 85% rename from crates/service/web/storyteller_web/src/http_server/web_utils/user_session/require_user_session_extended.rs rename to crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/require_user_session_extended.rs index 2bc037be10..a4c1fb1791 100644 --- a/crates/service/web/storyteller_web/src/http_server/web_utils/user_session/require_user_session_extended.rs +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/require_user_session_extended.rs @@ -3,8 +3,8 @@ use log::warn; use sqlx::MySqlConnection; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; -use crate::http_server::session::session_checker::SessionChecker; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; /// Pass an in-flight connection (`&mut *connection`) to reuse one the handler already holds. /// (The extended lookup runs two queries, so it takes a concrete `&mut MySqlConnection` — which diff --git a/crates/service/web/storyteller_web/src/http_server/session/lookup/mod.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/lookup/mod.rs similarity index 100% rename from crates/service/web/storyteller_web/src/http_server/session/lookup/mod.rs rename to crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/lookup/mod.rs diff --git a/crates/service/web/storyteller_web/src/http_server/session/lookup/user_session_extended.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/lookup/user_session_extended.rs similarity index 96% rename from crates/service/web/storyteller_web/src/http_server/session/lookup/user_session_extended.rs rename to crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/lookup/user_session_extended.rs index cb2c56cb61..ee8dcae790 100644 --- a/crates/service/web/storyteller_web/src/http_server/session/lookup/user_session_extended.rs +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/lookup/user_session_extended.rs @@ -3,7 +3,7 @@ use enums::common::payments_namespace::PaymentsNamespace; use enums::common::visibility::Visibility; use tokens::tokens::users::UserToken; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; #[derive(Clone, Default)] pub struct UserSessionExtended { diff --git a/crates/service/web/storyteller_web/src/http_server/session/lookup/user_session_feature_flags.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/lookup/user_session_feature_flags.rs similarity index 97% rename from crates/service/web/storyteller_web/src/http_server/session/lookup/user_session_feature_flags.rs rename to crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/lookup/user_session_feature_flags.rs index ace366d2dc..7db883bd1d 100644 --- a/crates/service/web/storyteller_web/src/http_server/session/lookup/user_session_feature_flags.rs +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/lookup/user_session_feature_flags.rs @@ -101,6 +101,10 @@ impl UserSessionFeatureFlags { pub fn can_use_referrals_program(&self) -> bool { self.feature_flags.contains(&UserFeatureFlag::ReferralsProgram) } + + pub fn can_create_api_key(&self) -> bool { + self.feature_flags.contains(&UserFeatureFlag::ApiKey) + } } #[cfg(test)] @@ -109,7 +113,7 @@ mod tests { use enums::by_table::users::user_feature_flag::UserFeatureFlag; - use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; + use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; mod construction { use super::*; diff --git a/crates/service/web/storyteller_web/src/http_server/session/mod.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/mod.rs similarity index 100% rename from crates/service/web/storyteller_web/src/http_server/session/mod.rs rename to crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/mod.rs diff --git a/crates/service/web/storyteller_web/src/http_server/session/session_checker.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/session_checker.rs similarity index 96% rename from crates/service/web/storyteller_web/src/http_server/session/session_checker.rs rename to crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/session_checker.rs index a1961a078d..2e0c0fa98d 100644 --- a/crates/service/web/storyteller_web/src/http_server/session/session_checker.rs +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/session_checker.rs @@ -14,9 +14,9 @@ use redis_common::redis_cache_keys::RedisCacheKeys; use sqlx::pool::PoolConnection; use sqlx::{Executor, MySql, MySqlConnection, MySqlPool}; -use crate::http_server::session::lookup::user_session_extended::{UserSessionExtended, UserSessionPreferences, UserSessionPremiumPlanInfo, UserSessionRoleAndPermissions, UserSessionSubscriptionPlan, UserSessionUserDetails}; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; -use crate::http_server::session::session_checker_error::SessionCheckerError; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::{UserSessionExtended, UserSessionPreferences, UserSessionPremiumPlanInfo, UserSessionRoleAndPermissions, UserSessionSubscriptionPlan, UserSessionUserDetails}; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::session_checker_error::SessionCheckerError; #[derive(Clone)] pub struct SessionChecker { diff --git a/crates/service/web/storyteller_web/src/http_server/session/session_checker_error.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/session_checker_error.rs similarity index 100% rename from crates/service/web/storyteller_web/src/http_server/session/session_checker_error.rs rename to crates/service/web/storyteller_web/src/http_server/user_lookup/user_session/session_utils/session_checker_error.rs diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/get_authorization_header_api_key.rs b/crates/service/web/storyteller_web/src/http_server/web_utils/get_authorization_header_api_key.rs new file mode 100644 index 0000000000..fad5f03ba2 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/web_utils/get_authorization_header_api_key.rs @@ -0,0 +1,144 @@ +use actix_http::header::{HeaderMap, HeaderName}; +use actix_web::HttpRequest; + +use artcraft_api_keys::ArtcraftApiKey; + +const AUTHORIZATION_HEADER_NAME: HeaderName = HeaderName::from_static("authorization"); + +/// Extract an [`ArtcraftApiKey`] from a request's `Authorization` header. +/// +/// The header name is matched case-insensitively (HTTP header names always are). The value parsing +/// — the `Bearer `, `Key `, and bare `` forms — lives in the +/// [`artcraft_api_keys`] crate. Returns `None` if the header is absent, unreadable (non-ASCII +/// bytes), or does not contain a usable key. +pub fn get_authorization_header_api_key(http_request: &HttpRequest) -> Option { + let header_map: &HeaderMap = http_request.headers(); + let header_value = header_map.get(AUTHORIZATION_HEADER_NAME)? + .to_str() + .ok()?; + + ArtcraftApiKey::parse_from_authorization_header_value(header_value) +} + +#[cfg(test)] +mod tests { + use actix_web::test::TestRequest; + + use super::*; + + const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh"; + + mod request_header_tests { + use super::*; + + #[test] + fn bearer_scheme() { + assert_eq!( + api_key_for_authorization_header(&format!("Bearer {SAMPLE_KEY}")), + Some(SAMPLE_KEY.to_string())); + } + + #[test] + fn key_scheme() { + assert_eq!( + api_key_for_authorization_header(&format!("Key {SAMPLE_KEY}")), + Some(SAMPLE_KEY.to_string())); + } + + #[test] + fn bare_key_aws_style() { + assert_eq!( + api_key_for_authorization_header(SAMPLE_KEY), + Some(SAMPLE_KEY.to_string())); + } + + #[test] + fn header_name_is_case_insensitive() { + let value = format!("Bearer {SAMPLE_KEY}"); + for header_name in ["authorization", "Authorization", "AUTHORIZATION"] { + let http_request = TestRequest::default() + .insert_header((header_name, value.as_str())) + .to_http_request(); + assert_eq!( + get_authorization_header_api_key(&http_request).map(|key| key.to_string_be_careful()), + Some(SAMPLE_KEY.to_string()), + "failed for header name {header_name:?}"); + } + } + + #[test] + fn missing_header_returns_none() { + let http_request = TestRequest::default().to_http_request(); + assert_eq!(get_authorization_header_api_key(&http_request), None); + } + } + + mod parsing_tests { + use super::*; + + #[test] + fn bearer_scheme_is_case_insensitive() { + assert_eq!(parse_authorization_header_api_key("Bearer abc"), Some("abc".to_string())); + assert_eq!(parse_authorization_header_api_key("bearer abc"), Some("abc".to_string())); + assert_eq!(parse_authorization_header_api_key("BEARER abc"), Some("abc".to_string())); + assert_eq!(parse_authorization_header_api_key("BeArEr abc"), Some("abc".to_string())); + } + + #[test] + fn key_scheme_is_case_insensitive() { + assert_eq!(parse_authorization_header_api_key("Key abc"), Some("abc".to_string())); + assert_eq!(parse_authorization_header_api_key("key abc"), Some("abc".to_string())); + assert_eq!(parse_authorization_header_api_key("KEY abc"), Some("abc".to_string())); + } + + #[test] + fn bare_key_aws_style() { + assert_eq!(parse_authorization_header_api_key("abc"), Some("abc".to_string())); + assert_eq!(parse_authorization_header_api_key(SAMPLE_KEY), Some(SAMPLE_KEY.to_string())); + } + + #[test] + fn surrounding_and_inner_whitespace_is_trimmed() { + assert_eq!(parse_authorization_header_api_key(" Bearer abc "), Some("abc".to_string())); + assert_eq!(parse_authorization_header_api_key("\tKey\tabc\t"), Some("abc".to_string())); + assert_eq!(parse_authorization_header_api_key(" abc "), Some("abc".to_string())); + } + + #[test] + fn empty_or_blank_is_none() { + assert_eq!(parse_authorization_header_api_key(""), None); + assert_eq!(parse_authorization_header_api_key(" "), None); + } + + #[test] + fn scheme_with_no_credential_is_none() { + assert_eq!(parse_authorization_header_api_key("Bearer"), None); + assert_eq!(parse_authorization_header_api_key("Bearer "), None); + assert_eq!(parse_authorization_header_api_key("Key"), None); + assert_eq!(parse_authorization_header_api_key("key "), None); + } + + #[test] + fn unsupported_scheme_is_none() { + assert_eq!(parse_authorization_header_api_key("Basic dXNlcjpwYXNz"), None); + assert_eq!(parse_authorization_header_api_key("Digest abc"), None); + // Two non-scheme tokens — a bare key never contains whitespace. + assert_eq!(parse_authorization_header_api_key("abc def"), None); + } + } + + // Thin string-returning adapters over the real implementation so the original test cases above + // can stay unchanged after the parser moved into the `artcraft_api_keys` crate. + + fn parse_authorization_header_api_key(header_value: &str) -> Option { + ArtcraftApiKey::parse_from_authorization_header_value(header_value) + .map(|key| key.to_string_be_careful()) + } + + fn api_key_for_authorization_header(value: &str) -> Option { + let http_request = TestRequest::default() + .insert_header(("Authorization", value)) + .to_http_request(); + get_authorization_header_api_key(&http_request).map(|key| key.to_string_be_careful()) + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs b/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs index a543348861..7e78273885 100644 --- a/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs @@ -1,5 +1,6 @@ pub mod bucket_urls; pub mod filter_model_name; +pub mod get_authorization_header_api_key; pub mod get_host_header; pub mod get_request_platform_type; pub mod handle_multipart_error; @@ -11,4 +12,3 @@ pub mod response_success_helpers; pub mod scoped_temp_dir_creator; pub mod serialize_as_json_error; pub mod try_delete_session_cache; -pub mod user_session; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/response_error_helpers.rs b/crates/service/web/storyteller_web/src/http_server/web_utils/response_error_helpers.rs index 41ffdca006..73cef455c1 100644 --- a/crates/service/web/storyteller_web/src/http_server/web_utils/response_error_helpers.rs +++ b/crates/service/web/storyteller_web/src/http_server/web_utils/response_error_helpers.rs @@ -25,6 +25,6 @@ pub fn to_simple_json_error( }; HttpResponseBuilder::new(status_code) - .set_header(CONTENT_TYPE, "application/json") + .insert_header((CONTENT_TYPE, "application/json")) .body(body) } \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/serialize_as_json_error.rs b/crates/service/web/storyteller_web/src/http_server/web_utils/serialize_as_json_error.rs index b4aa6fa1ab..8ca8857a03 100644 --- a/crates/service/web/storyteller_web/src/http_server/web_utils/serialize_as_json_error.rs +++ b/crates/service/web/storyteller_web/src/http_server/web_utils/serialize_as_json_error.rs @@ -16,6 +16,6 @@ pub fn serialize_as_json_error( }; HttpResponseBuilder::new(error_payload.status_code()) - .set_header(CONTENT_TYPE, "application/json") + .insert_header((CONTENT_TYPE, "application/json")) .body(body) } diff --git a/crates/service/web/storyteller_web/src/startup/build_dependencies.rs b/crates/service/web/storyteller_web/src/startup/build_dependencies.rs index a2ed3b4a4a..c02cc1dfdc 100644 --- a/crates/service/web/storyteller_web/src/startup/build_dependencies.rs +++ b/crates/service/web/storyteller_web/src/startup/build_dependencies.rs @@ -5,7 +5,7 @@ use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::Av use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager; use anyhow::anyhow; use chrono::Utc; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; use elasticsearch::http::transport::Transport; use elasticsearch::Elasticsearch; use errors::AnyhowResult; @@ -26,7 +26,7 @@ use url_config::third_party_url_redirector::ThirdPartyUrlRedirector; use crate::configs::app_startup::redis_rate_limiters::configure_redis_rate_limiters; use crate::configs::connect_to_database::connect_to_database; use crate::configs::static_api_tokens::StaticApiTokenSet; -use crate::http_server::session::session_checker::SessionChecker; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator; use crate::startup::setup_pager::build_pager; use crate::startup::setup_bans::{ @@ -34,6 +34,7 @@ use crate::startup::setup_bans::{ load_static_container_ip_bans, load_troll_user_token_bans, }; use crate::startup::setup_inference_providers::setup_inference_providers; +use crate::startup::setup_seedance_video_bucket::setup_seedance_video_bucket; use crate::startup::setup_static_feature_flags::setup_static_feature_flags; use crate::startup::setup_stripe_artcraft::setup_stripe_artcraft; use crate::startup::setup_stripe_fakeyou::setup_stripe_fakeyou; @@ -123,17 +124,17 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult AnyhowResult Option { + let bucket_name = read_non_empty_env("SEEDANCE_VIDEO_BUCKET_NAME"); + let region = read_non_empty_env("SEEDANCE_VIDEO_BUCKET_REGION"); + let endpoint = read_non_empty_env("SEEDANCE_VIDEO_BUCKET_ENDPOINT"); + let access_key = read_non_empty_env("SEEDANCE_VIDEO_BUCKET_ACCESS_KEY_ID"); + let secret_key = read_non_empty_env("SEEDANCE_VIDEO_BUCKET_SECRET_ACCESS_KEY"); + + match (bucket_name, region, endpoint, access_key, secret_key) { + (Some(bucket_name), Some(region), Some(endpoint), Some(access_key), Some(secret_key)) => { + match BucketClientBuilder::new() + .access_key(access_key) + .secret_key(secret_key) + .region_name(region) + .bucket_name(&bucket_name) + .endpoint(endpoint) + .build() + { + Ok(client) => { + info!("Seedance video bucket configured: {}", bucket_name); + Some(client) + } + Err(err) => { + warn!("Seedance video bucket env present but client setup failed: {}", err); + None + } + } + } + (None, None, None, None, None) => { + info!("Seedance video bucket not configured; archival uploads disabled."); + None + } + _ => { + warn!( + "Seedance video bucket is only partially configured \ + (need all of SEEDANCE_VIDEO_BUCKET_NAME / _REGION / _ENDPOINT / \ + _ACCESS_KEY_ID / _SECRET_ACCESS_KEY); skipping setup." + ); + None + } + } +} + +/// Read an env var, treating unset or whitespace-only as absent. +fn read_non_empty_env(name: &str) -> Option { + match env::var(name) { + Ok(value) if !value.trim().is_empty() => Some(value), + _ => None, + } +} diff --git a/crates/service/web/storyteller_web/src/state/server_state.rs b/crates/service/web/storyteller_web/src/state/server_state.rs index ac1ff64572..6165ec3dc6 100644 --- a/crates/service/web/storyteller_web/src/state/server_state.rs +++ b/crates/service/web/storyteller_web/src/state/server_state.rs @@ -6,7 +6,7 @@ use crate::http_server::endpoints::media_files::list::list_featured_media_files_ use crate::http_server::endpoints::stats::result_transformer::CacheableQueueStats; use crate::http_server::endpoints::tts::list_tts_models::TtsModelRecordForResponse; use crate::http_server::endpoints::voice_conversion::list_voice_conversion_models_handler::VoiceConversionModel; -use crate::http_server::session::session_checker::SessionChecker; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; use crate::http_server::web_utils::redis_rate_limiter::RedisRateLimiter; use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator; use crate::startup::setup_inference_providers::InferenceProviders; @@ -24,7 +24,8 @@ use actix_helpers::middleware::banned_ip_filter::ip_ban_list::ip_ban_list::IpBan use billing_artcraft_component::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient; use billing_component::stripe::stripe_config::StripeConfig; use chrono::{DateTime, Utc}; -use cloud_storage::bucket_client::BucketClient; +use cloud_storage::legacy_bucket_client::LegacyBucketClient; +use bucket_client::BucketClient as SeedanceVideoBucketClient; use elasticsearch::Elasticsearch; use memory_caching::arc_ttl_sieve::ArcTtlSieve; use memory_caching::single_item_ttl_cache::SingleItemTtlCache; @@ -84,9 +85,13 @@ pub struct ServerState { pub firehose_publisher: FirehosePublisher, pub badge_granter: BadgeGranter, - pub private_bucket_client: BucketClient, - pub public_bucket_client: BucketClient, - pub auto_gc_bucket_client: BucketClient, + pub private_bucket_client: LegacyBucketClient, + pub public_bucket_client: LegacyBucketClient, + pub auto_gc_bucket_client: LegacyBucketClient, + + /// Optional archive bucket for uploaded Seedance videos. `None` when the + /// `SEEDANCE_VIDEO_BUCKET_*` env vars aren't configured. + pub seedance_video_bucket: Option, pub inference_providers: InferenceProviders, @@ -160,6 +165,9 @@ pub struct RedisRateLimiters { /// For uploading files for voice conversion, face animator, etc. pub file_upload_logged_in: RedisRateLimiter, + + /// IP-based limiter for the read-only video-info upload endpoint. Fails open. + pub video_info_read_only: RedisRateLimiter, } /// In-memory caches of several types. diff --git a/crates/service/web/storyteller_web/src/util/allowed_explore_media_access.rs b/crates/service/web/storyteller_web/src/util/allowed_explore_media_access.rs index 2deba14b88..0f86b68b1a 100644 --- a/crates/service/web/storyteller_web/src/util/allowed_explore_media_access.rs +++ b/crates/service/web/storyteller_web/src/util/allowed_explore_media_access.rs @@ -1,7 +1,7 @@ use enums::by_table::users::user_feature_flag::UserFeatureFlag; use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; /// Check whether we should allow the request access to explore media. pub fn allowed_explore_media_access(maybe_session: Option) -> bool { diff --git a/crates/service/web/storyteller_web/src/util/allowed_studio_access.rs b/crates/service/web/storyteller_web/src/util/allowed_studio_access.rs index 475e2a0b67..2f9b19b482 100644 --- a/crates/service/web/storyteller_web/src/util/allowed_studio_access.rs +++ b/crates/service/web/storyteller_web/src/util/allowed_studio_access.rs @@ -1,7 +1,7 @@ use enums::by_table::users::user_feature_flag::UserFeatureFlag; use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::state::server_state::StaticFeatureFlags; diff --git a/crates/service/web/storyteller_web/src/util/allowed_video_style_transfer_access.rs b/crates/service/web/storyteller_web/src/util/allowed_video_style_transfer_access.rs index c64f78d113..b7a82653ac 100644 --- a/crates/service/web/storyteller_web/src/util/allowed_video_style_transfer_access.rs +++ b/crates/service/web/storyteller_web/src/util/allowed_video_style_transfer_access.rs @@ -1,7 +1,7 @@ use enums::by_table::users::user_feature_flag::UserFeatureFlag; use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord; -use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_extended::UserSessionExtended; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::state::server_state::StaticFeatureFlags; diff --git a/crates/service/web/storyteller_web/src/util/enroll_in_studio.rs b/crates/service/web/storyteller_web/src/util/enroll_in_studio.rs index 31f51806a7..3bf422ec42 100644 --- a/crates/service/web/storyteller_web/src/util/enroll_in_studio.rs +++ b/crates/service/web/storyteller_web/src/util/enroll_in_studio.rs @@ -7,7 +7,7 @@ use mysql_queries::queries::users::user::update::set_can_access_studio_transacti use mysql_queries::queries::users::user::update::set_user_feature_flags_transactional::{set_user_feature_flags_transactional, SetUserFeatureFlagTransactionalArgs}; use tokens::tokens::users::UserToken; -use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; +use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags; pub async fn enroll_in_studio( user_token: &UserToken, diff --git a/crates/service/web/storyteller_web/src/util/generate_api_key.rs b/crates/service/web/storyteller_web/src/util/generate_api_key.rs index e6e486a3e8..9ea2c8e10e 100644 --- a/crates/service/web/storyteller_web/src/util/generate_api_key.rs +++ b/crates/service/web/storyteller_web/src/util/generate_api_key.rs @@ -1,5 +1,7 @@ use rand::Rng; +use artcraft_api_keys::ArtcraftApiKey; + /// All Artcraft API keys begin with this prefix. It counts toward the total key length. /// /// The underscores in this literal are the ONLY underscores permitted in a key — the random @@ -27,7 +29,10 @@ const ENTROPY_CHAR_COUNT: usize = 40; /// /// Infallible: it draws from the thread-local CSPRNG (`rand::rng()`, a ChaCha-based, OS-seeded /// generator), which never fails. -pub fn generate_api_key() -> String { +/// +/// The full secret lives inside the returned [`ArtcraftApiKey`]; use `as_str()` to obtain it for +/// storage (`Debug`/`Display` deliberately redact it). +pub fn generate_api_key() -> ArtcraftApiKey { let mut rng = rand::rng(); let mut key = String::with_capacity(API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT); @@ -38,7 +43,7 @@ pub fn generate_api_key() -> String { key.push(CROCKFORD_LOWERCASE_CHARSET[index] as char); } - key + ArtcraftApiKey(key) } #[cfg(test)] @@ -49,19 +54,19 @@ mod tests { #[test] fn starts_with_prefix() { - assert!(generate_api_key().starts_with("artcraft_api_")); - assert!(generate_api_key().starts_with(API_KEY_PREFIX)); + assert!(generate_api_key().as_str_be_careful().starts_with("artcraft_api_")); + assert!(generate_api_key().as_str_be_careful().starts_with(API_KEY_PREFIX)); } #[test] fn has_expected_total_length() { - assert_eq!(generate_api_key().len(), API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT); + assert_eq!(generate_api_key().as_str_be_careful().len(), API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT); } #[test] fn entropy_suffix_is_crockford_lowercase() { let key = generate_api_key(); - let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); + let suffix = key.as_str_be_careful().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); assert_eq!(suffix.len(), ENTROPY_CHAR_COUNT); for byte in suffix.bytes() { @@ -79,21 +84,21 @@ mod tests { fn forbidden_characters_are_absent() { let key = generate_api_key(); for forbidden in ['.', '+', '/'] { - assert!(!key.contains(forbidden), "key must not contain {:?}", forbidden); + assert!(!key.as_str_be_careful().contains(forbidden), "key must not contain {:?}", forbidden); } } #[test] fn underscores_only_appear_in_the_prefix() { let key = generate_api_key(); - let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); + let suffix = key.as_str_be_careful().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); assert!(!suffix.contains('_'), "the random body must not contain underscores"); } #[test] fn entropy_suffix_is_alphanumeric() { let key = generate_api_key(); - let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); + let suffix = key.as_str_be_careful().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric())); } diff --git a/crates/service/web/storyteller_web/src/util/http_download_url_to_bytes.rs b/crates/service/web/storyteller_web/src/util/http_download_url_to_bytes.rs index 8e86553f57..b6af504d38 100644 --- a/crates/service/web/storyteller_web/src/util/http_download_url_to_bytes.rs +++ b/crates/service/web/storyteller_web/src/util/http_download_url_to_bytes.rs @@ -2,10 +2,17 @@ use bytes::Bytes; use log::warn; use std::error::Error; use std::fmt::{Display, Formatter}; +use wreq::redirect::Policy; use wreq::{Client, StatusCode}; const USER_AGENT: &str = "storyteller-web/1.0"; +/// Follow up to this many redirects. Many legitimate file URLs (CDN edges, +/// Wikimedia's `Special:FilePath`, S3 pre-sign indirections) answer with a 3xx +/// before serving the bytes, so we must follow them rather than treat the 3xx +/// as a download failure. +const MAX_REDIRECTS: usize = 10; + // TODO(bt, 2025-06-03): Don't load the entire file into memory! #[derive(Debug)] @@ -22,6 +29,7 @@ pub enum DownloadFileToBytesError { pub async fn http_download_url_to_bytes(url: &str) -> Result { let client = Client::builder() .gzip(true) + .redirect(Policy::limited(MAX_REDIRECTS)) .build()?; let response = client.get(url) // NB: No IntoUrl for &Url. diff --git a/frontend/.nxignore b/frontend/.nxignore new file mode 100644 index 0000000000..9b6ca4c783 --- /dev/null +++ b/frontend/.nxignore @@ -0,0 +1,8 @@ +# The admin dashboard is intentionally isolated from the Nx/npm workspace. +# +# It pins its own incompatible toolchain (React 19, Vite 7, Tailwind 4) and +# installs with pnpm into its own node_modules. Nx must NOT infer it as a +# project — otherwise `nx run-many`/`nx affected` would try to build it with the +# workspace's React 18 toolchain and root node_modules. It builds standalone +# (see apps/artcraft-dashboard/netlify.toml). +apps/artcraft-dashboard diff --git a/frontend/apps/artcraft-dashboard/.gitignore b/frontend/apps/artcraft-dashboard/.gitignore new file mode 100644 index 0000000000..3b0b403726 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.env \ No newline at end of file diff --git a/frontend/apps/artcraft-dashboard/README.md b/frontend/apps/artcraft-dashboard/README.md new file mode 100644 index 0000000000..9e8a61708e --- /dev/null +++ b/frontend/apps/artcraft-dashboard/README.md @@ -0,0 +1,2 @@ +# ArtCraft Admin Dashboard +Admin dashboard for managing ArtCraft diff --git a/frontend/apps/artcraft-dashboard/components.json b/frontend/apps/artcraft-dashboard/components.json new file mode 100644 index 0000000000..2a9908f264 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks", + "context": "@/context" + }, + "registries": {} +} diff --git a/frontend/apps/artcraft-dashboard/eslint.config.js b/frontend/apps/artcraft-dashboard/eslint.config.js new file mode 100644 index 0000000000..5e6b472f58 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/apps/artcraft-dashboard/index.html b/frontend/apps/artcraft-dashboard/index.html new file mode 100644 index 0000000000..1b06962dd0 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + ArtCraft Admin Dashboard + + +
+ + + diff --git a/frontend/apps/artcraft-dashboard/netlify.toml b/frontend/apps/artcraft-dashboard/netlify.toml new file mode 100644 index 0000000000..ff48b42250 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/netlify.toml @@ -0,0 +1,38 @@ +# Netlify configuration for the ArtCraft admin dashboard. +# +# Set up the Netlify site exactly like the other apps in this workspace: +# * Base directory: frontend (the Nx workspace root) +# * Build command: bash ./apps/artcraft-dashboard/script/netlify_build.sh +# * Publish dir: apps/artcraft-dashboard/dist +# +# (These match what's configured in the Netlify UI; paths are relative to the +# `frontend` base directory.) +# +# REQUIRED env var (set in the Netlify UI → Site config → Environment): +# * NODE_VERSION = 22 +# Vite 7 (used by this app) requires Node >= 20.19 / 22.12, which is newer than +# the rest of the workspace needs. The base directory is shared with the other +# apps, so this can't be pinned via a committed .node-version without affecting +# them — set it per-site in the UI instead. Netlify does not read this +# netlify.toml at base `frontend`, so a [build.environment] block here would NOT +# take effect; it must be a UI env var. +# +# The dashboard is deliberately ISOLATED from the Nx/npm workspace: it pins its +# own React 19 + Vite 7 + Tailwind 4 toolchain and installs with pnpm. The build +# script builds it standalone inside its own folder. Because the dashboard is +# excluded from the root npm `workspaces` globs, Netlify's automatic +# `npm install` at the base directory does NOT pull in its (incompatible) deps — +# that's what prevents the multi-minute install hangs we saw when it was a +# workspace member. + +[build] + command = "bash ./apps/artcraft-dashboard/script/netlify_build.sh" + publish = "apps/artcraft-dashboard/dist/" + +# Vite emits content-hashed assets under /assets/*, so they can be cached +# forever — a new deploy produces new filenames. +[[headers]] + for = "/assets/*" + + [headers.values] + "Cache-Control" = "public, max-age=31536000, immutable" diff --git a/frontend/apps/artcraft-dashboard/package.json b/frontend/apps/artcraft-dashboard/package.json new file mode 100644 index 0000000000..8604a886c5 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/package.json @@ -0,0 +1,55 @@ +{ + "name": "artcraft-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@dicebear/collection": "^9.4.0", + "@dicebear/core": "^9.4.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tabler/icons-react": "^3.38.0", + "@tailwindcss/vite": "^4.2.1", + "@tanstack/react-table": "^8.21.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "country-flag-icons": "^1.6.15", + "lucide-react": "^0.577.0", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1", + "recharts": "2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "vaul": "^1.1.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.11.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "shadcn": "^3.8.5", + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + }, + "packageManager": "pnpm@9.15.2+sha512.93e57b0126f0df74ce6bff29680394c0ba54ec47246b9cf321f0121d8d9bb03f750a705f24edc3c1180853afd7c2c3b94196d0a3d53d3e069d9e2793ef11f321" +} diff --git a/frontend/apps/artcraft-dashboard/pnpm-lock.yaml b/frontend/apps/artcraft-dashboard/pnpm-lock.yaml new file mode 100644 index 0000000000..debf7ad6a2 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/pnpm-lock.yaml @@ -0,0 +1,7252 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@dicebear/collection': + specifier: ^9.4.0 + version: 9.4.0(@dicebear/core@9.4.0) + '@dicebear/core': + specifier: ^9.4.0 + version: 9.4.0 + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.4) + '@tabler/icons-react': + specifier: ^3.38.0 + version: 3.38.0(react@19.2.4) + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + country-flag-icons: + specifier: ^1.6.15 + version: 1.6.15 + lucide-react: + specifier: ^0.577.0 + version: 0.577.0(react@19.2.4) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + radix-ui: + specifier: ^1.4.3 + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.2.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.0 + version: 19.2.4(react@19.2.4) + react-router-dom: + specifier: ^7.13.1 + version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + recharts: + specifier: 2.15.4 + version: 2.15.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.3 + '@types/node': + specifier: ^24.11.0 + version: 24.11.0 + '@types/react': + specifier: ^19.2.7 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react-swc': + specifier: ^4.2.2 + version: 4.2.3(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)) + eslint: + specifier: ^9.39.1 + version: 9.39.3(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.4.24 + version: 0.4.26(eslint@9.39.3(jiti@2.6.1)) + globals: + specifier: ^16.5.0 + version: 16.5.0 + shadcn: + specifier: ^3.8.5 + version: 3.8.5(@types/node@24.11.0)(typescript@5.9.3) + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.48.0 + version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1) + +packages: + + '@antfu/ni@25.0.0': + resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} + hasBin: true + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@dicebear/adventurer-neutral@9.4.0': + resolution: {integrity: sha512-zlpEF4KJhfl96j0M6wPmgaUVz20VKYZziIcIvf9pqGrvsTl1kDnoBtpmAROuU3e7FeCqDhk4qSQvorusW+L62g==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/adventurer@9.4.0': + resolution: {integrity: sha512-VfTOSc6XRdRGjdkTSC7AHmV1HdGlmUQ4/6TCb570uLsPFyFkG7nCVQYjbWZun3BilIQsyIuLSSWxrZWR+XH/rg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/avataaars-neutral@9.4.0': + resolution: {integrity: sha512-tGtmnBfjgdElgKouzEuIdJXQ0makePI1rZnVLW5hJxA6A3xWEAQOIHCqTA0UDBHjM/uJP5lspxUIJrJHU76/8Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/avataaars@9.4.0': + resolution: {integrity: sha512-zqpXcl+RHza3DeN3WcqtXMkQanI6wHUg/plJFb+uqI4KeXkJ6NBVsHNH7A4EImY/XZ4H3nw1g30io//ji5bxkw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/big-ears-neutral@9.4.0': + resolution: {integrity: sha512-xUJGFriKkBEs4dRe8rZ7fqT49x0JgOVwpl1A5hYXYI6BPZqyX4wfCPPynyPtYyZDWy+nuCWxFgc2fZCBV/hW7g==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/big-ears@9.4.0': + resolution: {integrity: sha512-d43CWzswbwed4q1RZFxt1qlhQfqzPGZVwGe0/+PZIr1B4U8y3/AqT7y1TptTdk6lL65XNhJKM30cxn72+x5fTA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/big-smile@9.4.0': + resolution: {integrity: sha512-LPXCc11Yw/p54OYNjyyiNoCdqXybuAWJRxkcpThx9S/TKouuwnEroj5PL3b1+unreCHtMDzkcO9dia7mqX9DYQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/bottts-neutral@9.4.0': + resolution: {integrity: sha512-ACIM6Cu0es4TdMA0jHUlKtWh50AZS0HJ5ykeBueZpPhMMGbjkRV90Sit/4+I2ghTOZ6Veug+UjEKz4VUbkfKwA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/bottts@9.4.0': + resolution: {integrity: sha512-vuFC5HRfzla7YH2s02CBrxBr+ninbZu9PtO3a72JoO8Da02/POI7RF3WjjlzfRG4+i5NHyn77gKsl2cy8rTTXA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/collection@9.4.0': + resolution: {integrity: sha512-OVMKwwS+npvbkJeOSIhtciOemUx//o1TpgwoOwGMffywsalL7+Mz9he/i6kT3xxi4mVkFDR46rtz4J/VlexXnQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/core@9.4.0': + resolution: {integrity: sha512-uoAG5mPBX+kQTtVerWUoH5e7rezG+DV/vJ5icd/kGooGyylH0nuJIlA6todkKGQv+/b0QNo+EzNF6Nc4UTE3wQ==} + engines: {node: '>=18.0.0'} + + '@dicebear/croodles-neutral@9.4.0': + resolution: {integrity: sha512-kRFE46B+WfGU4yDaD0ESSvt9A6CBtxuR7sGcFJ4YhK4T/O+tnP+iqRuQ3+ob1oNdEW3oQaD9aBioi3hBfbrrBA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/croodles@9.4.0': + resolution: {integrity: sha512-tC68VGu0XOtDd4aOORvchtRy1EMphuTWCl/vDIlS9zuKJJxIJCh0r7mREn/Azds07Hdg1R1Mr8j85tdVonEpgQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/dylan@9.4.0': + resolution: {integrity: sha512-1HxZyVmPf5ElERs4NqDtWHw6OBDae5v6t4zspCXRzMH/H0onwlbx3uAZDNGFdPgah8bSV3MhAzhggTCNcWtMxw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/fun-emoji@9.4.0': + resolution: {integrity: sha512-dDOw30RfCNfqqeXny4eQLgyMEXfZ0Y5Gz+rSPCuXGw735rCF+Wehyy4tzl2icCkXhWK9attlAY9anjV45k/2aQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/glass@9.4.0': + resolution: {integrity: sha512-piYKjXTPiTmdgkEW8OEAQNTbcAwtI0+iR2ODfKWnWBy8lM+rnY4TmBi3RgMFJXLFqjPgu38SXTsd2bWAfVa4MQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/icons@9.4.0': + resolution: {integrity: sha512-iwA4uM8E9B9kCEMJfxvgfDGje3h2ZE84SDuvJjjCWWZP/LJ5YX50QcRrfknRffD439DXJsKdXy9ku4OB5G7TkQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/identicon@9.4.0': + resolution: {integrity: sha512-6X5z7oHeGPuw9i7DaHQAQdHGAu9KYUgTZx8lWLJH/wutzCkygpNm7P0Q1FaP8zmdLkhj4AknQOoZ5AW0kaW4Lg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/initials@9.4.0': + resolution: {integrity: sha512-Qt0jDQKyo63HD8o3mXgb+PzM0L01BWpURtrEETZEGgES+C3Qz5fQPbVDdkKSNXn5yyjv6LbdniJJUjTxDmQAQw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/lorelei-neutral@9.4.0': + resolution: {integrity: sha512-3ceiazxgIN/9p6Ndg6X76N+RH61PSg0+717YiAZ5WN/epia/UUYzsZ5RrLyjrdq30SRNeHawp58qbAkOYMWD7g==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/lorelei@9.4.0': + resolution: {integrity: sha512-P91tqHckYj+IPw906F3SQwKvIMClJFwfYb4mvJGYoy/PyQVcRdT7ziKbYrG70bHKgdSEQSAarOdLH4EDLX4IpA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/micah@9.4.0': + resolution: {integrity: sha512-fMtENHrq7ZFNt+HpZTP0yr06dw76ur6SCjMK1eQBX6fwgtJ8HkHa/4TjhpjvQTarJJPs6FDPtGkHcYKCehBUNw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/miniavs@9.4.0': + resolution: {integrity: sha512-Gh4C8xF3vRM+FkEtfiYWLaRYCZP1Bzdg/gjLqvn/rJ9TCo645KksPcpABShZv7BPbOCkr17lhSrfBmlRjQnzkQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/notionists-neutral@9.4.0': + resolution: {integrity: sha512-wzg/NLcIzSM2O8IXcEFucYLJypS7I3VKmBsn4ShdM1qQ5nNlA8Ig3e9GKkfxRS2K+xTNDHyXuXNB88pj5Uzmig==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/notionists@9.4.0': + resolution: {integrity: sha512-MgZuW5of3b3cjLFi+D+iONZ3t/t9TZHYUyBXDmRxgeQW+l6td3n8Mjg8eA81jbzVC2RNyxKCOjZu6EyjyX88tA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/open-peeps@9.4.0': + resolution: {integrity: sha512-IxbfUWoYEUFdqYqz0iLYODbShV3GWx0t2Afq4pw6KTSewusjMIuYlvyK4z8cFkc2Ai/7VXRBLvQd+YA8KRMpIw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/personas@9.4.0': + resolution: {integrity: sha512-CjmIiOEwEmQeccIF0U7uzzBLOn9PWNFz87vAAiToWVzA4pVuzHgA+OiKzC6n91lZfRy76bGL1JtR8/ZppCN0YA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/pixel-art-neutral@9.4.0': + resolution: {integrity: sha512-OGYFbow6Hu345OObR0dPOAImuGP5vFqNkzkfkEPF4DPbLnCa3RjpeoCkyB+/Gvz7qAtyRR8W57Tfj6PQVRLLXg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/pixel-art@9.4.0': + resolution: {integrity: sha512-oQm9pGOaYCgfnxtzNY8xaJa3ZBH12xd7p4UT35ghvtRgk394uCnmz/bg71tnj2ynwVmZ4s5gBoWlUymnTvvCOw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/rings@9.4.0': + resolution: {integrity: sha512-lEhPwUd/uZFLAWM296/aNSGaCyT9NaTXm6V3izFtD8pywceze+sV3s46uLKpvCKUEcI4ia5iMERV35EH5P2ixg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/shapes@9.4.0': + resolution: {integrity: sha512-WTH1j6xqwdzBYiTPsCECqlB7kYC0TIbdlg49jEZJp9qP0tguVMH+M7GmWY5TO2chTRmYjJREmgvZWPgmE1Sd9Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/thumbs@9.4.0': + resolution: {integrity: sha512-eppbqo+3CvlDF4cwWNBsdNmtXHkVaj5AvM9KimVBWdp0S98foTTekCaQCBCmDfATywVXEGk+GaThTZdYgIE/0Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/toon-head@9.4.0': + resolution: {integrity: sha512-3u4ghFUFhnV1LYAfbltihOnASCk4qeWYLjg8B9U6drovrxY4yfX13vqNzQePormLvehXKpm9+gKbmy4kMt2w+g==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@dotenvx/dotenvx@1.52.0': + resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} + hasBin: true + + '@ecies/ciphers@0.2.5': + resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.4': + resolution: {integrity: sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.3': + resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mswjs/interceptors@0.41.3': + resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + engines: {node: '>=18'} + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.8': + resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.8': + resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.3': + resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@swc/core-darwin-arm64@1.15.18': + resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.18': + resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.18': + resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.18': + resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.18': + resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.18': + resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.18': + resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.18': + resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.18': + resolution: {integrity: sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.18': + resolution: {integrity: sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.18': + resolution: {integrity: sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + + '@tabler/icons-react@3.38.0': + resolution: {integrity: sha512-kR5wv+m4+GgmnSszg3rQd6SrTFAQ/XnQC/yTwIfuRJSfqB12KoIC7fPbIijFgOHTFlBN5DARnN0IVrR7KYG6/A==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.38.0': + resolution: {integrity: sha512-FdETQSpQ3lN7BEjEUzjKhsfTDCamrvMDops4HEMphTm3DmkIFpThoODn8XXZ8Q9MhjshIvphIYVHHB7zpq167w==} + + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.11.0': + resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.56.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.56.1': + resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react-swc@4.2.3': + resolution: {integrity: sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4 || ^5 || ^6 || ^7 + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} + hasBin: true + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001776: + resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + country-flag-icons@1.6.15: + resolution: {integrity: sha512-92HoA8l6DluEidku8tKBftjuFRj4Rv3zDW1lXxCuNnqAxhUSkvso9gM/Afj4F5BnK+wneHIe3ydI+s+4NA29/Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eciesjs@0.4.17: + resolution: {integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.307: + resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + engines: {node: '>=10.13.0'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.3: + resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.4: + resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + + fzf@0.5.2: + resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphql@16.13.1: + resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hono@4.12.5: + resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.577.0: + resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.12.10: + resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radix-ui@1.4.3: + resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.13.1: + resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.13.1: + resolution: {integrity: sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shadcn@3.8.5: + resolution: {integrity: sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA==} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + stringify-object@5.0.0: + resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} + engines: {node: '>=14.16'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tldts-core@7.0.24: + resolution: {integrity: sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==} + + tldts@7.0.24: + resolution: {integrity: sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@5.4.4: + resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} + engines: {node: '>=20'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript-eslint@8.56.1: + resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@antfu/ni@25.0.0': + dependencies: + ansis: 4.2.0 + fzf: 0.5.2 + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.28.6': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@dicebear/adventurer-neutral@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/adventurer@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/avataaars-neutral@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/avataaars@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/big-ears-neutral@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/big-ears@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/big-smile@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/bottts-neutral@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/bottts@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/collection@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/adventurer': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/adventurer-neutral': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/avataaars': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/avataaars-neutral': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/big-ears': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/big-ears-neutral': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/big-smile': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/bottts': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/bottts-neutral': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/core': 9.4.0 + '@dicebear/croodles': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/croodles-neutral': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/dylan': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/fun-emoji': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/glass': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/icons': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/identicon': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/initials': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/lorelei': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/lorelei-neutral': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/micah': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/miniavs': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/notionists': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/notionists-neutral': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/open-peeps': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/personas': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/pixel-art': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/pixel-art-neutral': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/rings': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/shapes': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/thumbs': 9.4.0(@dicebear/core@9.4.0) + '@dicebear/toon-head': 9.4.0(@dicebear/core@9.4.0) + + '@dicebear/core@9.4.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@dicebear/croodles-neutral@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/croodles@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/dylan@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/fun-emoji@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/glass@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/icons@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/identicon@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/initials@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/lorelei-neutral@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/lorelei@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/micah@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/miniavs@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/notionists-neutral@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/notionists@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/open-peeps@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/personas@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/pixel-art-neutral@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/pixel-art@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/rings@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/shapes@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/thumbs@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dicebear/toon-head@9.4.0(@dicebear/core@9.4.0)': + dependencies: + '@dicebear/core': 9.4.0 + + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tslib: 2.8.1 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dotenvx/dotenvx@1.52.0': + dependencies: + commander: 11.1.0 + dotenv: 17.3.1 + eciesjs: 0.4.17 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.3) + ignore: 5.3.2 + object-treeify: 1.1.33 + picomatch: 4.0.3 + which: 4.0.0 + + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.6.1))': + dependencies: + eslint: 9.39.3(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.4': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.3': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.11': {} + + '@hono/node-server@1.19.11(hono@4.12.5)': + dependencies: + hono: 4.12.5 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/confirm@5.1.21(@types/node@24.11.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.11.0) + '@inquirer/type': 3.0.10(@types/node@24.11.0) + optionalDependencies: + '@types/node': 24.11.0 + + '@inquirer/core@10.3.2(@types/node@24.11.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.11.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.11.0 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/type@3.0.10(@types/node@24.11.0)': + optionalDependencies: + '@types/node': 24.11.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.5) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.5 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@mswjs/interceptors@0.41.3': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/pluginutils@1.0.0-rc.2': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@swc/core-darwin-arm64@1.15.18': + optional: true + + '@swc/core-darwin-x64@1.15.18': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.18': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.18': + optional: true + + '@swc/core-linux-arm64-musl@1.15.18': + optional: true + + '@swc/core-linux-x64-gnu@1.15.18': + optional: true + + '@swc/core-linux-x64-musl@1.15.18': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.18': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.18': + optional: true + + '@swc/core-win32-x64-msvc@1.15.18': + optional: true + + '@swc/core@1.15.18': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.18 + '@swc/core-darwin-x64': 1.15.18 + '@swc/core-linux-arm-gnueabihf': 1.15.18 + '@swc/core-linux-arm64-gnu': 1.15.18 + '@swc/core-linux-arm64-musl': 1.15.18 + '@swc/core-linux-x64-gnu': 1.15.18 + '@swc/core-linux-x64-musl': 1.15.18 + '@swc/core-win32-arm64-msvc': 1.15.18 + '@swc/core-win32-ia32-msvc': 1.15.18 + '@swc/core-win32-x64-msvc': 1.15.18 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + + '@tabler/icons-react@3.38.0(react@19.2.4)': + dependencies: + '@tabler/icons': 3.38.0 + react: 19.2.4 + + '@tabler/icons@3.38.0': {} + + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1) + + '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tanstack/table-core@8.21.3': {} + + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.4 + path-browserify: 1.0.1 + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.11.0': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/statuses@2.0.6': {} + + '@types/validate-npm-package-name@4.0.2': {} + + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + eslint: 9.39.3(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.56.1': {} + + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + '@swc/core': 1.15.18 + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1) + transitivePeerDependencies: + - '@swc/helpers' + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansis@4.2.0: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.0: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001776 + electron-to-chromium: 1.5.307 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001776: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + code-block-writer@13.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@11.1.0: {} + + commander@14.0.3: {} + + concat-map@0.0.1: {} + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@9.0.1(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + country-flag-icons@1.6.15: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + diff@8.0.3: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.6 + csstype: 3.2.3 + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eciesjs@0.4.17: + dependencies: + '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.307: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.20.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 9.39.3(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.3(jiti@2.6.1)): + dependencies: + eslint: 9.39.3(jiti@2.6.1) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.3(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.4 + '@eslint/js': 9.39.3 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventemitter3@4.0.7: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-equals@5.4.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.0: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.4 + keyv: 4.5.4 + + flatted@3.3.4: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.3.4: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + fuzzysort@3.1.0: {} + + fzf@0.5.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-own-enumerable-keys@1.0.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphql@16.13.1: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + headers-polyfill@4.0.3: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hono@4.12.5: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + internmap@2.0.3: {} + + ip-address@10.0.1: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-node-process@1.2.0: {} + + is-number@7.0.0: {} + + is-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-regexp@3.1.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + jiti@2.6.1: {} + + jose@6.1.3: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lodash@4.17.23: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.577.0(react@19.2.4): + dependencies: + react: 19.2.4 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + msw@2.12.10(@types/node@24.11.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@24.11.0) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.13.1 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.4 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + mute-stream@2.0.0: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-releases@2.0.36: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-treeify@1.1.33: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + outvariant@1.4.3: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-manager-detector@1.6.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-ms@4.0.0: {} + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.3.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pkce-challenge@5.0.1: {} + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + prelude-ls@1.2.1: {} + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-router: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + + react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + cookie: 1.1.1 + react: 19.2.4 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + + react-smooth@4.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + fast-equals: 5.4.0 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-transition-group: 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@babel/runtime': 7.28.6 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + react@19.2.4: {} + + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.23 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rettime@0.10.1: {} + + reusify@1.1.0: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + shadcn@3.8.5(@types/node@24.11.0)(typescript@5.9.3): + dependencies: + '@antfu/ni': 25.0.0 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@dotenvx/dotenvx': 1.52.0 + '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.1 + commander: 14.0.3 + cosmiconfig: 9.0.1(typescript@5.9.3) + dedent: 1.7.2 + deepmerge: 4.3.1 + diff: 8.0.3 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.3.4 + fuzzysort: 3.1.0 + https-proxy-agent: 7.0.6 + kleur: 4.1.5 + msw: 2.12.10(@types/node@24.11.0)(typescript@5.9.3) + node-fetch: 3.3.2 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + prompts: 2.4.2 + recast: 0.23.11 + stringify-object: 5.0.0 + tailwind-merge: 3.5.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@types/node' + - babel-plugin-macros + - supports-color + - typescript + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + statuses@2.0.2: {} + + stdin-discarder@0.2.2: {} + + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tagged-tag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} + + tiny-invariant@1.3.3: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tldts-core@7.0.24: {} + + tldts@7.0.24: + dependencies: + tldts-core: 7.0.24 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.24 + + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@5.4.4: + dependencies: + tagged-tag: 1.0.0 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript-eslint@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + until-async@3.0.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + util-deprecate@1.0.2: {} + + validate-npm-package-name@7.0.2: {} + + vary@1.1.2: {} + + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.11.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + + web-streams-polyfill@3.3.3: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + word-wrap@1.2.5: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.1.2: {} + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@3.25.76: {} + + zod@4.3.6: {} diff --git a/frontend/apps/artcraft-dashboard/public/_redirects b/frontend/apps/artcraft-dashboard/public/_redirects new file mode 100644 index 0000000000..5192543dd7 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/public/_redirects @@ -0,0 +1,2 @@ +/api/* https://api.storyteller.ai/:splat 200 +/* /index.html 200 diff --git a/frontend/apps/artcraft-dashboard/public/artcraft-icon.svg b/frontend/apps/artcraft-dashboard/public/artcraft-icon.svg new file mode 100644 index 0000000000..efc0be9e7f --- /dev/null +++ b/frontend/apps/artcraft-dashboard/public/artcraft-icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/apps/artcraft-dashboard/script/netlify_build.sh b/frontend/apps/artcraft-dashboard/script/netlify_build.sh new file mode 100755 index 0000000000..a66a01fb39 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/script/netlify_build.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +set -euxo pipefail + +# Build the ArtCraft admin dashboard. +# +# Like the other apps, this runs from the Netlify base directory (= the Nx +# workspace root, `frontend/`). But unlike them, the dashboard is intentionally +# ISOLATED from the Nx/npm workspace: it pins its own React 19 + Vite 7 + +# Tailwind 4 toolchain and installs with pnpm into its own node_modules. +# +# We build it entirely inside its own folder with pnpm, so it never depends on +# (or conflicts with) the workspace's React 18 install. The dashboard is +# excluded from the root npm `workspaces` globs, so Netlify's automatic +# `npm install` at the base directory ignores it — that exclusion is what keeps +# the workspace install fast and prevents the multi-minute dependency-resolution +# hangs we hit when the dashboard was a workspace member. + +echo "Building artcraft-dashboard (standalone pnpm build)" + +pushd apps/artcraft-dashboard + +# Use the pnpm version pinned in package.json's `packageManager` field, via +# corepack (bundled with Node on Netlify). Disable the interactive download +# prompt so CI never blocks waiting for confirmation. +export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + +corepack pnpm install --frozen-lockfile +corepack pnpm build + +echo "Final build files:" +find dist/ + +popd diff --git a/frontend/apps/artcraft-dashboard/src/App.css b/frontend/apps/artcraft-dashboard/src/App.css new file mode 100644 index 0000000000..b9d355df2a --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/apps/artcraft-dashboard/src/App.tsx b/frontend/apps/artcraft-dashboard/src/App.tsx new file mode 100644 index 0000000000..3fcd83ea00 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/App.tsx @@ -0,0 +1,103 @@ +import { BrowserRouter, Routes, Route, Navigate, useLocation, useSearchParams } from "react-router-dom"; +import { AuthProvider, useAuth } from "@/context/AuthContext"; +import { DashboardLayout } from "@/layouts/DashboardLayout"; +import { LoginPage } from "@/pages/Login"; +import { DashboardHome } from "@/pages/DashboardHome"; +import { UserSearch } from "@/pages/UserSearch"; +import { UserProfile } from "@/pages/UserProfile"; +import { WalletHistory } from "@/pages/WalletHistory"; +import { StripeLookup } from "@/pages/StripeLookup"; +import { JobHistory } from "@/pages/JobHistory"; +import { UserCreations } from "@/pages/UserCreations"; +import { UserSignups } from "@/pages/UserSignups"; +import { SubscriberSignups } from "@/pages/SubscriberSignups"; +import { ExploreMedia } from "@/pages/ExploreMedia"; +import { Impersonation } from "@/pages/Impersonation"; +import { StaffAuditLogs } from "@/pages/StaffAuditLogs"; +import { SendPager } from "@/pages/SendPager"; +import { FeatureFlags } from "@/pages/FeatureFlags"; +import { UserEmailChanges } from "@/pages/UserEmailChanges"; +import { ReferralsAnalytics } from "@/pages/ReferralsAnalytics"; +import { UserReferrals } from "@/pages/UserReferrals"; +import { JobInfo } from "@/pages/JobInfo"; +import { JobTokenSearch } from "@/pages/JobTokenSearch"; +import { DebugLogs } from "@/pages/DebugLogs"; +import { DebugLogsSearch } from "@/pages/DebugLogsSearch"; +import { NotFoundPage } from "@/pages/NotFound"; +import { Spinner } from "./components/ui/spinner"; +import { ThemeProvider } from "@/components/theme-provider"; + +function AppRoutes() { + const { user, isLoading } = useAuth(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const redirectTo = searchParams.get("redirect") || "/"; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + + : } + /> + + : } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + + + {/* 404 */} + } /> + + ); +} + +function App() { + return ( + + + + + + + + ); +} + +export default App; diff --git a/frontend/apps/artcraft-dashboard/src/api/ApiManager.ts b/frontend/apps/artcraft-dashboard/src/api/ApiManager.ts new file mode 100644 index 0000000000..4551637ad6 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/api/ApiManager.ts @@ -0,0 +1,124 @@ +export interface ApiResponse { + success: boolean; + errorMessage?: string; + data?: T; + pagination?: P; +} + +const getApiHost = () => { + return import.meta.env.VITE_API_HOST || "/api"; +}; + +export class ApiManager { + protected getApiSchemeAndHost(): string { + return getApiHost(); + } + + public async fetch( + endpoint: string, + { + method, + query, + body, + }: { + method: string; + query?: Record; + body?: B; + }, + ): Promise { + const queryInString = + query && + Object.entries(query).reduce( + (allOptions, [key, value]) => { + if (!value) { + return allOptions; + } + allOptions[key] = value.toString(); + return allOptions; + }, + {} as Record, + ); + + const endpointWithQueries = queryInString + ? endpoint + "?" + new URLSearchParams(queryInString) + : endpoint; + + const bodyInString = body ? JSON.stringify(body) : undefined; + + const response = await fetch(endpointWithQueries, { + method, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + credentials: "include", + body: bodyInString, + }); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + console.error("API Error Response:", text); + throw new Error(text || `HTTP ${response.status}`); + } + } + + protected get({ + endpoint, + query, + }: { + endpoint: string; + query?: Record; + }): Promise { + return this.fetch(endpoint, { method: "GET", query }); + } + + protected post({ + endpoint, + query, + body, + }: { + endpoint: string; + query?: Record; + body?: B; + }): Promise { + return this.fetch(endpoint, { + method: "POST", + query, + body, + }); + } + + protected put({ + endpoint, + query, + body, + }: { + endpoint: string; + query?: Record; + body?: B; + }): Promise { + return this.fetch(endpoint, { + method: "PUT", + query, + body, + }); + } + + protected delete({ + endpoint, + query, + body, + }: { + endpoint: string; + query?: Record; + body?: B; + }): Promise { + return this.fetch(endpoint, { + method: "DELETE", + query, + body, + }); + } +} diff --git a/frontend/apps/artcraft-dashboard/src/api/MediaApi.ts b/frontend/apps/artcraft-dashboard/src/api/MediaApi.ts new file mode 100644 index 0000000000..9b3037e48c --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/api/MediaApi.ts @@ -0,0 +1,305 @@ +import { ApiManager, type ApiResponse } from "./ApiManager"; + +export interface Pagination { + total_items: number; + total_pages: number; + current_page: number; + items_per_page: number; +} + +export interface ListUserMediaQuery { + page_index?: number; + page_size?: number; + filter_media_classes?: string[]; + filter_media_type?: string[]; + filter_engine_categories?: string[]; +} + +export interface ListMediaFilesQuery { + sort_ascending?: boolean; + page_size?: number; + cursor?: string; + cursor_is_reversed?: boolean; + filter_media_classes?: string; + filter_media_type?: string; + filter_engine_categories?: string; + include_user_uploads?: boolean; +} + +export interface CursorPagination { + cursor_is_reversed: boolean; + maybe_next: string | null; + maybe_previous: string | null; +} + +export interface ExploreMediaFile { + token: string; + media_class: string; + media_type: string; + created_at: string; + updated_at: string; + maybe_title: string | null; + maybe_text_transcript: string | null; + creator_set_visibility: string; + is_user_upload: boolean; + is_intermediate_system_file: boolean; + media_links: { + cdn_url: string; + maybe_thumbnail_template: string | null; + maybe_video_previews: { + animated?: string; + thumbnail_template?: string; + } | null; + }; + cover_image: { + default_cover: { + color_index: number; + image_index: number; + }; + maybe_cover_image_public_bucket_url: string | null; + maybe_links: { + cdn_url: string; + maybe_thumbnail_template: string | null; + } | null; + }; + maybe_creator: { + default_avatar: { color_index: number; image_index: number }; + display_name: string; + gravatar_hash: string; + user_token: string; + username: string; + } | null; + maybe_duration_millis: number | null; + maybe_engine_category: string | null; + origin: { + origin_category: string; + product_category: string; + }; + stats: { + bookmark_count: number; + positive_rating_count: number; + }; +} + +export interface MediaInfo { + token: string; + media_class: string; + media_type: string; + public_bucket_url?: string; + media_links?: { + cdn_url?: string; + maybe_thumbnail_template?: string; + maybe_video_previews?: { + animated?: string; + thumbnail_template?: string; + } | null; + }; + maybe_title?: string; + maybe_text_transcript?: string; + created_at?: string; + updated_at?: string; + creator_set_visibility?: string; + // include arbitrary keys + [key: string]: any; +} + +export function getMediaThumbnailUrl( + item: MediaInfo | ExploreMediaFile, + width = 512, +): string | undefined { + const widthStr = width.toString(); + + // Videos: use animated preview + if (item.media_class === "video") { + if (item.media_links?.maybe_video_previews?.animated) { + return item.media_links.maybe_video_previews.animated; + } + } + + // 3D/dimensional: use cover image URL + if (item.media_class === "dimensional") { + if ("cover_image" in item && item.cover_image) { + if (item.cover_image.maybe_cover_image_public_bucket_url) { + return item.cover_image.maybe_cover_image_public_bucket_url; + } + if (item.cover_image.maybe_links?.maybe_thumbnail_template) { + return item.cover_image.maybe_links.maybe_thumbnail_template.replace( + "{WIDTH}", + widthStr, + ); + } + if (item.cover_image.maybe_links?.cdn_url) { + return item.cover_image.maybe_links.cdn_url; + } + } + } + + // Images (and fallback): use thumbnail template + if (item.media_links?.maybe_thumbnail_template) { + return item.media_links.maybe_thumbnail_template.replace( + "{WIDTH}", + widthStr, + ); + } + + if (item.media_links?.cdn_url) return item.media_links.cdn_url; + if ("public_bucket_url" in item && item.public_bucket_url) + return item.public_bucket_url as string; + return undefined; +} + +export interface MediaFileDetail { + token: string; + maybe_prompt_token: string | null; + media_class: string; + media_type: string; + maybe_title: string | null; + maybe_text_transcript: string | null; + [key: string]: any; +} + +export interface PromptInfo { + token: string; + maybe_model_type: string | null; + maybe_model_class: string | null; + maybe_positive_prompt: string | null; + maybe_negative_prompt: string | null; + maybe_aspect_ratio: string | null; + maybe_resolution: string | null; + maybe_generation_provider: string | null; + maybe_generation_mode: string | null; + maybe_duration_seconds: number | null; + maybe_inference_duration_millis: number | null; +} + +export class MediaApi extends ApiManager { + public async ListMediaFiles( + query: ListMediaFilesQuery = {}, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list`; + + const queryParams: Record = {}; + + if (query.page_size !== undefined) queryParams.page_size = query.page_size; + if (query.cursor) queryParams.cursor = query.cursor; + if (query.sort_ascending !== undefined) + queryParams.sort_ascending = query.sort_ascending; + if (query.cursor_is_reversed !== undefined) + queryParams.cursor_is_reversed = query.cursor_is_reversed; + if (query.filter_media_classes) + queryParams.filter_media_classes = query.filter_media_classes; + if (query.filter_media_type) + queryParams.filter_media_type = query.filter_media_type; + if (query.filter_engine_categories) + queryParams.filter_engine_categories = query.filter_engine_categories; + if (query.include_user_uploads) + queryParams.include_user_uploads = query.include_user_uploads; + + return await this.get<{ + success: boolean; + results: ExploreMediaFile[]; + pagination: CursorPagination; + error_message?: string; + }>({ endpoint, query: queryParams }) + .then((response) => ({ + success: response.success, + data: response.results ?? [], + pagination: response.pagination, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListUserMediaFiles( + username: string, + query: ListUserMediaQuery = {}, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list/user/${username}`; + + // Convert to fully stringified dict + const queryWithStrings: Record = { + include_user_uploads: true, + }; + + if (query.page_index !== undefined) + queryWithStrings.page_index = query.page_index; + if (query.page_size !== undefined) + queryWithStrings.page_size = query.page_size; + + if (query.filter_media_classes) { + queryWithStrings.filter_media_classes = + query.filter_media_classes.join(","); + } + if (query.filter_media_type) { + queryWithStrings.filter_media_type = query.filter_media_type.join(","); + } + if (query.filter_engine_categories) { + queryWithStrings.filter_engine_categories = + query.filter_engine_categories.join(","); + } + + return await this.get<{ + success: boolean; + results: MediaInfo[]; + pagination?: Pagination; + error_message?: string; + }>({ endpoint, query: queryWithStrings }) + .then((response) => ({ + success: response.success, + data: response.results ?? [], + pagination: response.pagination, + errorMessage: response.error_message, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + + public async GetMediaFile( + token: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/file/${token}`; + + return await this.get<{ + success: boolean; + media_file: MediaFileDetail; + error_message?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: response.media_file, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async GetPrompt( + token: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/prompts/${token}`; + + return await this.get<{ + success: boolean; + prompt: PromptInfo; + error_message?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: response.prompt, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } +} diff --git a/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts b/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts new file mode 100644 index 0000000000..23e94f082d --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts @@ -0,0 +1,798 @@ +import { ApiManager, type ApiResponse } from "./ApiManager"; +import type { + Wallet, + WalletWithOwner, + WalletLedgerEntry, + WalletLedgerEntryWithWallet, + UserJob, + SignupUser, + SubscriberUser, + ImpersonationRequest, + StaffAuditLog, + ModerationJob, + DebugLog, + UserEmailChange, +} from "@/types"; + +export interface FeatureFlagDescriptor { + key: string; + full_name: string; + description: string; +} + +export interface UserFeatureFlagsResponse { + user_token: string; + username: string; + display_name: string; + feature_flags: string[]; +} + +export type EditUserFeatureFlagsAction = + | { AddFlags: { flags: string[] } } + | { RemoveFlags: { flags: string[] } } + | { KeepFlags: { flags: string[] } } + | { SetExactFlags: { flags: string[] } } + | "ClearAllFlags"; + +export interface UserReferralInvitedUser { + token: string; + username: string; + display_name: string; + email_address: string; +} + +export interface UserReferralReferrerUser { + token: string; + username: string; + display_name: string; +} + +export interface UserReferralListItem { + created_at: string; + invited_user: UserReferralInvitedUser; + referrer_user: UserReferralReferrerUser; + maybe_landing_url: string | null; + maybe_referral_url: string | null; + maybe_referral_code_token: string | null; +} + +/** Which table the customer id was found in. */ +export type ModeratorStripeCustomerIdSource = "customer_link" | "subscription"; + +export interface ModeratorUserStripeCustomerIdEntry { + stripe_customer_id: string; + payments_namespace: string; + source: ModeratorStripeCustomerIdSource; +} + +export interface ModeratorUserLookupResponse { + display_name: string; + email_address: string; + email_confirmed: boolean; + email_gravatar_hash: string; + email_is_synthetic: boolean; + ip_address_creation: string; + ip_address_last_login: string; + is_banned: boolean; + is_temporary: boolean; + is_without_password: boolean; + maybe_avatar_media_file_token?: string; + created_at?: string; + token: string; + username: string; + username_is_generated: boolean; + username_is_not_customized: boolean; +} + +export class ModerationApi extends ApiManager { + public async UserLookup( + search: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/lookup`; + return await this.post< + { search: string }, + { + success: boolean; + maybe_user?: ModeratorUserLookupResponse; + error_message?: string; + } + >({ + endpoint, + body: { search }, + }) + .then((response) => ({ + success: response.success, + data: { + maybe_user: response.maybe_user, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async LookupByStripeCustomerId(stripe_customer_id: string): Promise< + ApiResponse<{ + users: Array<{ + display_name: string; + email_address: string; + maybe_stripe_subscription_id?: string | null; + subscription_namespace: string; + token: string; + username: string; + }>; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/lookup_by_stripe_customer_id`; + return await this.post< + { stripe_customer_id: string }, + { + success: boolean; + users: Array<{ + display_name: string; + email_address: string; + maybe_stripe_subscription_id?: string | null; + subscription_namespace: string; + token: string; + username: string; + }>; + error_message?: string; + } + >({ + endpoint, + body: { stripe_customer_id }, + }) + .then((response) => ({ + success: response.success, + data: { + users: response.users || [], + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async GetUserStripeCustomerIds( + user_token: string, + ): Promise< + ApiResponse<{ customer_ids: ModeratorUserStripeCustomerIdEntry[] }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_stripe_data/${encodeURIComponent(user_token)}/customer_ids`; + return await this.get<{ + success: boolean; + customer_ids: ModeratorUserStripeCustomerIdEntry[]; + error_message?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: { customer_ids: response.customer_ids || [] }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ImpersonateUser(params: { + username?: string; + user_token?: string; + email_address?: string; + username_email_or_token?: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_sessions/impersonate`; + return await this.post< + typeof params, + { + success: boolean; + password_token?: string; + error_message?: string; + } + >({ + endpoint, + body: params, + }) + .then((response) => ({ + success: response.success, + data: response.password_token + ? { password_token: response.password_token } + : undefined, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async SendAlert(params: { + title?: string | null; + description?: string | null; + urgency?: "high" | "medium" | "low" | null; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/alerts/send`; + return await this.post< + typeof params, + { success: boolean; error_message?: string } + >({ endpoint, body: params }) + .then((response) => ({ + success: response.success, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ManageUserBan(params: { + username: string; + is_banned: boolean; + mod_notes: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/moderation/user_bans/manage_ban`; + return await this.post< + typeof params, + { success: boolean; error_message?: string } + >({ endpoint, body: params }) + .then((response) => ({ + success: response.success, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListStaffAuditLogs( + cursor?: string | null, + limit?: number, + ): Promise< + ApiResponse<{ + audit_logs: StaffAuditLog[]; + next_cursor: string | null; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/staff_audit_logs/list`; + return await this.get<{ + success: boolean; + audit_logs: StaffAuditLog[]; + maybe_cursor: string | null; + error_message?: string; + }>({ + endpoint, + query: { + cursor: cursor ?? undefined, + limit, + }, + }) + .then((response) => ({ + success: response.success, + data: { + audit_logs: response.audit_logs || [], + next_cursor: response.maybe_cursor ?? null, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListAllImpersonationRequests( + cursor?: string | null, + limit?: number, + ): Promise< + ApiResponse<{ + impersonation_requests: ImpersonationRequest[]; + next_cursor: string | null; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_sessions/impersonation_requests/list`; + return await this.get<{ + success: boolean; + impersonation_requests: ImpersonationRequest[]; + maybe_cursor: string | null; + error_message?: string; + }>({ + endpoint, + query: { + cursor: cursor ?? undefined, + limit, + }, + }) + .then((response) => ({ + success: response.success, + data: { + impersonation_requests: response.impersonation_requests || [], + next_cursor: response.maybe_cursor ?? null, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListUserImpersonationRequests( + user_token: string, + cursor?: string | null, + limit?: number, + ): Promise< + ApiResponse<{ + impersonation_requests: ImpersonationRequest[]; + next_cursor: string | null; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_sessions/impersonation_requests/user/${user_token}`; + return await this.get<{ + success: boolean; + impersonation_requests: ImpersonationRequest[]; + maybe_cursor: string | null; + error_message?: string; + }>({ + endpoint, + query: { + cursor: cursor ?? undefined, + limit, + }, + }) + .then((response) => ({ + success: response.success, + data: { + impersonation_requests: response.impersonation_requests || [], + next_cursor: response.maybe_cursor ?? null, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListUserWallets( + user_token: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallets/user/${user_token}/list`; + return await this.get<{ + success: boolean; + wallets: Wallet[]; + error_message?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: { wallets: response.wallets || [] }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async GetWallet( + wallet_token: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet/${wallet_token}`; + return await this.get<{ + success: boolean; + maybe_wallet: WalletWithOwner | null; + error_message?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: { maybe_wallet: response.maybe_wallet }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async AddBankedBalance( + wallet_token: string, + credits: number, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet/${wallet_token}/add_banked_balance`; + return await this.post< + { credits: number }, + { success: boolean; error_message?: string } + >({ endpoint, body: { credits } }) + .then((response) => ({ + success: response.success, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListWalletLedgerEntries( + wallet_token: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet_ledger_entries/wallet/${wallet_token}/list`; + return await this.get<{ + success: boolean; + entries: WalletLedgerEntryWithWallet[]; + error_message?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: { entries: response.entries || [] }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async GetWalletLedgerEntry( + wallet_ledger_entry_token: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet_ledger_entry/${wallet_ledger_entry_token}`; + return await this.get<{ + success: boolean; + maybe_entry: WalletLedgerEntry | null; + error_message?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: { maybe_entry: response.maybe_entry }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async CreateWalletForUser( + user_token: string, + payments_namespace?: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet/create_for_user`; + return await this.post< + { user_token: string; payments_namespace?: string }, + { success: boolean; error_message?: string } + >({ + endpoint, + body: { user_token, payments_namespace }, + }) + .then((response) => ({ + success: response.success, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async GetJobByToken( + job_token: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/job/${encodeURIComponent(job_token)}`; + return await this.get<{ + success: boolean; + job?: ModerationJob | null; + error_message?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: { maybe_job: response.job ?? null }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListDebugLogs( + event_token: string, + limit?: number, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/debug_logs/list/${encodeURIComponent(event_token)}`; + return await this.get<{ + success: boolean; + debug_logs: DebugLog[]; + error_message?: string; + }>({ + endpoint, + query: { limit }, + }) + .then((response) => ({ + success: response.success, + data: { debug_logs: response.debug_logs || [] }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListUserJobs( + user_token: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/jobs/user/${user_token}/list`; + return await this.get<{ + success: boolean; + jobs: UserJob[]; + error_message?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: { jobs: response.jobs || [] }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListAllUsersBySignupDate( + id_cursor?: number | null, + ): Promise< + ApiResponse<{ users: SignupUser[]; next_cursor: number | null }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/list_all_by_signup_date`; + return await this.post< + { id_cursor?: number | null }, + { + success: boolean; + users: SignupUser[]; + next_cursor: number | null; + error_message?: string; + } + >({ + endpoint, + body: { id_cursor: id_cursor ?? null }, + }) + .then((response) => ({ + success: response.success, + data: { + users: response.users || [], + next_cursor: response.next_cursor ?? null, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListAvailableFeatureFlags(): Promise< + ApiResponse<{ feature_flags: FeatureFlagDescriptor[] }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_feature_flags/list`; + return await this.get<{ + success: boolean; + feature_flags: FeatureFlagDescriptor[]; + error_message?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: { feature_flags: response.feature_flags || [] }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListUserFeatureFlags( + username_or_token: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_feature_flags/user/${encodeURIComponent(username_or_token)}`; + return await this.get< + { success: boolean; error_message?: string } & UserFeatureFlagsResponse + >({ endpoint }) + .then((response) => ({ + success: response.success, + data: { + user_token: response.user_token, + username: response.username, + display_name: response.display_name, + feature_flags: response.feature_flags || [], + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async EditUserFeatureFlags( + username_or_token: string, + action: EditUserFeatureFlagsAction, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_feature_flags/user/${encodeURIComponent(username_or_token)}`; + return await this.post< + { action: EditUserFeatureFlagsAction }, + { success: boolean; error_message?: string } + >({ endpoint, body: { action } }) + .then((response) => ({ + success: response.success, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListGlobalUserReferrals( + cursor?: string | null, + limit?: number, + ): Promise< + ApiResponse<{ + referrals: UserReferralListItem[]; + next_cursor: string | null; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_referrals/list`; + return await this.get<{ + success: boolean; + referrals: UserReferralListItem[]; + maybe_cursor: string | null; + error_message?: string; + }>({ + endpoint, + query: { + cursor: cursor ?? undefined, + limit, + }, + }) + .then((response) => ({ + success: response.success, + data: { + referrals: response.referrals || [], + next_cursor: response.maybe_cursor ?? null, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListUserReferralsForUser( + username: string, + cursor?: string | null, + limit?: number, + ): Promise< + ApiResponse<{ + referrals: UserReferralListItem[]; + next_cursor: string | null; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_referrals/user/${encodeURIComponent(username)}/list`; + return await this.get<{ + success: boolean; + referrals: UserReferralListItem[]; + maybe_cursor: string | null; + error_message?: string; + }>({ + endpoint, + query: { + cursor: cursor ?? undefined, + limit, + }, + }) + .then((response) => ({ + success: response.success, + data: { + referrals: response.referrals || [], + next_cursor: response.maybe_cursor ?? null, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListSubscribersBySignupDate( + id_cursor?: number | null, + ): Promise< + ApiResponse<{ users: SubscriberUser[]; next_cursor: number | null }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/list_subscribers_by_signup_date`; + return await this.post< + { id_cursor?: number | null }, + { + success: boolean; + users: SubscriberUser[]; + next_cursor: number | null; + error_message?: string; + } + >({ + endpoint, + body: { id_cursor: id_cursor ?? null }, + }) + .then((response) => ({ + success: response.success, + data: { + users: response.users || [], + next_cursor: response.next_cursor ?? null, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ChangeUserEmail( + user_token: string, + new_email: string, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/moderation/user_emails/change`; + return await this.post< + { user_token: string; new_email: string }, + { success: boolean; error_message?: string } + >({ endpoint, body: { user_token, new_email } }) + .then((response) => ({ + success: response.success, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ListUserEmailChanges( + username: string, + cursor?: string | null, + limit?: number, + ): Promise< + ApiResponse<{ changes: UserEmailChange[]; next_cursor: string | null }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/moderation/user_emails/list/${encodeURIComponent(username)}`; + return await this.get<{ + success: boolean; + changes: UserEmailChange[]; + maybe_cursor: string | null; + error_message?: string; + }>({ + endpoint, + query: { + cursor: cursor ?? undefined, + limit, + }, + }) + .then((response) => ({ + success: response.success, + data: { + changes: response.changes || [], + next_cursor: response.maybe_cursor ?? null, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } +} diff --git a/frontend/apps/artcraft-dashboard/src/api/UsersApi.ts b/frontend/apps/artcraft-dashboard/src/api/UsersApi.ts new file mode 100644 index 0000000000..62b4fe65af --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/api/UsersApi.ts @@ -0,0 +1,299 @@ +import { ApiManager, type ApiResponse } from "./ApiManager"; +import type { UserInfo, AppStateResponse } from "@/types"; + +interface SignupRequest { + username: string; + email_address: string; + password: string; + password_confirmation: string; + signup_source?: string; +} + +export interface OnboardingData { + email_not_set: boolean; + email_not_confirmed: boolean; + password_not_set: boolean; + username_not_customized: boolean; +} + +export class UsersApi extends ApiManager { + public GetAppState(): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/app_state`; + return this.get({ endpoint }) + .then((response) => ({ + success: response.success, + data: response, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public GetUserProfile(username: string): Promise< + ApiResponse<{ + user?: UserInfo; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/user/${username}/profile`; + return this.get<{ + success: boolean; + user?: { + user_token: string; + username: string; + display_name: string; + email_gravatar_hash: string; + disable_gravatar: boolean; + created_at: string; + [key: string]: any; + }; + error_message?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: { + user: response.user + ? { + id: response.user.user_token, + username: response.user.username, + display_name: response.user.display_name, + email: "", + gravatar_hash: response.user.email_gravatar_hash, + disable_gravatar: response.user.disable_gravatar, + created_at: response.user.created_at, + } + : undefined, + }, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async Login({ + usernameOrEmail, + password, + }: { + usernameOrEmail: string; + password: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/login`; + const body = { + username_or_email: usernameOrEmail, + password, + }; + return await this.post< + { username_or_email: string; password: string }, + { + success: boolean; + signed_session?: string; + error_message?: string; + error_type?: string; + } + >({ + endpoint, + body, + }) + .then((response) => ({ + success: response.success, + data: { signedSession: response.signed_session }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public Logout(): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/logout`; + return this.post({ + endpoint, + }) + .then((response) => ({ + success: response.success, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async Signup({ + username, + email, + password, + passwordConfirmation, + signupSource, + }: { + username: string; + email: string; + password: string; + passwordConfirmation: string; + signupSource?: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/create_account`; + const body: SignupRequest = { + email_address: email, + password, + password_confirmation: passwordConfirmation, + username, + }; + if (signupSource) { + body.signup_source = signupSource; + } + return await this.post< + SignupRequest, + { + success: boolean; + signed_session?: string; + error_fields?: Record; + error_message?: string; + error_type?: string; + } + >({ + endpoint, + body, + }) + .then((response) => ({ + success: response.success, + data: { signedSession: response.signed_session }, + errorMessage: + response.error_message || + Object.values(response.error_fields ?? {}).join(", "), + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async RequestPasswordReset({ + email, + }: { + email: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/request_password_reset`; + return await this.post< + { email: string }, + { success: boolean; error_message?: string } + >({ + endpoint, + body: { email }, + }) + .then((response) => ({ + success: response.success, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ResetPassword({ + token, + password, + passwordConfirmation, + }: { + token: string; + password: string; + passwordConfirmation: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/reset_password`; + return await this.post< + { token: string; password: string; password_confirmation: string }, + { success: boolean; error_message?: string } + >({ + endpoint, + body: { + token, + password, + password_confirmation: passwordConfirmation, + }, + }) + .then((response) => ({ + success: response.success, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async ChangePassword({ + password, + passwordConfirmation, + }: { + password: string; + passwordConfirmation: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/user/change_password`; + return await this.post< + { password: string; password_confirmation: string }, + { success: boolean; error_message?: string } + >({ + endpoint, + body: { + password, + password_confirmation: passwordConfirmation, + }, + }) + .then((response) => ({ + success: response.success, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async EditEmail({ + emailAddress, + }: { + emailAddress: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/user/edit_email`; + return await this.post< + { email_address: string }, + { success: boolean; error_message?: string } + >({ + endpoint, + body: { email_address: emailAddress }, + }) + .then((response) => ({ + success: response.success, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async EditUsername({ + displayName, + }: { + displayName: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/user/edit_username`; + return await this.post< + { display_name: string }, + { success: boolean; error_message?: string } + >({ + endpoint, + body: { display_name: displayName }, + }) + .then((response) => ({ + success: response.success, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } +} diff --git a/frontend/apps/artcraft-dashboard/src/app/dashboard/data.json b/frontend/apps/artcraft-dashboard/src/app/dashboard/data.json new file mode 100644 index 0000000000..ec0873641b --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/app/dashboard/data.json @@ -0,0 +1,614 @@ +[ + { + "id": 1, + "header": "Cover page", + "type": "Cover page", + "status": "In Process", + "target": "18", + "limit": "5", + "reviewer": "Eddie Lake" + }, + { + "id": 2, + "header": "Table of contents", + "type": "Table of contents", + "status": "Done", + "target": "29", + "limit": "24", + "reviewer": "Eddie Lake" + }, + { + "id": 3, + "header": "Executive summary", + "type": "Narrative", + "status": "Done", + "target": "10", + "limit": "13", + "reviewer": "Eddie Lake" + }, + { + "id": 4, + "header": "Technical approach", + "type": "Narrative", + "status": "Done", + "target": "27", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 5, + "header": "Design", + "type": "Narrative", + "status": "In Process", + "target": "2", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 6, + "header": "Capabilities", + "type": "Narrative", + "status": "In Process", + "target": "20", + "limit": "8", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 7, + "header": "Integration with existing systems", + "type": "Narrative", + "status": "In Process", + "target": "19", + "limit": "21", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 8, + "header": "Innovation and Advantages", + "type": "Narrative", + "status": "Done", + "target": "25", + "limit": "26", + "reviewer": "Assign reviewer" + }, + { + "id": 9, + "header": "Overview of EMR's Innovative Solutions", + "type": "Technical content", + "status": "Done", + "target": "7", + "limit": "23", + "reviewer": "Assign reviewer" + }, + { + "id": 10, + "header": "Advanced Algorithms and Machine Learning", + "type": "Narrative", + "status": "Done", + "target": "30", + "limit": "28", + "reviewer": "Assign reviewer" + }, + { + "id": 11, + "header": "Adaptive Communication Protocols", + "type": "Narrative", + "status": "Done", + "target": "9", + "limit": "31", + "reviewer": "Assign reviewer" + }, + { + "id": 12, + "header": "Advantages Over Current Technologies", + "type": "Narrative", + "status": "Done", + "target": "12", + "limit": "0", + "reviewer": "Assign reviewer" + }, + { + "id": 13, + "header": "Past Performance", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "33", + "reviewer": "Assign reviewer" + }, + { + "id": 14, + "header": "Customer Feedback and Satisfaction Levels", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "34", + "reviewer": "Assign reviewer" + }, + { + "id": 15, + "header": "Implementation Challenges and Solutions", + "type": "Narrative", + "status": "Done", + "target": "3", + "limit": "35", + "reviewer": "Assign reviewer" + }, + { + "id": 16, + "header": "Security Measures and Data Protection Policies", + "type": "Narrative", + "status": "In Process", + "target": "6", + "limit": "36", + "reviewer": "Assign reviewer" + }, + { + "id": 17, + "header": "Scalability and Future Proofing", + "type": "Narrative", + "status": "Done", + "target": "4", + "limit": "37", + "reviewer": "Assign reviewer" + }, + { + "id": 18, + "header": "Cost-Benefit Analysis", + "type": "Plain language", + "status": "Done", + "target": "14", + "limit": "38", + "reviewer": "Assign reviewer" + }, + { + "id": 19, + "header": "User Training and Onboarding Experience", + "type": "Narrative", + "status": "Done", + "target": "17", + "limit": "39", + "reviewer": "Assign reviewer" + }, + { + "id": 20, + "header": "Future Development Roadmap", + "type": "Narrative", + "status": "Done", + "target": "11", + "limit": "40", + "reviewer": "Assign reviewer" + }, + { + "id": 21, + "header": "System Architecture Overview", + "type": "Technical content", + "status": "In Process", + "target": "24", + "limit": "18", + "reviewer": "Maya Johnson" + }, + { + "id": 22, + "header": "Risk Management Plan", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "22", + "reviewer": "Carlos Rodriguez" + }, + { + "id": 23, + "header": "Compliance Documentation", + "type": "Legal", + "status": "In Process", + "target": "31", + "limit": "27", + "reviewer": "Sarah Chen" + }, + { + "id": 24, + "header": "API Documentation", + "type": "Technical content", + "status": "Done", + "target": "8", + "limit": "12", + "reviewer": "Raj Patel" + }, + { + "id": 25, + "header": "User Interface Mockups", + "type": "Visual", + "status": "In Process", + "target": "19", + "limit": "25", + "reviewer": "Leila Ahmadi" + }, + { + "id": 26, + "header": "Database Schema", + "type": "Technical content", + "status": "Done", + "target": "22", + "limit": "20", + "reviewer": "Thomas Wilson" + }, + { + "id": 27, + "header": "Testing Methodology", + "type": "Technical content", + "status": "In Process", + "target": "17", + "limit": "14", + "reviewer": "Assign reviewer" + }, + { + "id": 28, + "header": "Deployment Strategy", + "type": "Narrative", + "status": "Done", + "target": "26", + "limit": "30", + "reviewer": "Eddie Lake" + }, + { + "id": 29, + "header": "Budget Breakdown", + "type": "Financial", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 30, + "header": "Market Analysis", + "type": "Research", + "status": "Done", + "target": "29", + "limit": "32", + "reviewer": "Sophia Martinez" + }, + { + "id": 31, + "header": "Competitor Comparison", + "type": "Research", + "status": "In Process", + "target": "21", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 32, + "header": "Maintenance Plan", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "23", + "reviewer": "Alex Thompson" + }, + { + "id": 33, + "header": "User Personas", + "type": "Research", + "status": "In Process", + "target": "27", + "limit": "24", + "reviewer": "Nina Patel" + }, + { + "id": 34, + "header": "Accessibility Compliance", + "type": "Legal", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 35, + "header": "Performance Metrics", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "David Kim" + }, + { + "id": 36, + "header": "Disaster Recovery Plan", + "type": "Technical content", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 37, + "header": "Third-party Integrations", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Eddie Lake" + }, + { + "id": 38, + "header": "User Feedback Summary", + "type": "Research", + "status": "Done", + "target": "20", + "limit": "15", + "reviewer": "Assign reviewer" + }, + { + "id": 39, + "header": "Localization Strategy", + "type": "Narrative", + "status": "In Process", + "target": "12", + "limit": "19", + "reviewer": "Maria Garcia" + }, + { + "id": 40, + "header": "Mobile Compatibility", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "James Wilson" + }, + { + "id": 41, + "header": "Data Migration Plan", + "type": "Technical content", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Assign reviewer" + }, + { + "id": 42, + "header": "Quality Assurance Protocols", + "type": "Technical content", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Priya Singh" + }, + { + "id": 43, + "header": "Stakeholder Analysis", + "type": "Research", + "status": "In Process", + "target": "11", + "limit": "14", + "reviewer": "Eddie Lake" + }, + { + "id": 44, + "header": "Environmental Impact Assessment", + "type": "Research", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Assign reviewer" + }, + { + "id": 45, + "header": "Intellectual Property Rights", + "type": "Legal", + "status": "In Process", + "target": "17", + "limit": "20", + "reviewer": "Sarah Johnson" + }, + { + "id": 46, + "header": "Customer Support Framework", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 47, + "header": "Version Control Strategy", + "type": "Technical content", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 48, + "header": "Continuous Integration Pipeline", + "type": "Technical content", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Michael Chen" + }, + { + "id": 49, + "header": "Regulatory Compliance", + "type": "Legal", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Assign reviewer" + }, + { + "id": 50, + "header": "User Authentication System", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "Eddie Lake" + }, + { + "id": 51, + "header": "Data Analytics Framework", + "type": "Technical content", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 52, + "header": "Cloud Infrastructure", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 53, + "header": "Network Security Measures", + "type": "Technical content", + "status": "In Process", + "target": "29", + "limit": "32", + "reviewer": "Lisa Wong" + }, + { + "id": 54, + "header": "Project Timeline", + "type": "Planning", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Eddie Lake" + }, + { + "id": 55, + "header": "Resource Allocation", + "type": "Planning", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Assign reviewer" + }, + { + "id": 56, + "header": "Team Structure and Roles", + "type": "Planning", + "status": "Done", + "target": "20", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 57, + "header": "Communication Protocols", + "type": "Planning", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 58, + "header": "Success Metrics", + "type": "Planning", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Eddie Lake" + }, + { + "id": 59, + "header": "Internationalization Support", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 60, + "header": "Backup and Recovery Procedures", + "type": "Technical content", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 61, + "header": "Monitoring and Alerting System", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Daniel Park" + }, + { + "id": 62, + "header": "Code Review Guidelines", + "type": "Technical content", + "status": "Done", + "target": "12", + "limit": "15", + "reviewer": "Eddie Lake" + }, + { + "id": 63, + "header": "Documentation Standards", + "type": "Technical content", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 64, + "header": "Release Management Process", + "type": "Planning", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Assign reviewer" + }, + { + "id": 65, + "header": "Feature Prioritization Matrix", + "type": "Planning", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Emma Davis" + }, + { + "id": 66, + "header": "Technical Debt Assessment", + "type": "Technical content", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Eddie Lake" + }, + { + "id": 67, + "header": "Capacity Planning", + "type": "Planning", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 68, + "header": "Service Level Agreements", + "type": "Legal", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Assign reviewer" + } +] diff --git a/frontend/apps/artcraft-dashboard/src/assets/artcraft-icon.svg b/frontend/apps/artcraft-dashboard/src/assets/artcraft-icon.svg new file mode 100644 index 0000000000..efc0be9e7f --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/assets/artcraft-icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/apps/artcraft-dashboard/src/assets/react.svg b/frontend/apps/artcraft-dashboard/src/assets/react.svg new file mode 100644 index 0000000000..6c87de9bb3 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/apps/artcraft-dashboard/src/components/BanUserDialog.tsx b/frontend/apps/artcraft-dashboard/src/components/BanUserDialog.tsx new file mode 100644 index 0000000000..4d6a03c835 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/components/BanUserDialog.tsx @@ -0,0 +1,216 @@ +import { useEffect, useState } from "react"; +import { ModerationApi } from "@/api/ModerationApi"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Label } from "@/components/ui/label"; +import { + IconAlertCircle, + IconBan, + IconShieldCheck, +} from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; + +interface BanUserDialogProps { + username: string; + displayName: string; + isBanned: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function BanUserDialog({ + username, + displayName, + isBanned, + open, + onOpenChange, + onSuccess, +}: BanUserDialogProps) { + const [modNotes, setModNotes] = useState(""); + const [confirmOpen, setConfirmOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) { + setModNotes(""); + setConfirmOpen(false); + setIsSubmitting(false); + setError(null); + } + }, [open]); + + const trimmedNotes = modNotes.trim(); + const isValid = trimmedNotes.length > 0; + const willBan = !isBanned; + const actionLabel = willBan ? "Ban" : "Unban"; + const ActionIcon = willBan ? IconBan : IconShieldCheck; + + const handleConfirm = async () => { + if (!isValid || isSubmitting) return; + setIsSubmitting(true); + setError(null); + const modApi = new ModerationApi(); + const resp = await modApi.ManageUserBan({ + username, + is_banned: willBan, + mod_notes: trimmedNotes, + }); + setIsSubmitting(false); + if (resp.success) { + setConfirmOpen(false); + onOpenChange(false); + onSuccess?.(); + } else { + setError(resp.errorMessage || "Failed to update ban status"); + setConfirmOpen(false); + } + }; + + return ( + <> + + + + + + {actionLabel} User + + + {willBan ? ( + <> + You are about to ban{" "} + + {displayName} + {" "} + (@{username}). They will lose access to their account. + + ) : ( + <> + You are about to unban{" "} + + {displayName} + {" "} + (@{username}). They will regain access to their account. + + )} + + + +
+
+ +