Compare commits

...
Author SHA1 Message Date
Brandon Thomas 27e911d46b don't serve bad traffic 2026-07-06 16:51:42 -04:00
Brandon Thomas 977cd933f7 another bad path 2026-07-06 16:49:30 -04:00
Brandon Thomas 89f20d9452 black hole these requests 2026-07-06 16:31:01 -04:00
Brandon Thomas cec3b50ae4 characters API support (#1738) 2026-07-06 16:12:00 -04:00
BFlat 3040aaa0d1 Default og:image is now the ArtCraft logo banner (#1737) 2026-07-06 19:56:53 +07:00
BFlat f47e8b9b14 Default og:image is now the ArtCraft logo banner (#1736) 2026-07-06 19:52:50 +07:00
BFlat 83115e7ef9 Prompt character counters on web, seedance CJK limit waiver, and model creator mapping (#1735)
## Changes

- **Prompt character counters on web**: the website and webapp
promptboxes (image + video) now show a `123 / 2000` counter driven by
the API's `text_prompt_max_length`, matching the desktop app. It turns
red when over the limit and generation is blocked with a toast; typing
is never clamped. The webapp's fullscreen prompt editor shows the
counter too.
- **Seedance CJK waiver**: seedance video models normally use the API
limit, but the limit is waived (shows `∞`) while the prompt contains
Chinese characters. Applies on desktop and web via a shared
`effectivePromptMaxLength` helper.
- **API type parity**: added the new `model_creator` field to the web
`OmniGenImageModelInfo` / `OmniGenVideoModelInfo` types (desktop types
were already up to date).
- **Cleanup**: deleted the manual model-to-creator map
(`MODEL_TYPE_TO_CREATOR`, ~150 lines) now that creator info comes from
the API; lightbox model icons use the shared
`getCreatorIconPathForModelId` lookup like every other surface.
2026-07-06 19:42:56 +07:00
43 changed files with 897 additions and 503 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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>,
}
@@ -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),
}))
}
@@ -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;
@@ -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,
@@ -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();
@@ -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();
@@ -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;
@@ -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>,
@@ -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 {
@@ -0,0 +1 @@
pub mod require_api_or_web_session;
@@ -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;
+31
View File
@@ -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;
+4
View File
@@ -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