mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-06 06:14:52 -05:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e911d46b | ||
|
|
977cd933f7 | ||
|
|
89f20d9452 | ||
|
|
cec3b50ae4 | ||
|
|
3040aaa0d1 | ||
|
|
f47e8b9b14 | ||
|
|
83115e7ef9 | ||
|
|
4fbe9f54e0 | ||
|
|
e850022fe8 |
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE tts_results\nSET\n mod_deleted_at = CURRENT_TIMESTAMP,\n maybe_mod_user_token = ?\nWHERE\n token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "376e36b1357952599866262c7356ba35f15ccdfb338d8ccdbdc06ccd69331fc3"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE tts_results\nSET\n mod_deleted_at = NULL,\n maybe_mod_user_token = ?\nWHERE\n token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "40a3301e9a3201e74d49b6b339f67291e2ae22d315188401708609ac2d28a68c"
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n job_type as queue_type,\n count(*) as pending_job_count,\n NOW() as present_time\n FROM (\n SELECT\n token,\n job_type\n FROM generic_inference_jobs\n WHERE status IN (\"pending\", \"attempt_failed\")\n AND job_type IS NOT NULL\n UNION\n SELECT\n token,\n job_type\n FROM generic_inference_jobs\n WHERE status IN (\"started\")\n AND created_at > (CURDATE() - INTERVAL 15 MINUTE)\n AND job_type IS NOT NULL\n) as generic_inner\nGROUP BY queue_type\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "queue_type",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "pending_job_count",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "present_time",
|
||||
"type_info": {
|
||||
"type": "Datetime",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "73dc054488b2fa3efc923d3a90914a1450b73e99a377562484a0498ccce1676f"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO tts_model_upload_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n creator_user_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n title = ?,\n tts_model_type = ?,\n download_url = ?,\n status = \"pending\"\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "886861e91c7e8af53eb856c949389a172b8d9e079f1c2070d115423907c47c50"
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "user_role_slug",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "is_banned",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "can_ban_users",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "api_key_token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9188e235caa439d50628333c2f4722b9f1a8f575c192d4d36a2039709c412ac0"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE tts_results\nSET\n user_deleted_at = CURRENT_TIMESTAMP\nWHERE\n token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "961f792c24bee6009e3e8a215561db932871a53c48839b8cb80292de00e7b2e1"
|
||||
}
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n jobs.token as job_token,\n\n jobs.status,\n jobs.attempt_count,\n jobs.on_success_result_token as maybe_result_token,\n results.public_bucket_wav_audio_path as maybe_public_bucket_wav_audio_path,\n\n jobs.model_token,\n tts.tts_model_type,\n tts.title,\n\n jobs.raw_inference_text,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM tts_inference_jobs as jobs\nJOIN tts_models as tts\n ON tts.token = jobs.model_token\nLEFT OUTER JOIN tts_results as results\n ON jobs.on_success_result_token = results.token\n\nWHERE jobs.token = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "status",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "attempt_count",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_result_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_public_bucket_wav_audio_path",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "model_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "tts_model_type",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 60
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "title",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "raw_inference_text",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 262140
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"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,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a2b586dd2120f29dd26281626f7381e292c821f8e826b9bc584b92ffa45035b2"
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n users.username,\n users.display_name,\n users.email_gravatar_hash as gravatar_hash,\n creator_user_token,\n count(*) as uploaded_count\nFROM tts_models\nJOIN users\nON\n users.token = creator_user_token\nWHERE\n users.is_banned IS FALSE\n AND users.token NOT IN (\"U:DRB1SKEMKHT1J\")\n AND tts_models.user_deleted_at IS NULL\n AND tts_models.mod_deleted_at IS NULL\nGROUP BY creator_user_token\nORDER BY uploaded_count desc\nLIMIT 25;\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "gravatar_hash",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "creator_user_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "uploaded_count",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a82fa4eb36528ebf52af2585cdda203476a4ba3645837c792830474fca2eda4f"
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "user_role_slug",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "is_banned",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "can_ban_users",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c77126adb36f9e2f0248720eb4d8c1cd9d14719f64a02678d8021e26addd2c18"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE tts_results\nSET\n user_deleted_at = NULL\nWHERE\n token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dbce51eb1c80dded902c9509e50713483c7131f115e8ec10646e99e88110278b"
|
||||
}
|
||||
-472
@@ -1,472 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, MySql, MySqlPool, QueryBuilder, Row};
|
||||
use sqlx::mysql::MySqlRow;
|
||||
|
||||
use enums::by_table::model_weights::{
|
||||
weights_category::WeightsCategory,
|
||||
weights_types::WeightsType,
|
||||
};
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow as _;
|
||||
use enums::common::visibility::Visibility;
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::model_weights::ModelWeightToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct FeaturedWeightsPage {
|
||||
pub records: Vec<FeaturedWeight>,
|
||||
|
||||
pub sort_ascending: bool,
|
||||
|
||||
/// ID of the first record in the result set.
|
||||
pub first_id: Option<i64>,
|
||||
|
||||
/// ID of the last record in the result set.
|
||||
pub last_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FeaturedWeight {
|
||||
pub token: ModelWeightToken,
|
||||
|
||||
pub weights_type: WeightsType,
|
||||
pub weights_category: WeightsCategory,
|
||||
|
||||
pub title: String,
|
||||
|
||||
// TODO(bt,2023-12-24): These aren't really appropriate for a list endpoint.
|
||||
// Hopefully we don't break the frontend by omitting these.
|
||||
//pub maybe_description_markdown: String,
|
||||
//pub maybe_description_rendered_html: String,
|
||||
|
||||
pub maybe_ietf_language_tag: Option<String>,
|
||||
pub maybe_ietf_primary_language_subtag: Option<String>,
|
||||
|
||||
pub maybe_creator_user_token: Option<UserToken>,
|
||||
pub maybe_creator_username: Option<String>,
|
||||
pub maybe_creator_display_name: Option<String>,
|
||||
pub maybe_creator_email_gravatar_hash: Option<String>,
|
||||
|
||||
pub creator_ip_address: String,
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
pub maybe_last_update_user_token: Option<UserToken>,
|
||||
|
||||
pub original_download_url: Option<String>,
|
||||
pub original_filename: Option<String>,
|
||||
|
||||
pub file_size_bytes: i32,
|
||||
pub file_checksum_sha2: String,
|
||||
|
||||
pub public_bucket_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_cover_image_public_bucket_hash: Option<String>,
|
||||
pub maybe_cover_image_public_bucket_prefix: Option<String>,
|
||||
pub maybe_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_ratings_positive_count: Option<u32>,
|
||||
pub maybe_ratings_negative_count: Option<u32>,
|
||||
pub maybe_bookmark_count: Option<u32>,
|
||||
pub cached_usage_count: u64,
|
||||
|
||||
pub version: i32,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
|
||||
pub user_deleted_at: Option<DateTime<Utc>>,
|
||||
pub mod_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub struct ListFeaturedWeightsArgs<'a> {
|
||||
pub limit: usize,
|
||||
pub maybe_offset: Option<usize>,
|
||||
|
||||
pub cursor_is_reversed: bool,
|
||||
pub sort_ascending: bool,
|
||||
|
||||
pub view_as: ViewAs,
|
||||
|
||||
pub maybe_scoped_weight_types: Option<&'a HashSet<WeightsType>>,
|
||||
pub maybe_scoped_weight_categories: Option<&'a HashSet<WeightsCategory>>,
|
||||
|
||||
pub mysql_pool: &'a MySqlPool,
|
||||
}
|
||||
|
||||
pub async fn list_featured_weights(args: ListFeaturedWeightsArgs<'_>) -> AnyhowResult<FeaturedWeightsPage> {
|
||||
// TODO(bt,2024-05-05): Why were we doing this, again?
|
||||
// let count_fields = select_total_count_field();
|
||||
// let mut count_query_builder = query_builder(
|
||||
// args.limit,
|
||||
// args.maybe_offset,
|
||||
// args.cursor_is_reversed,
|
||||
// args.sort_ascending,
|
||||
// count_fields.as_str(),
|
||||
// args.view_as,
|
||||
// args.maybe_scoped_weight_type,
|
||||
// args.maybe_scoped_weight_category,
|
||||
// );
|
||||
// let row_count_query = count_query_builder.build_query_scalar::<i64>();
|
||||
// let row_count_result = row_count_query.fetch_one(args.mysql_pool).await?;
|
||||
|
||||
/// Now fetch the actual results with all the fields
|
||||
let result_fields = select_result_fields();
|
||||
let mut query = query_builder(
|
||||
args.limit,
|
||||
args.maybe_offset,
|
||||
args.cursor_is_reversed,
|
||||
args.sort_ascending,
|
||||
result_fields.as_str(),
|
||||
args.view_as,
|
||||
args.maybe_scoped_weight_types,
|
||||
args.maybe_scoped_weight_categories,
|
||||
);
|
||||
|
||||
let query = query.build_query_as::<RawWeightJoinUser>();
|
||||
let results = query.fetch_all(args.mysql_pool).await?;
|
||||
|
||||
let first_id = results.first()
|
||||
.map(|raw_result| raw_result.id);
|
||||
|
||||
let last_id = results.last()
|
||||
.map(|raw_result| raw_result.id);
|
||||
|
||||
let weights_records: Vec<FeaturedWeight> = map_to_weights(results).await;
|
||||
|
||||
Ok(FeaturedWeightsPage {
|
||||
records: weights_records,
|
||||
sort_ascending: args.sort_ascending,
|
||||
first_id,
|
||||
last_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn select_total_count_field() -> String {
|
||||
r#"
|
||||
COUNT(mw.id) AS total_count
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn select_result_fields() -> String {
|
||||
r#"
|
||||
mw.id,
|
||||
mw.token,
|
||||
mw.title,
|
||||
mw.weights_type,
|
||||
mw.weights_category,
|
||||
mw.maybe_ietf_language_tag,
|
||||
mw.maybe_ietf_primary_language_subtag,
|
||||
u.token as maybe_creator_user_token,
|
||||
u.username as maybe_creator_username,
|
||||
u.display_name as maybe_creator_display_name,
|
||||
u.email_gravatar_hash as maybe_creator_email_gravatar_hash,
|
||||
mw.creator_ip_address,
|
||||
mw.creator_set_visibility,
|
||||
mw.maybe_last_update_user_token,
|
||||
mw.original_download_url,
|
||||
mw.original_filename,
|
||||
mw.file_size_bytes,
|
||||
mw.file_checksum_sha2,
|
||||
|
||||
mw.public_bucket_hash,
|
||||
mw.maybe_public_bucket_prefix,
|
||||
mw.maybe_public_bucket_extension,
|
||||
|
||||
cover_image.public_bucket_directory_hash as maybe_cover_image_public_bucket_hash,
|
||||
cover_image.maybe_public_bucket_prefix as maybe_cover_image_public_bucket_prefix,
|
||||
cover_image.maybe_public_bucket_extension as maybe_cover_image_public_bucket_extension,
|
||||
|
||||
entity_stats.ratings_positive_count as maybe_ratings_positive_count,
|
||||
entity_stats.ratings_negative_count as maybe_ratings_negative_count,
|
||||
entity_stats.bookmark_count as maybe_bookmark_count,
|
||||
mw.cached_usage_count,
|
||||
|
||||
mw.version,
|
||||
mw.created_at,
|
||||
mw.updated_at,
|
||||
mw.user_deleted_at,
|
||||
mw.mod_deleted_at
|
||||
"#.to_string()
|
||||
}
|
||||
|
||||
fn query_builder<'a>(
|
||||
_limit: usize, // TODO(bt,2024-05-16): This seems wrong. Why is this unused?
|
||||
maybe_offset: Option<usize>,
|
||||
cursor_is_reversed: bool,
|
||||
sort_ascending: bool,
|
||||
select_fields: &'a str,
|
||||
view_as: ViewAs,
|
||||
maybe_scoped_weight_types: Option<&'a HashSet<WeightsType>>,
|
||||
maybe_scoped_weight_categories: Option<&'a HashSet<WeightsCategory>>,
|
||||
) -> QueryBuilder<'a, MySql> {
|
||||
|
||||
// NB: Query cannot be statically checked by sqlx
|
||||
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
|
||||
format!(r#"
|
||||
SELECT
|
||||
{select_fields}
|
||||
FROM model_weights as mw
|
||||
|
||||
JOIN featured_items
|
||||
ON featured_items.entity_type = "model_weight"
|
||||
AND featured_items.deleted_at IS NULL
|
||||
AND featured_items.entity_token = mw.token
|
||||
|
||||
LEFT OUTER JOIN users as u
|
||||
ON u.token = mw.creator_user_token
|
||||
LEFT OUTER JOIN media_files as cover_image
|
||||
ON cover_image.token = mw.maybe_cover_image_media_file_token
|
||||
LEFT OUTER JOIN entity_stats
|
||||
ON entity_stats.entity_type = "model_weights"
|
||||
AND entity_stats.entity_token = mw.token
|
||||
"#
|
||||
));
|
||||
|
||||
let mut first_predicate_added = false;
|
||||
|
||||
match view_as {
|
||||
ViewAs::Author | ViewAs::Moderator => {
|
||||
if !first_predicate_added {
|
||||
query_builder.push(" WHERE ");
|
||||
first_predicate_added = true;
|
||||
} else {
|
||||
query_builder.push(" AND ");
|
||||
}
|
||||
// NB(bt): Actually, mods don't want to see deleted models. We'll improve the moderator UI later.
|
||||
query_builder.push(" mw.user_deleted_at IS NULL AND mw.mod_deleted_at IS NULL ");
|
||||
}
|
||||
ViewAs::AnotherUser => {
|
||||
if !first_predicate_added {
|
||||
query_builder.push(" WHERE ");
|
||||
first_predicate_added = true;
|
||||
} else {
|
||||
query_builder.push(" AND ");
|
||||
}
|
||||
query_builder.push(" mw.user_deleted_at IS NULL AND mw.mod_deleted_at IS NULL ");
|
||||
query_builder.push(" AND mw.creator_set_visibility = ");
|
||||
query_builder.push_bind(Visibility::Public.to_str());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(weight_types) = maybe_scoped_weight_types {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !weight_types.is_empty() {
|
||||
if !first_predicate_added {
|
||||
query_builder.push(" WHERE ");
|
||||
first_predicate_added = true;
|
||||
} else {
|
||||
query_builder.push(" AND ");
|
||||
}
|
||||
query_builder.push(" mw.weights_type IN ( ");
|
||||
let mut separated = query_builder.separated(", ");
|
||||
for weight_type in weight_types.iter() {
|
||||
separated.push_bind(weight_type.to_str());
|
||||
}
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(weight_categories) = maybe_scoped_weight_categories {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !weight_categories.is_empty() {
|
||||
if !first_predicate_added {
|
||||
query_builder.push(" WHERE ");
|
||||
first_predicate_added = true;
|
||||
} else {
|
||||
query_builder.push(" AND ");
|
||||
}
|
||||
query_builder.push(" mw.weights_category IN ( ");
|
||||
let mut separated = query_builder.separated(", ");
|
||||
for weights_category in weight_categories.iter() {
|
||||
separated.push_bind(weights_category.to_str());
|
||||
}
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(offset) = maybe_offset {
|
||||
if !first_predicate_added {
|
||||
query_builder.push(" WHERE ");
|
||||
first_predicate_added = true;
|
||||
} else {
|
||||
query_builder.push(" AND ");
|
||||
}
|
||||
|
||||
let mut sort_ascending = sort_ascending;
|
||||
|
||||
if sort_ascending {
|
||||
if cursor_is_reversed {
|
||||
// NB: We're searching backwards.
|
||||
query_builder.push(" m.id < ");
|
||||
sort_ascending = !sort_ascending;
|
||||
} else {
|
||||
query_builder.push(" m.id > ");
|
||||
}
|
||||
} else {
|
||||
if cursor_is_reversed {
|
||||
// NB: We're searching backwards.
|
||||
query_builder.push(" m.id > ");
|
||||
sort_ascending = !sort_ascending;
|
||||
} else {
|
||||
query_builder.push(" m.id < ");
|
||||
}
|
||||
}
|
||||
query_builder.push_bind(offset as i64);
|
||||
}
|
||||
|
||||
query_builder
|
||||
}
|
||||
|
||||
async fn map_to_weights(dataset:Vec<RawWeightJoinUser>) -> Vec<FeaturedWeight> {
|
||||
let weights: Vec<FeaturedWeight> = dataset
|
||||
.into_iter()
|
||||
.map(|weight: RawWeightJoinUser| {
|
||||
FeaturedWeight {
|
||||
token: weight.token,
|
||||
title: weight.title,
|
||||
weights_type: weight.weights_type,
|
||||
weights_category: weight.weights_category,
|
||||
|
||||
maybe_ietf_language_tag: weight.maybe_ietf_language_tag,
|
||||
maybe_ietf_primary_language_subtag: weight.maybe_ietf_primary_language_subtag,
|
||||
|
||||
maybe_creator_user_token: weight.maybe_creator_user_token,
|
||||
maybe_creator_username: weight.maybe_creator_username,
|
||||
maybe_creator_display_name: weight.maybe_creator_display_name,
|
||||
maybe_creator_email_gravatar_hash: weight.maybe_creator_email_gravatar_hash,
|
||||
|
||||
creator_ip_address: weight.creator_ip_address,
|
||||
creator_set_visibility: weight.creator_set_visibility,
|
||||
|
||||
maybe_last_update_user_token: weight.maybe_last_update_user_token,
|
||||
original_download_url: weight.original_download_url,
|
||||
original_filename: weight.original_filename,
|
||||
file_size_bytes: weight.file_size_bytes,
|
||||
file_checksum_sha2: weight.file_checksum_sha2,
|
||||
public_bucket_hash: weight.public_bucket_hash,
|
||||
maybe_public_bucket_prefix: weight.maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension: weight.maybe_public_bucket_extension,
|
||||
|
||||
maybe_cover_image_public_bucket_hash: weight.maybe_cover_image_public_bucket_hash,
|
||||
maybe_cover_image_public_bucket_prefix: weight.maybe_cover_image_public_bucket_prefix,
|
||||
maybe_cover_image_public_bucket_extension: weight.maybe_cover_image_public_bucket_extension,
|
||||
|
||||
maybe_ratings_positive_count: weight.maybe_ratings_positive_count,
|
||||
maybe_ratings_negative_count: weight.maybe_ratings_negative_count,
|
||||
maybe_bookmark_count: weight.maybe_bookmark_count,
|
||||
cached_usage_count: weight.cached_usage_count,
|
||||
|
||||
version: weight.version,
|
||||
created_at: weight.created_at,
|
||||
updated_at: weight.updated_at,
|
||||
user_deleted_at: weight.user_deleted_at,
|
||||
mod_deleted_at: weight.mod_deleted_at,
|
||||
}
|
||||
}).collect();
|
||||
weights
|
||||
}
|
||||
|
||||
struct RawWeightJoinUser {
|
||||
id: i64,
|
||||
token: ModelWeightToken,
|
||||
|
||||
weights_type: WeightsType,
|
||||
weights_category: WeightsCategory,
|
||||
|
||||
title: String,
|
||||
|
||||
maybe_ietf_language_tag: Option<String>,
|
||||
maybe_ietf_primary_language_subtag: Option<String>,
|
||||
|
||||
// TODO(bt,2023-12-24): These aren't really appropriate for a list endpoint.
|
||||
// Hopefully we don't break the frontend by omitting these.
|
||||
//pub description_markdown: String,
|
||||
//pub description_rendered_html: String,
|
||||
|
||||
maybe_creator_user_token: Option<UserToken>,
|
||||
maybe_creator_username: Option<String>,
|
||||
maybe_creator_display_name: Option<String>,
|
||||
maybe_creator_email_gravatar_hash: Option<String>,
|
||||
|
||||
creator_ip_address: String,
|
||||
creator_set_visibility: Visibility,
|
||||
|
||||
maybe_last_update_user_token: Option<UserToken>,
|
||||
|
||||
original_download_url: Option<String>,
|
||||
original_filename: Option<String>,
|
||||
|
||||
file_size_bytes: i32,
|
||||
file_checksum_sha2: String,
|
||||
|
||||
public_bucket_hash: String,
|
||||
maybe_public_bucket_prefix: Option<String>,
|
||||
maybe_public_bucket_extension: Option<String>,
|
||||
cached_usage_count: u64,
|
||||
|
||||
maybe_cover_image_public_bucket_hash: Option<String>,
|
||||
maybe_cover_image_public_bucket_prefix: Option<String>,
|
||||
maybe_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_ratings_positive_count: Option<u32>,
|
||||
maybe_ratings_negative_count: Option<u32>,
|
||||
maybe_bookmark_count: Option<u32>,
|
||||
|
||||
version: i32,
|
||||
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
|
||||
user_deleted_at: Option<DateTime<Utc>>,
|
||||
mod_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl FromRow<'_, MySqlRow> for RawWeightJoinUser {
|
||||
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
id: row.try_get("id")?,
|
||||
token: row.try_get("token")?,
|
||||
weights_type: row.try_get("weights_type")?,
|
||||
weights_category: row.try_get("weights_category")?,
|
||||
title: row.try_get("title")?,
|
||||
maybe_ietf_language_tag: row.try_get("maybe_ietf_language_tag")?,
|
||||
maybe_ietf_primary_language_subtag: row.try_get("maybe_ietf_primary_language_subtag")?,
|
||||
// TODO(bt,2023-12-24): These aren't really appropriate for a list endpoint.
|
||||
// Hopefully we don't break the frontend by omitting these.
|
||||
//maybe_description_markdown: row.try_get("maybe_description_markdown")?,
|
||||
//maybe_description_rendered_html: row.try_get("maybe_description_rendered_html")?,
|
||||
maybe_creator_user_token: row.try_get("maybe_creator_user_token")?,
|
||||
maybe_creator_username: row.try_get("maybe_creator_username")?,
|
||||
maybe_creator_display_name: row.try_get("maybe_creator_display_name")?,
|
||||
maybe_creator_email_gravatar_hash: row.try_get("maybe_creator_email_gravatar_hash")?,
|
||||
creator_ip_address: row.try_get("creator_ip_address")?,
|
||||
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
|
||||
maybe_last_update_user_token: row.try_get("maybe_last_update_user_token")?,
|
||||
original_download_url: row.try_get("original_download_url")?,
|
||||
original_filename: row.try_get("original_filename")?,
|
||||
file_size_bytes: row.try_get("file_size_bytes")?,
|
||||
file_checksum_sha2: row.try_get("file_checksum_sha2")?,
|
||||
public_bucket_hash: row.try_get("public_bucket_hash")?,
|
||||
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
|
||||
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
|
||||
cached_usage_count: row.try_get("cached_usage_count")?,
|
||||
maybe_cover_image_public_bucket_hash: row.try_get("maybe_cover_image_public_bucket_hash")?,
|
||||
maybe_cover_image_public_bucket_prefix: row.try_get("maybe_cover_image_public_bucket_prefix")?,
|
||||
maybe_cover_image_public_bucket_extension: row.try_get("maybe_cover_image_public_bucket_extension")?,
|
||||
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
|
||||
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
|
||||
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
|
||||
version: row.try_get("version")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
user_deleted_at: row.try_get("user_deleted_at")?,
|
||||
mod_deleted_at: row.try_get("mod_deleted_at")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
pub mod list_all_model_weight_tokens_for_backfill;
|
||||
pub mod list_all_model_weight_tokens_for_cached_usage_backfill;
|
||||
pub mod list_featured_weights;
|
||||
pub mod list_model_weight_tokens_updated_since;
|
||||
pub mod list_model_weights_for_elastic_search_backfill_using_cursor;
|
||||
pub mod list_model_weights_for_text_to_speech;
|
||||
|
||||
-279
@@ -1,279 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{MySql, MySqlPool};
|
||||
use sqlx::pool::PoolConnection;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_optional_bool};
|
||||
|
||||
/// List of assigned categories for a TTS model
|
||||
/// This is *NOT* paginated, as that defeats the purpose of having a category system.
|
||||
#[derive(Serialize)]
|
||||
pub struct AssignedCategoryList {
|
||||
pub categories: Vec<AssignedCategory>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AssignedCategory {
|
||||
pub category_token: String,
|
||||
|
||||
pub model_type: String, // TODO: ENUM
|
||||
|
||||
pub maybe_super_category_token: Option<String>,
|
||||
|
||||
pub can_directly_have_models: bool,
|
||||
pub can_have_subcategories: bool,
|
||||
pub can_only_mods_apply: bool,
|
||||
|
||||
pub name: String,
|
||||
pub maybe_dropdown_name: Option<String>,
|
||||
|
||||
pub category_creator_user_token: Option<String>,
|
||||
pub category_creator_username: Option<String>,
|
||||
pub category_creator_display_name: Option<String>,
|
||||
pub category_creator_gravatar_hash: Option<String>,
|
||||
|
||||
// Moderator fields
|
||||
pub is_mod_approved: Option<bool>,
|
||||
pub maybe_mod_comments: Option<String>,
|
||||
//pub maybe_mod_user_token: Option<String>,
|
||||
//pub maybe_mod_username: Option<String>,
|
||||
//pub maybe_mod_display_name: Option<String>,
|
||||
|
||||
pub category_created_at: DateTime<Utc>,
|
||||
pub category_updated_at: DateTime<Utc>,
|
||||
pub category_deleted_at: Option<DateTime<Utc>>,
|
||||
|
||||
pub category_assignment_created_at: DateTime<Utc>,
|
||||
pub category_assignment_updated_at: DateTime<Utc>,
|
||||
pub category_assignment_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Query Builder for listing categories
|
||||
/// These are very difficult queries, so the builder helps for
|
||||
/// testability, construction, and correctness.
|
||||
pub struct ListAssignedTtsCategoriesQueryBuilder {
|
||||
// REQUIRED.
|
||||
scope_tts_model_token: String,
|
||||
|
||||
// Sometimes "can_directly_have_models" categories change state.
|
||||
// Mods will want to see invalid uses.
|
||||
show_invalid_model_not_allowed_categories: bool,
|
||||
|
||||
// The public lists won't include unapproved, but mod views
|
||||
// will want to see them.
|
||||
show_unapproved: bool,
|
||||
|
||||
// The public lists won't include deleted models, but mod views
|
||||
// will want to see them.
|
||||
show_deleted: bool,
|
||||
}
|
||||
|
||||
impl ListAssignedTtsCategoriesQueryBuilder {
|
||||
pub fn for_model_token(model_token: &str) -> Self {
|
||||
Self {
|
||||
scope_tts_model_token: model_token.to_string(),
|
||||
show_invalid_model_not_allowed_categories: false,
|
||||
show_unapproved: false,
|
||||
show_deleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_invalid_model_not_allowed_categories(mut self, show_invalid_model_not_allowed_categories: bool) -> Self {
|
||||
self.show_invalid_model_not_allowed_categories = show_invalid_model_not_allowed_categories;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_unapproved(mut self, show_unapproved: bool) -> Self {
|
||||
self.show_unapproved = show_unapproved;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_deleted(mut self, show_deleted: bool) -> Self {
|
||||
self.show_deleted = show_deleted;
|
||||
self
|
||||
}
|
||||
|
||||
/// Perform the query based on the set predicates.
|
||||
#[deprecated = "Use the PoolConnection<MySql> method instead of the MySqlPool one."]
|
||||
pub async fn perform_query(
|
||||
&self,
|
||||
mysql_pool: &MySqlPool
|
||||
) -> AnyhowResult<AssignedCategoryList> {
|
||||
let mut mysql_connection = mysql_pool.acquire().await?;
|
||||
self.perform_query_by_connection(&mut mysql_connection).await
|
||||
}
|
||||
|
||||
/// Perform the query based on the set predicates.
|
||||
pub async fn perform_query_by_connection(
|
||||
&self,
|
||||
mysql_connection: &mut PoolConnection<MySql>
|
||||
) -> AnyhowResult<AssignedCategoryList> {
|
||||
|
||||
let internal_results = self.perform_internal_query(mysql_connection).await?;
|
||||
|
||||
let categories = internal_results
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
AssignedCategory {
|
||||
category_token: c.category_token,
|
||||
model_type: c.model_type,
|
||||
maybe_super_category_token: c.maybe_super_category_token,
|
||||
can_directly_have_models: i8_to_bool(c.can_directly_have_models),
|
||||
can_have_subcategories: i8_to_bool(c.can_have_subcategories),
|
||||
can_only_mods_apply: i8_to_bool(c.can_only_mods_apply),
|
||||
name: c.name,
|
||||
maybe_dropdown_name: c.maybe_dropdown_name,
|
||||
category_creator_user_token: c.category_creator_user_token,
|
||||
category_creator_username: c.category_creator_username,
|
||||
category_creator_display_name: c.category_creator_display_name,
|
||||
category_creator_gravatar_hash: c.category_creator_gravatar_hash,
|
||||
is_mod_approved: nullable_i8_to_optional_bool(c.is_mod_approved),
|
||||
maybe_mod_comments: c.maybe_mod_comments,
|
||||
category_created_at: c.category_created_at,
|
||||
category_updated_at: c.category_updated_at,
|
||||
category_deleted_at: c.category_deleted_at,
|
||||
category_assignment_created_at: c.category_assignment_created_at,
|
||||
category_assignment_updated_at: c.category_assignment_updated_at,
|
||||
category_assignment_deleted_at: c.category_assignment_deleted_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<AssignedCategory>>();
|
||||
|
||||
Ok(AssignedCategoryList {
|
||||
categories,
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform the query based on the set predicates.
|
||||
async fn perform_internal_query(
|
||||
&self,
|
||||
mysql_connection: &mut PoolConnection<MySql>
|
||||
) -> AnyhowResult<Vec<RawInternalCategoryRecord>> {
|
||||
|
||||
let query = self.build_query_string();
|
||||
let mut query = sqlx::query_as::<_, RawInternalCategoryRecord>(&query);
|
||||
|
||||
// NB: The following bindings must match the order of the query builder !!
|
||||
|
||||
// NB: Model token is always used for scoping.
|
||||
query = query.bind(&self.scope_tts_model_token);
|
||||
|
||||
let results = query.fetch_all(&mut **mysql_connection)
|
||||
.await?;
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn build_query_string(&self) -> String {
|
||||
// TODO: I haven't figured out how to get field name disambiguation and type coercion working here.
|
||||
// (1) tts_results.creator_set_visibility `creator_set_visibility: crate::database::enums::record_visibility::RecordVisibility`,
|
||||
// Query error: no column found for name: creator_set_visibility
|
||||
// (2) creator_set_visibility `creator_set_visibility: crate::database::enums::record_visibility::RecordVisibility`,
|
||||
// Column 'creator_set_visibility' in field list is ambiguous
|
||||
|
||||
// TODO/NB: Unfortunately SQLx can't statically typecheck this query
|
||||
let mut query = r#"
|
||||
SELECT
|
||||
model_categories.token as category_token,
|
||||
|
||||
model_categories.model_type,
|
||||
|
||||
model_categories.maybe_super_category_token,
|
||||
|
||||
model_categories.can_directly_have_models,
|
||||
model_categories.can_have_subcategories,
|
||||
model_categories.can_only_mods_apply,
|
||||
|
||||
model_categories.name,
|
||||
model_categories.maybe_dropdown_name,
|
||||
|
||||
users.token as category_creator_user_token,
|
||||
users.username as category_creator_username,
|
||||
users.display_name as category_creator_display_name,
|
||||
users.email_gravatar_hash as category_creator_gravatar_hash,
|
||||
|
||||
model_categories.is_mod_approved,
|
||||
model_categories.maybe_mod_comments,
|
||||
|
||||
model_categories.created_at as category_created_at,
|
||||
model_categories.updated_at as category_updated_at,
|
||||
model_categories.deleted_at as category_deleted_at,
|
||||
|
||||
tts_category_assignments.created_at as category_assignment_created_at,
|
||||
tts_category_assignments.updated_at as category_assignment_updated_at,
|
||||
tts_category_assignments.deleted_at as category_assignment_deleted_at
|
||||
|
||||
FROM model_categories
|
||||
JOIN tts_category_assignments
|
||||
ON tts_category_assignments.category_token = model_categories.token
|
||||
LEFT OUTER JOIN users
|
||||
ON model_categories.creator_user_token = users.token
|
||||
"#.to_string();
|
||||
|
||||
query.push_str(&self.build_predicates());
|
||||
query
|
||||
}
|
||||
|
||||
pub fn build_predicates(&self) -> String {
|
||||
let mut query = "".to_string();
|
||||
|
||||
// Scoping to tts model is required.
|
||||
query.push_str(" WHERE tts_category_assignments.model_token = ?");
|
||||
query.push_str(" AND model_categories.model_type = 'tts'");
|
||||
|
||||
// We also don't care to ever show soft-deleted assignments
|
||||
query.push_str(" AND tts_category_assignments.deleted_at IS NULL");
|
||||
|
||||
if !self.show_invalid_model_not_allowed_categories {
|
||||
query.push_str(" AND model_categories.can_directly_have_models IS TRUE");
|
||||
}
|
||||
|
||||
if !self.show_unapproved {
|
||||
query.push_str(" AND model_categories.is_mod_approved IS TRUE");
|
||||
}
|
||||
|
||||
if !self.show_deleted {
|
||||
query.push_str(" AND model_categories.deleted_at IS NULL");
|
||||
}
|
||||
|
||||
query
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct RawInternalCategoryRecord {
|
||||
pub category_token: String,
|
||||
|
||||
pub model_type: String, // TODO: ENUM
|
||||
|
||||
pub maybe_super_category_token: Option<String>,
|
||||
|
||||
pub can_directly_have_models: i8,
|
||||
pub can_have_subcategories: i8,
|
||||
pub can_only_mods_apply: i8,
|
||||
|
||||
pub name: String,
|
||||
pub maybe_dropdown_name: Option<String>,
|
||||
|
||||
pub category_creator_user_token: Option<String>,
|
||||
pub category_creator_username: Option<String>,
|
||||
pub category_creator_display_name: Option<String>,
|
||||
pub category_creator_gravatar_hash: Option<String>,
|
||||
|
||||
// Moderator fields
|
||||
pub is_mod_approved: Option<i8>,
|
||||
pub maybe_mod_comments: Option<String>,
|
||||
|
||||
pub category_created_at: DateTime<Utc>,
|
||||
pub category_updated_at: DateTime<Utc>,
|
||||
pub category_deleted_at: Option<DateTime<Utc>>,
|
||||
|
||||
pub category_assignment_created_at: DateTime<Utc>,
|
||||
pub category_assignment_updated_at: DateTime<Utc>,
|
||||
pub category_assignment_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
pub mod fetch_and_build_tts_model_category_map;
|
||||
pub mod list_assigned_tts_categories_query_builder;
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TtsInferenceJobStatusRecord {
|
||||
pub job_token: String,
|
||||
|
||||
pub status: String,
|
||||
pub attempt_count: i32,
|
||||
|
||||
pub maybe_result_token: Option<String>,
|
||||
pub maybe_public_bucket_wav_audio_path: Option<String>,
|
||||
|
||||
pub model_token: String,
|
||||
pub tts_model_type: String,
|
||||
pub title: String, // Name of the TTS model
|
||||
|
||||
pub raw_inference_text: String, // User text
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn get_tts_inference_job_status(
|
||||
tts_inference_job_token: &str,
|
||||
mysql_pool: &MySqlPool
|
||||
) -> AnyhowResult<Option<TtsInferenceJobStatusRecord>> {
|
||||
|
||||
// NB: Lookup failure is Err(RowNotFound).
|
||||
// NB: Since this is publicly exposed, we don't query sensitive data.
|
||||
let maybe_status = sqlx::query_as!(
|
||||
TtsInferenceJobStatusRecord,
|
||||
r#"
|
||||
SELECT
|
||||
jobs.token as job_token,
|
||||
|
||||
jobs.status,
|
||||
jobs.attempt_count,
|
||||
jobs.on_success_result_token as maybe_result_token,
|
||||
results.public_bucket_wav_audio_path as maybe_public_bucket_wav_audio_path,
|
||||
|
||||
jobs.model_token,
|
||||
tts.tts_model_type,
|
||||
tts.title,
|
||||
|
||||
jobs.raw_inference_text,
|
||||
|
||||
jobs.created_at,
|
||||
jobs.updated_at
|
||||
|
||||
FROM tts_inference_jobs as jobs
|
||||
JOIN tts_models as tts
|
||||
ON tts.token = jobs.model_token
|
||||
LEFT OUTER JOIN tts_results as results
|
||||
ON jobs.on_success_result_token = results.token
|
||||
|
||||
WHERE jobs.token = ?
|
||||
"#,
|
||||
tts_inference_job_token
|
||||
)
|
||||
.fetch_one(mysql_pool)
|
||||
.await; // TODO: This will return error if it doesn't exist
|
||||
|
||||
match maybe_status {
|
||||
Ok(record) => Ok(Some(record)),
|
||||
Err(err) => match err {
|
||||
sqlx::Error::RowNotFound => Ok(None),
|
||||
_ => Err(anyhow!("tts job query error: {:?}", err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
pub mod _keys;
|
||||
pub mod get_pending_tts_inference_job_detailed_stats;
|
||||
pub mod get_tts_inference_job_status;
|
||||
pub mod insert_tts_inference_job;
|
||||
pub mod kill_tts_inference_jobs;
|
||||
pub mod list_available_tts_inference_jobs;
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A user authenticated by either an API key or a web session cookie.
|
||||
///
|
||||
/// The two lookup queries (`get_api_or_web_session_user_by_api_key` and
|
||||
/// `get_api_or_web_session_user_by_session_token`) select congruent user fields so both can
|
||||
/// produce this one uniform record.
|
||||
pub struct ApiOrWebSessionUserRecord {
|
||||
pub user_token: UserToken,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email_address: String,
|
||||
|
||||
pub user_role_slug: String,
|
||||
pub is_banned: bool,
|
||||
pub can_ban_users: bool,
|
||||
|
||||
/// Only set when the lookup was by API key.
|
||||
pub maybe_api_key_token: Option<ApiKeyToken>,
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
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, nullable_i8_to_bool_default_false};
|
||||
use crate::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord;
|
||||
|
||||
pub struct GetApiOrWebSessionUserByApiKeyArgs<'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>,
|
||||
}
|
||||
|
||||
/// Look up the user behind an API key secret, selecting the same user fields as
|
||||
/// `get_api_or_web_session_user_by_session_token` so both auth paths share one record shape.
|
||||
///
|
||||
/// Returns `Ok(None)` when no row matches, when the matching API key is soft-deleted, or when the
|
||||
/// owning user is missing or deleted. The full secret is never echoed back.
|
||||
pub async fn get_api_or_web_session_user_by_api_key<'e, 'c: 'e, E>(
|
||||
args: GetApiOrWebSessionUserByApiKeyArgs<'e, 'c, E>,
|
||||
) -> Result<Option<ApiOrWebSessionUserRecord>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
users.token as `user_token: UserToken`,
|
||||
users.username,
|
||||
users.display_name,
|
||||
users.email_address,
|
||||
users.user_role_slug,
|
||||
users.is_banned,
|
||||
user_roles.can_ban_users,
|
||||
api_keys.token as `api_key_token: ApiKeyToken`
|
||||
FROM api_keys
|
||||
INNER JOIN users
|
||||
ON users.token = api_keys.owner_user_token
|
||||
LEFT OUTER JOIN user_roles
|
||||
ON users.user_role_slug = user_roles.slug
|
||||
WHERE api_keys.api_key = ?
|
||||
AND api_keys.maybe_deleted_at IS NULL
|
||||
AND users.user_deleted_at IS NULL
|
||||
AND users.mod_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.api_key.as_str_be_careful(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| ApiOrWebSessionUserRecord {
|
||||
user_token: r.user_token,
|
||||
username: r.username,
|
||||
display_name: r.display_name,
|
||||
email_address: r.email_address,
|
||||
user_role_slug: r.user_role_slug,
|
||||
is_banned: i8_to_bool(r.is_banned),
|
||||
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
|
||||
maybe_api_key_token: Some(r.api_key_token),
|
||||
}))
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_bool_default_false};
|
||||
use crate::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord;
|
||||
|
||||
pub struct GetApiOrWebSessionUserBySessionTokenArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
/// The session token decoded from the session cookie.
|
||||
pub session_token: &'e str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up the user behind a web session token, selecting the same user fields as
|
||||
/// `get_api_or_web_session_user_by_api_key` so both auth paths share one record shape.
|
||||
///
|
||||
/// Returns `Ok(None)` when no row matches, when the session is deleted, or when the owning user
|
||||
/// is missing or deleted.
|
||||
pub async fn get_api_or_web_session_user_by_session_token<'e, 'c: 'e, E>(
|
||||
args: GetApiOrWebSessionUserBySessionTokenArgs<'e, 'c, E>,
|
||||
) -> Result<Option<ApiOrWebSessionUserRecord>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
users.token as `user_token: UserToken`,
|
||||
users.username,
|
||||
users.display_name,
|
||||
users.email_address,
|
||||
users.user_role_slug,
|
||||
users.is_banned,
|
||||
user_roles.can_ban_users
|
||||
FROM user_sessions
|
||||
INNER JOIN users
|
||||
ON users.token = user_sessions.user_token
|
||||
LEFT OUTER JOIN user_roles
|
||||
ON users.user_role_slug = user_roles.slug
|
||||
WHERE user_sessions.token = ?
|
||||
AND user_sessions.deleted_at IS NULL
|
||||
AND users.user_deleted_at IS NULL
|
||||
AND users.mod_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.session_token,
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| ApiOrWebSessionUserRecord {
|
||||
user_token: r.user_token,
|
||||
username: r.username,
|
||||
display_name: r.display_name,
|
||||
email_address: r.email_address,
|
||||
user_role_slug: r.user_role_slug,
|
||||
is_banned: i8_to_bool(r.is_banned),
|
||||
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
|
||||
maybe_api_key_token: None,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api_or_web_session_user_record;
|
||||
pub mod get_api_or_web_session_user_by_api_key;
|
||||
pub mod get_api_or_web_session_user_by_session_token;
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod api_or_web_sessions;
|
||||
pub mod user;
|
||||
pub mod user_badges;
|
||||
pub mod user_bookmarks;
|
||||
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
// NB: Incrementally getting rid of build warnings...
|
||||
#![forbid(unused_imports)]
|
||||
#![forbid(unused_mut)]
|
||||
#![forbid(unused_variables)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::warn;
|
||||
|
||||
use mysql_queries::queries::tts::tts_category_assignments::list_assigned_tts_categories_query_builder::ListAssignedTtsCategoriesQueryBuilder;
|
||||
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
// =============== Request ===============
|
||||
|
||||
/// For the URL PathInfo
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListTtsModelAssignedCategoriesPathInfo {
|
||||
/// TTS model token
|
||||
token: String,
|
||||
}
|
||||
|
||||
// =============== Success Response ===============
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ListTtsModelAssignedCategoriesResponse {
|
||||
pub success: bool,
|
||||
pub categories: Vec<DisplayCategory>,
|
||||
}
|
||||
|
||||
/// Public-facing and optimized (non-irrelevant fields) category list
|
||||
/// Used for the main TTS dropdown as well as the TTS edit/tagging UI
|
||||
#[derive(Serialize)]
|
||||
pub struct DisplayCategory {
|
||||
pub category_token: String,
|
||||
|
||||
pub model_type: String, // TODO: ENUM
|
||||
|
||||
pub maybe_super_category_token: Option<String>,
|
||||
|
||||
pub can_directly_have_models: bool,
|
||||
pub can_have_subcategories: bool,
|
||||
pub can_only_mods_apply: bool,
|
||||
|
||||
pub name: String,
|
||||
pub maybe_dropdown_name: Option<String>,
|
||||
|
||||
// NB: It's okay for non-mods to see this.
|
||||
pub is_mod_approved: Option<bool>,
|
||||
|
||||
pub category_created_at: DateTime<Utc>,
|
||||
pub category_updated_at: DateTime<Utc>,
|
||||
pub category_deleted_at: Option<DateTime<Utc>>,
|
||||
|
||||
//pub creator_user_token: Option<String>,
|
||||
//pub creator_username: Option<String>,
|
||||
//pub creator_display_name: Option<String>,
|
||||
}
|
||||
|
||||
// =============== Error Response ===============
|
||||
// NB: Not using derive_more::Display since Clion doesn't understand it.
|
||||
// =============== Handler ===============
|
||||
|
||||
pub async fn list_tts_model_assigned_categories_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ListTtsModelAssignedCategoriesPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
{
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Could not acquire DB pool: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
// TODO: We don't have any permissions for categories. This is a proxy.
|
||||
let is_mod = maybe_user_session
|
||||
.map(|session| session.can_ban_users)
|
||||
.unwrap_or(false);
|
||||
|
||||
let query_builder = ListAssignedTtsCategoriesQueryBuilder::for_model_token(&path.token)
|
||||
.show_invalid_model_not_allowed_categories(is_mod)
|
||||
.show_deleted(is_mod)
|
||||
.show_unapproved(is_mod);
|
||||
|
||||
let query_result =
|
||||
query_builder.perform_query_by_connection(&mut mysql_connection).await;
|
||||
|
||||
let results = match query_result {
|
||||
Ok(results) => results,
|
||||
Err(err) => {
|
||||
warn!("DB error: {:?}", err);
|
||||
return Err(CommonWebError::from_anyhow_error(err));
|
||||
}
|
||||
};
|
||||
|
||||
let mut categories = results.categories.into_iter()
|
||||
.map(|c| {
|
||||
DisplayCategory {
|
||||
category_token: c.category_token,
|
||||
model_type: c.model_type,
|
||||
maybe_super_category_token: c.maybe_super_category_token,
|
||||
can_directly_have_models: c.can_directly_have_models,
|
||||
can_have_subcategories: c.can_have_subcategories,
|
||||
can_only_mods_apply: c.can_only_mods_apply,
|
||||
name: c.name.clone(),
|
||||
maybe_dropdown_name:c.maybe_dropdown_name,
|
||||
is_mod_approved: c.is_mod_approved,
|
||||
category_created_at: c.category_created_at,
|
||||
category_updated_at: c.category_updated_at,
|
||||
category_deleted_at: c.category_deleted_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<DisplayCategory>>();
|
||||
|
||||
// TODO: Sort by dropdown name too.
|
||||
categories.sort_by(|c1, c2| c1.name.cmp(&c2.name));
|
||||
|
||||
let response = ListTtsModelAssignedCategoriesResponse {
|
||||
success: true,
|
||||
categories,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
}
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
pub mod assign_tts_category;
|
||||
pub mod list_fully_computed_assigned_tts_categories;
|
||||
pub mod list_tts_categories;
|
||||
pub mod list_tts_model_assigned_categories;
|
||||
|
||||
+8
-14
@@ -24,6 +24,7 @@ use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url, ExtractExtensions};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
@@ -58,19 +59,12 @@ pub async fn create_character_handler(
|
||||
.acquire()
|
||||
.await?;
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from(e)
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => return Err(CommonWebError::NotAuthorized),
|
||||
};
|
||||
let user_session = require_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &user_session.user_token;
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
@@ -194,7 +188,7 @@ pub async fn create_character_handler(
|
||||
kinovi_character_id: &gen_result.character_id,
|
||||
maybe_model_type: None,
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
maybe_avt_token: None,
|
||||
maybe_avt_token: user_session.maybe_avt_token.as_ref(),
|
||||
creator_ip_address: &ip_address,
|
||||
creator_set_visibility: Visibility::Hidden,
|
||||
mysql_executor: &mut *transaction,
|
||||
|
||||
+7
-2
@@ -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::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Delete a character.
|
||||
@@ -40,7 +40,12 @@ pub async fn delete_character_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
|
||||
let user_session = require_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &user_session.user_token;
|
||||
let is_mod = user_session.is_mod();
|
||||
|
||||
+7
-2
@@ -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::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Edit a character's name or description.
|
||||
@@ -38,7 +38,12 @@ pub async fn edit_character_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
|
||||
let user_session = require_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &user_session.user_token;
|
||||
let is_mod = user_session.is_mod();
|
||||
|
||||
+7
-2
@@ -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::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// List characters for the current session.
|
||||
@@ -39,7 +39,12 @@ pub async fn list_characters_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
|
||||
let user_session = require_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &user_session.user_token;
|
||||
|
||||
|
||||
+22
-6
@@ -4,6 +4,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::inference_job::utils::estimates::estimate_job_progress::estimate_job_progress;
|
||||
use crate::http_server::endpoints::tts::enqueue_infer_tts_handler::enqueue_infer_tts_handler::SYNTHETIC_INFERENCE_JOB_TOKEN;
|
||||
use crate::http_server::endpoints::inference_job::utils::extractors::extract_lipsync_details::extract_lipsync_details;
|
||||
use crate::http_server::endpoints::inference_job::utils::extractors::extract_live_portrait_details::extract_live_portrait_details;
|
||||
use crate::http_server::endpoints::inference_job::utils::extractors::extract_polymorphic_inference_args::extract_polymorphic_inference_args;
|
||||
@@ -154,12 +155,9 @@ pub async fn get_inference_job_status_handler(
|
||||
path: Path<GetInferenceJobStatusPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<GetInferenceJobStatusSuccessResponse>, CommonWebError> {
|
||||
if path.token.as_str().trim() == "None" {
|
||||
// NB: A bunch of Python clients use our API and can fail in this manner.
|
||||
// This was a large traffic driver during the 2023-03-08 outage.
|
||||
// Presumably, it's this client: https://github.com/shards-7/fakeyou.py
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
// Don't serve bad traffic
|
||||
blackhole_jobs(&path.token)?;
|
||||
|
||||
// NB: Since this is publicly exposed, we don't query sensitive data.
|
||||
let maybe_status = get_inference_job_status(
|
||||
@@ -233,6 +231,24 @@ pub async fn get_inference_job_status_handler(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reject junk job tokens that abusive or broken clients poll in high volume, before they reach
|
||||
/// MySQL or Redis. Deliberately not logged — the whole point is to make these requests as cheap
|
||||
/// as possible.
|
||||
fn blackhole_jobs(token: &InferenceJobToken) -> Result<(), CommonWebError> {
|
||||
match token.as_str().trim() {
|
||||
// NB: The shut-down legacy TTS enqueue endpoint hands this sentinel token to old clients,
|
||||
// and some of them poll it forever.
|
||||
SYNTHETIC_INFERENCE_JOB_TOKEN => Err(CommonWebError::TooManyRequests),
|
||||
// NB: A bunch of Python clients use our API and can fail in this manner.
|
||||
// This was a large traffic driver during the 2023-03-08 outage.
|
||||
// Presumably, it's this client: https://github.com/shards-7/fakeyou.py
|
||||
"None" => Err(CommonWebError::NotFound),
|
||||
// NB: Broken Javascript clients poll with a stringified `undefined` token.
|
||||
"undefined" => Err(CommonWebError::NotFound),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_to_payload(
|
||||
record: GenericInferenceJobStatus,
|
||||
maybe_extra_status_description: Option<String>,
|
||||
|
||||
+3
-2
@@ -2,8 +2,9 @@ use actix_web::web::Json;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Sentinel job token returned to legacy clients now that TTS inference is shut down.
|
||||
/// There is no real job behind it; status polls for it will never complete.
|
||||
const SYNTHETIC_INFERENCE_JOB_TOKEN: &str = "synthetic_too_many_requests";
|
||||
/// There is no real job behind it; status polls for it are rejected with a 429 before
|
||||
/// touching the database (see `get_inference_job_status_handler`).
|
||||
pub const SYNTHETIC_INFERENCE_JOB_TOKEN: &str = "synthetic_too_many_requests";
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct InferTtsSuccessResponse {
|
||||
|
||||
+34
-174
@@ -1,25 +1,15 @@
|
||||
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};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::error;
|
||||
use redis::{Commands, RedisResult};
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use mysql_queries::queries::generic_inference::web::get_inference_job_status::get_inference_job_status;
|
||||
use mysql_queries::queries::tts::tts_inference_jobs::get_tts_inference_job_status::get_tts_inference_job_status;
|
||||
use redis_common::redis_keys::RedisKeys;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
use crate::http_server::web_utils::filter_model_name::filter_model_name;
|
||||
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;
|
||||
|
||||
/// Terminal status reported for every job so legacy clients stop polling.
|
||||
const SYNTHETIC_JOB_STATUS: &str = "dead";
|
||||
|
||||
const SYNTHETIC_MODEL_TOKEN: &str = "synthetic_model_token";
|
||||
const SYNTHETIC_MODEL_TITLE: &str = "Legacy TTS is retired";
|
||||
const SYNTHETIC_MODEL_TYPE: &str = "tacotron2";
|
||||
|
||||
/// For the URL PathInfo
|
||||
#[derive(Deserialize)]
|
||||
@@ -27,6 +17,12 @@ pub struct GetTtsInferenceStatusPathInfo {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetTtsInferenceStatusSuccessResponse {
|
||||
pub success: bool,
|
||||
pub state: TtsInferenceJobStatusForResponse,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TtsInferenceJobStatusForResponse {
|
||||
pub job_token: String,
|
||||
@@ -53,17 +49,14 @@ pub struct TtsInferenceJobStatusForResponse {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetTtsInferenceStatusSuccessResponse {
|
||||
pub success: bool,
|
||||
pub state: TtsInferenceJobStatusForResponse,
|
||||
}
|
||||
// NB: Not using derive_more::Display since Clion doesn't understand it.
|
||||
/// RETIRED endpoint that still receives heavy zombie traffic.
|
||||
///
|
||||
/// Legacy TTS inference is shut down and the job tables are no longer served.
|
||||
/// Every job is reported as terminally "dead" (without touching MySQL or
|
||||
/// Redis) so old clients stop polling.
|
||||
pub async fn get_tts_inference_job_status_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<GetTtsInferenceStatusPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<Json<GetTtsInferenceStatusSuccessResponse>, CommonWebError>
|
||||
{
|
||||
) -> Result<Json<GetTtsInferenceStatusSuccessResponse>, CommonWebError> {
|
||||
let job_token = path.into_inner().token;
|
||||
|
||||
if job_token.trim() == "None" {
|
||||
@@ -72,156 +65,23 @@ pub async fn get_tts_inference_job_status_handler(
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
// NB(bt,2023-11-27): We're moving TT2 over to `inference-job` (from `tts-inference-job`), which
|
||||
// uses a wholly different job table. The token prefix determines the type of job:
|
||||
// Legacy TT2 jobs (tts_inference_jobs) start with "JTINF:", and generic jobs start with "jinf_".
|
||||
let record_for_response = if job_token.starts_with("jinf_") {
|
||||
modern_lookup(&job_token, &server_state).await?
|
||||
} else {
|
||||
legacy_lookup(&job_token, &server_state).await?
|
||||
};
|
||||
let now = Utc::now();
|
||||
|
||||
Ok(Json(GetTtsInferenceStatusSuccessResponse {
|
||||
success: true,
|
||||
state: record_for_response,
|
||||
state: TtsInferenceJobStatusForResponse {
|
||||
job_token,
|
||||
status: SYNTHETIC_JOB_STATUS.to_string(),
|
||||
maybe_extra_status_description: None,
|
||||
attempt_count: 1,
|
||||
maybe_result_token: None,
|
||||
maybe_public_bucket_wav_audio_path: None,
|
||||
model_token: SYNTHETIC_MODEL_TOKEN.to_string(),
|
||||
tts_model_type: SYNTHETIC_MODEL_TYPE.to_string(),
|
||||
title: SYNTHETIC_MODEL_TITLE.to_string(),
|
||||
raw_inference_text: String::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn legacy_lookup(job_token: &str, server_state: &ServerState)
|
||||
-> Result<TtsInferenceJobStatusForResponse, CommonWebError>
|
||||
{
|
||||
// NB: Lookup failure is Err(RowNotFound).
|
||||
// NB: Since this is publicly exposed, we don't query sensitive data.
|
||||
let maybe_status =
|
||||
get_tts_inference_job_status(&job_token, &server_state.mysql_pool).await;
|
||||
|
||||
let record = match maybe_status {
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => return Err(CommonWebError::NotFound),
|
||||
Err(err) => {
|
||||
error!("tts job query error: {:?}", err);
|
||||
return Err(CommonWebError::from_anyhow_error(err));
|
||||
}
|
||||
};
|
||||
|
||||
let mut redis = server_state.redis_pool
|
||||
.get()
|
||||
.map_err(|e| {
|
||||
error!("redis error: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
// TODO(bt,2023-05-21): Make async.
|
||||
let extra_status_key = RedisKeys::tts_inference_extra_status_info(&job_token);
|
||||
let maybe_extra_status_description : Option<String> = match redis.get(&extra_status_key) {
|
||||
Ok(Some(status)) => {
|
||||
Some(status)
|
||||
},
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
error!("redis error: {:?}", e);
|
||||
None // Fail open
|
||||
},
|
||||
};
|
||||
|
||||
Ok(TtsInferenceJobStatusForResponse {
|
||||
job_token: record.job_token,
|
||||
status: record.status,
|
||||
maybe_extra_status_description,
|
||||
attempt_count: record.attempt_count as u8,
|
||||
maybe_result_token: record.maybe_result_token,
|
||||
maybe_public_bucket_wav_audio_path: record.maybe_public_bucket_wav_audio_path,
|
||||
model_token: record.model_token,
|
||||
tts_model_type: record.tts_model_type,
|
||||
title: record.title,
|
||||
raw_inference_text: record.raw_inference_text,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn modern_lookup(job_token: &str, server_state: &ServerState)
|
||||
-> Result<TtsInferenceJobStatusForResponse, CommonWebError>
|
||||
{
|
||||
// NB: Lookup failure is Err(RowNotFound).
|
||||
// NB: Since this is publicly exposed, we don't query sensitive data.
|
||||
let maybe_status =
|
||||
get_tts_inference_job_status(&job_token, &server_state.mysql_pool).await;
|
||||
|
||||
let job_token = InferenceJobToken::new_from_str(job_token);
|
||||
|
||||
let maybe_status = get_inference_job_status(&job_token, &server_state.mysql_pool).await;
|
||||
|
||||
let record = match maybe_status {
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => return Err(CommonWebError::NotFound),
|
||||
Err(err) => {
|
||||
error!("tts job query error: {:?}", err);
|
||||
return Err(CommonWebError::from_anyhow_error(err));
|
||||
}
|
||||
};
|
||||
|
||||
let mut redis = server_state.redis_pool
|
||||
.get()
|
||||
.map_err(|e| {
|
||||
error!("redis error: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
// TODO(bt,2023-05-21): Make async.
|
||||
let extra_status_key = RedisKeys::generic_inference_extra_status_info(job_token.as_str());
|
||||
let maybe_extra_status_value : RedisResult<Option<String>> = redis.get(&extra_status_key);
|
||||
|
||||
let maybe_extra_status_description = match maybe_extra_status_value {
|
||||
Err(e) => {
|
||||
error!("redis error: {:?}", e);
|
||||
None // Fail open
|
||||
},
|
||||
Ok(maybe_value) => match maybe_value.as_deref() {
|
||||
Some("1") => {
|
||||
// TODO(bt,2023-10-20): Redis is reporting "1" and it's been surfacing this as a weird
|
||||
// message to the frontend for months. This needs proper fixing.
|
||||
None
|
||||
},
|
||||
Some(value) => Some(value.to_string()),
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
// NB: Model type is probably TT2, but let's filter it in case a hidden model type ever sneaks in
|
||||
let model_type = record.request_details.maybe_model_type.as_deref().unwrap_or_else(|| "tacotron2");
|
||||
let model_type = filter_model_name(model_type);
|
||||
|
||||
Ok(TtsInferenceJobStatusForResponse {
|
||||
job_token: record.job_token.to_string(),
|
||||
status: record.status.to_string(),
|
||||
maybe_extra_status_description,
|
||||
attempt_count: record.attempt_count as u8,
|
||||
maybe_result_token: record.maybe_result_details.as_ref().map(|result| result.entity_token.clone()),
|
||||
maybe_public_bucket_wav_audio_path: record.maybe_result_details.map(|result_details| {
|
||||
match result_details.entity_type.as_str() {
|
||||
"media_file" => {
|
||||
// NB: We're migrating TTS to media_files.
|
||||
// Zero shot TTS uses media files.
|
||||
// Legacy TT2 uses old pathing.
|
||||
MediaFileBucketPath::from_object_hash(
|
||||
&result_details.public_bucket_location_or_hash,
|
||||
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
|
||||
result_details.maybe_media_file_public_bucket_extension.as_deref())
|
||||
.get_full_object_path_str()
|
||||
.to_string()
|
||||
}
|
||||
_ => {
|
||||
// NB: TTS results receive the legacy treatment where their table only reports the full bucket path
|
||||
result_details.public_bucket_location_or_hash
|
||||
}
|
||||
}
|
||||
}),
|
||||
model_token: record.request_details.maybe_model_token.unwrap_or_else(|| "NO_MODEL_TOKEN".to_string()),
|
||||
tts_model_type: model_type,
|
||||
title: record.request_details.maybe_model_title.unwrap_or_else(|| "no model title".to_string()),
|
||||
raw_inference_text: record.request_details.maybe_raw_inference_text.unwrap_or_else(|| "no inference text".to_string()),
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
+9
-160
@@ -1,30 +1,17 @@
|
||||
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};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::warn;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::model_weights::weights_category::WeightsCategory;
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::by_table::model_weights::weights_types::WeightsType;
|
||||
use mysql_queries::queries::model_weights::list::list_featured_weights::{list_featured_weights, ListFeaturedWeightsArgs};
|
||||
use primitives::numerics::u64_to_u32_saturating::u64_to_u32_saturating;
|
||||
use tokens::tokens::model_weights::ModelWeightToken;
|
||||
|
||||
use crate::http_server::common_responses::media::weights_cover_image_details::WeightsCoverImageDetails;
|
||||
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
|
||||
use crate::http_server::common_responses::user_details_lite::UserDetailsLight;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::endpoints::weights::helpers::get_scoped_weights_categories::get_scoped_weights_categories;
|
||||
use crate::http_server::endpoints::weights::helpers::get_scoped_weights_types::get_scoped_weights_types;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::title_to_url_slug::title_to_url_slug;
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListFeaturedWeightsQueryParams {
|
||||
@@ -101,9 +88,6 @@ pub struct FeaturedModelWeightForList {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// The key we store featured weights tokens under
|
||||
const REDIS_KEY : &str = "featured_weights_list";
|
||||
|
||||
#[derive(Debug, ToSchema)]
|
||||
pub enum ListFeaturedWeightsError {
|
||||
NotAuthorized,
|
||||
@@ -125,7 +109,10 @@ impl ResponseError for ListFeaturedWeightsError {
|
||||
}
|
||||
}
|
||||
|
||||
/// List model weights that the staff has selected as "the best" models on the site.
|
||||
/// RETIRED: featured model weights are shut down.
|
||||
///
|
||||
/// Always returns an empty result list (no database access) for the legacy
|
||||
/// clients that still call this endpoint.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Model Weights",
|
||||
@@ -138,148 +125,10 @@ impl ResponseError for ListFeaturedWeightsError {
|
||||
),
|
||||
)]
|
||||
pub async fn list_featured_weights_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListFeaturedWeightsQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<Json<ListFeaturedWeightsSuccessResponse>, impl ResponseError> {
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
ListFeaturedWeightsError::ServerError
|
||||
})?;
|
||||
|
||||
let mut is_mod = false;
|
||||
|
||||
let maybe_scoped_weights_types = get_scoped_weights_types(query.filter_weights_types.as_deref());
|
||||
let maybe_scoped_weights_categories = get_scoped_weights_categories(query.filter_weights_categories.as_deref());
|
||||
|
||||
match maybe_user_session {
|
||||
None => {},
|
||||
Some(session) => {
|
||||
is_mod = session.can_ban_users;
|
||||
},
|
||||
};
|
||||
|
||||
// TODO(bt,2023-12-04): Enforce real maximums and defaults
|
||||
let limit = query.page_size.unwrap_or(25);
|
||||
|
||||
let sort_ascending = query.sort_ascending.unwrap_or(false);
|
||||
let cursor_is_reversed = query.cursor_is_reversed.unwrap_or(false);
|
||||
|
||||
let cursor = if let Some(cursor) = query.cursor.as_deref() {
|
||||
let cursor = server_state.sort_key_crypto.decrypt_id(cursor)
|
||||
.map_err(|e| {
|
||||
warn!("crypto error: {:?}", e);
|
||||
ListFeaturedWeightsError::ServerError
|
||||
})?;
|
||||
Some(cursor as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let view_as = if is_mod {
|
||||
ViewAs::Moderator
|
||||
} else {
|
||||
ViewAs::AnotherUser
|
||||
};
|
||||
|
||||
let query_results = list_featured_weights(ListFeaturedWeightsArgs {
|
||||
limit,
|
||||
maybe_offset: cursor,
|
||||
sort_ascending,
|
||||
cursor_is_reversed,
|
||||
view_as,
|
||||
maybe_scoped_weight_types: maybe_scoped_weights_types.as_ref(),
|
||||
maybe_scoped_weight_categories: maybe_scoped_weights_categories.as_ref(),
|
||||
mysql_pool: &server_state.mysql_pool,
|
||||
}).await;
|
||||
|
||||
let results_page = match query_results {
|
||||
Ok(results) => results,
|
||||
Err(e) => {
|
||||
warn!("Query error: {:?}", e);
|
||||
return Err(ListFeaturedWeightsError::ServerError);
|
||||
}
|
||||
};
|
||||
|
||||
let cursor_next = if let Some(id) = results_page.last_id {
|
||||
let cursor = server_state.sort_key_crypto.encrypt_id(id as u64)
|
||||
.map_err(|e| {
|
||||
warn!("crypto error: {:?}", e);
|
||||
ListFeaturedWeightsError::ServerError
|
||||
})?;
|
||||
Some(cursor)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let cursor_previous = if let Some(id) = results_page.first_id {
|
||||
let cursor = server_state.sort_key_crypto.encrypt_id(id as u64)
|
||||
.map_err(|e| {
|
||||
warn!("crypto error: {:?}", e);
|
||||
ListFeaturedWeightsError::ServerError
|
||||
})?;
|
||||
Some(cursor)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
|
||||
let response = ListFeaturedWeightsSuccessResponse {
|
||||
_query: Query<ListFeaturedWeightsQueryParams>,
|
||||
) -> Result<Json<ListFeaturedWeightsSuccessResponse>, ListFeaturedWeightsError> {
|
||||
Ok(Json(ListFeaturedWeightsSuccessResponse {
|
||||
success: true,
|
||||
results: results_page.records.into_iter()
|
||||
.map(|w| {
|
||||
let cover_image_details = WeightsCoverImageDetails::from_optional_db_fields(
|
||||
media_domain,
|
||||
server_state.server_environment,
|
||||
&w.token,
|
||||
w.maybe_cover_image_public_bucket_hash.as_deref(),
|
||||
w.maybe_cover_image_public_bucket_prefix.as_deref(),
|
||||
w.maybe_cover_image_public_bucket_extension.as_deref(),
|
||||
);
|
||||
|
||||
let maybe_cover_image = w.maybe_cover_image_public_bucket_hash
|
||||
.as_deref()
|
||||
.map(|hash| {
|
||||
MediaFileBucketPath::from_object_hash(
|
||||
hash,
|
||||
w.maybe_cover_image_public_bucket_prefix.as_deref(),
|
||||
w.maybe_cover_image_public_bucket_extension.as_deref())
|
||||
.get_full_object_path_str()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
FeaturedModelWeightForList {
|
||||
weight_token: w.token,
|
||||
maybe_url_slug: title_to_url_slug(&w.title),
|
||||
title: w.title,
|
||||
maybe_ietf_language_tag: w.maybe_ietf_language_tag,
|
||||
maybe_ietf_primary_language_subtag: w.maybe_ietf_primary_language_subtag,
|
||||
weight_type: w.weights_type,
|
||||
weight_category: w.weights_category,
|
||||
cover_image: cover_image_details,
|
||||
maybe_cover_image_public_bucket_path: maybe_cover_image,
|
||||
creator: UserDetailsLight::from_optional_db_fields(
|
||||
w.maybe_creator_user_token.as_ref(),
|
||||
w.maybe_creator_username.as_deref(),
|
||||
w.maybe_creator_display_name.as_deref(),
|
||||
w.maybe_creator_email_gravatar_hash.as_deref(),
|
||||
),
|
||||
stats: SimpleEntityStats {
|
||||
positive_rating_count: w.maybe_ratings_positive_count.unwrap_or(0),
|
||||
bookmark_count: w.maybe_bookmark_count.unwrap_or(0),
|
||||
},
|
||||
usage_count: u64_to_u32_saturating(w.cached_usage_count),
|
||||
created_at: w.created_at,
|
||||
updated_at: w.updated_at,
|
||||
}
|
||||
}).collect::<Vec<_>>(),
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
results: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
+19
-135
@@ -3,7 +3,6 @@
|
||||
#![forbid(unused_mut)]
|
||||
#![forbid(unused_variables)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -12,23 +11,18 @@ use actix_web::http::StatusCode;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::error;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::by_table::model_weights::weights_category::WeightsCategory;
|
||||
use enums::by_table::model_weights::weights_types::WeightsType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::model_weights::ModelWeightToken;
|
||||
|
||||
use crate::http_server::common_responses::media::weights_cover_image_details::WeightsCoverImageDetails;
|
||||
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
|
||||
use crate::http_server::common_responses::user_details_lite::UserDetailsLight;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::title_to_url_slug::title_to_url_slug;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use elasticsearch_schema::searches::search_model_weights::search_model_weights::{search_model_weights, ModelWeightsSortDirection, ModelWeightsSortField, SearchArgs};
|
||||
use enums::by_table::model_weights::weights_category::WeightsCategory;
|
||||
use enums::by_table::model_weights::weights_types::WeightsType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use primitives::numerics::i32_to_u32_zero_clamped::i32_to_u32_zero_clamped;
|
||||
use tokens::tokens::model_weights::ModelWeightToken;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SearchModelWeightsRequest {
|
||||
@@ -63,6 +57,12 @@ pub enum SearchModelWeightsSortDirection {
|
||||
Descending,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SearchModelWeightsSuccessResponse {
|
||||
pub success: bool,
|
||||
pub weights: Vec<ModelWeightSearchResult>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, ToSchema)]
|
||||
pub struct ModelWeightSearchResult {
|
||||
pub weight_token: ModelWeightToken,
|
||||
@@ -108,12 +108,6 @@ pub struct ModelWeightSearchResult {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SearchModelWeightsSuccessResponse {
|
||||
pub success: bool,
|
||||
pub weights: Vec<ModelWeightSearchResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema)]
|
||||
pub enum SearchModelWeightsError {
|
||||
ServerError,
|
||||
@@ -142,128 +136,18 @@ impl fmt::Display for SearchModelWeightsError {
|
||||
}
|
||||
}
|
||||
|
||||
/// RETIRED: model weight search is shut down.
|
||||
///
|
||||
/// Always returns an empty result list (no Elasticsearch access) for the
|
||||
/// legacy clients that still call the search endpoints.
|
||||
pub async fn search_model_weights_impl(
|
||||
http_request: HttpRequest,
|
||||
request: SearchModelWeightsRequest,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
_http_request: HttpRequest,
|
||||
_request: SearchModelWeightsRequest,
|
||||
_server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<Json<SearchModelWeightsSuccessResponse>, SearchModelWeightsError>
|
||||
{
|
||||
let maybe_weights_categories = request.weight_category
|
||||
.map(|weight_category| {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(weight_category);
|
||||
set
|
||||
});
|
||||
|
||||
let maybe_weights_types = request.weight_type
|
||||
.map(|weight_type| {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(weight_type);
|
||||
set
|
||||
});
|
||||
|
||||
let sort_field = match request.sort_field {
|
||||
Some(SearchModelWeightsSortField::MatchScore) => Some(ModelWeightsSortField::MatchScore),
|
||||
Some(SearchModelWeightsSortField::CreatedAt) => Some(ModelWeightsSortField::CreatedAt),
|
||||
Some(SearchModelWeightsSortField::UsageCount) => Some(ModelWeightsSortField::UsageCount),
|
||||
Some(SearchModelWeightsSortField::BookmarkCount) => Some(ModelWeightsSortField::BookmarkCount),
|
||||
Some(SearchModelWeightsSortField::PositiveRatingCount) => Some(ModelWeightsSortField::PositiveRatingCount),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let sort_direction = match request.sort_direction {
|
||||
Some(SearchModelWeightsSortDirection::Ascending) => Some(ModelWeightsSortDirection::Ascending),
|
||||
Some(SearchModelWeightsSortDirection::Descending) => Some(ModelWeightsSortDirection::Descending),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let results = search_model_weights(SearchArgs {
|
||||
search_term: &request.search_term,
|
||||
maybe_creator_user_token: None,
|
||||
maybe_ietf_primary_language_subtag: request.ietf_language_subtag.as_deref(),
|
||||
maybe_weights_categories,
|
||||
maybe_weights_types,
|
||||
sort_field,
|
||||
sort_direction,
|
||||
minimum_score: request.minimum_score,
|
||||
client: &server_state.elasticsearch,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Searching error: {:?}", err);
|
||||
SearchModelWeightsError::ServerError
|
||||
})?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
|
||||
let results = results.into_iter()
|
||||
.map(|result| {
|
||||
let cover_image_details = WeightsCoverImageDetails::from_optional_db_fields(
|
||||
media_domain,
|
||||
server_state.server_environment,
|
||||
&result.token,
|
||||
result.maybe_cover_image_public_bucket_hash.as_deref(),
|
||||
result.maybe_cover_image_public_bucket_prefix.as_deref(),
|
||||
result.maybe_cover_image_public_bucket_extension.as_deref(),
|
||||
);
|
||||
|
||||
let maybe_cover_image = result.maybe_cover_image_public_bucket_hash
|
||||
.as_deref()
|
||||
.map(|hash| {
|
||||
MediaFileBucketPath::from_object_hash(
|
||||
&hash,
|
||||
result.maybe_cover_image_public_bucket_prefix.as_deref(),
|
||||
result.maybe_cover_image_public_bucket_extension.as_deref())
|
||||
.get_full_object_path_str()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
ModelWeightSearchResult {
|
||||
weight_token: result.token,
|
||||
weight_type: result.weights_type,
|
||||
weight_category: result.weights_category,
|
||||
maybe_url_slug: title_to_url_slug(&result.title),
|
||||
title: result.title,
|
||||
creator: UserDetailsLight::from_db_fields(
|
||||
&result.creator_user_token,
|
||||
&result.creator_username,
|
||||
&result.creator_display_name,
|
||||
&result.creator_gravatar_hash,
|
||||
),
|
||||
cover_image: cover_image_details,
|
||||
maybe_cover_image_public_bucket_path: maybe_cover_image,
|
||||
is_featured: result.is_featured.unwrap_or(false),
|
||||
stats: SimpleEntityStats {
|
||||
positive_rating_count: result.ratings_positive_count,
|
||||
bookmark_count: result.bookmark_count,
|
||||
},
|
||||
usage_count: i32_to_u32_zero_clamped(result.cached_usage_count.unwrap_or(0)),
|
||||
maybe_ietf_language_tag: result.maybe_ietf_language_tag,
|
||||
maybe_ietf_primary_language_subtag: result.maybe_ietf_primary_language_subtag,
|
||||
creator_set_visibility: result.creator_set_visibility,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// TODO(bt,2023-10-27): For some reason Elasticsearch returns duplicates. Maybe we populated the
|
||||
// DB twice? Need to filter them out, or React chokes and gets stuck on duplicates. (Effectively
|
||||
// freezing them into the UI, despite component updates)
|
||||
|
||||
let mut added_tokens = HashSet::new();
|
||||
let mut new_results = Vec::with_capacity(results.len());
|
||||
|
||||
for result in results.into_iter() {
|
||||
if added_tokens.contains(&result.weight_token) {
|
||||
continue;
|
||||
}
|
||||
added_tokens.insert(result.weight_token.clone());
|
||||
new_results.push(result);
|
||||
}
|
||||
|
||||
Ok(Json(SearchModelWeightsSuccessResponse {
|
||||
success: true,
|
||||
weights: new_results,
|
||||
weights: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
-9
@@ -9,7 +9,6 @@ use crate::http_server::deprecated_endpoints::categories::get_category::get_cate
|
||||
use crate::http_server::deprecated_endpoints::categories::tts::assign_tts_category::assign_tts_category_handler;
|
||||
use crate::http_server::deprecated_endpoints::categories::tts::list_fully_computed_assigned_tts_categories::list_fully_computed_assigned_tts_categories::list_fully_computed_assigned_tts_categories_handler;
|
||||
use crate::http_server::deprecated_endpoints::categories::tts::list_tts_categories::list_tts_categories_handler;
|
||||
use crate::http_server::deprecated_endpoints::categories::tts::list_tts_model_assigned_categories::list_tts_model_assigned_categories_handler;
|
||||
use crate::http_server::deprecated_endpoints::conversion::enqueue_fbx_to_gltf_handler::enqueue_fbx_to_gltf_handler;
|
||||
use crate::http_server::deprecated_endpoints::conversion::enqueue_render_engine_scene_to_video_handler::enqueue_render_engine_scene_to_video_handler;
|
||||
use crate::http_server::deprecated_endpoints::engine::create_scene_handler::create_scene_handler;
|
||||
@@ -233,14 +232,6 @@ where
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
.service(
|
||||
web::scope("/assignments")
|
||||
.service(
|
||||
web::resource("/tts/{token}")
|
||||
.route(web::get().to(list_tts_model_assigned_categories_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod require_api_or_web_session;
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use actix_web::http::header;
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
|
||||
use mysql_queries::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord;
|
||||
use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_api_key::{
|
||||
get_api_or_web_session_user_by_api_key, GetApiOrWebSessionUserByApiKeyArgs,
|
||||
};
|
||||
use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_session_token::{
|
||||
get_api_or_web_session_user_by_session_token, GetApiOrWebSessionUserBySessionTokenArgs,
|
||||
};
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
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::user_lookup::user_session::session_utils::session_checker::SessionChecker;
|
||||
use crate::http_server::web_utils::get_authorization_header_api_key::get_authorization_header_api_key;
|
||||
|
||||
const ADMIN_ROLE_SLUG: &str = "admin";
|
||||
|
||||
/// The authenticated identity behind a request that may come from either an API client
|
||||
/// (`Authorization` header API key) or the website (session cookie). Fields common to both auth
|
||||
/// paths are populated uniformly, so handlers don't need to care which path authenticated the
|
||||
/// user.
|
||||
pub struct ApiOrWebSession {
|
||||
pub session_type: SessionType,
|
||||
pub user_token: UserToken,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email_address: String,
|
||||
|
||||
pub user_role_slug: String,
|
||||
pub can_ban_users: bool,
|
||||
|
||||
/// Only present for API-key sessions.
|
||||
pub maybe_api_key_token: Option<ApiKeyToken>,
|
||||
|
||||
/// Anonymous visitor tracking cookie. Only ever present for web sessions.
|
||||
pub maybe_avt_token: Option<AnonymousVisitorTrackingToken>,
|
||||
}
|
||||
|
||||
/// Which auth path authenticated the request.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SessionType {
|
||||
Api,
|
||||
WebSession,
|
||||
}
|
||||
|
||||
impl ApiOrWebSession {
|
||||
pub fn is_mod(&self) -> bool {
|
||||
self.can_ban_users || self.user_role_slug == ADMIN_ROLE_SLUG
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate a request as either an API-key user or a web-session (cookie) user.
|
||||
///
|
||||
/// If an `Authorization` header is present, ONLY the API-key lookup is attempted — a malformed or
|
||||
/// unknown key is a 401, never a fallthrough to cookies. Otherwise the session cookie is decoded
|
||||
/// and looked up, and the AVT (anonymous visitor tracking) cookie is captured alongside it.
|
||||
///
|
||||
/// Neither path is cached: every request performs a fresh MySQL lookup. `mysql_executor` can be
|
||||
/// any sqlx executor — prefer passing an already-open connection (`&mut *connection`) so the
|
||||
/// lookup reuses it rather than acquiring a fresh one from the pool.
|
||||
pub async fn require_api_or_web_session<'c, E>(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
avt_cookie_manager: &AvtCookieManager,
|
||||
mysql_executor: E,
|
||||
) -> Result<ApiOrWebSession, CommonWebError>
|
||||
where E: 'c + Executor<'c, Database = MySql>
|
||||
{
|
||||
if http_request.headers().contains_key(header::AUTHORIZATION) {
|
||||
authenticate_by_api_key(http_request, mysql_executor).await
|
||||
} else {
|
||||
authenticate_by_web_session(http_request, session_checker, avt_cookie_manager, mysql_executor).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate_by_api_key<'c, E>(
|
||||
http_request: &HttpRequest,
|
||||
mysql_executor: E,
|
||||
) -> Result<ApiOrWebSession, CommonWebError>
|
||||
where E: 'c + Executor<'c, Database = MySql>
|
||||
{
|
||||
let api_key = match get_authorization_header_api_key(http_request) {
|
||||
Some(api_key) => api_key,
|
||||
None => {
|
||||
warn!("Authorization header present but not a usable API key");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
let maybe_record = get_api_or_web_session_user_by_api_key(GetApiOrWebSessionUserByApiKeyArgs {
|
||||
api_key: &api_key,
|
||||
mysql_executor,
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
into_session_rejecting_banned(record, SessionType::Api, None)
|
||||
}
|
||||
|
||||
async fn authenticate_by_web_session<'c, E>(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
avt_cookie_manager: &AvtCookieManager,
|
||||
mysql_executor: E,
|
||||
) -> Result<ApiOrWebSession, CommonWebError>
|
||||
where E: 'c + Executor<'c, Database = MySql>
|
||||
{
|
||||
let maybe_session_token = session_checker
|
||||
.get_session_token(http_request)
|
||||
.map_err(|err| {
|
||||
warn!("Session cookie decode error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let session_token = match maybe_session_token {
|
||||
Some(session_token) => session_token,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
let maybe_record = get_api_or_web_session_user_by_session_token(GetApiOrWebSessionUserBySessionTokenArgs {
|
||||
session_token: &session_token,
|
||||
mysql_executor,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Web session user lookup error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let record = match maybe_record {
|
||||
Some(record) => record,
|
||||
None => {
|
||||
warn!("Valid cookie; invalid session: {}", session_token);
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
let maybe_avt_token = avt_cookie_manager.get_avt_token_from_request(http_request);
|
||||
|
||||
into_session_rejecting_banned(record, SessionType::WebSession, maybe_avt_token)
|
||||
}
|
||||
|
||||
fn into_session_rejecting_banned(
|
||||
record: ApiOrWebSessionUserRecord,
|
||||
session_type: SessionType,
|
||||
maybe_avt_token: Option<AnonymousVisitorTrackingToken>,
|
||||
) -> Result<ApiOrWebSession, CommonWebError> {
|
||||
if record.is_banned {
|
||||
warn!("user is banned: {:?}", record.user_token.as_str());
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(ApiOrWebSession {
|
||||
session_type,
|
||||
user_token: record.user_token,
|
||||
username: record.username,
|
||||
display_name: record.display_name,
|
||||
email_address: record.email_address,
|
||||
user_role_slug: record.user_role_slug,
|
||||
can_ban_users: record.can_ban_users,
|
||||
maybe_api_key_token: record.maybe_api_key_token,
|
||||
maybe_avt_token,
|
||||
})
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod api_keys;
|
||||
pub mod api_or_web_session;
|
||||
pub mod user_session;
|
||||
|
||||
@@ -26,6 +26,37 @@
|
||||
content="ArtCraft, AI, AI Video, AI Images, Open Source"
|
||||
/>
|
||||
|
||||
<!-- Social share (Open Graph + Twitter Card). Crawlers don't run JS, so
|
||||
these baked-in tags are what Discord/Slack/X show for any webapp URL.
|
||||
Image URLs must be absolute or crawlers ignore them. -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="ArtCraft" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="ArtCraft. AI Video and Images. Fast and Open Desktop App."
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!"
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://app.getartcraft.com/images/og-image.png"
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="ArtCraft. AI Video and Images. Fast and Open Desktop App."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://app.getartcraft.com/images/og-image.png"
|
||||
/>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -28,7 +28,7 @@ import {
|
||||
THUMBNAIL_SIZES,
|
||||
} from "@storyteller/common";
|
||||
import {
|
||||
getModelCreatorIcon,
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDisplayName,
|
||||
getProviderDisplayName,
|
||||
getProviderIconByName,
|
||||
@@ -361,7 +361,13 @@ export function LightboxDetails({
|
||||
label="Model"
|
||||
value={
|
||||
<>
|
||||
{getModelCreatorIcon(promptData.modelType)}
|
||||
<img
|
||||
src={getCreatorIconPathForModelId(
|
||||
promptData.modelType,
|
||||
)}
|
||||
alt="Model creator logo"
|
||||
className="h-4 w-4 invert"
|
||||
/>
|
||||
<span>
|
||||
{getModelDisplayName(promptData.modelType)}
|
||||
</span>
|
||||
|
||||
@@ -72,6 +72,11 @@ interface PromptBoxProps {
|
||||
|
||||
// @-mention support (enables colored prompt overlay + autocomplete)
|
||||
mentionItems?: MentionItem[];
|
||||
|
||||
// Soft prompt-length limit from the model API (`text_prompt_max_length`).
|
||||
// Undefined = unlimited (no counter). The limit is not enforced here; the
|
||||
// page's submit handler blocks generation when over.
|
||||
maxPromptLength?: number;
|
||||
}
|
||||
|
||||
export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
@@ -102,6 +107,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
mediaReferenceRow,
|
||||
modelSelector,
|
||||
mentionItems,
|
||||
maxPromptLength,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -391,7 +397,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="relative flex-1">
|
||||
<div className="promptbox-resize-wrap relative flex-1">
|
||||
{hasMentionItems && mentionItems ? (
|
||||
<MentionTextarea
|
||||
ref={mentionEditorRef}
|
||||
@@ -519,6 +525,21 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
</>
|
||||
)}
|
||||
<PromptFullscreenButton onClick={openFullscreen} />
|
||||
|
||||
{maxPromptLength !== undefined && (
|
||||
<div
|
||||
className={twMerge(
|
||||
// right-4 keeps the counter clear of the textarea's resize grip.
|
||||
"pointer-events-none absolute -bottom-1 right-4 text-[10px] tabular-nums",
|
||||
isFinite(maxPromptLength) && prompt.length > maxPromptLength
|
||||
? "text-red-500"
|
||||
: "text-base-fg/40",
|
||||
)}
|
||||
>
|
||||
{prompt.length} /{" "}
|
||||
{isFinite(maxPromptLength) ? maxPromptLength : "∞"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -565,6 +586,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
<PromptFullscreenModal
|
||||
isOpen={isFullscreen}
|
||||
onClose={closeFullscreen}
|
||||
promptLength={prompt.length}
|
||||
maxPromptLength={maxPromptLength}
|
||||
footerControls={
|
||||
<>
|
||||
{modelSelector}
|
||||
|
||||
@@ -57,6 +57,10 @@ interface PromptFullscreenModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
/** Current prompt length, shown with the counter when a limit is set. */
|
||||
promptLength?: number;
|
||||
/** Soft prompt-length limit; undefined hides the counter entirely. */
|
||||
maxPromptLength?: number;
|
||||
/** Controls rendered in the footer, left of the Done button (e.g. the model
|
||||
* selector + character button). */
|
||||
footerControls?: ReactNode;
|
||||
@@ -68,9 +72,15 @@ export const PromptFullscreenModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
promptLength,
|
||||
maxPromptLength,
|
||||
footerControls,
|
||||
imagePromptRow,
|
||||
}: PromptFullscreenModalProps) => {
|
||||
const showCounter =
|
||||
promptLength !== undefined && maxPromptLength !== undefined;
|
||||
const overLimit =
|
||||
showCounter && isFinite(maxPromptLength) && promptLength > maxPromptLength;
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus the editor and drop the caret at the end once the overlay opens.
|
||||
@@ -106,6 +116,17 @@ export const PromptFullscreenModal = ({
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{showCounter && (
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
<span
|
||||
className={`text-[11px] tabular-nums ${
|
||||
overLimit ? "text-red-500" : "text-base-fg/40"
|
||||
}`}
|
||||
>
|
||||
{promptLength} / {isFinite(maxPromptLength) ? maxPromptLength : "∞"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{imagePromptRow && <div className="shrink-0">{imagePromptRow}</div>}
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">{footerControls}</div>
|
||||
|
||||
@@ -121,6 +121,9 @@ export default function CreateImage() {
|
||||
return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0];
|
||||
}, [apiModels, ui.selectedModelId]);
|
||||
|
||||
// Soft prompt limit from the API; undefined (no model / unset) = unlimited.
|
||||
const maxPromptLength = selectedModel?.text_prompt_max_length ?? undefined;
|
||||
|
||||
const prompt = ui.prompt;
|
||||
const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]);
|
||||
|
||||
@@ -363,6 +366,13 @@ export default function CreateImage() {
|
||||
}
|
||||
if (!prompt.trim() || isGenerating || !selectedModel) return;
|
||||
|
||||
if (maxPromptLength !== undefined && prompt.length > maxPromptLength) {
|
||||
toast.error(
|
||||
`Prompt exceeds the ${maxPromptLength} character limit for this model`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
const batchId = startBatch(
|
||||
prompt,
|
||||
@@ -435,6 +445,7 @@ export default function CreateImage() {
|
||||
prompt,
|
||||
isGenerating,
|
||||
selectedModel,
|
||||
maxPromptLength,
|
||||
numImages,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
@@ -618,6 +629,7 @@ export default function CreateImage() {
|
||||
onSubmit={handleGenerate}
|
||||
isSubmitting={isGenerating}
|
||||
credits={estimatedCredits}
|
||||
maxPromptLength={maxPromptLength}
|
||||
placeholder="Describe what you want in the image..."
|
||||
supportsImagePrompts={!!selectedModel?.image_refs_supported}
|
||||
maxImagePromptCount={maxImageRefs}
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
OMNI_GENERATE_OUTAGE_MESSAGE,
|
||||
} from "@storyteller/omni-gen";
|
||||
import {
|
||||
effectivePromptMaxLength,
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDescription,
|
||||
getModelInfo,
|
||||
@@ -273,6 +274,18 @@ export default function CreateVideo() {
|
||||
|
||||
const requiresImageInput = selectedModel?.text_to_video_supported === false;
|
||||
|
||||
// Soft prompt limit from the API; undefined = unlimited. Seedance waives
|
||||
// the limit (Infinity) when the prompt contains Chinese characters: the
|
||||
// server counts CJK as more than one unit, so a client-side character
|
||||
// count would false-positive.
|
||||
const maxPromptLength = selectedModel
|
||||
? effectivePromptMaxLength(
|
||||
selectedModel.model,
|
||||
selectedModel.text_prompt_max_length ?? undefined,
|
||||
ui.prompt,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const prompt = ui.prompt;
|
||||
const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]);
|
||||
|
||||
@@ -877,6 +890,12 @@ export default function CreateVideo() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (maxPromptLength !== undefined && prompt.length > maxPromptLength) {
|
||||
toast.error(
|
||||
`Prompt exceeds the ${maxPromptLength} character limit for this model`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log("[generate-video] starting", {
|
||||
model: selectedModel.model,
|
||||
numVideos,
|
||||
@@ -1036,6 +1055,7 @@ export default function CreateVideo() {
|
||||
needsImage,
|
||||
isReferenceMode,
|
||||
selectedModel,
|
||||
maxPromptLength,
|
||||
selectedSize,
|
||||
numVideos,
|
||||
effectiveDuration,
|
||||
@@ -1364,6 +1384,7 @@ export default function CreateVideo() {
|
||||
onSubmit={handleGenerate}
|
||||
isSubmitting={isGenerating}
|
||||
credits={estimatedCredits}
|
||||
maxPromptLength={maxPromptLength}
|
||||
placeholder="Describe the video you want to generate..."
|
||||
supportsImagePrompts={supportsImagePrompts}
|
||||
maxImagePromptCount={
|
||||
|
||||
@@ -271,6 +271,43 @@
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Hide the native resize grip (Chromium draws different grips for textareas
|
||||
vs contenteditable divs) and draw one consistent grip on the wrapper so the
|
||||
plain and @-mention prompt editors look identical. */
|
||||
.promptbox-scrollbar::-webkit-resizer {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.promptbox-resize-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.promptbox-resize-wrap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
transparent 40%,
|
||||
rgba(255, 255, 255, 0.5) 40%,
|
||||
rgba(255, 255, 255, 0.5) 45%,
|
||||
transparent 45%,
|
||||
transparent 55%,
|
||||
rgba(255, 255, 255, 0.5) 55%,
|
||||
rgba(255, 255, 255, 0.5) 60%,
|
||||
transparent 60%,
|
||||
transparent 70%,
|
||||
rgba(255, 255, 255, 0.5) 70%,
|
||||
rgba(255, 255, 255, 0.5) 75%,
|
||||
transparent 75%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -25,7 +25,7 @@ import {
|
||||
THUMBNAIL_SIZES,
|
||||
} from "@storyteller/common";
|
||||
import {
|
||||
getModelCreatorIcon,
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDisplayName,
|
||||
getProviderDisplayName,
|
||||
getProviderIconByName,
|
||||
@@ -297,7 +297,13 @@ export function LightboxDetails({
|
||||
label="Model"
|
||||
value={
|
||||
<>
|
||||
{getModelCreatorIcon(promptData.modelType)}
|
||||
<img
|
||||
src={getCreatorIconPathForModelId(
|
||||
promptData.modelType,
|
||||
)}
|
||||
alt="Model creator logo"
|
||||
className="h-4 w-4 invert"
|
||||
/>
|
||||
<span>
|
||||
{getModelDisplayName(promptData.modelType)}
|
||||
</span>
|
||||
|
||||
@@ -109,6 +109,11 @@ interface PromptBoxProps {
|
||||
|
||||
// @-mention support (enables colored prompt overlay + autocomplete)
|
||||
mentionItems?: MentionItem[];
|
||||
|
||||
// Soft prompt-length limit from the model API (`text_prompt_max_length`).
|
||||
// Undefined = unlimited (no counter). The limit is not enforced here; the
|
||||
// page's submit handler blocks generation when over.
|
||||
maxPromptLength?: number;
|
||||
}
|
||||
|
||||
export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
@@ -139,6 +144,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
mediaReferenceRow,
|
||||
modelSelector,
|
||||
mentionItems,
|
||||
maxPromptLength,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -423,7 +429,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="relative flex-1">
|
||||
<div className="promptbox-resize-wrap relative flex-1">
|
||||
{hasMentionItems && mentionItems ? (
|
||||
<MentionTextarea
|
||||
ref={mentionEditorRef}
|
||||
@@ -545,6 +551,21 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{maxPromptLength !== undefined && (
|
||||
<div
|
||||
className={twMerge(
|
||||
// right-4 keeps the counter clear of the textarea's resize grip.
|
||||
"pointer-events-none absolute -bottom-1 right-4 text-[10px] tabular-nums",
|
||||
isFinite(maxPromptLength) && prompt.length > maxPromptLength
|
||||
? "text-red-500"
|
||||
: "text-base-fg/40",
|
||||
)}
|
||||
>
|
||||
{prompt.length} /{" "}
|
||||
{isFinite(maxPromptLength) ? maxPromptLength : "∞"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
export const SITE_ORIGIN = "https://getartcraft.com";
|
||||
|
||||
// Default share image, served from /public. Pages may override per-route.
|
||||
export const DEFAULT_OG_IMAGE = "/images/3d-interface-preview.jpg";
|
||||
export const DEFAULT_OG_IMAGE = "/images/og-image.png";
|
||||
|
||||
export interface PageMeta {
|
||||
title: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../../components/generation-gallery";
|
||||
import { Lightbox } from "../../components/lightbox/lightbox";
|
||||
import { AppDownloadCta } from "../../components/app-download-cta/AppDownloadCta";
|
||||
import { toast } from "../../components/toast/toast";
|
||||
import { useCreateImageStore } from "./create-image-store";
|
||||
import { enqueueImageGeneration, startPolling } from "./generate-image-api";
|
||||
import { AspectRatioPicker } from "./components/AspectRatioPicker";
|
||||
@@ -83,6 +84,9 @@ export default function CreateImage() {
|
||||
return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0];
|
||||
}, [apiModels, ui.selectedModelId]);
|
||||
|
||||
// Soft prompt limit from the API; undefined (no model / unset) = unlimited.
|
||||
const maxPromptLength = selectedModel?.text_prompt_max_length ?? undefined;
|
||||
|
||||
const prompt = ui.prompt;
|
||||
const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]);
|
||||
const aspectRatio = ui.aspectRatio;
|
||||
@@ -278,6 +282,13 @@ export default function CreateImage() {
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!prompt.trim() || isGenerating || !selectedModel) return;
|
||||
|
||||
if (maxPromptLength !== undefined && prompt.length > maxPromptLength) {
|
||||
toast.error(
|
||||
`Prompt exceeds the ${maxPromptLength} character limit for this model`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
const batchId = startBatch(
|
||||
prompt,
|
||||
@@ -337,6 +348,7 @@ export default function CreateImage() {
|
||||
prompt,
|
||||
isGenerating,
|
||||
selectedModel,
|
||||
maxPromptLength,
|
||||
numImages,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
@@ -396,6 +408,7 @@ export default function CreateImage() {
|
||||
onSubmit={handleGenerate}
|
||||
isSubmitting={isGenerating}
|
||||
credits={estimatedCredits}
|
||||
maxPromptLength={maxPromptLength}
|
||||
placeholder="Describe what you want in the image..."
|
||||
supportsImagePrompts={!!selectedModel?.image_refs_supported}
|
||||
maxImagePromptCount={maxImageRefs}
|
||||
|
||||
@@ -46,7 +46,11 @@ import {
|
||||
import { GenerationCountPicker } from "../create-image/components/GenerationCountPicker";
|
||||
import { useVideoCostEstimate } from "../../lib/cost-estimate-api";
|
||||
import { useOmniGenVideoModels } from "@storyteller/omni-gen";
|
||||
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
|
||||
import {
|
||||
effectivePromptMaxLength,
|
||||
getCreatorIconPathForModelId,
|
||||
} from "@storyteller/model-list";
|
||||
import { toast } from "../../components/toast/toast";
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -182,6 +186,18 @@ export default function CreateVideo() {
|
||||
return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0];
|
||||
}, [apiModels, ui.selectedModelId]);
|
||||
|
||||
// Soft prompt limit from the API; undefined = unlimited. Seedance waives
|
||||
// the limit (Infinity) when the prompt contains Chinese characters: the
|
||||
// server counts CJK as more than one unit, so a client-side character
|
||||
// count would false-positive.
|
||||
const maxPromptLength = selectedModel
|
||||
? effectivePromptMaxLength(
|
||||
selectedModel.model,
|
||||
selectedModel.text_prompt_max_length ?? undefined,
|
||||
ui.prompt,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const prompt = ui.prompt;
|
||||
const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]);
|
||||
const selectedSize = ui.selectedSize;
|
||||
@@ -717,6 +733,12 @@ export default function CreateVideo() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (maxPromptLength !== undefined && prompt.length > maxPromptLength) {
|
||||
toast.error(
|
||||
`Prompt exceeds the ${maxPromptLength} character limit for this model`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log("[generate-video] starting", {
|
||||
model: selectedModel.model,
|
||||
numVideos,
|
||||
@@ -852,6 +874,7 @@ export default function CreateVideo() {
|
||||
needsImage,
|
||||
isReferenceMode,
|
||||
selectedModel,
|
||||
maxPromptLength,
|
||||
selectedSize,
|
||||
numVideos,
|
||||
duration,
|
||||
@@ -935,6 +958,7 @@ export default function CreateVideo() {
|
||||
onSubmit={handleGenerate}
|
||||
isSubmitting={isGenerating || needsImage}
|
||||
credits={estimatedCredits}
|
||||
maxPromptLength={maxPromptLength}
|
||||
placeholder="Describe the video you want to generate..."
|
||||
supportsImagePrompts={supportsImagePrompts}
|
||||
maxImagePromptCount={
|
||||
|
||||
@@ -120,6 +120,43 @@
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Hide the native resize grip (Chromium draws different grips for textareas
|
||||
vs contenteditable divs) and draw one consistent grip on the wrapper so the
|
||||
plain and @-mention prompt editors look identical. */
|
||||
.promptbox-scrollbar::-webkit-resizer {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.promptbox-resize-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.promptbox-resize-wrap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
transparent 40%,
|
||||
rgba(255, 255, 255, 0.5) 40%,
|
||||
rgba(255, 255, 255, 0.5) 45%,
|
||||
transparent 45%,
|
||||
transparent 55%,
|
||||
rgba(255, 255, 255, 0.5) 55%,
|
||||
rgba(255, 255, 255, 0.5) 60%,
|
||||
transparent 60%,
|
||||
transparent 70%,
|
||||
rgba(255, 255, 255, 0.5) 70%,
|
||||
rgba(255, 255, 255, 0.5) 75%,
|
||||
transparent 75%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface OmniGenVideoGenerateResponse {
|
||||
|
||||
export interface OmniGenImageModelInfo {
|
||||
model: string;
|
||||
// ModelCreator enum as a snake_case string, e.g. "bytedance", "google".
|
||||
model_creator?: string | null;
|
||||
is_disabled: boolean | null;
|
||||
full_name: string | null;
|
||||
extra_info_short?: string | null;
|
||||
@@ -111,6 +113,8 @@ export interface OmniGenImageModelsResponse {
|
||||
|
||||
export interface OmniGenVideoModelInfo {
|
||||
model: string;
|
||||
// ModelCreator enum as a snake_case string, e.g. "bytedance", "open_ai".
|
||||
model_creator?: string | null;
|
||||
is_disabled: boolean | null;
|
||||
full_name: string | null;
|
||||
extra_info_short?: string | null;
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
isActionReminderOpen,
|
||||
} from "@storyteller/ui-action-reminder-modal";
|
||||
import {
|
||||
getModelCreatorIcon,
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDisplayName,
|
||||
getProviderDisplayName,
|
||||
getProviderIconByName,
|
||||
@@ -1327,7 +1327,11 @@ function InfoSection({
|
||||
label="Model"
|
||||
value={
|
||||
<>
|
||||
{getModelCreatorIcon(modelType)}
|
||||
<img
|
||||
src={getCreatorIconPathForModelId(modelType)}
|
||||
alt="Model creator logo"
|
||||
className="h-4 w-4 invert"
|
||||
/>
|
||||
<span>{getModelDisplayName(modelType)}</span>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
CommonResolution,
|
||||
effectivePromptMaxLength,
|
||||
SizeIconOption,
|
||||
SizeOption,
|
||||
VideoModel,
|
||||
@@ -697,7 +698,12 @@ export const PromptBoxVideo = ({
|
||||
mentionAnchorRef.current = null;
|
||||
};
|
||||
|
||||
const maxLen = selectedModel?.maxPromptLength ?? 1000;
|
||||
const maxLen =
|
||||
effectivePromptMaxLength(
|
||||
selectedModel?.tauriId ?? "",
|
||||
selectedModel?.maxPromptLength,
|
||||
prompt,
|
||||
) ?? 1000;
|
||||
|
||||
const handleEnqueue = async () => {
|
||||
if (!prompt.trim()) {
|
||||
|
||||
@@ -2,165 +2,31 @@ import React from "react";
|
||||
import { ModelCreator } from "./ModelCreator.js";
|
||||
import { getCreatorIcon } from "./ModelCreatorIcons.js";
|
||||
|
||||
// Map from model type strings to ModelCreator enum values
|
||||
export const MODEL_TYPE_TO_CREATOR: Record<string, ModelCreator> = {
|
||||
flux_1_dev: ModelCreator.BlackForestLabs,
|
||||
flux_1_schnell: ModelCreator.BlackForestLabs,
|
||||
flux_pro_1p1: ModelCreator.BlackForestLabs,
|
||||
flux_pro_1p1_ultra: ModelCreator.BlackForestLabs,
|
||||
flux_pro_kontext_max: ModelCreator.BlackForestLabs,
|
||||
flux_dev_juggernaut: ModelCreator.BlackForestLabs,
|
||||
flux_pro_1: ModelCreator.BlackForestLabs,
|
||||
// Aliases commonly returned by other services
|
||||
flux_pro_1_1: ModelCreator.BlackForestLabs,
|
||||
flux_pro_1_1_ultra: ModelCreator.BlackForestLabs,
|
||||
gpt_image_1: ModelCreator.OpenAi,
|
||||
gpt_image_1p5: ModelCreator.OpenAi,
|
||||
gpt_image_2: ModelCreator.OpenAi,
|
||||
// Kling — p-style IDs
|
||||
kling_1p6_pro: ModelCreator.Kling,
|
||||
kling_2p1_pro: ModelCreator.Kling,
|
||||
kling_2p1_master: ModelCreator.Kling,
|
||||
kling_2p5_turbo_pro: ModelCreator.Kling,
|
||||
kling_2p6_pro: ModelCreator.Kling,
|
||||
kling_3p0_standard: ModelCreator.Kling,
|
||||
kling_3p0_pro: ModelCreator.Kling,
|
||||
// Kling — dot-normalized aliases (backend sends e.g. "kling_1.6_pro", normalizeModelKey converts . → _)
|
||||
kling_1_6_pro: ModelCreator.Kling,
|
||||
kling_2_1_pro: ModelCreator.Kling,
|
||||
kling_2_1_master: ModelCreator.Kling,
|
||||
// Seedance — p-style IDs
|
||||
seedance_1p0_lite: ModelCreator.Bytedance,
|
||||
seedance_1p5_pro: ModelCreator.Bytedance,
|
||||
seedance_2p0: ModelCreator.Bytedance,
|
||||
seedance_2p0_fast: ModelCreator.Bytedance,
|
||||
seedance_2p0_bp: ModelCreator.Bytedance,
|
||||
seedance_2p0_bp_fast: ModelCreator.Bytedance,
|
||||
seedance_2p0_u: ModelCreator.Bytedance,
|
||||
seedance_2p0_u_fast: ModelCreator.Bytedance,
|
||||
seedance_2p0_bpu: ModelCreator.Bytedance,
|
||||
seedance_2p0_bpu_fast: ModelCreator.Bytedance,
|
||||
// Seedance — dot-normalized aliases
|
||||
seedance_1_0_lite: ModelCreator.Bytedance,
|
||||
// Seedream
|
||||
seedream_4: ModelCreator.Bytedance,
|
||||
seedream_4p5: ModelCreator.Bytedance,
|
||||
seedream_5_lite: ModelCreator.Bytedance,
|
||||
// Sora
|
||||
sora_2: ModelCreator.OpenAi,
|
||||
sora_2_pro: ModelCreator.OpenAi,
|
||||
// Veo
|
||||
veo_2: ModelCreator.Google,
|
||||
veo_3: ModelCreator.Google,
|
||||
veo_3_fast: ModelCreator.Google,
|
||||
veo_3p1: ModelCreator.Google,
|
||||
veo_3p1_fast: ModelCreator.Google,
|
||||
gemini_25_flash: ModelCreator.Google,
|
||||
nano_banana: ModelCreator.Google,
|
||||
nano_banana_2: ModelCreator.Google,
|
||||
nano_banana_pro: ModelCreator.Google,
|
||||
recraft_3: ModelCreator.Recraft,
|
||||
// Hunyuan — p-style IDs
|
||||
hunyuan_3d: ModelCreator.Tencent,
|
||||
hunyuan_3d_2p0: ModelCreator.Tencent,
|
||||
hunyuan_3d_2p1: ModelCreator.Tencent,
|
||||
hunyuan_3d_2: ModelCreator.Tencent,
|
||||
hunyuan_3d_3: ModelCreator.Tencent,
|
||||
// Hunyuan — dot-normalized aliases
|
||||
hunyuan_3d_2_0: ModelCreator.Tencent,
|
||||
hunyuan_3d_2_1: ModelCreator.Tencent,
|
||||
worldlabs_gaussian: ModelCreator.WorldLabs,
|
||||
marble_0p1_mini: ModelCreator.WorldLabs,
|
||||
marble_0p1_plus: ModelCreator.WorldLabs,
|
||||
grok_image: ModelCreator.Grok,
|
||||
grok_imagine_image: ModelCreator.Grok,
|
||||
grok_imagine_image_q: ModelCreator.Grok,
|
||||
grok_video: ModelCreator.Grok,
|
||||
grok_imagine_video: ModelCreator.Grok,
|
||||
grok_imagine_video_1p5: ModelCreator.Grok,
|
||||
midjourney: ModelCreator.Midjourney,
|
||||
midjourney_v6: ModelCreator.Midjourney,
|
||||
midjourney_v6p1: ModelCreator.Midjourney,
|
||||
midjourney_v6p1_raw: ModelCreator.Midjourney,
|
||||
midjourney_v7: ModelCreator.Midjourney,
|
||||
midjourney_v7_raw: ModelCreator.Midjourney,
|
||||
midjourney_v7_draft_raw: ModelCreator.Midjourney,
|
||||
// Underscore-numbered IDs the backend actually sends for current MJ models
|
||||
midjourney_7: ModelCreator.Midjourney,
|
||||
midjourney_7_niji: ModelCreator.Midjourney,
|
||||
midjourney_8: ModelCreator.Midjourney,
|
||||
// Angles
|
||||
flux_2_lora_angles: ModelCreator.BlackForestLabs,
|
||||
qwen_edit_2511_angles: ModelCreator.Alibaba,
|
||||
// Happy Horse (Alibaba wanvideo)
|
||||
happy_horse_1p0: ModelCreator.Alibaba,
|
||||
// Beeble (Background Change / VFX)
|
||||
switch_x: ModelCreator.Beeble,
|
||||
};
|
||||
|
||||
// Get creator icon for a model type
|
||||
// Normalize a model id/type key for lookups (lowercase, dots → underscores)
|
||||
const normalizeModelKey = (modelType: string): string =>
|
||||
modelType.toLowerCase().replace(/\./g, "_").trim();
|
||||
|
||||
export const getModelCreatorIcon = (
|
||||
modelType: string,
|
||||
): React.ReactNode | null => {
|
||||
const creator = MODEL_TYPE_TO_CREATOR[normalizeModelKey(modelType)];
|
||||
if (!creator) return null;
|
||||
return getCreatorIcon(creator, "h-4 w-4 invert");
|
||||
};
|
||||
/** Matches all seedance_* variants (video-only; image models are seedream_*). */
|
||||
export const isSeedanceVideoModelId = (modelId: string): boolean =>
|
||||
normalizeModelKey(modelId).startsWith("seedance");
|
||||
|
||||
// Get creator name for display
|
||||
export const getModelCreatorName = (modelType: string): string | null => {
|
||||
const creator = MODEL_TYPE_TO_CREATOR[normalizeModelKey(modelType)];
|
||||
const CHINESE_CHARACTERS_REGEX = /\p{Script=Han}/u;
|
||||
|
||||
// Convert enum value to display name
|
||||
switch (creator) {
|
||||
case ModelCreator.BlackForestLabs:
|
||||
return "Black Forest Labs";
|
||||
case ModelCreator.OpenAi:
|
||||
return "OpenAI";
|
||||
case ModelCreator.Kling:
|
||||
return "Kling AI";
|
||||
case ModelCreator.Bytedance:
|
||||
return "ByteDance";
|
||||
case ModelCreator.Google:
|
||||
return "Google";
|
||||
case ModelCreator.Midjourney:
|
||||
return "Midjourney";
|
||||
case ModelCreator.Stability:
|
||||
return "Stability AI";
|
||||
case ModelCreator.Runway:
|
||||
return "Runway";
|
||||
case ModelCreator.Hailuo:
|
||||
return "Hailuo";
|
||||
case ModelCreator.Recraft:
|
||||
return "Recraft";
|
||||
case ModelCreator.Tencent:
|
||||
return "Tencent";
|
||||
case ModelCreator.Alibaba:
|
||||
return "Alibaba";
|
||||
case ModelCreator.Vidu:
|
||||
return "Vidu";
|
||||
case ModelCreator.Fal:
|
||||
return "Fal";
|
||||
case ModelCreator.Replicate:
|
||||
return "Replicate";
|
||||
case ModelCreator.TensorArt:
|
||||
return "TensorArt";
|
||||
case ModelCreator.OpenArt:
|
||||
return "OpenArt";
|
||||
case ModelCreator.Higgsfield:
|
||||
return "Higgsfield";
|
||||
case ModelCreator.Krea:
|
||||
return "Krea";
|
||||
case ModelCreator.Grok:
|
||||
return "Grok";
|
||||
case ModelCreator.WorldLabs:
|
||||
return "World Labs";
|
||||
default:
|
||||
return creator;
|
||||
/**
|
||||
* Effective soft prompt-length limit for a model. Normally the model's
|
||||
* `text_prompt_max_length`, but seedance waives the limit (Infinity) when the
|
||||
* prompt contains Chinese characters: the server counts CJK characters as
|
||||
* more than one unit, so a client-side character count would false-positive.
|
||||
*/
|
||||
export const effectivePromptMaxLength = (
|
||||
modelId: string,
|
||||
modelMaxLength: number | undefined,
|
||||
prompt: string,
|
||||
): number | undefined => {
|
||||
if (isSeedanceVideoModelId(modelId) && CHINESE_CHARACTERS_REGEX.test(prompt)) {
|
||||
return Infinity;
|
||||
}
|
||||
return modelMaxLength;
|
||||
};
|
||||
|
||||
// Convert model type string to human-readable display name. An API-provided
|
||||
|
||||
Reference in New Issue
Block a user