Compare commits

...
Author SHA1 Message Date
Brandon Thomas fab2f8f0ee set prompt from fal 2025-07-24 01:52:30 -04:00
Brandon Thomas ff360f83b1 populate provider and model 2025-07-24 00:21:00 -04:00
Brandon Thomas fb68554f85 model type enum 2025-07-24 00:11:45 -04:00
Brandon Thomas 77fbd84006 insert prompt for gpt-image-1 via fal 2025-07-23 19:50:13 -04:00
Brandon Thomas bd3668ba5c make job insertion flexible / use executor 2025-07-23 19:22:54 -04:00
Brandon Thomas 8ae2cfa5fc insert prompts on veo2 (more to land) 2025-07-23 19:02:06 -04:00
Brandon Thomas c34f81975e add prompt to veo2 (lots more to do, need to add fk refs) 2025-07-23 18:48:26 -04:00
Brandon Thomas b4a9da50de Prompts enums 2025-07-23 18:22:00 -04:00
Brandon Thomas 48a6b1a046 more migrations (#742) 2025-07-23 17:35:10 -04:00
Brandon Thomas 7791b9b6cc Bt prompts schema (#741) 2025-07-23 16:22:13 -04:00
Brandon Thomas ff30213af8 more GA instrumentation (#740) 2025-07-22 21:12:52 -04:00
Brandon Thomas 766f726dc1 Google Analytics package and events (#739) 2025-07-22 19:12:14 -04:00
Brandon Thomas 76d7d4c100 add microsoft clarity (#738) 2025-07-22 10:58:56 -04:00
BFlat 2902db074e Upload asset ux update (#737)
- image upload modal added
- everything under + button popover
<img width="393" height="263" alt="image"
src="https://github.com/user-attachments/assets/9f6bda8d-10c0-460f-8eab-227ad8155e68"
/>
2025-07-22 20:25:07 +07:00
Brandon Thomas b8ee03f1b7 load glb assets without cors (#736) 2025-07-22 08:43:38 -04:00
Brandon Thomas 2cf300588e inference job query reaper (#735) 2025-07-21 19:42:27 -04:00
BFlat d2720d0a62 services icons for the models list (#733)
added model service icons:
- google
- black forest labs
- bytedance
- kling
- midjourney
- openai
2025-07-21 14:51:15 +07:00
BFlat 69ed3ec48b bug fix: added z index to scene state (#734)
layers z-index now are persisted in scene state
2025-07-21 14:49:23 +07:00
bflatastic 81f5797e2f removed download link from completed card 2025-07-21 13:28:45 +07:00
Brandon Thomas a80c69ab1e update download links (#732) 2025-07-17 22:05:56 -04:00
125 changed files with 2083 additions and 479 deletions
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = 'cancelled_by_system',\n retry_at = NULL,\n internal_debugging_failure_reason = 'reap_stale_fakeyou_jobs'\nWHERE\n job_type != 'fal_queue'\n AND status IN (\n 'pending',\n 'started',\n 'attempt_failed'\n )\n AND created_at > NOW() - INTERVAL 30 MINUTE\nLIMIT 5000\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "19708db99f2b3c6283d2115a511e0eb1c99fab396ad0effd9eb2dd79926e9c93"
}
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts DROP COLUMN maybe_model_type;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
ADD COLUMN maybe_model_type VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts DROP COLUMN maybe_generation_provider;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
ADD COLUMN maybe_generation_provider VARCHAR(16) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts DROP COLUMN maybe_anonymous_visitor_token;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
ADD COLUMN maybe_anonymous_visitor_token VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE prompt_context_items;
@@ -0,0 +1,30 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- An n:m join table between prompts and media_files.
-- This associates mostly image media files with prompts, though we could conceivably
-- also store 3D sets, etc.
CREATE TABLE prompt_context_items (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- The semantic type of context.
context_semantic_type VARCHAR(16) NOT NULL,
-- Foreign key, many-to-many
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
prompt_token VARCHAR(32) NOT NULL,
-- Foreign key to the media token
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
media_token VARCHAR(32) NOT NULL,
-- INDICES --
PRIMARY KEY (id),
KEY index_prompt_token (prompt_token),
KEY index_media_token (media_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs DROP COLUMN maybe_prompt_token;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_prompt_token VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files DROP COLUMN maybe_generation_provider;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN maybe_generation_provider VARCHAR(16) DEFAULT NULL;
@@ -21,6 +21,8 @@ CREATE TABLE generic_inference_jobs (
-- The new enum for the type of job, which will eventually replace `inference_category` and `maybe_model_type`.
-- This isn't fully supported yet in the inference-job, but we'll start populating it and adding logic around it.
-- We'll start this out as nullable, then migrate the old rows with a default column value.
--
-- Fal jobs have the type `fal_queue`.
job_type VARCHAR(32) DEFAULT NULL,
-- There is an index on this column.
@@ -103,6 +105,11 @@ CREATE TABLE generic_inference_jobs (
-- And furthermore, not all download URLs have a cover image.
maybe_cover_image_media_file_token VARCHAR(32) DEFAULT NULL,
-- If we pre-constructed a prompt, this will be copied to the resultant media generated.
-- This is especially important for Fal-related jobs and endpoints. We need this for keeping
-- track of all the prompt details, context images, etc.
maybe_prompt_token VARCHAR(30) DEFAULT NULL,
-- A migration flag we can control from the backend API service which will tell
-- the worker where to store the results. If false, GCP. If true, AWS.
store_in_aws BOOLEAN NOT NULL DEFAULT FALSE,
+3
View File
@@ -61,6 +61,9 @@ CREATE TABLE media_files (
-- * 'workflow' (DEPRECATED)
origin_product_category VARCHAR(16) NOT NULL DEFAULT "unknown",
-- What service provided the generation (if any).
maybe_generation_provider VARCHAR(16) DEFAULT NULL,
-- For inference that can be tied back to a model, the type of model.
-- There are multiple indices on this column!
-- DO NOT EXPOSE THIS TO USERS VIA THE API, as we may leak secrets in doing so.
@@ -15,9 +15,9 @@ CREATE TABLE batch_generations (
-- The type of record this is attached to
-- Currently supported values:
-- * 'model_weight'
-- * 'model_weight' (deprecated)
-- * 'comment' (deprecated)
-- * 'media_file'
-- * 'comment'
entity_type VARCHAR(32) NOT NULL,
-- The token for the record.
@@ -37,6 +37,4 @@ CREATE TABLE batch_generations (
-- NB: Realistically, we probably don't need this index until we have a lot of entity types, and the entity_token
-- entropy is probably sufficient enough as-is.
-- KEY index_entity_type_and_token (entity_type, entity_token),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,30 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- An n:m join table between prompts and media_files.
-- This associates mostly image media files with prompts, though we could conceivably
-- also store 3D sets, etc.
CREATE TABLE prompt_context_items (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- The semantic type of context.
context_semantic_type VARCHAR(16) NOT NULL,
-- Foreign key, many-to-many
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
prompt_token VARCHAR(32) NOT NULL,
-- Foreign key to the media token
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
media_token VARCHAR(32) NOT NULL,
-- INDICES --
PRIMARY KEY (id),
KEY index_prompt_token (prompt_token),
KEY index_media_token (media_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -19,13 +19,25 @@ CREATE TABLE prompts (
-- Original product to which the prompt applied.
-- Prompts may or may not be compatible across products.
-- Possible values:
-- * 'stable_diffusion'
-- * 'comfy_ui'
-- * 'artcraft_app'
-- * 'stable_diffusion' (legacy)
-- * 'comfy_ui' (legacy)
prompt_type VARCHAR(16) NOT NULL,
-- Whomever created the prompt
-- What type of model was used to generate the result.
-- NB: We're conflating prompts with outputs, but in a
-- sense the model is inextricably part of the prompt.
maybe_model_type VARCHAR(32) DEFAULT NULL,
-- What service provided the generation.
maybe_generation_provider VARCHAR(16) NOT NULL, -- TODO
-- Whoever created the prompt
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- Whoever created the prompt (anonymous user tracking)
maybe_anonymous_visitor_token VARCHAR(32) DEFAULT NULL,
-- ========== PROMPT DETAILS ==========
-- The positive prompt (optional)
@@ -0,0 +1,43 @@
use futures::TryFutureExt;
use log::{error, info};
use reqwest::Client;
use tauri::ipc::Response;
/// Request a URL and load it without worrying about CORS.
#[tauri::command]
pub async fn load_without_cors_command(url: String) -> Result<Response, String> {
info!("load_without_cors_command for URL: {}", url);
// TODO(bt,2025-07-22): Make sure the URL is valid and safe to fetch. Check an allowlist.
// TODO(bt,2025-07-22): Should we cache clients so we don't need to SSL handshake every time?
let client = Client::builder()
.gzip(true)
.build()
.map_err(|e| {
error!("Failed to create HTTP client: {:?}", e);
format!("Failed to create HTTP client: {:?}", e)
})?;
let response = client.get(url.clone())
.send()
.await
.map_err(|e| {
error!("Failed to send HTTP request: {:?}", e);
format!("Failed to send HTTP request: {:?}", e)
})?;
if response.status() != 200 {
return Err(format!("Failed to fetch URL: {}. Status: {}", url, response.status()));
}
let bytes = response.bytes()
.await
.map_err(|e| {
error!("Failed to read response bytes: {:?}", e);
format!("Failed to read response bytes: {:?}", e)
})?;
Ok(Response::new(bytes.to_vec()))
}
@@ -2,6 +2,7 @@ pub mod app_preferences;
pub mod enqueue;
pub mod flip_image;
pub mod get_app_info_command;
pub mod load_without_cors_command;
pub mod platform_info_command;
pub mod providers;
pub mod response;
+2
View File
@@ -12,6 +12,7 @@ use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::enqueue_image_to_video_command;
use crate::core::commands::flip_image::flip_image;
use crate::core::commands::get_app_info_command::get_app_info_command;
use crate::core::commands::load_without_cors_command::load_without_cors_command;
use crate::core::commands::platform_info_command::platform_info_command;
use crate::core::commands::providers::get_provider_order_command::get_provider_order_command;
use crate::core::commands::providers::set_provider_order_command::set_provider_order_command;
@@ -154,6 +155,7 @@ pub fn run() {
get_app_preferences_command,
get_fal_api_key_command,
get_provider_order_command,
load_without_cors_command,
open_sora_login_command,
platform_info_command,
set_fal_api_key_command,
@@ -1,3 +1,4 @@
use log::{debug, warn};
use crate::payloads::generic_inference_args::inner_payloads::f5_tts_payload::F5TTSPayload;
use crate::payloads::generic_inference_args::inner_payloads::face_fusion_payload::FaceFusionPayload;
use crate::payloads::generic_inference_args::inner_payloads::gptsovits_payload::GptSovitsPayload;
@@ -180,6 +181,19 @@ impl GenericInferenceArgs {
Ok(serde_json::from_str(json)?)
}
pub fn from_json_optional(json: Option<&str>) -> AnyhowResult<Option<Self>> {
match json {
None => Ok(None),
Some("" | "null" | "NULL" | "undefined" | "UNDEFINED") => {
// NB: Something inserted a few of these records (only in local dev).
// It's better not to stall the job queue than to assert correctness.
warn!("Received empty or null JSON `{json:?}` for GenericInferenceArgs, returning None.");
Ok(None)
},
Some(json_str) => Ok(Some(Self::from_json(json_str)?)),
}
}
pub fn to_json(&self) -> AnyhowResult<String> {
Ok(serde_json::to_string(self)?)
}
@@ -13,6 +13,7 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::helpers::boolean_converters::i8_to_bool;
@@ -30,11 +31,35 @@ pub struct FalJobDetails {
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
pub creator_ip_address: String,
pub maybe_prompt_token: Option<PromptToken>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Default)]
struct RawJobRecord {
job_token: InferenceJobToken,
status: JobStatusPlus,
// NB: Nullable, but required to be filled for this query
external_third_party: Option<InferenceJobExternalThirdParty>,
// NB: Nullable, but required to be filled for this query
external_third_party_id: Option<String>,
maybe_creator_user_token: Option<UserToken>,
maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
creator_ip_address: String,
maybe_prompt_token: Option<PromptToken>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
/// Returns Ok(None) when the record cannot be found.
pub async fn get_inference_job_by_fal_id(fal_id: &str, mysql_pool: &MySqlPool)
-> AnyhowResult<Option<FalJobDetails>>
@@ -66,6 +91,8 @@ SELECT
jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,
jobs.creator_ip_address,
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
jobs.created_at,
jobs.updated_at
@@ -106,28 +133,8 @@ fn raw_record_to_public_result(record: RawJobRecord) -> AnyhowResult<FalJobDetai
maybe_creator_user_token: record.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: record.maybe_creator_anonymous_visitor_token,
creator_ip_address: record.creator_ip_address,
maybe_prompt_token: record.maybe_prompt_token,
created_at: record.created_at,
updated_at: record.updated_at,
})
}
#[derive(Debug, Default)]
struct RawJobRecord {
pub job_token: InferenceJobToken,
pub status: JobStatusPlus,
// NB: Nullable, but required to be filled for this query
pub external_third_party: Option<InferenceJobExternalThirdParty>,
// NB: Nullable, but required to be filled for this query
pub external_third_party_id: Option<String>,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
pub creator_ip_address: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -1,6 +1,8 @@
use anyhow::anyhow;
use log::info;
use sqlx::MySqlPool;
use sqlx::{Executor, MySql};
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::by_table::generic_inference_jobs::inference_input_source_token_type::InferenceInputSourceTokenType;
@@ -13,6 +15,7 @@ use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::errors::database_query_error::DatabaseQueryError;
@@ -27,26 +30,35 @@ pub enum FalCategory {
ObjectGeneration,
}
pub struct InsertGenericInferenceForFalArgs<'a> {
pub uuid_idempotency_token: &'a str,
pub struct InsertGenericInferenceForFalArgs<'e, 'c, E>
where E: 'e + Executor<'c, Database = MySql>
{
pub uuid_idempotency_token: &'e str,
/// The external primary key identifier for the job.
pub maybe_external_third_party_id: &'a str,
pub maybe_external_third_party_id: &'e str,
pub fal_category: FalCategory,
pub maybe_inference_args: Option<GenericInferenceArgs>,
pub maybe_prompt_token: Option<&'e PromptToken>,
pub maybe_creator_user_token: Option<&'a UserToken>,
pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'a str,
pub maybe_creator_user_token: Option<&'e UserToken>,
pub maybe_avt_token: Option<&'e AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'e str,
pub creator_set_visibility: Visibility,
pub mysql_pool: &'a MySqlPool,
pub mysql_executor: E,
// TODO: Not sure if this works to tell the compiler we need the lifetime annotation.
// See: https://doc.rust-lang.org/std/marker/struct.PhantomData.html#unused-lifetime-parameters
pub phantom: PhantomData<&'c E>,
}
pub async fn insert_generic_inference_job_for_fal_queue(args: InsertGenericInferenceForFalArgs<'_>)
pub async fn insert_generic_inference_job_for_fal_queue<'e, 'c : 'e, E>(args: InsertGenericInferenceForFalArgs<'e, 'c, E>)
-> Result<InferenceJobToken, DatabaseQueryError>
where E: 'e + Executor<'c, Database = MySql>
{
let job_token = InferenceJobToken::generate();
@@ -105,6 +117,8 @@ SET
maybe_download_url = NULL,
maybe_cover_image_media_file_token = NULL,
maybe_prompt_token = ?,
maybe_raw_inference_text = NULL,
@@ -134,6 +148,8 @@ SET
product_category.to_str(),
inference_category.to_str(),
args.maybe_prompt_token.map(|t| t.to_string()),
serialized_args_payload,
@@ -145,7 +161,7 @@ SET
STATUS.to_str(),
);
let query_result = query.execute(args.mysql_pool)
let query_result = query.execute(args.mysql_executor)
.await;
let record_id = match query_result {
@@ -2,6 +2,7 @@ use std::collections::BTreeSet;
use anyhow::anyhow;
use chrono::Utc;
use log::debug;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
@@ -151,10 +152,8 @@ pub async fn list_available_generic_inference_jobs(
.map(|token_type| InferenceInputSourceTokenType::from_str(token_type)
.map_err(|err| anyhow!("error: {:?}", err)))
.transpose()?,
maybe_inference_args: record.maybe_inference_args
.as_deref()
.map(|args| GenericInferenceArgs::from_json(args))
.transpose()?,
maybe_inference_args: GenericInferenceArgs::from_json_optional(
record.maybe_inference_args.as_deref())?,
maybe_raw_inference_text: record.maybe_raw_inference_text,
status: JobStatusPlus::from_str(&record.status)
.map_err(|err| anyhow!("JobStatus failure to parse: {:?}", err))?,
@@ -5,3 +5,4 @@ pub mod mark_generic_inference_job_completely_failed;
pub mod mark_generic_inference_job_failure;
pub mod mark_generic_inference_job_pending_and_grab_lock;
pub mod mark_generic_inference_job_successfully_done;
pub mod reap_stale_fakeyou_jobs;
@@ -0,0 +1,52 @@
use anyhow::anyhow;
use log::info;
use sqlx;
use sqlx::pool::PoolConnection;
use sqlx::{MySql, MySqlPool};
use errors::AnyhowResult;
/// Kill FakeYou jobs that are "stale".
///
/// - This leaves Fal jobs alone, as they do not use the job runner.
/// - This kills jobs that are over a threshold (e.g. an hour) overdue.
pub async fn reap_stale_fakeyou_jobs(mysql_pool: &MySqlPool) -> AnyhowResult<()>
{
let mut connection = mysql_pool.acquire().await?;
reap_stale_fakeyou_jobs_using_connection(&mut connection).await
}
pub async fn reap_stale_fakeyou_jobs_using_connection(
mysql_connection: &mut PoolConnection<MySql>,
) -> AnyhowResult<()> {
let query_result = sqlx::query!(
r#"
UPDATE generic_inference_jobs
SET
status = 'cancelled_by_system',
retry_at = NULL,
internal_debugging_failure_reason = 'reap_stale_fakeyou_jobs'
WHERE
job_type != 'fal_queue'
AND status IN (
'pending',
'started',
'attempt_failed'
)
AND created_at > NOW() - INTERVAL 30 MINUTE
LIMIT 5000
"#
)
.execute(&mut **mysql_connection)
.await;
match query_result {
Ok(result) => {
info!("Reaped stale FakeYou jobs: {} rows affected.", result.rows_affected());
Ok(())
},
Err(err) => {
Err(anyhow!("error with reaping stale jobs: {:?}", err))
}
}
}
@@ -1,19 +1,18 @@
use crate::queries::media_files::create::generic_insert::insert_media_file_generic::{insert_media_file_generic, InsertArgs};
use anyhow::anyhow;
use crate::queries::media_files::create::insert_builder::media_file_insert_builder_error::MediaFileInsertBuilderError;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::generic_synthetic_ids::id_category::IdCategory;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use sqlx::MySqlPool;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::queries::media_files::create::insert_builder::media_file_insert_builder_error::MediaFileInsertBuilderError;
/// Builder for inserting a media files into the database.
/// Resembles an ORM, but can still use statically type checked queries (which is probably
@@ -53,7 +52,9 @@ pub struct MediaFileInsertBuilder {
checksum_sha2: Option<String>, // NB: Non-nullable field
// Media info for certain product areas
pub maybe_engine_category: Option<MediaFileEngineCategory>, // TODO: Deprecate
maybe_engine_category: Option<MediaFileEngineCategory>, // TODO: Deprecate
maybe_prompt_token: Option<PromptToken>,
// // User text information
// maybe_title: Option<&'a str>,
@@ -100,6 +101,7 @@ impl MediaFileInsertBuilder {
checksum_sha2: None,
maybe_engine_category: None,
public_bucket_directory_hash: None,
maybe_prompt_token: None,
}
}
@@ -165,6 +167,11 @@ impl MediaFileInsertBuilder {
self
}
pub fn maybe_prompt_token(mut self, maybe_prompt_token: Option<&PromptToken>) -> Self {
self.maybe_prompt_token = maybe_prompt_token.map(|token| token.clone());
self
}
// TODO: Method for maybe_origin_model_type
// TODO: Method for maybe_origin_model_token
// TODO: Method for maybe_origin_filename
@@ -273,7 +280,7 @@ impl MediaFileInsertBuilder {
maybe_title: None, // TODO
maybe_text_transcript: None, // TODO
maybe_scene_source_media_file_token: None, // TODO
maybe_prompt_token: None, // TODO
maybe_prompt_token: self.maybe_prompt_token.as_ref(),
maybe_batch_token: None, // TODO
public_bucket_directory_hash: bucket_path.get_object_hash(),
maybe_public_bucket_prefix: bucket_path.get_optional_prefix(),
@@ -1,7 +1,7 @@
use chrono::{DateTime, Utc};
use sqlx::mysql::MySqlRow;
use sqlx::{Acquire, FromRow, MySql, MySqlPool, QueryBuilder, Row};
use sqlx::pool::PoolConnection;
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
@@ -84,6 +84,18 @@ pub async fn batch_get_media_files_by_tokens(
).await
}
pub async fn batch_get_media_files_by_tokens_with_connection(
mysql_connection: &mut PoolConnection<MySql>,
media_file_tokens: &[MediaFileToken],
can_see_deleted: bool
) -> AnyhowResult<Vec<MediaFilesByTokensRecord>> {
batch_get_media_files_by_tokens_with_transactor(
Transactor::Connection{ connection: mysql_connection },
media_file_tokens,
can_see_deleted
).await
}
pub async fn batch_get_media_files_by_tokens_with_transactor(
transactor: Transactor<'_, '_>,
media_file_tokens: &[MediaFileToken],
@@ -5,8 +5,6 @@
use anyhow::anyhow;
use chrono::{DateTime, Utc};
use sqlx::MySqlPool;
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
@@ -16,6 +14,8 @@ use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::by_table::model_weights::weights_types::WeightsType;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use sqlx::pool::PoolConnection;
use sqlx::{MySql, MySqlPool};
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -215,6 +215,18 @@ pub async fn get_media_file(
).await
}
pub async fn get_media_file_with_connection(
media_file_token: &MediaFileToken,
can_see_deleted: bool,
mysql_connection: &mut PoolConnection<MySql>,
) -> AnyhowResult<Option<MediaFile>> {
get_media_file_with_transactor(
media_file_token,
can_see_deleted,
Transactor::for_connection(mysql_connection)
).await
}
pub async fn get_media_file_with_transactor(
media_file_token: &MediaFileToken,
can_see_deleted: bool,
@@ -6,6 +6,8 @@ use sqlx;
use sqlx::{Executor, MySql};
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use errors::AnyhowResult;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
@@ -21,6 +23,10 @@ pub struct InsertPromptArgs<'e, 'c, E>
pub prompt_type: PromptType,
pub maybe_creator_user_token: Option<&'e UserToken>,
pub maybe_model_type: Option<ModelType>,
pub maybe_generation_provider: Option<GenerationProvider>,
pub maybe_positive_prompt: Option<&'e str>,
@@ -65,9 +71,13 @@ SET
prompt_type = ?,
maybe_creator_user_token = ?,
maybe_model_type = ?,
maybe_generation_provider = ?,
maybe_positive_prompt = ?,
maybe_negative_prompt = ?,
maybe_other_args = ?,
creator_ip_address = ?
@@ -75,6 +85,8 @@ SET
prompt_token.as_str(),
args.prompt_type.to_str(),
args.maybe_creator_user_token.map(|t| t.as_str()),
args.maybe_model_type.map(|m| m.to_str()),
args.maybe_generation_provider.map(|g| g.to_str()),
args.maybe_positive_prompt,
args.maybe_negative_prompt,
maybe_other_args,
@@ -259,7 +259,8 @@ mod tests {
#[test]
fn serialized_length_ok_for_database() {
const MAX_LENGTH : usize = 32;
// NB: The media_files table has allocated width for VARCHAR(32), but let's slim it down to 24.
const MAX_LENGTH : usize = 24;
for variant in MediaFileOriginModelType::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
@@ -13,6 +13,7 @@ pub mod media_files;
pub mod media_uploads;
pub mod model_categories;
pub mod model_weights;
pub mod prompt_context_items;
pub mod prompts;
pub mod tag_uses;
pub mod trending_model_analytics;
@@ -0,0 +1 @@
pub mod prompt_context_semantic_type;
@@ -0,0 +1,163 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
/// Used in the `prompt_context_items` table in a `VARCHAR(16)` field.
///
/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum PromptContextSemanticType {
/// Image-to-Video starting frame
VidStartFrame,
/// Image-to-Video ending frame
VidEndFrame,
/// Standard image reference without a semantic type (e.g. Sora/ChatGPT 4o/gpt-image-1)
Imgref,
ImgrefCharacter,
ImgrefStyle,
ImgrefBg,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
impl_enum_display_and_debug_using_to_str!(PromptContextSemanticType);
impl_mysql_enum_coders!(PromptContextSemanticType);
impl_mysql_from_row!(PromptContextSemanticType);
/// NB: Legacy API for older code.
impl PromptContextSemanticType {
pub fn to_str(&self) -> &'static str {
match self {
Self::VidStartFrame => "vid_start_frame",
Self::VidEndFrame => "vid_end_frame",
Self::Imgref => "imgref",
Self::ImgrefCharacter => "imgref_character",
Self::ImgrefStyle => "imgref_style",
Self::ImgrefBg => "imgref_bg",
}
}
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"vid_start_frame" => Ok(Self::VidStartFrame),
"vid_end_frame" => Ok(Self::VidEndFrame),
"imgref" => Ok(Self::Imgref),
"imgref_character" => Ok(Self::ImgrefCharacter),
"imgref_style" => Ok(Self::ImgrefStyle),
"imgref_bg" => Ok(Self::ImgrefBg),
_ => Err(format!("invalid value: {:?}", value)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::VidStartFrame,
Self::VidEndFrame,
Self::Imgref,
Self::ImgrefCharacter,
Self::ImgrefStyle,
Self::ImgrefBg,
])
}
}
#[cfg(test)]
mod tests {
use crate::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use crate::test_helpers::assert_serialization;
mod serde {
use super::*;
#[test]
fn test_serialization() {
assert_serialization(PromptContextSemanticType::VidStartFrame, "vid_start_frame");
assert_serialization(PromptContextSemanticType::VidEndFrame, "vid_end_frame");
assert_serialization(PromptContextSemanticType::Imgref, "imgref");
assert_serialization(PromptContextSemanticType::ImgrefCharacter, "imgref_character");
assert_serialization(PromptContextSemanticType::ImgrefStyle, "imgref_style");
assert_serialization(PromptContextSemanticType::ImgrefBg, "imgref_bg");
}
}
mod impl_methods {
use super::*;
#[test]
fn to_str() {
assert_eq!(PromptContextSemanticType::VidStartFrame.to_str(), "vid_start_frame");
assert_eq!(PromptContextSemanticType::VidEndFrame.to_str(), "vid_end_frame");
assert_eq!(PromptContextSemanticType::Imgref.to_str(), "imgref");
assert_eq!(PromptContextSemanticType::ImgrefCharacter.to_str(), "imgref_character");
assert_eq!(PromptContextSemanticType::ImgrefStyle.to_str(), "imgref_style");
assert_eq!(PromptContextSemanticType::ImgrefBg.to_str(), "imgref_bg");
}
#[test]
fn from_str() {
assert_eq!(PromptContextSemanticType::from_str("vid_start_frame").unwrap(), PromptContextSemanticType::VidStartFrame);
assert_eq!(PromptContextSemanticType::from_str("vid_end_frame").unwrap(), PromptContextSemanticType::VidEndFrame);
assert_eq!(PromptContextSemanticType::from_str("imgref").unwrap(), PromptContextSemanticType::Imgref);
assert_eq!(PromptContextSemanticType::from_str("imgref_character").unwrap(), PromptContextSemanticType::ImgrefCharacter);
assert_eq!(PromptContextSemanticType::from_str("imgref_style").unwrap(), PromptContextSemanticType::ImgrefStyle);
assert_eq!(PromptContextSemanticType::from_str("imgref_bg").unwrap(), PromptContextSemanticType::ImgrefBg);
assert!(PromptContextSemanticType::from_str("foo").is_err());
}
}
mod manual_variant_checks {
use super::*;
#[test]
fn all_variants() {
let mut variants = PromptContextSemanticType::all_variants();
assert_eq!(variants.len(), 6);
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::VidStartFrame));
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::VidEndFrame));
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::Imgref));
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::ImgrefCharacter));
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::ImgrefStyle));
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::ImgrefBg));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(PromptContextSemanticType::all_variants().len(), PromptContextSemanticType::iter().len());
}
#[test]
fn round_trip() {
for variant in PromptContextSemanticType::all_variants() {
assert_eq!(variant, PromptContextSemanticType::from_str(variant.to_str()).unwrap());
assert_eq!(variant, PromptContextSemanticType::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, PromptContextSemanticType::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
const MAX_LENGTH : usize = 16;
for variant in PromptContextSemanticType::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant);
}
}
}
}
@@ -13,10 +13,15 @@ use utoipa::ToSchema;
#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum PromptType {
/// Artcraft (App)
ArtcraftApp,
/// Stable diffusion
#[deprecated]
StableDiffusion,
/// Comfy UI
#[deprecated]
ComfyUi,
}
@@ -29,6 +34,7 @@ impl_mysql_from_row!(PromptType);
impl PromptType {
pub fn to_str(&self) -> &'static str {
match self {
Self::ArtcraftApp => "artcraft_app",
Self::StableDiffusion => "stable_diffusion",
Self::ComfyUi => "comfy_ui",
}
@@ -36,6 +42,7 @@ impl PromptType {
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"artcraft_app" => Ok(Self::ArtcraftApp),
"stable_diffusion" => Ok(Self::StableDiffusion),
"comfy_ui" => Ok(Self::ComfyUi),
_ => Err(format!("invalid value: {:?}", value)),
@@ -46,6 +53,7 @@ impl PromptType {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::ArtcraftApp,
Self::StableDiffusion,
Self::ComfyUi,
])
@@ -62,6 +70,7 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(PromptType::ArtcraftApp, "artcraft_app");
assert_serialization(PromptType::StableDiffusion, "stable_diffusion");
assert_serialization(PromptType::ComfyUi, "comfy_ui");
}
@@ -72,12 +81,14 @@ mod tests {
#[test]
fn to_str() {
assert_eq!(PromptType::ArtcraftApp.to_str(), "artcraft_app");
assert_eq!(PromptType::StableDiffusion.to_str(), "stable_diffusion");
assert_eq!(PromptType::ComfyUi.to_str(), "comfy_ui");
}
#[test]
fn from_str() {
assert_eq!(PromptType::from_str("artcraft_app").unwrap(), PromptType::ArtcraftApp);
assert_eq!(PromptType::from_str("stable_diffusion").unwrap(), PromptType::StableDiffusion);
assert_eq!(PromptType::from_str("comfy_ui").unwrap(), PromptType::ComfyUi);
assert!(PromptType::from_str("foo").is_err());
@@ -90,7 +101,8 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = PromptType::all_variants();
assert_eq!(variants.len(), 2);
assert_eq!(variants.len(), 3);
assert_eq!(variants.pop_first(), Some(PromptType::ArtcraftApp));
assert_eq!(variants.pop_first(), Some(PromptType::StableDiffusion));
assert_eq!(variants.pop_first(), Some(PromptType::ComfyUi));
assert_eq!(variants.pop_first(), None);
@@ -6,6 +6,8 @@ use strum::EnumCount;
use strum::EnumIter;
use utoipa::ToSchema;
/// NB: This will be used by a variety of tables (MySQL and sqlite)!
/// Keep the max length to 16 characters.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
@@ -108,5 +110,15 @@ mod tests {
assert_eq!(variant, GenerationProvider::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
const MAX_LENGTH : usize = 16;
for variant in GenerationProvider::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant);
}
}
}
}
@@ -1,6 +1,7 @@
pub mod generation_provider;
pub mod job_status;
pub mod job_status_plus;
pub mod model_type;
pub mod sqlite;
pub mod view_as;
pub mod visibility;
@@ -0,0 +1,253 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
/// NB: This will be used by a variety of tables (MySQL and sqlite)!
/// Keep the max length to 24 characters.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ModelType {
// Image models
#[serde(rename = "flux_1_dev")]
Flux1Dev,
#[serde(rename = "flux_1_schnell")]
Flux1Schnell,
#[serde(rename = "flux_pro_1p1")]
FluxPro11,
#[serde(rename = "flux_pro_1p1_ultra")]
FluxPro11Ultra,
#[serde(rename = "gpt_image_1")]
GptImage1,
#[serde(rename = "recraft_3")]
Recraft3,
// Video models
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
#[serde(rename = "kling_2p1_pro")]
Kling21Pro,
#[serde(rename = "kling_2p1_master")]
Kling21Master,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
#[serde(rename = "veo_2")]
Veo2,
// 3D Object generation models
#[serde(rename = "hunyuan_3d_2p0")]
Hunyuan3d2_0,
#[serde(rename = "hunyuan_3d_2p1")]
Hunyuan3d2_1,
}
impl_enum_display_and_debug_using_to_str!(ModelType);
impl_mysql_enum_coders!(ModelType);
impl_mysql_from_row!(ModelType);
// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders`
impl ModelType {
pub fn to_str(&self) -> &'static str {
match self {
// Image models
Self::Flux1Dev => "flux_1_dev",
Self::Flux1Schnell => "flux_1_schnell",
Self::FluxPro11 => "flux_pro_1p1",
Self::FluxPro11Ultra => "flux_pro_1p1_ultra",
Self::GptImage1 => "gpt_image_1",
Self::Recraft3 => "recraft_3",
// Video models
Self::Kling16Pro => "kling_1p6_pro",
Self::Kling21Pro => "kling_2p1_pro",
Self::Kling21Master => "kling_2p1_master",
Self::Seedance10Lite => "seedance_1p0_lite",
Self::Veo2 => "veo_2",
// 3D Object generation models
Self::Hunyuan3d2_0 => "hunyuan_3d_2p0",
Self::Hunyuan3d2_1 => "hunyuan_3d_2p1",
}
}
pub fn from_str(job_status: &str) -> Result<Self, String> {
match job_status {
// Image models
"flux_1_dev" => Ok(Self::Flux1Dev),
"flux_1_schnell" => Ok(Self::Flux1Schnell),
"flux_pro_1p1" => Ok(Self::FluxPro11),
"flux_pro_1p1_ultra" => Ok(Self::FluxPro11Ultra),
"gpt_image_1" => Ok(Self::GptImage1),
"recraft_3" => Ok(Self::Recraft3),
// Video models
"kling_1p6_pro" => Ok(Self::Kling16Pro),
"kling_2p1_pro" => Ok(Self::Kling21Pro),
"kling_2p1_master" => Ok(Self::Kling21Master),
"seedance_1p0_lite" => Ok(Self::Seedance10Lite),
"veo_2" => Ok(Self::Veo2),
// 3D Object generation models
"hunyuan_3d_2p0" => Ok(Self::Hunyuan3d2_0),
"hunyuan_3d_2p1" => Ok(Self::Hunyuan3d2_1),
_ => Err(format!("invalid task_state: {:?}", job_status)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
// Image models
Self::Flux1Dev,
Self::Flux1Schnell,
Self::FluxPro11,
Self::FluxPro11Ultra,
Self::GptImage1,
Self::Recraft3,
// Video models
Self::Kling16Pro,
Self::Kling21Pro,
Self::Kling21Master,
Self::Seedance10Lite,
Self::Veo2,
// 3D Object generation models
Self::Hunyuan3d2_0,
Self::Hunyuan3d2_1,
])
}
}
#[cfg(test)]
mod tests {
use crate::common::model_type::ModelType;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
#[test]
fn test_serialization() {
// Image models
assert_serialization(ModelType::Flux1Dev, "flux_1_dev");
assert_serialization(ModelType::Flux1Schnell, "flux_1_schnell");
assert_serialization(ModelType::FluxPro11, "flux_pro_1p1");
assert_serialization(ModelType::FluxPro11Ultra, "flux_pro_1p1_ultra");
assert_serialization(ModelType::GptImage1, "gpt_image_1");
assert_serialization(ModelType::Recraft3, "recraft_3");
// Video models
assert_serialization(ModelType::Kling16Pro, "kling_1p6_pro");
assert_serialization(ModelType::Kling21Pro, "kling_2p1_pro");
assert_serialization(ModelType::Kling21Master, "kling_2p1_master");
assert_serialization(ModelType::Seedance10Lite, "seedance_1p0_lite");
assert_serialization(ModelType::Veo2, "veo_2");
// 3D Object generation models
assert_serialization(ModelType::Hunyuan3d2_0, "hunyuan_3d_2p0");
assert_serialization(ModelType::Hunyuan3d2_1, "hunyuan_3d_2p1");
}
#[test]
fn to_str() {
// Image models
assert_eq!(ModelType::Flux1Dev.to_str(), "flux_1_dev");
assert_eq!(ModelType::Flux1Schnell.to_str(), "flux_1_schnell");
assert_eq!(ModelType::FluxPro11.to_str(), "flux_pro_1p1");
assert_eq!(ModelType::FluxPro11Ultra.to_str(), "flux_pro_1p1_ultra");
assert_eq!(ModelType::GptImage1.to_str(), "gpt_image_1");
assert_eq!(ModelType::Recraft3.to_str(), "recraft_3");
// Video models
assert_eq!(ModelType::Kling16Pro.to_str(), "kling_1p6_pro");
assert_eq!(ModelType::Kling21Pro.to_str(), "kling_2p1_pro");
assert_eq!(ModelType::Kling21Master.to_str(), "kling_2p1_master");
assert_eq!(ModelType::Seedance10Lite.to_str(), "seedance_1p0_lite");
assert_eq!(ModelType::Veo2.to_str(), "veo_2");
// 3D Object generation models
assert_eq!(ModelType::Hunyuan3d2_0.to_str(), "hunyuan_3d_2p0");
assert_eq!(ModelType::Hunyuan3d2_1.to_str(), "hunyuan_3d_2p1");
}
#[test]
fn from_str() {
// Image models
assert_eq!(ModelType::from_str("flux_1_dev").unwrap(), ModelType::Flux1Dev);
assert_eq!(ModelType::from_str("flux_1_schnell").unwrap(), ModelType::Flux1Schnell);
assert_eq!(ModelType::from_str("flux_pro_1p1").unwrap(), ModelType::FluxPro11);
assert_eq!(ModelType::from_str("flux_pro_1p1_ultra").unwrap(), ModelType::FluxPro11Ultra);
assert_eq!(ModelType::from_str("gpt_image_1").unwrap(), ModelType::GptImage1);
assert_eq!(ModelType::from_str("recraft_3").unwrap(), ModelType::Recraft3);
// Video models
assert_eq!(ModelType::from_str("kling_1p6_pro").unwrap(), ModelType::Kling16Pro);
assert_eq!(ModelType::from_str("kling_2p1_pro").unwrap(), ModelType::Kling21Pro);
assert_eq!(ModelType::from_str("kling_2p1_master").unwrap(), ModelType::Kling21Master);
assert_eq!(ModelType::from_str("seedance_1p0_lite").unwrap(), ModelType::Seedance10Lite);
assert_eq!(ModelType::from_str("veo_2").unwrap(), ModelType::Veo2);
// 3D Object generation models
assert_eq!(ModelType::from_str("hunyuan_3d_2p0").unwrap(), ModelType::Hunyuan3d2_0);
assert_eq!(ModelType::from_str("hunyuan_3d_2p1").unwrap(), ModelType::Hunyuan3d2_1);
}
#[test]
fn all_variants() {
let mut variants = ModelType::all_variants();
assert_eq!(variants.len(), 13);
// Image models
assert_eq!(variants.pop_first(), Some(ModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(ModelType::Flux1Schnell));
assert_eq!(variants.pop_first(), Some(ModelType::FluxPro11));
assert_eq!(variants.pop_first(), Some(ModelType::FluxPro11Ultra));
assert_eq!(variants.pop_first(), Some(ModelType::GptImage1));
assert_eq!(variants.pop_first(), Some(ModelType::Recraft3));
// Video models
assert_eq!(variants.pop_first(), Some(ModelType::Kling16Pro));
assert_eq!(variants.pop_first(), Some(ModelType::Kling21Pro));
assert_eq!(variants.pop_first(), Some(ModelType::Kling21Master));
assert_eq!(variants.pop_first(), Some(ModelType::Seedance10Lite));
assert_eq!(variants.pop_first(), Some(ModelType::Veo2));
// 3D Object generation models
assert_eq!(variants.pop_first(), Some(ModelType::Hunyuan3d2_0));
assert_eq!(variants.pop_first(), Some(ModelType::Hunyuan3d2_1));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(ModelType::all_variants().len(), ModelType::iter().len());
}
#[test]
fn round_trip() {
for variant in ModelType::all_variants() {
// Test to_str(), from_str(), Display, and Debug.
assert_eq!(variant, ModelType::from_str(variant.to_str()).unwrap());
assert_eq!(variant, ModelType::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, ModelType::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
const MAX_LENGTH : usize = 24;
for variant in ModelType::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant);
}
}
}
}
@@ -40,6 +40,8 @@ pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server>
// NB: We shouldn't be logging much as the /_status endpoint is all we aim to expose.
let log_format = "[%{HOSTNAME}e] IP=[%{X-Forwarded-For}i] \"%r\" %s %b \"%{Referer}i\" \"%{User-Agent}i\" %T";
info!("HTTP server will bind to: {}", bind_address);
let handle = HttpServer::new(move || {
// NB: app_data being clone()'d below should all be safe (dependencies included)
App::new()
@@ -15,6 +15,8 @@ use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginM
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use filesys::path_to_string::path_to_string;
use mysql_queries::payloads::media_file_extra_info::inner_payloads::stable_diffusion_extra_info::StableDiffusionExtraInfo;
use mysql_queries::payloads::media_file_extra_info::media_file_extra_info::MediaFileExtraInfo;
@@ -328,6 +330,8 @@ pub async fn process_job_inference(
maybe_apriori_prompt_token: Some(&prompt_token),
prompt_type: PromptType::StableDiffusion,
maybe_creator_user_token: job.maybe_creator_user_token_typed.as_ref(),
maybe_model_type: None,
maybe_generation_provider: None,
maybe_positive_prompt: Some(&positive_prompt),
maybe_negative_prompt: maybe_negative_prompt.as_deref(),
maybe_other_args: None, // TODO(bt,2024-02-22): Support other arguments
@@ -8,6 +8,8 @@ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path:
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use filesys::file_size::file_size;
use filesys::path_to_string::path_to_string;
use hashing::sha256::sha256_hash_file::sha256_hash_file;
@@ -290,6 +292,8 @@ pub async fn validate_and_save_results(args: SaveResultsArgs<'_>) -> Result<Medi
maybe_apriori_prompt_token: Some(&prompt_token),
prompt_type: PromptType::ComfyUi,
maybe_creator_user_token: args.job.maybe_creator_user_token_typed.as_ref(),
maybe_model_type: None,
maybe_generation_provider: None,
maybe_positive_prompt: args.comfy_args.positive_prompt.as_deref(),
maybe_negative_prompt: args.comfy_args.negative_prompt.as_deref(),
maybe_other_args: maybe_other_args.as_ref(),
@@ -53,6 +53,7 @@ use crate::state::job_dependencies::{BucketDependencies, ClientDependencies, Dat
use crate::state::job_specific_dependencies::JobSpecificDependencies;
use crate::state::scoped_job_type_execution::ScopedJobTypeExecution;
use crate::state::scoped_model_type_execution::ScopedModelTypeExecution;
use crate::threads::reap_jobs_thread::reap_jobs_thread;
use crate::util::filesystem::scoped_temp_dir_creator::ScopedTempDirCreator;
use crate::util::instrumentation::JobInstruments;
use crate::util::instrumentation::{init_otel_metrics_pipeline, JobInstrumentLabels};
@@ -61,6 +62,7 @@ use crate::util::model_weights_cache::model_weights_cache_directory::ModelWeight
pub mod http_server;
pub mod job;
pub mod state;
pub mod threads;
pub mod util;
// Buckets (shared config)
@@ -163,6 +165,8 @@ async fn main() -> AnyhowResult<()> {
.connect(&db_connection_string)
.await?;
let mysql_pool_2 = mysql_pool.clone();
info!("Connected to MySQL.");
let common_env = CommonEnv::read_from_env()?;
@@ -345,6 +349,14 @@ async fn main() -> AnyhowResult<()> {
set_up_directories(&job_dependencies)?;
std::thread::spawn(move || {
let actix_runtime = actix_web::rt::System::new();
actix_runtime.block_on(reap_jobs_thread(mysql_pool_2));
warn!("Reap jobs thread terminated");
});
std::thread::spawn(move || {
let actix_runtime = actix_web::rt::System::new();
let http_server_handle = launch_http_server(create_server_args);
@@ -0,0 +1 @@
pub mod reap_jobs_thread;
@@ -0,0 +1,33 @@
use errors::AnyhowResult;
use log::{error, info};
use mysql_queries::queries::generic_inference::job::reap_stale_fakeyou_jobs::reap_stale_fakeyou_jobs;
use sqlx::MySqlPool;
use std::time::Duration;
const WAIT_BETWEEN_REAPING_SECONDS: u64 = 60 * 2; // 2 minutes
pub async fn reap_jobs_thread(
mysql_pool: MySqlPool,
) -> ! {
info!("Database job reaping thread starting...");
loop {
tokio::time::sleep(Duration::from_secs(WAIT_BETWEEN_REAPING_SECONDS)).await;
info!("Reaping jobs from database...");
let result = do_reap_jobs(&mysql_pool).await;
if let Err(err) = result {
error!("Error reaping jobs: {:?}", err);
tokio::time::sleep(Duration::from_secs(300)).await;
}
}
}
pub async fn do_reap_jobs(
mysql_pool: &MySqlPool,
) -> AnyhowResult<()> {
reap_stale_fakeyou_jobs(mysql_pool).await?;
Ok(())
}
@@ -9,6 +9,9 @@ use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{GptImage1EditImageImageQuality, GptImage1EditImageImageSize, GptImage1EditImageNumImages, GptImage1EditImageRequest, GptImage1EditImageResponse};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::visibility::Visibility;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
use fal_client::requests::webhook::image::enqueue_gpt_image_1_edit_image_webhook::enqueue_gpt_image_1_edit_image_webhook;
@@ -20,7 +23,9 @@ use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::InsertGenericInferenceForFalArgs;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::batch_get_media_files_by_tokens;
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use sqlx::Acquire;
use utoipa::ToSchema;
/// Gpt Image 1 Image Editing
@@ -40,9 +45,17 @@ pub async fn gpt_image_1_edit_image_handler(
request: Json<GptImage1EditImageRequest>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<GptImage1EditImageResponse>, CommonWebError> {
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await
.map_err(|err| {
error!("MySql pool error: {:?}", err);
CommonWebError::ServerError
})?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
@@ -77,8 +90,8 @@ pub async fn gpt_image_1_edit_image_handler(
}
};
let result = batch_get_media_files_by_tokens(
&server_state.mysql_pool,
let result = batch_get_media_files_by_tokens_with_connection(
&mut mysql_connection,
tokens,
CAN_SEE_DELETED,
).await;
@@ -116,7 +129,7 @@ pub async fn gpt_image_1_edit_image_handler(
})
.collect::<Vec<_>>();
insert_idempotency_token(&request.uuid_idempotency_token, &server_state.mysql_pool)
insert_idempotency_token(&request.uuid_idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
@@ -180,16 +193,51 @@ pub async fn gpt_image_1_edit_image_handler(
let ip_address = get_request_ip(&http_request);
let mut transaction = mysql_connection
.begin()
.await
.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
CommonWebError::ServerError
})?;
// NB: Don't fail the job if the query fails.
let prompt_result = insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: maybe_user_session
.as_ref()
.map(|s| &s.user_token),
maybe_model_type: Some(ModelType::GptImage1),
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: request.prompt.as_deref(),
maybe_negative_prompt: None,
maybe_other_args: None,
creator_ip_address: &ip_address,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}).await;
let prompt_token = match prompt_result {
Ok(token) => Some(token),
Err(err) => {
warn!("Error inserting prompt: {:?}", err);
None // Don't fail the job if the prompt insertion fails.
}
};
let db_result = insert_generic_inference_job_for_fal_queue(InsertGenericInferenceForFalArgs {
uuid_idempotency_token: &request.uuid_idempotency_token,
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::ImageGeneration,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -199,6 +247,14 @@ pub async fn gpt_image_1_edit_image_handler(
return Err(CommonWebError::ServerError);
}
};
let _r = transaction
.commit()
.await
.map_err(|err| {
error!("Error committing MySQL transaction: {:?}", err);
CommonWebError::ServerError
})?;
Ok(Json(GptImage1EditImageResponse {
success: true,
@@ -120,11 +120,13 @@ pub async fn generate_flux_1_dev_text_to_image_handler(
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::ImageGeneration,
maybe_inference_args: None,
maybe_prompt_token: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -119,12 +119,14 @@ pub async fn generate_flux_1_schnell_text_to_image_handler(
uuid_idempotency_token: &request.uuid_idempotency_token,
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::ImageGeneration,
maybe_prompt_token: None,
maybe_inference_args: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -121,11 +121,13 @@ pub async fn generate_flux_pro_11_text_to_image_handler(
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::ImageGeneration,
maybe_inference_args: None,
maybe_prompt_token: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -121,11 +121,13 @@ pub async fn generate_flux_pro_11_ultra_text_to_image_handler(
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::ImageGeneration,
maybe_inference_args: None,
maybe_prompt_token: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -145,11 +145,13 @@ pub async fn remove_image_background_handler(
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::BackgroundRemoval,
maybe_inference_args: None,
maybe_prompt_token: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -146,11 +146,13 @@ pub async fn generate_hunyuan_2_0_image_to_3d_handler(
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::ObjectGeneration,
maybe_inference_args: None,
maybe_prompt_token: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -146,11 +146,13 @@ pub async fn generate_hunyuan_2_1_image_to_3d_handler(
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::ObjectGeneration,
maybe_inference_args: None,
maybe_prompt_token: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -191,11 +191,13 @@ pub async fn generate_kling_1_6_pro_video_handler(
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::VideoGeneration,
maybe_inference_args: None,
maybe_prompt_token: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -192,11 +192,13 @@ pub async fn generate_kling_2_1_master_video_handler(
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::VideoGeneration,
maybe_inference_args: None,
maybe_prompt_token: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -192,11 +192,13 @@ pub async fn generate_kling_2_1_pro_video_handler(
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::VideoGeneration,
maybe_inference_args: None,
maybe_prompt_token: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -171,11 +171,13 @@ pub async fn generate_seedance_1_0_lite_image_to_video_handler(
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::VideoGeneration,
maybe_inference_args: None,
maybe_prompt_token: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -2,16 +2,21 @@ use std::sync::Arc;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::analytics::log_browser_session_handler::LogBrowserSessionError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use anyhow::anyhow;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2AspectRatio;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2Duration;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoResponse;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::visibility::Visibility;
use fal_client::requests::webhook::video::enqueue_veo_2_image_to_video_webhook::enqueue_veo_2_image_to_video_webhook;
use fal_client::requests::webhook::video::enqueue_veo_2_image_to_video_webhook::Veo2Args;
@@ -23,7 +28,10 @@ use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::InsertGenericInferenceForFalArgs;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
use mysql_queries::queries::media_files::get::get_media_file::get_media_file_with_connection;
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use sqlx::Acquire;
use tokens::tokens::prompts::PromptToken;
use utoipa::ToSchema;
/// Veo 2 Image to Video
@@ -43,9 +51,17 @@ pub async fn generate_veo_2_image_to_video_handler(
request: Json<GenerateVeo2ImageToVideoRequest>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<GenerateVeo2ImageToVideoResponse>, CommonWebError> {
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await
.map_err(|err| {
error!("MySql pool error: {:?}", err);
CommonWebError::ServerError
})?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
@@ -78,7 +94,7 @@ pub async fn generate_veo_2_image_to_video_handler(
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
insert_idempotency_token(&request.uuid_idempotency_token, &server_state.mysql_pool)
insert_idempotency_token(&request.uuid_idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
@@ -86,10 +102,10 @@ pub async fn generate_veo_2_image_to_video_handler(
})?;
const IS_MOD : bool = false;
let media_file_lookup_result = get_media_file(
let media_file_lookup_result = get_media_file_with_connection(
media_file_token,
IS_MOD,
&server_state.mysql_pool,
&mut mysql_connection,
).await;
let media_file = match media_file_lookup_result {
@@ -169,16 +185,53 @@ pub async fn generate_veo_2_image_to_video_handler(
let ip_address = get_request_ip(&http_request);
let mut transaction = mysql_connection
.begin()
.await
.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
CommonWebError::ServerError
})?;
// NB: Don't fail the job if the query fails.
let prompt_result = insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: maybe_user_session
.as_ref()
.map(|s| &s.user_token),
maybe_model_type: Some(ModelType::Veo2),
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: Some(prompt),
maybe_negative_prompt: None,
maybe_other_args: None,
creator_ip_address: &ip_address,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}).await;
let prompt_token = match prompt_result {
Ok(token) => Some(token),
Err(err) => {
warn!("Error inserting prompt: {:?}", err);
None // Don't fail the job if the prompt insertion fails.
}
};
let db_result = insert_generic_inference_job_for_fal_queue(InsertGenericInferenceForFalArgs {
uuid_idempotency_token: &request.uuid_idempotency_token,
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::VideoGeneration,
maybe_inference_args: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: maybe_user_session
.as_ref()
.map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}).await;
let job_token = match db_result {
@@ -189,6 +242,14 @@ pub async fn generate_veo_2_image_to_video_handler(
}
};
let _r = transaction
.commit()
.await
.map_err(|err| {
error!("Error committing MySQL transaction: {:?}", err);
CommonWebError::ServerError
})?;
Ok(Json(GenerateVeo2ImageToVideoResponse {
success: true,
inference_job_token: job_token,
@@ -86,6 +86,7 @@ pub async fn handle_image_payload(
.media_file_class(MediaFileClass::Image)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
//.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
@@ -109,6 +109,7 @@ pub async fn upload_image(
.file_size_bytes(file_size_bytes as u64)
.frame_width(image_info.width())
.frame_height(image_info.height())
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.checksum_sha2(&file_hash)
.insert_pool(&server_state.mysql_pool)
.await?;
@@ -89,6 +89,7 @@ pub async fn handle_model_mesh_payload(
//.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.checksum_sha2(&file_hash)
.insert_pool(&server_state.mysql_pool)
.await?;
@@ -119,6 +119,7 @@ pub async fn handle_video_payload(
.maybe_frame_height(maybe_frame_height)
.maybe_duration_millis(maybe_duration_millis)
.checksum_sha2(&file_hash)
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.insert_pool(&server_state.mysql_pool)
.await?;
@@ -16,12 +16,12 @@ import { useState, useRef, useEffect } from "react";
import ModelBadgeGrid from "../../components/model-badge-grid";
//const MAC_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_2025.06.04.dmg";
const MAC_LINK =
"https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_2025.07.13.dmg";
//const MAC_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_2025.07.13.dmg";
const MAC_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_2025.07.17.dmg";
//const WINDOWS_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/windows/ArtCraft_0.0.1_x64-setup_2025.06.04.exe";
const WINDOWS_LINK =
"https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/windows/ArtCraft_0.0.1_x64-setup_2025.07.13.exe";
//const WINDOWS_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/windows/ArtCraft_0.0.1_x64-setup_2025.07.13.exe";
const WINDOWS_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/windows/ArtCraft_0.0.1_x64-setup_2025.07.17.exe";
const fadeUpVariants = {
hidden: { opacity: 0, y: 20 },
+9
View File
@@ -14,6 +14,15 @@
gtag('config', 'G-Y4Y8KMLPQ7');
</script>
<!-- Microsoft Clarity -->
<script type="text/javascript">
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "sixp9uu263");
</script>
<base href="/" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Flux</title><path d="M0 20.683L12.01 2.5 24 20.683h-2.233L12.009 5.878 3.471 18.806h12.122l1.239 1.877H0z"></path><path d="M8.069 16.724l2.073-3.115 2.074 3.115H8.069zM18.24 20.683l-5.668-8.707h2.177l5.686 8.707h-2.196zM19.74 11.676l2.13-3.19 2.13 3.19h-4.26z"></path></svg>

After

Width:  |  Height:  |  Size: 438 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ByteDance</title><path d="M14.944 18.587l-1.704-.445V10.01l1.824-.462c1-.254 1.84-.461 1.88-.453.032 0 .056 2.235.056 4.972v4.973l-.176-.008c-.104 0-.952-.207-1.88-.446z"></path><path d="M7 16.542c0-2.736.024-4.98.064-4.98.032-.008.872.2 1.88.454l1.816.461-.016 4.05-.024 4.049-1.632.422c-.896.23-1.736.445-1.856.469L7 21.523v-4.98z"></path><path d="M19.24 12.477c0-9.03.008-9.515.144-9.475.072.024.784.207 1.576.406.792.207 1.576.405 1.744.445l.296.08-.016 8.56-.024 8.568-1.624.414c-.888.23-1.728.437-1.856.47l-.24.055v-9.523z"></path><path d="M1 12.509c0-4.678.024-8.505.064-8.505.032 0 .872.207 1.872.454l1.824.461v7.582c0 4.16-.016 7.574-.032 7.574-.024 0-.872.215-1.88.47L1 21.013v-8.505z"></path></svg>

After

Width:  |  Height:  |  Size: 873 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z"></path></svg>

After

Width:  |  Height:  |  Size: 651 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kling</title><path clip-rule="evenodd" d="M5.493 21.234c-1.112-1.451-1.109-4.263-.081-7.459l-4.557-2.63a1.683 1.683 0 01-.85-1.304 1.505 1.505 0 01.08-.622 13.18 13.18 0 011.037-2.255c3.476-6.02 10.916-8.23 16.619-4.938.46.266.82.67 1.081 1.184.785 1.545.685 4.096-.234 6.954l4.557 2.631c.339.196.596.492.736.832a1.53 1.53 0 01.034 1.093 13.146 13.146 0 01-1.037 2.255c-3.476 6.02-10.916 8.23-16.619 4.938a2.6 2.6 0 01-.766-.68zm11.096-6.615c-2.073 3.591-5.808 5.316-8.343 3.852-1.267-.731-1.994-2.122-2.145-3.778-.095-1.035.036-2.173.4-3.32.217-.684.517-1.37.902-2.039l.008-.014c2.073-3.59 5.808-5.315 8.343-3.852.633.366 1.13.895 1.49 1.54.986 1.772.922 4.415-.285 6.914-.111.23-.232.457-.362.683l-.008.014z"></path></svg>

After

Width:  |  Height:  |  Size: 888 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Midjourney</title><path d="M22.369 17.676c-1.387 1.259-3.17 2.378-5.332 3.417.044.03.086.057.13.083l.018.01.019.012c.216.123.42.184.641.184.222 0 .426-.061.642-.184l.018-.011.019-.011c.14-.084.266-.178.492-.366l.178-.148c.279-.232.426-.342.625-.456.304-.174.612-.266.949-.266.337 0 .645.092.949.266l.023.014c.188.109.334.219.602.442l.178.148c.221.184.346.278.483.36l.028.017.018.01c.21.12.407.181.62.185h.022a.31.31 0 110 .618c-.337 0-.645-.092-.95-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.02-.014a5.356 5.356 0 01-.49-.377l-.159-.132a3.836 3.836 0 00-.483-.36l-.027-.017-.019-.01a1.256 1.256 0 00-.641-.185c-.222 0-.426.061-.641.184l-.02.011-.018.011c-.14.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.51.39l-.022.014-.022.014-.09.054a1.868 1.868 0 01-.95.266c-.337 0-.644-.092-.949-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.026-.017a4.881 4.881 0 01-.425-.325.308.308 0 01-.12-.1l-.098-.081a3.836 3.836 0 00-.483-.36l-.027-.017-.019-.01a1.256 1.256 0 00-.641-.185c-.222 0-.426.061-.642.184l-.018.011-.019.011c-.14.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.51.39l-.023.014-.022.014-.09.054A1.868 1.868 0 0112 22c-.337 0-.645-.092-.949-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.021-.014a5.356 5.356 0 01-.49-.377l-.158-.132a3.836 3.836 0 00-.483-.36l-.028-.017-.018-.01a1.256 1.256 0 00-.642-.185c-.221 0-.425.061-.641.184l-.019.011-.018.011c-.141.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.511.39l-.022.014-.022.014-.09.054a1.868 1.868 0 01-.986.264c-.746-.09-1.319-.38-1.89-.866l-.035-.03c-.047-.041-.118-.106-.192-.174l-.196-.181-.107-.1-.011-.01a1.531 1.531 0 00-.336-.253.313.313 0 00-.095-.03h-.005c-.119.022-.238.059-.361.11a.308.308 0 01-.077.061l-.008.005a.309.309 0 01-.126.034 5.66 5.66 0 00-.774.518l-.416.324-.055.043a6.542 6.542 0 01-.324.236c-.305.207-.552.315-.8.315a.31.31 0 01-.01-.618h.01c.09 0 .235-.062.438-.198l.04-.027c.077-.054.163-.117.27-.199l.385-.301.06-.047c.268-.206.506-.373.73-.505l-.633-1.21a.309.309 0 01.254-.451l20.287-1.305a.309.309 0 01.228.537zm-1.118.14L2.369 19.03l.423.809c.128-.045.256-.078.388-.1a.31.31 0 01.052-.005c.132 0 .26.032.386.093.153.073.294.179.483.35l.016.015.092.086.144.134.097.089c.065.06.125.114.16.144.485.418.948.658 1.554.736h.011a1.25 1.25 0 00.6-.172l.021-.011.019-.011.018-.011c.141-.084.266-.178.492-.366l.178-.148c.279-.232.426-.342.625-.456.305-.174.612-.266.95-.266.336 0 .644.092.948.266l.023.014c.188.109.335.219.603.442l.177.148c.222.184.346.278.484.36l.027.017.019.01c.215.124.42.185.641.185.222 0 .426-.061.641-.184l.019-.011.018-.011c.141-.084.267-.178.493-.366l.177-.148c.28-.232.427-.342.626-.456.304-.174.612-.266.949-.266.337 0 .644.092.949.266l.025.015c.187.109.334.22.603.443 1.867-.878 3.448-1.811 4.73-2.832l.02-.016zM3.653 2.026C6.073 3.06 8.69 4.941 10.8 7.258c2.46 2.7 4.109 5.828 4.637 9.149a.31.31 0 01-.421.335c-2.348-.945-4.54-1.258-6.59-1.02-1.739.2-3.337.792-4.816 1.703-.294.182-.62-.182-.405-.454 1.856-2.355 2.581-4.99 2.343-7.794-.195-2.292-1.031-4.61-2.284-6.709a.31.31 0 01.388-.442zM10.04 4.45c1.778.543 3.892 2.102 5.782 4.243 1.984 2.248 3.552 4.934 4.347 7.582a.31.31 0 01-.401.38l-.022-.01-.386-.154a10.594 10.594 0 00-.291-.112l-.016-.006c-.68-.247-1.199-.291-1.944-.101a.31.31 0 01-.375-.218C15.378 11.123 13.073 7.276 9.775 5c-.291-.201-.072-.653.266-.55zM4.273 2.996l.008.015c1.028 1.94 1.708 4.031 1.885 6.113.213 2.513-.31 4.906-1.673 7.092l-.02.031.003-.001c1.198-.581 2.47-.969 3.825-1.132l.055-.006c1.981-.23 4.083.029 6.309.837l.066.025-.007-.039c-.593-2.95-2.108-5.737-4.31-8.179l-.07-.078c-1.785-1.96-3.944-3.6-6.014-4.65l-.057-.028zm7.92 3.238l.048.048c2.237 2.295 3.885 5.431 4.974 9.191l.038.132.022-.004c.71-.133 1.284-.063 1.963.18l.027.01.066.024.046.018-.025-.073c-.811-2.307-2.208-4.62-3.936-6.594l-.058-.065c-1.02-1.155-2.103-2.132-3.15-2.856l-.015-.011z"></path></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,8 +1,6 @@
import { useCallback, useEffect, useState, useRef } from "react";
import { ListDropdown } from "@storyteller/ui-list-dropdown";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { Label } from "@storyteller/ui-label";
import { FileUploader } from "@storyteller/ui-file-uploader";
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "./utilities";
import { upload3DObjects } from "./utilities/upload3DObjects";
@@ -48,13 +46,6 @@ export const UploadFiles3D = ({
: undefined,
);
const [uploadTitle, setUploadTitle] = useState<{
value: string;
error?: string;
}>({
value: "",
});
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
@@ -70,14 +61,6 @@ export const UploadFiles3D = ({
);
const handleSubmit = () => {
if (!uploadTitle.value) {
setUploadTitle((curr) => ({
...curr,
error: "Please enter a title.",
}));
return;
}
if (!assetFile.value) {
setAssetFile((curr) => ({
...curr,
@@ -86,8 +69,11 @@ export const UploadFiles3D = ({
return;
}
// Use the filename as the title
const title = assetFile.value.name.split(".")[0];
upload3DObjects({
title: uploadTitle.value,
title: title,
assetFile: assetFile.value,
thumbnailSnapshot: thumbnailFile,
engineCategory: engineCategory,
@@ -123,18 +109,7 @@ export const UploadFiles3D = ({
return (
<>
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col">
<Label required>Asset Name</Label>
<Input
placeholder="Enter the asset name here"
errorMessage={uploadTitle.error}
value={uploadTitle.value}
onChange={(event) => setUploadTitle({ value: event.target.value })}
className={uploadTitle.error ? "mb-3" : ""}
/>
</div>
<div className="flex flex-col gap-3">
<FileUploader
fileTypes={fileTypes}
file={assetFile.value ?? undefined}
@@ -183,7 +158,7 @@ export const UploadFiles3D = ({
<Button
variant="primary"
onClick={handleSubmit}
disabled={!uploadTitle.value || !assetFile.value}
disabled={!assetFile.value}
>
Upload
</Button>
@@ -1,19 +1,19 @@
import { useEffect, useState } from "react";
import { LoadingDots } from "@storyteller/ui-loading";
import { Modal } from "@storyteller/ui-modal";
import { Label } from "@storyteller/ui-label";
import { UploadAssetError, UploadSuccess } from "@storyteller/ui-upload-modal";
import { UploadFiles3D } from "./UploadFiles3D";
import { initialUploaderState, UploaderState } from "../../../models";
import { Select } from "@storyteller/ui-select";
import {
FilterEngineCategories,
UploaderStates,
OBJECT_FILE_TYPE,
CHARACTER_MIXAMO_FILE_TYPE,
IMAGEPLANE_FILE_TYPE,
} from "../../../enums";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
} from "@storyteller/ui-gallery-modal";
interface Props {
onClose: () => void;
@@ -26,63 +26,17 @@ interface Props {
hasLength?: boolean;
hasThumbnailUpload?: boolean;
};
preselectedCategory?: FilterEngineCategories;
isSelectVisible: boolean;
}
// Map categories to their allowed file types
const categoryFileTypes: Partial<Record<FilterEngineCategories, string[]>> = {
[FilterEngineCategories.OBJECT]: Object.values(OBJECT_FILE_TYPE),
[FilterEngineCategories.CHARACTER]: [
...Object.values(CHARACTER_MIXAMO_FILE_TYPE),
],
[FilterEngineCategories.IMAGE_PLANE]: Object.values(IMAGEPLANE_FILE_TYPE),
[FilterEngineCategories.LOCATION]: Object.values(OBJECT_FILE_TYPE),
[FilterEngineCategories.CREATURE]: Object.values(OBJECT_FILE_TYPE),
};
// Excluded category options for the upload modal
const categoryOptions = Object.entries(FilterEngineCategories)
.filter(
([, value]) =>
![
"audio",
"video_plane",
"skybox",
"set_dressing",
"expression",
"animation",
"image_plane",
"scene",
].includes(value),
)
.sort(([, a], [, b]) => (a === "object" ? -1 : b === "object" ? 1 : 0))
.map(([key, value]) => ({
// This is because sets is location i guess - BFlat
label:
key === "LOCATION"
? "Set"
: key.charAt(0) + key.slice(1).toLowerCase().replace(/_/g, " "),
value: value,
}));
const objectFileTypes = Object.values(OBJECT_FILE_TYPE);
export function UploadModal3D({
isOpen,
onClose,
onSuccess,
title,
titleIcon,
options,
preselectedCategory,
isSelectVisible,
}: Props & { preselectedCategory?: FilterEngineCategories }) {
export function UploadModal3D(props: Props) {
const { isOpen, onClose, onSuccess, title, titleIcon, options } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
const [selectedCategory, setSelectedCategory] =
useState<FilterEngineCategories>(
preselectedCategory !== undefined
? preselectedCategory
: FilterEngineCategories.OBJECT,
);
// Category fixed to OBJECT
const selectedCategory = FilterEngineCategories.OBJECT;
const updateUploaderState = (newState: UploaderState) => {
setUploaderState(newState);
@@ -98,19 +52,12 @@ export function UploadModal3D({
}
}, [isOpen]);
useEffect(() => {
if (preselectedCategory !== undefined) {
setSelectedCategory(preselectedCategory);
}
}, [preselectedCategory]);
useEffect(() => {
console.log("Preselected category:", preselectedCategory);
console.log("Initial selected category:", selectedCategory);
}, [preselectedCategory, selectedCategory]);
useEffect(() => {
if (uploaderState.status === UploaderStates.success) {
// Automatically open the global Gallery modal after a successful upload
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
}
@@ -122,31 +69,10 @@ export function UploadModal3D({
case UploaderStates.ready:
return (
<div className="space-y-4">
{isSelectVisible && (
<div className="flex w-full flex-col">
<Label className="mb-1">Category</Label>
<Select
id="category-select"
options={categoryOptions}
value={selectedCategory}
onChange={(value) => {
if (
typeof value === "string" &&
Object.values(FilterEngineCategories).includes(
value as FilterEngineCategories,
)
) {
setSelectedCategory(value as FilterEngineCategories);
}
}}
placeholder="Select a category"
/>
</div>
)}
<UploadFiles3D
title={title}
engineCategory={selectedCategory}
fileTypes={categoryFileTypes[selectedCategory] || []}
fileTypes={objectFileTypes}
options={options}
onClose={onClose}
onUploadProgress={updateUploaderState}
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Button } from "@storyteller/ui-button";
import { FileUploader } from "@storyteller/ui-file-uploader";
import { uploadImage } from "./utilities/uploadImage";
import { UploaderState } from "../../../models";
interface Props {
title: string;
fileTypes: string[];
onClose: () => void;
onUploadProgress: (newState: UploaderState) => void;
}
export const UploadFilesImage = ({
fileTypes,
onClose,
onUploadProgress,
}: Props) => {
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
const handleSubmit = () => {
if (!assetFile.value) {
setAssetFile((curr) => ({
...curr,
error: "Please select an image to upload.",
}));
return;
}
// Use the filename as the title
const title = assetFile.value.name.split(".")[0];
uploadImage({
title: title,
assetFile: assetFile.value,
progressCallback: onUploadProgress,
});
};
return (
<>
<div className="flex flex-col gap-3">
<FileUploader
fileTypes={fileTypes}
file={assetFile.value ?? undefined}
handleChange={(file: File) => {
setAssetFile({
value: file,
});
}}
/>
{assetFile.error && (
<h6 className="z-10 text-red">{assetFile.error}</h6>
)}
{assetFile.value && (
<div className="relative m-auto flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg bg-brand-secondary">
<img
alt="Preview"
className="m-auto max-h-full max-w-full object-contain"
src={URL.createObjectURL(assetFile.value)}
/>
</div>
)}
<div className="flex justify-end gap-4">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" onClick={handleSubmit}>
Upload
</Button>
</div>
</div>
</>
);
};
@@ -0,0 +1,134 @@
import { useEffect, useState } from "react";
import { LoadingDots } from "@storyteller/ui-loading";
import { Modal } from "@storyteller/ui-modal";
import { UploadAssetError, UploadSuccess } from "@storyteller/ui-upload-modal";
import { UploadFilesImage } from "./UploadFilesImage";
import { initialUploaderState, UploaderState } from "../../../models";
import {
FilterEngineCategories,
UploaderStates,
IMAGEPLANE_FILE_TYPE,
} from "../../../enums";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
} from "@storyteller/ui-gallery-modal";
interface Props {
onClose: () => void;
onSuccess: (category: FilterEngineCategories) => void;
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
}
const imageFileTypes = Object.values(IMAGEPLANE_FILE_TYPE);
export function UploadModalImage(props: Props) {
const { isOpen, onClose, onSuccess, title, titleIcon } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
// Category fixed to IMAGE_PLANE
const selectedCategory = FilterEngineCategories.IMAGE_PLANE;
const updateUploaderState = (newState: UploaderState) => {
setUploaderState(newState);
};
const resetModalState = () => {
setUploaderState(initialUploaderState);
};
useEffect(() => {
if (isOpen) {
resetModalState();
}
}, [isOpen]);
useEffect(() => {
if (uploaderState.status === UploaderStates.success) {
// Automatically open the global Gallery modal after a successful upload
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploaderState.status]);
const UploaderModalContent = () => {
switch (uploaderState.status) {
case UploaderStates.ready:
return (
<div className="space-y-4">
<UploadFilesImage
title={title}
fileTypes={imageFileTypes}
onClose={onClose}
onUploadProgress={updateUploaderState}
/>
</div>
);
case UploaderStates.uploadingAsset:
case UploaderStates.uploadingCover:
case UploaderStates.settingCover:
return (
<>
<LoadingDots className="mb-1 bg-transparent" />
<div className="w-100 text-center opacity-50">Uploading...</div>
</>
);
case UploaderStates.success: {
return (
<UploadSuccess
title="Image"
onOk={() => {
onClose();
onSuccess(selectedCategory);
}}
/>
);
}
case UploaderStates.assetError:
return (
<UploadAssetError
onCancel={onClose}
onRetry={() => {
resetModalState();
}}
type={selectedCategory}
errorMessage={uploaderState.errorMessage}
/>
);
case UploaderStates.coverCreateError:
case UploaderStates.coverSetError:
return (
<UploadAssetError
onCancel={onClose}
onRetry={() => {
resetModalState();
}}
type={"Thumbnail"}
errorMessage={uploaderState.errorMessage}
/>
);
}
return undefined;
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
titleIcon={titleIcon}
title={title}
className="max-w-xl"
showClose={true}
>
<UploaderModalContent />
</Modal>
);
}
@@ -0,0 +1 @@
export { UploadModalImage } from "./UploadModalImage";
@@ -0,0 +1,56 @@
import { v4 as uuidv4 } from "uuid";
import { MediaUploadApi } from "../../../../Classes/ApiManager";
import { UploaderStates } from "../../../../enums";
import { UploaderState } from "../../../../models";
import { getFileName } from "../../../../utilities";
import { EIntermediateFile } from "~/enums/EIntermediateFile";
export const uploadImage = async ({
title,
assetFile,
progressCallback,
}: {
title: string;
assetFile: File;
progressCallback: (newState: UploaderState) => void;
}) => {
const mediaUploadApi = new MediaUploadApi();
console.log("Uploading image plane:", assetFile);
progressCallback({ status: UploaderStates.uploadingAsset });
const assetResponse = await mediaUploadApi.UploadImage({
blob: assetFile,
fileName: getFileName(assetFile),
uuid: uuidv4(),
maybe_title: title,
is_intermediate_system_file: EIntermediateFile.false,
});
console.log("Image plane response:", assetResponse);
if (assetResponse == undefined) {
console.log("Error: Could not upload image plane!");
progressCallback({
status: UploaderStates.assetError,
errorMessage: "Could not upload image plane!",
});
return;
}
if (!assetResponse.success || !assetResponse.data) {
console.log("Error:", assetResponse.errorMessage);
progressCallback({
status: UploaderStates.assetError,
errorMessage: assetResponse.errorMessage,
});
return;
}
console.log("Upload successful:", assetResponse.data);
progressCallback({
status: UploaderStates.success,
data: assetResponse.data,
});
};
@@ -37,6 +37,8 @@ import {
} from "~/pages/PageEnigma/Editor/editor";
// import { usePricingModalStore } from "@storyteller/ui-pricing-modal"; - Uncomment for pricing modal - BFlat
import toast from "react-hot-toast";
import { gtagEvent } from "@storyteller/google-analytics";
interface Props {
pageName: string;
loginSignUpPressed: () => void;
@@ -84,6 +86,7 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
const handleOpenGalleryModal = () => {
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
gtagEvent("open_gallery_modal", { "tab": tabStore.activeTabId });
};
const tabStore = useTabStore();
@@ -138,6 +141,8 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
activeMenu={tabStore.activeTabId}
disabled={disableTabSwitcher()}
onMenuChange={(tabId) => {
gtagEvent("switch_tab", { "tab": tabId });
// Prevent a second input if the switcher is throttled.
if (switcherThrottle.current) {
return;
@@ -193,7 +198,10 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
variant="secondary"
icon={faGear}
className="h-[38px] w-[38px]"
onClick={() => setIsSettingsModalOpen(true)}
onClick={() => {
setIsSettingsModalOpen(true);
gtagEvent("open_settings_modal");
}}
/>
</Tooltip>
<Button
@@ -15,6 +15,7 @@ import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap";
import { captureStageImageBitmap } from "./hooks/useUpdateSnapshot";
import { ContextMenuContainer } from "./components/ui/ContextMenu";
import { FalBackgroundRemoval } from "@storyteller/tauri-api";
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
export const DecodeBase64ToImage = async (
base64String: string,
@@ -146,14 +147,18 @@ const PageDraw = () => {
const modelList: PopoverItem[] = [
{
label: "GPT-4o",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
icon: getCreatorIcon(ModelCreator.OpenAi) || (
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
),
selected: selectedModel === "GPT-4o",
description: "High quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
label: "FLUX.1 Kontext",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
icon: getCreatorIcon(ModelCreator.BlackForestLabs) || (
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
),
selected: selectedModel === "FLUX.1 Kontext",
description: "Fast and high-quality model",
badges: [{ label: "20 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
@@ -1035,6 +1035,8 @@ export const useSceneStore = create<SceneState>((set, get) => ({
scaleY: node.scaleY || 1,
offsetX: node.offsetX || 0,
offsetY: node.offsetY || 0,
zIndex: node.zIndex || 0,
locked: node.locked || false,
};
// Convert imageFile to base64 if it exists
@@ -11,13 +11,12 @@ import { ChromaKeyMaterial } from "./chromakey";
import { TimeLine } from "./timeline";
import { ClipGroup, ClipType } from "~/enums";
import { ClipUI } from "../clips/clip_ui";
import { GetFrontendEnvironment } from "~/Classes/GetFrontendEnvironment";
import { LoadWithoutCors } from "@storyteller/tauri-api";
import { gridVisibility } from "../signals/engine";
import { InfiniteGridHelper } from "./InfiniteGridHelper";
import { cameras, selectedCameraId } from "../signals/camera";
import { MediaFilesApi } from "@storyteller/api";
import toast from "react-hot-toast";
class Scene {
name: string;
@@ -625,19 +624,38 @@ class Scene {
// await this.delay(3000); // artificial delay.
const glb = await this.load_glb_wrapped(url, async (progress) => {
const total_loaded = progress.loaded / progress.total;
//const percent = this.floatToPercent(total_loaded);
//console.log(`GLB Loading: ${percent}`);
// NB(bt,2025-07-22): This is causing a lot of CORS errors in dev, so I'm starting
// to funnel everything through Tauri. Not sure if that'll be more inefficient due
// to spinning up new HTTP clients with new SSL handshakes without caching, but at
// least it should bypass the cors errors.
//
// const glb = await this.load_glb_wrapped(url, async (progress) => {
// const total_loaded = progress.loaded / progress.total;
// //const percent = this.floatToPercent(total_loaded);
// //console.log(`GLB Loading: ${percent}`);
//
// if (total_loaded == 1.0) {
// if (this.placeholder_manager === undefined) {
// throw Error("Place holder Manager is undefined");
// }
// //console.log(`GLB Loading: ${total_loaded} === ${key}`);
// await this.placeholder_manager.remove(key);
// }
// }).catch((error: Error) => {
// throw error;
// });
if (total_loaded == 1.0) {
if (this.placeholder_manager === undefined) {
throw Error("Place holder Manager is undefined");
}
//console.log(`GLB Loading: ${total_loaded} === ${key}`);
const glb = await this.load_glb_wrapped_no_cors(url, async () => {
if (this.placeholder_manager === undefined) {
throw Error("Place holder Manager is undefined");
}
await this.placeholder_manager.remove(key);
}).catch(async (error) => {
if (this.placeholder_manager !== undefined) {
await this.placeholder_manager.remove(key);
}
}).catch((error: Error) => {
toast.error("Failed to load 3D asset.");
throw error;
});
@@ -735,6 +753,41 @@ class Scene {
return child_result;
}
/**
* Load a full media_url via Tauri (without browser cors)
* @param media_url
* @param onComplete
* @returns
*/
private async load_glb_wrapped_no_cors(
media_url: string,
onComplete: () => void,
) {
return new Promise(async (resolve, reject) => {
let buffer;
try {
buffer = await LoadWithoutCors(media_url); // Load via Tauri!
} catch (error) {
console.error("load GLB from Tauri error:", error)
reject(error);
return;
}
const glbLoader = new GLTFLoader();
glbLoader.parse(
buffer,
"", // NB: No relative path to load additional assets!
(gltf) => {
resolve(gltf);
onComplete();
},
(error) => {
console.error("GLTF loader error:", error)
reject(error);
},
);
});
}
/**
* Using a constructed media_url load a glb, grab the progress.
* @param media_url
@@ -10,14 +10,13 @@ import {
faChevronRight,
faMountainCity,
faDog,
faImage,
faUpFromLine,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { CloseButton } from "@storyteller/ui-close-button";
import { Input } from "@storyteller/ui-input";
import { TabSelector } from "@storyteller/ui-tab-selector";
// import { TabSelector } from "@storyteller/ui-tab-selector";
import React, {
useState,
useEffect,
@@ -306,17 +305,17 @@ export const AssetModal = () => {
icon: faSun,
items: activeLibraryTab === "library" ? demoSkyboxItems.value : [],
},
{
id: "image-planes",
label: "Image Planes",
labelSingle: "Image Plane",
icon: faImage,
engineCategory: FilterEngineCategories.IMAGE_PLANE,
items:
activeLibraryTab === "library"
? (featuredImagePlanes ?? [])
: (userImagePlanes ?? []),
},
// {
// id: "image-planes",
// label: "Image Planes",
// labelSingle: "Image Plane",
// icon: faImage,
// engineCategory: FilterEngineCategories.IMAGE_PLANE,
// items:
// activeLibraryTab === "library"
// ? (featuredImagePlanes ?? [])
// : (userImagePlanes ?? []),
// },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -500,7 +499,7 @@ export const AssetModal = () => {
<div className="relative col-span-3 flex h-full flex-col p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:bg-white/10">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold opacity-80">
3D Assets
ArtCraft Presets
</h2>
{/* <Tooltip content="Upload model" position="top" delay={200}>
<Button
@@ -513,7 +512,7 @@ export const AssetModal = () => {
</div>
<hr className="my-2 w-full border-white/10" />
<div className="flex h-full flex-col space-y-2">
<Button
{/* <Button
variant="primary"
className={twMerge(
"w-full justify-start rounded-xl border border-white/[2%] bg-white/[4%] px-3.5 py-2.5 text-left hover:bg-white/15",
@@ -526,7 +525,7 @@ export const AssetModal = () => {
className="mr-2 text-lg opacity-70"
/>
Upload an asset
</Button>
</Button> */}
{assetTabs.map((tab) => (
<Button
key={tab.id}
@@ -581,12 +580,12 @@ export const AssetModal = () => {
<div className="flex h-full flex-col">
<div className="h-full">
<div className="flex items-center gap-4">
<TabSelector
{/* <TabSelector
tabs={libraryTabs}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
className="relative z-[51] w-auto"
/>
/> */}
<div className="relative grow">
<Input
ref={searchInputRef}
@@ -633,7 +632,7 @@ export const AssetModal = () => {
</Button>
{currentTab.label}
</div>
{activeAssetTab !== "skybox" &&
{/* {activeAssetTab !== "skybox" &&
activeAssetTab !== "all" && (
<Button
icon={faUpFromLine}
@@ -648,7 +647,7 @@ export const AssetModal = () => {
Upload {getArticle(currentTab.labelSingle)}{" "}
{currentTab.labelSingle}
</Button>
)}
)} */}
</div>
)}
{renderContent()}
@@ -5,6 +5,7 @@ import {
faPlus,
faUpRightAndDownLeftFromCenter,
faCube,
faImages,
faArrowUpFromBracket,
} from "@fortawesome/pro-solid-svg-icons";
import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
@@ -33,10 +34,17 @@ import { AssetModal } from "../AssetMenu/AssetModal";
import { selectedMode } from "../../signals/selectedMode";
import { useSignals } from "@preact/signals-react/runtime";
import { outlinerState } from "../../signals/outliner/outliner";
import { twMerge } from "tailwind-merge";
// eslint-disable-next-line import/no-unresolved
import { setLogoutStates } from "~/signals/authentication/utilities";
import { UploadModal3D } from "../../../../components/reusable/UploadModal3D/UploadModal3D";
import { PopoverMenu } from "@storyteller/ui-popover";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
} from "@storyteller/ui-gallery-modal";
import { twMerge } from "tailwind-merge";
import { UploadModalImage } from "../../../../components/reusable/UploadModalImage";
export const Controls3D = () => {
useSignals();
@@ -45,6 +53,8 @@ export const Controls3D = () => {
// Action reminder is now handled through signals
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [upload3DIsShowing, setUpload3DIsShowing] = useState(false);
const [isAddAssetPopoverOpen, setIsAddAssetPopoverOpen] = useState(false);
const [uploadImageIsShowing, setUploadImageIsShowing] = useState(false);
// Track processed 3D models by their media token to prevent duplicates
const processedModelsRef = useRef<Record<string, boolean>>({});
@@ -53,7 +63,12 @@ export const Controls3D = () => {
// Check if scene is empty and onboarding helper is not visible
const checkSceneEmpty = () => {
const isSceneEmpty =
outlinerState.items.value.length === 0 && !assetModalVisible.value;
outlinerState.items.value.length === 0 &&
!assetModalVisible.value &&
!galleryModalVisibleViewMode.value &&
!isAddAssetPopoverOpen &&
!upload3DIsShowing &&
!uploadImageIsShowing;
setShowEmptySceneTooltip(isSceneEmpty);
};
@@ -68,7 +83,13 @@ export const Controls3D = () => {
unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assetModalVisible.value]);
}, [
assetModalVisible.value,
galleryModalVisibleViewMode.value,
isAddAssetPopoverOpen,
upload3DIsShowing,
uploadImageIsShowing,
]);
const handleModeChange = (value: string) => {
selectedMode.value = value;
@@ -169,8 +190,29 @@ export const Controls3D = () => {
}
};
const handleOpenUpload3DModal = () => {
setUpload3DIsShowing(true);
// Open global Gallery modal
const handleOpenGalleryModal = () => {
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
};
const handleAddAssetAction = (action: string) => {
switch (action) {
case "presets":
handleOpenModal();
break;
case "library":
handleOpenGalleryModal();
break;
case "upload-3d":
setUpload3DIsShowing(true);
break;
case "upload-image":
setUploadImageIsShowing(true);
break;
default:
break;
}
};
const openSettingsModal = () => {
@@ -219,14 +261,14 @@ export const Controls3D = () => {
<div className="relative">
{showEmptySceneTooltip && (
<div className="absolute -bottom-14 left-1/2 -translate-x-1/2 transform whitespace-nowrap">
<div className="animate-bounce rounded-lg bg-brand-primary px-4 py-2 text-sm font-medium text-white shadow-lg">
<div className="animate-bounce rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white shadow-lg">
Click + to add your first 3D asset!
<div className="absolute -top-1.5 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 transform bg-brand-primary" />
<div className="absolute -top-1.5 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 transform bg-primary" />
</div>
</div>
)}
<Tooltip
content="Add 3D asset to scene (B)"
content="Add an asset to scene"
position="bottom"
delay={300}
closeOnClick
@@ -234,30 +276,68 @@ export const Controls3D = () => {
showEmptySceneTooltip ? "hidden" : "block",
)}
>
<Button
icon={faPlus}
className={`h-9 w-9 rounded-[10px] text-lg ${
<PopoverMenu
mode="button"
position="bottom"
panelTitle="Add an asset to scene"
onOpenChange={setIsAddAssetPopoverOpen}
items={[
{
label: "ArtCraft Presets (B)",
selected: false,
icon: (
<FontAwesomeIcon icon={faCube} className="h-4 w-4" />
),
action: "presets",
},
{
label: "My Library",
selected: false,
icon: (
<FontAwesomeIcon
icon={faImages}
className="h-4 w-4"
/>
),
action: "library",
divider: true,
},
{
label: "Upload 3D Model",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload-3d",
},
{
label: "Upload Image",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload-image",
},
]}
onPanelAction={handleAddAssetAction}
showIconsInList
buttonClassName={`h-9 w-9 rounded-[10px] text-lg ${
showEmptySceneTooltip
? "border-white/80 bg-brand-primary/90"
: ""
? "bg-primary/90 hover:bg-primary/70"
: "border-transparent bg-primary/90 hover:bg-primary/70"
}`}
onClick={handleOpenModal}
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
/>
</Tooltip>
</div>
<Tooltip
content="Upload 3D asset"
position="bottom"
delay={300}
closeOnClick
>
<Button
icon={faArrowUpFromBracket}
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
variant="secondary"
onClick={handleOpenUpload3DModal}
/>
</Tooltip>
<Tooltip
content="Create 3D model from image"
position="bottom"
@@ -298,9 +378,16 @@ export const Controls3D = () => {
isOpen={upload3DIsShowing}
onClose={() => setUpload3DIsShowing(false)}
onSuccess={() => setUpload3DIsShowing(false)}
title="Upload 3D Asset"
title="Upload a 3D Model"
titleIcon={faCube}
isSelectVisible={true}
/>
<UploadModalImage
isOpen={uploadImageIsShowing}
onClose={() => setUploadImageIsShowing(false)}
onSuccess={() => setUploadImageIsShowing(false)}
title="Upload an Image"
titleIcon={faImages}
/>
</>
);
@@ -1,7 +1,5 @@
import { useState } from "react";
import dayjs from "dayjs";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faDownToLine } from "@fortawesome/pro-solid-svg-icons";
import { LightboxModal } from "@storyteller/ui-lightbox-modal";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
@@ -41,7 +39,9 @@ export function CompletedCard({ job }: Props) {
<div className="flex aspect-square h-14 w-14 justify-center overflow-hidden rounded-lg border border-[#A9A9A9]/50 bg-black/60">
<img
src={
loadError ? "/resources/images/movie-placeholder.png" : thumbnailUrl
loadError
? "/resources/images/movie-placeholder.png"
: thumbnailUrl
}
className="h-full w-full object-cover"
alt={job.maybe_title ?? "unknown"}
@@ -57,17 +57,18 @@ export function CompletedCard({ job }: Props) {
</div>
</div>
</div>
<a
{/* <a
href={downloadLink}
download
onClick={(e) => e.stopPropagation()}
className="flex h-9 w-9 items-center justify-center text-lg text-white/60 transition-all hover:text-white"
>
<FontAwesomeIcon icon={faDownToLine} />
</a>
</a> */}
</div>
<LightboxModal
onCloseGallery={() => setIsModalOpen(false)}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
imageUrl={fullImageUrl}
@@ -24,7 +24,8 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
selectedModels[ModelCategory.TextToImage] ||
imageGenerationModels[0]?.label;
const selectedModelInfo : ModelInfo | undefined = IMAGE_MODELS_BY_LABEL[selectedModel];
const selectedModelInfo: ModelInfo | undefined =
IMAGE_MODELS_BY_LABEL[selectedModel];
const jobContext: JobContextType = {
jobTokens: [],
@@ -24,7 +24,8 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
selectedModels[ModelCategory.ImageToVideo] ||
videoGenerationModels[0]?.label;
const selectedModelInfo : ModelInfo | undefined = VIDEO_MODELS_BY_LABEL[selectedModel];
const selectedModelInfo: ModelInfo | undefined =
VIDEO_MODELS_BY_LABEL[selectedModel];
const jobContext: JobContextType = {
jobTokens: [],
@@ -1,6 +1,7 @@
import { authentication } from "./authentication";
import { UsersApi } from "~/Classes/ApiManager/UsersApi";
import { BillingApi } from "~/Classes/ApiManager/BillingApi";
import { gtagLogin, gtagLogout } from "@storyteller/google-analytics";
import {
updateActiveSubscriptions,
@@ -23,6 +24,7 @@ export const logout = async (
// if success, nothing
// regarldess of success/fail, clear the state and localstorage
setLogoutStates();
gtagLogout();
};
export const login = async ({
@@ -114,6 +116,11 @@ async function getUserInfoAndSubcriptions() {
updateAuthStatus(AUTH_STATUS.NO_ACCESS);
return;
}
const userToken = sessionResponse.data.user.user_token;
if (!!userToken) {
gtagLogin(userToken);
}
const billingApi = new BillingApi();
const subscriptionsResponse = await billingApi.ListActiveSubscriptions();
-33
View File
@@ -1,33 +0,0 @@
[build]
command = "./apps/editor3d/script/netlify_build.sh"
publish = "apps/editor3d/dist/"
[build.environment]
REACT_APP_PUBLIC_POSTHOG_KEY = "phc_jBFgac0mVALAFk3negnSfYCcHgvkT00yBLQDmCDYNBb"
REACT_APP_PUBLIC_POSTHOG_UI = "https://us.i.posthog.com"
#[dev]
#command = "npm run dev"
#framework = "vite"
# Set immutable caching for static files, because they have fingerprinted filenames
[[headers]]
for = "/build/*"
[headers.values]
"Cache-Control" = "public, max-age=31560000, immutable"
[[redirects]]
from = "/ingest/static/*"
to = "https://us-assets.i.posthog.com/static/:splat"
host = "us-assets.i.posthog.com"
status = 200
force = true
[[redirects]]
from = "/ingest/*"
to = "https://us.i.posthog.com/:splat"
host = "us.i.posthog.com"
status = 200
force = true
+3
View File
@@ -144,6 +144,9 @@
},
{
"path": "../../libs/api"
},
{
"path": "../../libs/analytics/google-analytics"
}
]
}
@@ -0,0 +1,50 @@
{
"extends": [
"../../../.eslintrc.json"
],
"ignorePatterns": [
"!**/*"
],
"overrides": [
{
"files": [
"*.ts",
"*.tsx",
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": [
"*.ts",
"*.tsx"
],
"rules": {}
},
{
"files": [
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": [
"*.json"
],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": [
"error",
{
"ignoredFiles": [
"{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}",
"{projectRoot}/vite.config.{js,ts,mjs,mts}"
]
}
]
}
}
]
}
@@ -0,0 +1,7 @@
# google-analytics
This library was generated with [Nx](https://nx.dev).
## Building
Run `nx build google-analytics` to build the library.
@@ -0,0 +1,19 @@
{
"name": "@storyteller/google-analytics",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"dependencies": {}
}
@@ -0,0 +1,4 @@
export * from './lib/gtag-login.js';
export * from './lib/gtag-logout.js';
export * from './lib/gtag-event.js';
@@ -0,0 +1,10 @@
// Send an event with name and "args". Args is a string-to-string map.
export const gtagEvent = function(eventName: string, args?: { [key: string]: string }) {
console.debug("gtagEvent", eventName, args);
if (!args) {
(window as any).gtag('event', eventName);
} else {
(window as any).gtag('event', eventName, args);
}
}
@@ -0,0 +1,10 @@
import { TAG_ID } from './tag-id.js';
export const gtagLogin = function(userToken: string) {
console.debug("gtagLogin", TAG_ID, userToken);
(window as any).gtag(
'config', TAG_ID, {
'user_id': userToken
});
}
@@ -0,0 +1,10 @@
import { TAG_ID } from './tag-id.js';
export const gtagLogout = function() {
console.debug("gtagLogout", TAG_ID);
(window as any).gtag(
'config', TAG_ID, {
'user_id': null
});
}
@@ -0,0 +1 @@
export const TAG_ID = 'G-Y4Y8KMLPQ7';
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
"emitDeclarationOnly": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"forceConsistentCasingInFileNames": true,
"types": [
"node",
"vite/client"
]
},
"include": [
"src/**/*.ts"
],
"references": []
}
@@ -0,0 +1,37 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/analytics/google-analytics',
plugins: [dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@storyteller/google-analytics',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: []
},
},
}));
@@ -8,6 +8,7 @@ import {
} from "@storyteller/tauri-api";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import React from "react";
import { gtagEvent } from "@storyteller/google-analytics";
interface LightboxModalProps {
isOpen: boolean;
@@ -131,6 +132,7 @@ export function LightboxModal({
<div className="mt-15 mb-15 flex justify-end gap-2">
<Button
onClick={async (e) => {
gtagEvent("image_to_3d_clicked");
//let _result = await FalHunyuanImageTo3d({
// image_media_token: mediaId,
// //base64_image: downloadUrl,
@@ -152,6 +154,7 @@ export function LightboxModal({
<Button
onClick={async (e) => {
e.stopPropagation();
gtagEvent("add_to_scene_clicked");
await onAddToSceneClicked(downloadUrl, mediaId);
onClose(); // close the lightbox
onCloseGallery(); // close the gallery
@@ -167,6 +170,7 @@ export function LightboxModal({
icon={faDownToLine}
onClick={async (e) => {
e.stopPropagation();
gtagEvent("download_clicked");
await onDownloadClicked(downloadUrl, mediaClass);
}}
>
@@ -176,7 +180,10 @@ export function LightboxModal({
<a
href={downloadUrl}
download
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
gtagEvent("download_clicked");
}}
className="no-underline"
>
<Button icon={faDownToLine}>Download</Button>

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