Compare commits

...
Author SHA1 Message Date
Michael Chung e4bbafeae6 Used Aspect Ratio to fix.
Prompt box handles both web and desktop.
2025-04-21 16:58:02 -04:00
bflatastic e39a7f0360 bug fix: asset modal flickers when items are drag and dropped too fast 2025-04-22 01:47:03 +07:00
bflatastic 5ea90154b1 bug fix: selecting object in scene crashes (Headless UI Transition component bug) 2025-04-22 01:29:51 +07:00
bflatastic f627da6d8f updated readme: ui component import 2025-04-22 01:19:30 +07:00
ArEnScandMichael Chung 682f11c79e Tauri and login for open ai (#540)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-21 14:15:14 -04:00
BFlat 0bdbb5214b settings modal (#539)
new shared components:
- button
- close button
- input
- label
- modal
- select
- settings modal
2025-04-22 01:00:58 +07:00
Brandon Thomas 94f2d2ec68 fix tauri build configs 2025-04-21 13:13:23 -04:00
Michael Chung 384b841528 Add a scale factor to the capture canvas
just use a hack to get it released
2025-04-21 12:55:02 -04:00
Michael Chung 62f97317ff Add component information into README 2025-04-21 09:23:30 -04:00
BFlat 8210a9f063 update headless ui version (#538) 2025-04-21 14:36:55 +07:00
BFlat b4d603098d working upload 3d asset modal (#537)
![image](https://github.com/user-attachments/assets/01df49cd-82a2-4f67-a02c-8adec34d1794)
2025-04-21 12:23:46 +07:00
Brandon Thomas ffd86f896c this configuration can launch the 2d app 2025-04-20 15:52:41 -04:00
Brandon Thomas 30b77afb23 fixup tauri scripts (wip) 2025-04-20 01:55:48 -04:00
Brandon Thomas 0d879af3a4 Refactor, clean up tauri builds 2025-04-20 01:24:25 -04:00
Brandon Thomas 4ac71a18c5 fix tauri build 2025-04-20 01:06:37 -04:00
Brandon Thomas 81e1fce6a0 cleanup and delete old scripts and configs (#536) 2025-04-20 00:52:42 -04:00
Brandon Thomas 855ddbdf2d Refactor editor3d pathing (#535) 2025-04-20 00:38:29 -04:00
Brandon Thomas 0a7d3028c9 ignore more vim temp files 2025-04-19 23:03:55 -04:00
Brandon Thomas c5aebaffb9 Ingore files from VSCode/Cursor 2025-04-19 23:01:20 -04:00
Brandon Thomasandbflatastic 931304887f Restore Bombay's toaster PR (#533)
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2025-04-19 22:56:22 -04:00
bflatastic 3dcec8bc85 editor3d: login and sign up page styling fixes 2025-04-20 08:44:43 +07:00
Brandon Thomas af30eea6f7 start removing remix (#531) 2025-04-19 14:40:58 -04:00
bflatastic df7db6b35d editor2d: link to 3d editor 2025-04-19 20:07:33 +07:00
bflatastic d228a43baf editor3d: link to go to editor2d 2025-04-19 19:49:29 +07:00
bflatastic ebcc52dd1a margin fix login and signup pages 2025-04-19 15:59:45 +07:00
bflatastic 726206fd0c editor2d: login and sign up pages restyled 2025-04-19 15:58:07 +07:00
BFlat ac896fd93a editor2d demo video on open (#532)
- demo video modal opens at first load
2025-04-19 14:30:19 +07:00
Brandon Thomas 85a4ff5c55 Revert "shared toaster component (#528)" (#530)
This reverts commit e3f15b0d93.
2025-04-18 18:42:50 -04:00
BFlat e3f15b0d93 shared toaster component (#528) 2025-04-18 14:57:51 +07:00
BFlat e31a2c0a91 fix nx project discoverability (#527) 2025-04-18 14:10:40 +07:00
Kasisnu ea85abdc62 image-gen: make unused bools in task response optional 2025-04-18 10:34:52 +05:30
Brandon Thomas 4f3c93635f don't log debug level in prod 2025-04-18 00:07:51 -04:00
ArEnScandMichael Chung 82b9b90ff9 Added the apis to the api folder it loads correctly now (#525)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-17 22:49:27 -04:00
bflatastic ad5ea26a81 commented out test component it was pushing the ui down 2025-04-18 08:59:53 +07:00
bflatastic 6121d118f0 reduced topbar right side gap 2025-04-18 08:54:40 +07:00
bflatastic 28ca7be96c activity update 2025-04-18 08:50:55 +07:00
Brandon Thomas a2a7f7d274 could also be a bearer token issue 2025-04-17 21:39:45 -04:00
Brandon Thomas e1255a55a0 specific error message for cookie expiration 2025-04-17 21:30:16 -04:00
9f629ee5e2 Nx working test (#524)
It works now.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Tanish Manku <tanish2k09.dev@gmail.com>
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-17 16:32:30 -04:00
BFlat 6a82fcb6e0 Revert "shared component starting with toaster" (#522)
Reverts storytold/storyteller-rust#521

it was causing an application error on build.
2025-04-17 17:21:05 +07:00
BFlat 35eac53b9d shared component starting with toaster (#521)
- share the toaster component between both editor2d and editor 3d
2025-04-17 17:14:02 +07:00
bflatastic c84c36d774 escape key to deselect objects and exit FK pose mode 2025-04-17 13:33:12 +07:00
bflatastic 3269b62e14 editor2d: artcraft logo 2025-04-17 13:11:55 +07:00
BFlat cd49fe0015 pose mode toggle button on top when character is selected (#520)
- pose mode toggle button to go in and out of pose mode when a character
is selected
- tooltip text size change
- button styling fixes
2025-04-17 13:04:53 +07:00
ArEnScandMichael Chung 326fa8c4a4 Adding API imports (#519)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-16 20:16:12 -04:00
Tanish Manku 51bdd4eff8 Create a really sick precision selector (#506)
Steps to activate:
1 - Select an object
2 - In the transform box, click and hold "Scale"
3 - Drag vertically to select the incremental precision
4 - Drag horizontally to apply increments of the selected precision

Issues:
- Releasing the mouse on top of the canvas will also trigger a "mouseup"
event on the canvas (since it listens to the window, not the canvas) and
deselect the object.
2025-04-16 15:01:44 -07:00
BFlat a64ca7d4ac editor3d: activity card improvements and share lightbox with gallery (#518)
**activity:**
- dropdown cards to show better download icon for completed items
- show spinner for in progress items
- activity button itself to show a spinner when at least one job is
running
- use the new lightbox component
- click the completed item to view image in lightbox

**gallery (library modal):**
- use the new lightbox component
- the new lightbox has a download button
- use cdn images
2025-04-17 02:48:25 +07:00
Kasisnu ec3ac242b0 image-gen: generate sentinel token in rust 2025-04-16 16:19:32 +05:30
Brandon Thomas fbe7fa3c77 don't leak logged in session jobs when logging out 2025-04-15 23:26:58 -04:00
Brandon Thomas 418d824c35 session jobs for anonymous users 2025-04-15 22:43:53 -04:00
Tanish Manku 2e6823834f Create the shapes centered on the canvas (#515)
- Also match all the shape sizes
2025-04-15 17:24:29 -07:00
1145 changed files with 12232 additions and 1924 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"files.exclude": {
"**/*~": true,
"frontend_old/**": true
}
}
Generated
+41 -41
View File
@@ -802,47 +802,6 @@ dependencies = [
"num",
]
[[package]]
name = "artcraft"
version = "0.1.0"
dependencies = [
"anyhow",
"assertor",
"base64 0.22.1",
"bytes 1.10.0",
"chrono",
"const_format",
"crockford",
"directories 6.0.0",
"dirs 6.0.0",
"errors",
"filesys",
"futures 0.3.29",
"image",
"libloading 0.8.6",
"log",
"memory_caching",
"once_cell",
"openai_sora_client",
"primitives",
"rand 0.9.0",
"reqwest 0.12.15",
"serde",
"serde_derive",
"serde_json",
"serde_yaml",
"serial_test",
"subprocess_common",
"tauri",
"tauri-build",
"tauri-plugin-log",
"tempdir",
"tempfile",
"tokio",
"tokio-util",
"workspace-build-acceleration",
]
[[package]]
name = "ascii"
version = "0.9.3"
@@ -12026,6 +11985,47 @@ dependencies = [
"windows 0.60.0",
]
[[package]]
name = "tauri-artcraft"
version = "0.1.0"
dependencies = [
"anyhow",
"assertor",
"base64 0.22.1",
"bytes 1.10.0",
"chrono",
"const_format",
"crockford",
"directories 6.0.0",
"dirs 6.0.0",
"errors",
"filesys",
"futures 0.3.29",
"image",
"libloading 0.8.6",
"log",
"memory_caching",
"once_cell",
"openai_sora_client",
"primitives",
"rand 0.9.0",
"reqwest 0.12.15",
"serde",
"serde_derive",
"serde_json",
"serde_yaml",
"serial_test",
"subprocess_common",
"tauri",
"tauri-build",
"tauri-plugin-log",
"tempdir",
"tempfile",
"tokio",
"tokio-util",
"workspace-build-acceleration",
]
[[package]]
name = "tauri-build"
version = "2.1.1"
+1 -1
View File
@@ -24,7 +24,7 @@ members = [
"crates/cli/production/migration-tool",
# Desktop apps
"crates/desktop/tauri-4o",
"crates/desktop/tauri-artcraft",
"crates/desktop/tauri-realtime",
# Shared libraries
@@ -1,7 +0,0 @@
#[test]
fn test_dynamic_library_loading() {
let name = libloading::library_filename("cublas");
assert_eq!(name, "libcublas.so");
}
@@ -1,5 +1,5 @@
[package]
name = "artcraft"
name = "tauri-artcraft"
version = "0.1.0"
description = "Artcraft"
authors = ["you"]
@@ -12,7 +12,7 @@ publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
name = "tauri_artcraft_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
tauri_artcraft_app_lib::run();
}
@@ -0,0 +1,5 @@
# For some reason, we can't pass json files to the tauri dev command
[build]
frontendDist = "../../../frontend/apps/editor2d/dist"
beforeDevCommand = "nx run editor2d:dev"
devUrl = "http://localhost:5741"
@@ -0,0 +1,5 @@
# For some reason, we can't pass json files to the tauri dev command
[build]
frontendDist = "../../../frontend/apps/editor3d/dist"
beforeDevCommand = "nx run editor3d:dev"
devUrl = "http://localhost:5173"
@@ -2,12 +2,12 @@
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.1.0",
"identifier": "com.unique.value.thing.why",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/realtime/dist",
"devUrl": "http://localhost:5174",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build2"
"frontendDist": "../../../frontend/apps/editor2d/dist",
"devUrl": "http://localhost:5741",
"beforeDevCommand": "nx run editor2d:dev",
"beforeBuildCommand": "nx run editor2d:build"
},
"app": {
"windows": [
@@ -83,16 +83,16 @@ pub struct Generation {
pub created_at: String,
pub deleted_at: Option<String>,
pub url: String,
pub seed: i64,
pub can_download: bool,
pub download_status: String,
pub is_favorite: bool,
pub is_liked: bool,
pub is_public: bool,
pub is_archived: bool,
pub is_featured: bool,
pub has_feedback: bool,
pub like_count: i32,
pub seed: Option<i64>,
pub can_download: Option<bool>,
pub download_status: Option<String>,
pub is_favorite: Option<bool>,
pub is_liked: Option<bool>,
pub is_public: Option<bool>,
pub is_archived: Option<bool>,
pub is_featured: Option<bool>,
pub has_feedback: Option<bool>,
pub like_count: Option<i32>,
pub cloudflare_metadata: Option<serde_json::Value>,
pub cf_thumbnail_url: Option<String>,
pub encodings: Encodings,
@@ -2,4 +2,5 @@ pub mod credentials;
pub mod image_gen;
pub mod job;
pub mod upload;
pub mod sentinel_refresh;
pub mod sentinel_refresh;
mod sora_error;
@@ -668,8 +668,4 @@ pub fn get_random_document_key() -> String {
pub fn get_random_window_key() -> String {
return WINDOW_KEY[rand::rng().random_range(0..WINDOW_KEY.len())].to_string();
}
pub fn get_random_cached_script() -> String {
return CACHED_SCRIPTS[rand::rng().random_range(0..CACHED_SCRIPTS.len())].to_string();
}
}
@@ -48,7 +48,6 @@ pub struct GenerateSentinelRefreshRequest {
fn generate_date() -> String {
let now_utc = Utc::now();
let today_utc = now_utc.date_naive();
let offset = FixedOffset::west_opt(5 * 60 * 60).unwrap();
let now_with_offset = Utc::now().with_timezone(&offset);
@@ -3,6 +3,8 @@ use serde_derive::{Deserialize, Serialize};
use thiserror::Error;
use idempotency::uuid::generate_random_uuid;
use super::request::GenerateSentinelRefreshRequest;
const SORA_IMAGE_GEN_URL: &str = "https://chatgpt.com/backend-api/sentinel/req";
/// This user agent is tied to the sentinel generation. If we need to change it, we may need to change sentinel generation too.
@@ -39,8 +41,9 @@ pub struct SentinelResponse {
}
pub async fn generate_token(problem: String) -> AnyhowResult<String> {
let request = SentinelRequest::new(problem);
pub async fn generate_token() -> AnyhowResult<String> {
let (_request, base64_request) = GenerateSentinelRefreshRequest::new().with_fourth_and_tenth();
let request = SentinelRequest::new(base64_request);
let client = reqwest::Client::new();
let response = client.post(SORA_IMAGE_GEN_URL)
.header("User-Agent", USER_AGENT)
@@ -57,12 +60,10 @@ pub async fn generate_token(problem: String) -> AnyhowResult<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::sentinel_refresh::generate::request::GenerateSentinelRefreshRequest;
#[tokio::test]
async fn test_generate_token() {
let (_request, base64_request) = GenerateSentinelRefreshRequest::new().with_fourth_and_tenth();
let token = generate_token(base64_request.to_string()).await.unwrap();
let token = generate_token().await.unwrap();
println!("{}", token);
}
}
@@ -0,0 +1,48 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum SoraError {
/// Unauthorized, cookie and/or bearer token expired. We'll need to ask for a refreshed login.
///
/// Example message, e.g. from the upload endpoint:
/// {
/// "error": {
/// "message": "Your authentication token has expired. Please try signing in again.",
/// "type": "invalid_request_error",
/// "param": null,
/// "code": "token_expired"
/// }
/// }
UnauthorizedCookieOrBearerExpired,
/// Another error occurred.
OtherBadStatus(anyhow::Error),
/// Reqwest Error
ReqwestError(reqwest::Error),
}
impl Error for SoraError {}
impl Display for SoraError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SoraError::UnauthorizedCookieOrBearerExpired => {
write!(f, "Unauthorized: cookie and/or bearer token expired")
}
SoraError::OtherBadStatus(err) => {
write!(f, "Other error: {}", err)
}
SoraError::ReqwestError(err) => {
write!(f, "Reqwest error: {}", err)
}
}
}
}
impl From<reqwest::Error> for SoraError {
fn from(err: reqwest::Error) -> SoraError {
SoraError::ReqwestError(err)
}
}
@@ -3,6 +3,7 @@ use errors::AnyhowResult;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde::Deserialize;
use crate::sora_error::SoraError;
const SORA_UPLOAD_MEDIA_URL: &str = "https://sora.com/backend/uploads";
@@ -25,6 +26,17 @@ Response:
"size_bytes": 118501,
"thumbnail_url": "https://videos.openai.com/vg-assets/assets%2Fclient_upload%2Fmedia%2Fuser-9wf6JFdgRSJLjvSJ53LNAbV8%2Fmedia_01jqt9vt20erx9zvryf3v1pecx.jpg?st=2025-04-02T02%3A39%3A01Z&se=2025-04-08T03%3A39%3A01Z&sks=b&skt=2025-04-02T02%3A39%3A01Z&ske=2025-04-08T03%3A39%3A01Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=3d249c53-07fa-4ba4-9b65-0bf8eb4ea46a&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=9aU9xKI0BkhRtrTb0RD05%2BT6prBnmXk1I7nV2nTsFbY%3D&az=oaivgprodscus"
}
Error response:
Failed to upload scene media to Sora: Upload failed with status 401 Unauthorized: {
"error": {
"message": "Your authentication token has expired. Please try signing in again.",
"type": "invalid_request_error",
"param": null,
"code": "token_expired"
}
}
*/
#[derive(Debug, Deserialize)]
@@ -50,7 +62,13 @@ pub (crate) struct SoraMediaUploadRequest<'a> {
pub credentials: &'a SoraCredentials,
}
pub (crate) async fn upload_media_http_request(file_bytes: Vec<u8>, filename: String, mime_type: &str, credentials: &SoraCredentials) -> AnyhowResult<SoraMediaUploadResponse> {
pub (crate) async fn upload_media_http_request(
file_bytes: Vec<u8>,
filename: String,
mime_type: &str,
credentials: &SoraCredentials,
) -> Result<SoraMediaUploadResponse, SoraError> {
// Create multipart form
let part = Part::bytes(file_bytes) // NB: Reqwest needs to own the bytes.
.file_name(filename) // NB: Reqwest needs to own the bytes
@@ -70,12 +88,29 @@ pub (crate) async fn upload_media_http_request(file_bytes: Vec<u8>, filename: St
// Check response status
if !response.status().is_success() {
let status = response.status();
let text = response.text().await?;
return Err(anyhow::anyhow!("Upload failed with status {}: {}", status, text));
let error = classify_error(response).await;
return Err(error);
}
// Parse response
let upload_response = response.json::<SoraMediaUploadResponse>().await?;
Ok(upload_response)
}
async fn classify_error(response: reqwest::Response) -> SoraError {
let status = response.status();
let message = match response.text().await {
Ok(text) => text,
Err(err) => return SoraError::ReqwestError(err),
};
let cookie_expired =
message.contains("Your authentication token has expired. Please try signing in again.")
|| message.contains("token_expired");
if cookie_expired {
SoraError::UnauthorizedCookieOrBearerExpired
} else {
SoraError::OtherBadStatus(anyhow::anyhow!("Upload failed with status {}: {}", status, message))
}
}
@@ -4,7 +4,6 @@
/// NB: `sqlx::query` is spammy and dumps all queries as "info"-level log lines.
/// NB: `hyper::proto::h1::io` is incredibly spammy and logs every chunk of bytes in very large files being downloaded.
pub const DEFAULT_RUST_LOG: &str = concat!(
"debug,",
"actix_web=info,",
"sqlx::query=warn,",
"hyper::proto::h1::io=warn,",
@@ -21,9 +21,14 @@ use crate::helpers::boolean_converters::i8_to_bool;
use crate::payloads::generic_inference_args::generic_inference_args::{GenericInferenceArgs, PolymorphicInferenceArgs};
use crate::queries::generic_inference::web::job_status::{GenericInferenceJobStatus, RequestDetails, ResultDetails, UserDetails};
pub enum SessionUser<'a> {
User(&'a UserToken),
Anonymous(&'a AnonymousVisitorTrackingToken),
}
pub struct ListSessionJobsForUserArgs<'a> {
// TODO(bt,2024-04-23): Support AnonymousVisitorToken
pub user_token: &'a UserToken,
pub user: SessionUser<'a>,
pub maybe_include_job_statuses: Option<&'a HashSet<JobStatusPlus>>,
pub maybe_exclude_job_statuses: Option<&'a HashSet<JobStatusPlus>>,
}
@@ -63,14 +68,29 @@ pub async fn list_session_jobs_from_connection(
// Furthermore, even though the join query's `created_at` lacks an index, sort
// by id will accomplish this quickly.
{
query_builder.push(r#"
INNER JOIN (
SELECT id
FROM generic_inference_jobs
WHERE maybe_creator_user_token =
"#);
query_builder.push_bind(args.user_token.to_string());
match args.user {
SessionUser::User(user_token) => {
query_builder.push(r#"
INNER JOIN (
SELECT id
FROM generic_inference_jobs
WHERE maybe_creator_user_token =
"#);
query_builder.push_bind(user_token.to_string());
}
SessionUser::Anonymous(avt_token) => {
query_builder.push(r#"
INNER JOIN (
SELECT id
FROM generic_inference_jobs
WHERE maybe_creator_anonymous_visitor_token =
"#);
query_builder.push_bind(avt_token.to_string());
query_builder.push(r#"
AND maybe_creator_user_token IS NULL
"#);
}
}
query_builder.push(r#"
AND created_at > DATE_SUB(NOW(), INTERVAL 36 HOUR)
@@ -16,9 +16,12 @@ use log::warn;
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::image_gen::SoraError;
use openai_sora_client::sentinel_refresh::generate::token::generate_token;
use openai_sora_client::sentinel_refresh::refresh_sentinel;
use serde::Deserialize;
use serde::Serialize;
use shared_service_components::sora_redis_credentials::set_sora_credential_field_in_redis::set_sora_credential_field_in_redis;
use shared_service_components::sora_redis_credentials::keys::RedisSoraCredentialSubkey;
use sqlx::MySqlPool;
use tempdir::TempDir;
use utoipa::ToSchema;
@@ -97,6 +100,7 @@ pub enum EnqueueImageGenRequestError {
NotAuthorized,
ServerError,
RateLimited,
TooManyConcurrentTasks,
}
impl ResponseError for EnqueueImageGenRequestError {
@@ -106,6 +110,7 @@ impl ResponseError for EnqueueImageGenRequestError {
EnqueueImageGenRequestError::NotAuthorized => StatusCode::UNAUTHORIZED,
EnqueueImageGenRequestError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
EnqueueImageGenRequestError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
EnqueueImageGenRequestError::TooManyConcurrentTasks => StatusCode::TOO_MANY_REQUESTS,
}
}
@@ -115,6 +120,7 @@ impl ResponseError for EnqueueImageGenRequestError {
EnqueueImageGenRequestError::NotAuthorized => "unauthorized".to_string(),
EnqueueImageGenRequestError::ServerError => "server error".to_string(),
EnqueueImageGenRequestError::RateLimited => "rate limited".to_string(),
EnqueueImageGenRequestError::TooManyConcurrentTasks => "too many concurrent tasks".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
@@ -288,12 +294,12 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
let prompt = create_prompt(&request);
let mut response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: prompt.clone(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_credentials
let mut response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: prompt.clone(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_credentials
}).await;
debug!("Sora image gen response: {:?}", response);
@@ -301,27 +307,54 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
if let Err(SoraError::SentinelBlock(msg)) = &response {
error!("Sora sentinel block, attempting refresh: {}", msg);
// Try to refresh sentinel
match refresh_sentinel().await {
Ok(new_sentinel) => {
// Update the credentials with new sentinel
match generate_token().await {
Ok(sentinel_token) => {
set_sora_credential_field_in_redis(
&mut redis,
RedisSoraCredentialSubkey::Sentinel,
&sentinel_token
).map_err(|e| {
EnqueueImageGenRequestError::ServerError
})?;
let updated_sora_credentials = get_sora_credentials_from_request(&http_request, &mut redis).map_err(|e| {
error!("sora credential error: {:?}", e);
EnqueueImageGenRequestError::ServerError
})?;
// Retry the request with new sentinel
response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: prompt.clone(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &updated_sora_credentials
response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: prompt.clone(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &updated_sora_credentials
}).await;
},
Err(e) => {
error!("Failed to refresh Sora sentinel: {:?}", e);
},
// Fallback to the old refresh stragegy relying on the external service
match refresh_sentinel().await {
Ok(new_sentinel) => {
// Update the credentials with new sentinel
let updated_sora_credentials = get_sora_credentials_from_request(&http_request, &mut redis).map_err(|e| {
error!("sora credential error: {:?}", e);
EnqueueImageGenRequestError::ServerError
})?;
// Retry the request with new sentinel
response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: prompt.clone(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &updated_sora_credentials
}).await;
},
Err(e) => {
error!("Failed to refresh Sora sentinel: {:?}", e);
}
}
}
}
}
@@ -330,27 +363,33 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
SoraError::TokenExpired(msg) => {
error!("Sora token expired, needs refresh: {}", msg);
// TODO: Implement token refresh logic here
EnqueueImageGenRequestError::ServerError
},
SoraError::SentinelBlock(msg) => {
error!("Sora sentinel block still occurring after refresh: {}", msg);
EnqueueImageGenRequestError::ServerError
},
SoraError::TooManyConcurrentTasks(msg) => {
error!("Sora too many concurrent tasks: {}", msg);
EnqueueImageGenRequestError::TooManyConcurrentTasks
},
_ => {
error!("Failed to call Sora image generation: {:?}", err);
EnqueueImageGenRequestError::ServerError
},
}
EnqueueImageGenRequestError::ServerError
})?;
debug!("Sora image gen response: {:?}", response);
// Store the actual inference args that will go to the database
let inference_args = SoraImageGenArgs {
prompt: Some(prompt),
scene_snapshot_media_token: Some(request.snapshot_media_token.clone()),
maybe_additional_media_file_tokens: Some(additional_images),
maybe_number_of_samples: Some(number_of_samples),
maybe_sora_media_upload_tokens: Some(sora_media_tokens),
maybe_sora_task_id: Some(response.task_id)
let inference_args = SoraImageGenArgs {
prompt: Some(prompt),
scene_snapshot_media_token: Some(request.snapshot_media_token.clone()),
maybe_additional_media_file_tokens: Some(additional_images),
maybe_number_of_samples: Some(number_of_samples),
maybe_sora_media_upload_tokens: Some(sora_media_tokens),
maybe_sora_task_id: Some(response.task_id)
};
// create the inference args here
@@ -370,8 +409,8 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
maybe_cover_image_media_file_token: None,
maybe_raw_inference_text: None,
maybe_max_duration_seconds: None,
maybe_inference_args: Some(GenericInferenceArgs {
inference_category: Some(InferenceCategoryAbbreviated::ImageGeneration),
maybe_inference_args: Some(GenericInferenceArgs {
inference_category: Some(InferenceCategoryAbbreviated::ImageGeneration),
args: Some(PolymorphicInferenceArgs::Sg(inference_args)),
}),
maybe_creator_user_token: maybe_user_token.as_ref(),
@@ -31,7 +31,7 @@ use enums::by_table::generic_inference_jobs::inference_category::InferenceCatego
use enums::common::job_status_plus::JobStatusPlus;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use mysql_queries::queries::generic_inference::web::list_session_jobs::{list_session_jobs_from_connection, ListSessionJobsForUserArgs};
use mysql_queries::queries::generic_inference::web::list_session_jobs::{list_session_jobs_from_connection, ListSessionJobsForUserArgs, SessionUser};
use primitives::numerics::i64_to_u64_zero_clamped::i64_to_u64_zero_clamped;
use redis_common::redis_keys::RedisKeys;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
@@ -238,11 +238,6 @@ pub async fn list_session_jobs_handler(
ListSessionJobsError::ServerError
})?;
let session = match maybe_user_session {
Some(session) => session,
None => return Err(ListSessionJobsError::NotAuthorized),
};
let include_states = query.include_states
.as_deref()
.map(|s| s.split(",")
@@ -255,8 +250,23 @@ pub async fn list_session_jobs_handler(
.filter_map(|status| JobStatusPlus::from_str(status).ok())
.collect::<HashSet<_>>());
let user;
match (maybe_user_session.as_ref(), maybe_avt_token.as_ref()) {
(Some(session), _) => {
user = SessionUser::User(&session.user_token_typed);
}
(None, Some(avt_token)) => {
user = SessionUser::Anonymous(avt_token);
}
(None, None) => {
// TODO(bt,2025-04-15): We should install an AVT cookie.
return Err(ListSessionJobsError::NotAuthorized);
}
}
let args = ListSessionJobsForUserArgs {
user_token: &session.user_token_typed,
user,
maybe_include_job_statuses: include_states.as_ref(),
maybe_exclude_job_statuses: exclude_states.as_ref(),
};
+30 -2
View File
@@ -2,8 +2,7 @@
This is an `nx` monorepo that can contain multiple apps and shared libraries.
All commands to run these projects are performed from _this_ directory.
All commands to run these projects are performed from _this_ directory.
## Install dependencies
@@ -27,4 +26,33 @@ Run dev server:
nx dev editor3d
```
```
call when starting on main or a new branch:
./clean_modules
The names for @frontend and @storyteller come from the package.json file in the libs folder.
import { Login } from "@frontend/login";
import { api } from "@storyteller/api";
For shared UI components, import from @storyteller/ui-[componentname].
import { Button } from "@storyteller/ui-button";
import { Modal } from "@storyteller/ui-modal";
building:
npx nx build editor3d
npx nx build editor2d
dev:
npx nx dev editor3d
npx nx dev editor2d
# component build
1.
npx nx g @nx/react:library libs/components/toaster --import-path=@storyteller/ui-toaster --bundler=vite
npm install
2.import it first in code
3.nx sync
4.nx build
```
+8 -2
View File
@@ -4,7 +4,14 @@
"version": "0.0.0",
"type": "module",
"nx": {
"name": "editor2d"
"name": "editor2d",
"targets": {
"build": {
"dependsOn": [
"^build"
]
}
}
},
"scripts": {
"dev": "echo 'editor2d' && vite",
@@ -21,7 +28,6 @@
"@fortawesome/pro-regular-svg-icons": "^6.7.2",
"@fortawesome/pro-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@headlessui/react": "^2.1.3",
"@preact/signals-react": "^2.2.0",
"@tauri-apps/api": "^2.2.0",
"@techstark/opencv-js": "^4.10.0-release.1",

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.
@@ -62,6 +62,7 @@ export class EditEngine {
this.editMode = EditModes.SELECT;
this.boardCanvasRef = boardCanvasRef;
this.stage = new Konva.Stage({
container: this.boardCanvasRef,
width: window.innerWidth,
@@ -238,13 +239,19 @@ export class EditEngine {
}
private onBoardCanvasResize() {
this.realTimeDrawEngine.updateCaptureCanvas(undefined, undefined);
this.realTimeDrawEngine.updateCaptureCanvas(0, 0);
console.log("onBoardCanvasResize Event called");
console.log("Board Canvas values below");
console.log(this.boardCanvasRef.offsetWidth);
console.log(this.boardCanvasRef.offsetHeight);
this.matteBox.updateSize({
boardCanvasSize: {
width: this.boardCanvasRef.offsetWidth,
height: this.boardCanvasRef.offsetHeight,
},
});
this.uiLayer.draw();
this.stage.width(this.boardCanvasRef.offsetWidth);
this.stage.height(this.boardCanvasRef.offsetHeight);
+14 -1
View File
@@ -510,7 +510,20 @@ export class Engine {
}
private onBoardCanvasResize() {
this.realTimeDrawEngine.updateCaptureCanvas(undefined, undefined);
console.log("onBoardCanvasResize Event called");
// Print window dimensions for debugging
console.log("Window dimensions:", {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
outerWidth: window.outerWidth,
outerHeight: window.outerHeight,
});
this.realTimeDrawEngine.updateCaptureCanvas(
window.innerWidth,
window.innerHeight,
);
this.matteBox.updateSize({
boardCanvasSize: {
width: this.boardCanvasRef.offsetWidth,
@@ -56,11 +56,8 @@ export class ShapeNode extends NetworkedNode {
switch (shapeType) {
case ShapeType.CIRCLE:
shape = new Konva.Ellipse({
x: size.width,
y: size.height,
radiusX: size.width,
radiusY: size.height,
...transform,
radiusX: size.width / 2,
radiusY: size.height / 2,
fill: color,
strokeScaleEnabled: false,
draggable: true,
@@ -80,8 +77,6 @@ export class ShapeNode extends NetworkedNode {
case ShapeType.TRIANGLE:
shape = new Konva.Shape({
x: 0,
y: 0,
width: size.width,
height: size.height,
...transform,
@@ -113,12 +108,20 @@ export class ShapeNode extends NetworkedNode {
this.size = size;
this.color = color;
// Add shape to group
// Center the shape
const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({
canvasOffset: canvasPosition,
componentSize: canvasSize,
componentSize: this.size,
maxSize: canvasSize,
});
// If the shape is an ellipse, the center is the origin so we need to offset it
if (shapeType === ShapeType.CIRCLE) {
centerPosition.x += size.width / 2;
centerPosition.y += size.height / 2;
}
// Add shape to group
// bring to the front
this.kNode.moveToTop();
this.kNode.setPosition(centerPosition);
@@ -516,36 +516,74 @@ export class RealTimeDrawEngine {
width: number | undefined,
height: number | undefined,
) {
console.log("updateCaptureCanvas");
if (!this.captureCanvas) {
return;
}
if (width) {
this.width = width;
}
if (height) {
this.height = height;
}
// Ensures that all the nodes stag in the same place should
// there be a window resize.
// recompute the position
// to ensure that the position of this stays
const scaleFactor = 0.9;
// Store old values for calculating delta
const oldPositionX = this.positionX;
const oldPositionY = this.positionY;
// recompute the position
const oldWidth = this.width;
const oldHeight = this.height;
// Original aspect ratio
const originalAspectRatio = oldWidth / oldHeight;
// Calculate new dimensions based on window size
let newWidth: number;
let newHeight: number;
if (width === undefined) {
newWidth = window.innerWidth * scaleFactor;
} else {
newWidth = width * scaleFactor;
}
if (height === undefined) {
newHeight = window.innerHeight * scaleFactor;
} else {
newHeight = height * scaleFactor;
}
// Preserve aspect ratio
const newAspectRatio = newWidth / newHeight;
if (newAspectRatio > originalAspectRatio) {
// Width is constraining factor
newWidth = newHeight * originalAspectRatio;
} else {
// Height is constraining factor
newHeight = newWidth / originalAspectRatio;
}
this.width = newWidth;
this.height = newHeight;
console.log("New dimensions:", this.width, this.height);
// Calculate new position to center the canvas in the window
this.positionX = (window.innerWidth - this.width) / 2;
this.positionY = (window.innerHeight - this.height) / 2;
console.log("New centered position:", this.positionX, this.positionY);
// Update the canvas position and size
this.captureCanvas.setPosition({
x: this.positionX / 2,
y: this.positionY / 2,
x: this.positionX,
y: this.positionY,
});
this.captureCanvas.size({ width: this.width, height: this.height });
// this is the change in positions
const deltaX = this.positionX - oldPositionX;
const deltaY = this.positionY - oldPositionY;
// Calculate scale factors to maintain proportions
const scaleX = this.width / oldWidth;
const scaleY = this.height / oldHeight;
// Use a uniform scale factor to preserve aspect ratio
const uniformScale = Math.min(scaleX, scaleY);
// Update all children except the capture canvas
var children = this.mediaLayerRef.getChildren();
for (let i = 0; i < children.length; i++) {
let node = children[i];
@@ -554,11 +592,27 @@ export class RealTimeDrawEngine {
if (node.name() === "CaptureCanvas") {
continue;
}
const pos = node.getPosition();
const oldScale = node.scale();
// Apply both position shift and scaling to maintain relative positioning
node.setPosition({
x: pos.x + deltaX,
y: pos.y + deltaY,
x: (pos.x - oldPositionX) * uniformScale + this.positionX,
y: (pos.y - oldPositionY) * uniformScale + this.positionY,
});
// Update scale proportionally if the node has a scale property
if (
oldScale &&
typeof oldScale.x === "number" &&
typeof oldScale.y === "number"
) {
node.scale({
x: oldScale.x * uniformScale,
y: oldScale.y * uniformScale,
});
}
}
this.mediaLayerRef.batchDraw();
+37 -22
View File
@@ -2,6 +2,38 @@
@tailwind components;
@tailwind utilities;
* {
scrollbar-width: thin;
scrollbar-color: #363636 #171717;
}
/* Works on Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 8px;
}
*::-webkit-scrollbar-track {
background: #171717;
}
*::-webkit-scrollbar-thumb {
background-color: #363636;
border-radius: 4px;
border: 2px solid #171717;
}
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
}
@layer base {
:root {
@apply select-none bg-ui-background font-sans text-white;
@@ -27,28 +59,7 @@
background-size: 36px 36px;
@apply bg-center;
}
h1 {
@apply text-3xl font-medium md:text-5xl;
}
h2 {
@apply text-2xl font-medium md:text-4xl;
}
h3 {
@apply text-xl font-medium md:text-3xl;
}
h4 {
@apply text-lg font-medium;
}
h5,
label {
@apply text-base;
}
h6 {
@apply text-sm;
}
p {
@apply text-sm font-normal;
}
a {
@apply font-medium text-primary-400 hover:text-primary-300;
}
@@ -125,6 +136,10 @@
@apply relative border border-ui-border shadow-lg backdrop-blur-xl transition-colors duration-300;
@apply bg-ui-controls/50 hover:bg-ui-controls/90 !important;
}
.glass-no-hover {
@apply hover:bg-ui-controls/50 !important;
}
}
@keyframes glow {
+10 -16
View File
@@ -1,12 +1,9 @@
import { useRef } from "react";
import { useNavigate, useSearchParams, Link } from "react-router-dom";
import { useSignalEffect } from "@preact/signals-react/runtime";
import { faKey, faUser } from "@fortawesome/pro-solid-svg-icons";
import { Button, Input, LoadingSpinner } from "~/components/ui";
import { authentication } from "~/signals";
import { paperWrapperStyles } from "~/components/styles";
import { twMerge } from "tailwind-merge";
export const Login = () => {
@@ -62,24 +59,21 @@ export const Login = () => {
});
return (
<div className="fixed flex h-full w-full flex-col items-center justify-center bg-[url('/svg/bg-dots.svg')] bg-[length:200px_200px] bg-center bg-repeat">
<div className="my-7 flex w-10/12 max-w-2xl items-center justify-center gap-4">
<div className="pegboard fixed flex h-full w-full flex-col items-center justify-center bg-center bg-repeat">
<div className="mb-7 flex w-10/12 max-w-2xl items-center justify-center gap-4">
<img
src="/brand/Storyteller-Logo-Black.png"
alt="Storyteller Logo"
className="h-11 select-none"
src="/brand/artcraft-logo.png"
alt="ArtCraft Logo"
className="h-9 select-none"
/>
</div>
<div className="w-full max-w-xl rounded-xl shadow-lg">
<div
className={twMerge(
paperWrapperStyles,
"relative mx-auto w-full rounded-none rounded-t-xl p-8 shadow-none",
"glass glass-no-hover relative mx-auto w-full rounded-none rounded-t-xl p-6 shadow-none",
)}
>
<h1 className="mb-9 text-center text-2xl font-bold">
Log in to Board
</h1>
<h1 className="mb-6 text-center text-2xl font-bold">Log in</h1>
<form
ref={formRef}
onSubmit={handleOnSumbit}
@@ -106,7 +100,7 @@ export const Login = () => {
required
/>
<Button className="mt-6 py-3">Continue</Button>
<Button className="mt-4 py-3">Continue</Button>
</form>
{shouldShowLoader && (
<div className="absolute left-0 top-0 flex h-full w-full items-center justify-center">
@@ -114,10 +108,10 @@ export const Login = () => {
</div>
)}
</div>
<div className="align-items flex w-full max-w-xl rounded-b-xl border border-t-0 bg-gray-100 px-8 py-4">
<div className="align-items flex w-full max-w-xl rounded-b-xl bg-ui-controls px-6 py-4">
<a
href="https://storyteller.ai/password-reset"
className="text-brand-primary hover:text-brand-primary-400 grow text-sm transition-all duration-150"
className="grow text-sm !text-primary-400 transition-all duration-150 hover:!text-primary-300"
>
Forgot your password?
</a>
@@ -1,6 +1,7 @@
import { useParams } from "react-router-dom";
import { withProtectionRoute } from "~/components/hoc";
import { useRenderCounter } from "~/hooks/useRenderCounter";
import { Resource, invoke } from '@tauri-apps/api/core';
// Components of the page
import { ToolbarUserProfile } from "~/components/features";
@@ -14,10 +15,22 @@ import { appMode } from "~/signals";
import { useSignals } from "@preact/signals-react/runtime";
import { GenerationRootComponent } from "~/GenerationRootComponent/GenerationRootComponent";
import { EditModeRootComponent } from "~/EditModeRootComponent/EditModeRootComponent";
import { useRef } from "react";
import { useRef, useState } from "react";
import { EngineType } from "~/KonvaApp";
import { GenerationEngine } from "~/KonvaApp/GenerationEngine";
import { GalleryRootComponent } from "~/GalleryRootComponent";
import { BaseDialog } from "~/components/ui/BaseDialog";
import { Button } from "~/components/ui/Button";
import {
faArrowRight,
faMagicWandSparkles,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export const isTauri = (): boolean => {
console.log(window.__TAURI_INTERNALS__);
return window.__TAURI_INTERNALS__;
};
export const Main = withProtectionRoute(() => {
// This is a hook that will log the number of times the component has rerendered
@@ -32,7 +45,8 @@ export const Main = withProtectionRoute(() => {
const realtimeEngineRef = useRef<EngineType | null>(null);
const generationEngineRef = useRef<GenerationEngine | null>(null);
const [firstTimeDialogOpen, setFirstTimeDialogOpen] = useState(true);
const [signinDialogOpen, setSigninDialogOpen] = useState(false);
const appModeValue = appMode.value;
let childView;
@@ -60,25 +74,87 @@ export const Main = withProtectionRoute(() => {
}
return (
<div className="fixed h-full w-full">
<div className="fixed grid h-full w-full grid-cols-12 grid-rows-12">
{childView}
</div>
<div className="absolute top-0 w-full p-3.5">
<div className="relative flex w-full">
<div className="absolute left-0">
<ToolbarTopLeft />
</div>
<div className="flex w-full justify-center">
<ToolbarTopCenter />
</div>
<div className="absolute right-0">
<ToolbarTopRight />
<>
<div className="fixed h-full w-full">
<div className="fixed grid h-full w-full grid-cols-12 grid-rows-12">
{childView}
</div>
<div className="absolute top-0 w-full p-3.5">
<div className="relative flex w-full">
<div className="absolute left-0">
<ToolbarTopLeft />
</div>
<div className="flex w-full justify-center">
<ToolbarTopCenter />
</div>
<div className="absolute right-0">
<ToolbarTopRight />
</div>
</div>
</div>
{appModeValue === "gallery" && (
<GalleryRootComponent className="z-1 fixed h-full w-full bg-black" />
)}
</div>
{appModeValue === "gallery" &&
(<GalleryRootComponent className="fixed h-full w-full z-1 bg-black" />)}
</div>
<BaseDialog
isOpen={firstTimeDialogOpen}
onClose={() => {
setFirstTimeDialogOpen(false);
}}
className="max-w-5xl"
>
<div className="flex flex-col items-center justify-center">
<div className="flex flex-col items-center gap-3">
<h1 className="text-3xl font-bold">
<FontAwesomeIcon
icon={faMagicWandSparkles}
className="mr-3 text-[24px]"
/>
Welcome to ArtCraft
</h1>
<div className="text-center">
<p className="text-lg font-medium text-white/80">
Your creative canvas for digital art and design
</p>
<p className="text-white/60">
Draw freely, create collages, and design with our intuitive
tool. Bring your ideas to life in seconds!
</p>
</div>
</div>
<div className="aspect-video w-full overflow-hidden rounded-md">
<video autoPlay muted loop controls={false}>
<source src="/videos/demo_video.mp4" type="video/mp4" />
</video>
</div>
<Button
className="font-semibold"
icon={faArrowRight}
iconFlip={true}
onClick={async () => {
if (isTauri()) {
await invoke('open_login_command');
setFirstTimeDialogOpen(false);
} else {
console.error('Tauri is not available in this environment');
}
}}
>
Signin to OpenAI to get Started
</Button>
{/* <Button
className="font-semibold"
icon={faArrowRight}
iconFlip={true}
onClick={() => {
setFirstTimeDialogOpen(false);
}}
>
Start creating now
</Button> */}
</div>
</BaseDialog>
</>
);
});
@@ -1,12 +1,9 @@
import { useRef } from "react";
import { useNavigate, useSearchParams, Link } from "react-router-dom";
import { useSignalEffect } from "@preact/signals-react/runtime";
import { faKey, faUser } from "@fortawesome/pro-solid-svg-icons";
import { Button, Input, LoadingSpinner } from "~/components/ui";
import { authentication } from "~/signals";
import { paperWrapperStyles } from "~/components/styles";
import { twMerge } from "tailwind-merge";
export const Signup = () => {
@@ -77,22 +74,21 @@ export const Signup = () => {
});
return (
<div className="fixed flex h-full w-full flex-col items-center justify-center bg-[url('/svg/bg-dots.svg')] bg-[length:200px_200px] bg-center bg-repeat">
<div className="my-7 flex w-10/12 max-w-2xl items-center justify-center gap-4">
<div className="pegboard fixed flex h-full w-full flex-col items-center justify-center bg-center bg-repeat">
<div className="mb-7 flex w-10/12 max-w-2xl items-center justify-center gap-4">
<img
src="/brand/Storyteller-Logo-Black.png"
alt="Storyteller Logo"
className="h-11 select-none"
src="/brand/artcraft-logo.png"
alt="ArtCraft Logo"
className="h-9 select-none"
/>
</div>
<div className="w-full max-w-xl rounded-xl shadow-lg">
<div
className={twMerge(
paperWrapperStyles,
"relative mx-auto w-full rounded-none rounded-t-xl p-8 shadow-none",
"glass glass-no-hover relative mx-auto w-full rounded-none rounded-t-xl p-6 shadow-none",
)}
>
<h1 className="mb-9 text-center text-2xl font-bold">
<h1 className="mb-6 text-center text-2xl font-bold">
Create your Account
</h1>
<form
@@ -139,7 +135,7 @@ export const Signup = () => {
required
/>
<Button className="mt-6 py-3">Create Account</Button>
<Button className="mt-4 py-3">Create Account</Button>
</form>
{shouldShowLoader && (
<div className="absolute left-0 top-0 flex h-full w-full items-center justify-center">
@@ -147,7 +143,7 @@ export const Signup = () => {
</div>
)}
</div>
<div className="align-items flex w-full max-w-xl rounded-b-xl border border-t-0 bg-gray-100 px-8 py-4">
<div className="align-items flex w-full max-w-xl rounded-b-xl bg-ui-controls px-6 py-4">
<div className="flex w-full justify-end gap-1 text-sm">
<p className="opacity-75">Already have an account?</p>
<Link to="/login">Log In</Link>
@@ -177,7 +177,100 @@ export const PromptBox = () => {
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value);
};
const handleTauriEnqueue = async () => {
const api = new Api();
let image = getCanvasRenderBitmap();
if (image === undefined) {
return;
}
const base64Bitmap = await EncodeImageBitmapToBase64(image);
const byteString = atob(base64Bitmap);
const mimeString = "image/png";
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const uuid = crypto.randomUUID(); // Generate a new UUID
const file = new File([ab], `${uuid}.png`, { type: mimeString });
const snapshotMediaToken = await api.uploadSceneSnapshot({
screenshot: file,
});
if (snapshotMediaToken.data === undefined) {
toast.error("Error: Unable to upload scene snapshot Please try again.");
return;
}
console.log("useSystemPrompt", useSystemPrompt);
console.log("Snapshot media token:", snapshotMediaToken.data);
const generateResponse = await invoke("sora_image_generation_command", {
snapshot_media_token: snapshotMediaToken.data,
disable_system_prompt: !useSystemPrompt,
prompt: prompt,
maybe_additional_images: referenceImages.map((image) => image.mediaToken),
maybe_number_of_samples: 1,
});
toast.success("Please wait while we process your image.");
};
const handleWebEnqueue = async () => {
const api = new Api();
let image = getCanvasRenderBitmap();
if (image === undefined) {
toast.error(
"Error: Unable to generate image. Please check the input and try again.",
);
return;
}
const base64Bitmap = await EncodeImageBitmapToBase64(image);
const byteString = atob(base64Bitmap);
const mimeString = "image/png";
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const uuid = crypto.randomUUID(); // Generate a new UUID
const file = new File([ab], `${uuid}.png`, { type: mimeString });
const snapshotMediaToken = await api.uploadSceneSnapshot({
screenshot: file,
});
if (snapshotMediaToken.data === undefined) {
toast.error("Error: Unable to upload scene snapshot Please try again.");
return;
}
console.log("useSystemPrompt", useSystemPrompt);
console.log("Snapshot media token:", snapshotMediaToken.data);
const response = await api.enqueueImageGeneration({
disableSystemPrompt: !useSystemPrompt,
prompt: prompt,
snapshotMediaToken: snapshotMediaToken.data,
additionalImages: referenceImages.map((image) => image.mediaToken),
});
if (response.success === true) {
toast.success("Please wait while we process your image.");
if (response.data) {
addJobToken(response.data);
}
return;
} else {
toast.error("Failed to enqueue image generation. Please try again.");
}
};
const handleEnqueue = async () => {
if (!prompt.trim()) return;
@@ -191,69 +284,10 @@ export const PromptBox = () => {
referenceImages,
);
if (isDesktopApp) {
let image = getCanvasRenderBitmap();
if (image === undefined) {
return;
}
const base64Bitmap = await EncodeImageBitmapToBase64(image);
const generateResponse = await invoke("image_generation_command", {
image: base64Bitmap,
prompt: prompt,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!isDesktopApp) {
const api = new Api();
let image = getCanvasRenderBitmap();
if (image === undefined) {
toast.error(
"Error: Unable to generate image. Please check the input and try again.",
);
return;
}
const base64Bitmap = await EncodeImageBitmapToBase64(image);
const byteString = atob(base64Bitmap);
const mimeString = "image/png";
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const uuid = crypto.randomUUID(); // Generate a new UUID
const file = new File([ab], `${uuid}.png`, { type: mimeString });
const snapshotMediaToken = await api.uploadSceneSnapshot({
screenshot: file,
});
if (snapshotMediaToken.data === undefined) {
toast.error(
"Error: Unable to upload scene snapshot Please try again.",
);
return;
}
console.log("useSystemPrompt", useSystemPrompt);
console.log("Snapshot media token:", snapshotMediaToken.data);
const response = await api.enqueueImageGeneration({
disableSystemPrompt: !useSystemPrompt,
prompt: prompt,
snapshotMediaToken: snapshotMediaToken.data,
additionalImages: referenceImages.map((image) => image.mediaToken),
});
if (response.success === true) {
toast.success("Please wait while we process your image.");
if (response.data) {
addJobToken(response.data);
}
return;
} else {
toast.error("Failed to enqueue image generation. Please try again.");
}
if (isDesktopApp == true) {
handleTauriEnqueue();
} else {
handleWebEnqueue();
}
} catch (error) {
console.error("Error during image generation:", error);
@@ -7,6 +7,8 @@ import {
GenerationLoadingState,
generationSignal,
} from "~/signals";
import { faChevronRight } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "~/components/ui/Button";
// import { paperWrapperStyles } from "~/components/styles";
// import { faPlus, faQuestion } from "@fortawesome/pro-solid-svg-icons";
@@ -32,6 +34,10 @@ export const ToolbarTopLeft = () => {
const generationState = generationSignal.value;
const handleClick = () => {
window.location.href = "https://storyteller-3d.netlify.app/";
};
console.log(appMode.value);
return (
@@ -41,19 +47,30 @@ export const ToolbarTopLeft = () => {
)}
>
<img
src="/brand/mira-logo.png"
src="/brand/artcraft-logo.png"
alt="logo"
className="h-[30px] select-none pb-1"
className="h-[28px] select-none"
/>
<TabSelector
<Button
variant="secondary"
icon={faChevronRight}
iconClassName="text-xs"
iconFlip={true}
onClick={handleClick}
className="bg-transparent p-0 text-sm text-white/80 hover:bg-transparent hover:text-white hover:underline hover:underline-offset-2"
>
Go to 3D Stage Editor
</Button>
{/* <TabSelector
tabs={tabs}
activeTab={activeTab.value}
onTabChange={handleTabChange}
disabled={
generationState.loadingState === GenerationLoadingState.GENERATING
}
/>
/> */}
{/* <ToolbarButton icon={faPlus}>
<span className="font-semibold">New Board</span>
@@ -1,16 +1,32 @@
import { faGear } from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { Login } from "~/app/pages/login";
import { Activity } from "~/components/ui/Activity/Activity";
// import { paperWrapperStyles } from "~/components/styles";
// import { faPlus, faQuestion } from "@fortawesome/pro-solid-svg-icons";
// import { ToolbarButton } from "~/components/features/ToolbarButton";
import { Button } from "@storyteller/ui-button";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { useState } from "react";
export const ToolbarTopRight = () => {
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
return (
<div
className={twMerge("relative z-50 flex h-fit w-fit items-center gap-2")}
>
<Activity />
</div>
<>
<div
className={twMerge("relative z-50 flex h-fit w-fit items-center gap-2")}
>
<Button
icon={faGear}
className="h-[42px] w-[42px] bg-[#5F5F68]/60 backdrop-blur-lg hover:bg-[#5F5F68]/90"
onClick={() => setIsSettingsModalOpen(true)}
/>
<Activity />
</div>
<SettingsModal
isOpen={isSettingsModalOpen}
onClose={() => setIsSettingsModalOpen(false)}
/>
</>
);
};
+35 -3
View File
@@ -4,7 +4,11 @@ import defaultTheme from "tailwindcss/defaultTheme";
import { storytellerColors } from "./tailwind.stcolors";
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"../../libs/components/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
fontFamily: {
@@ -44,14 +48,42 @@ export default {
info: colors.sky[500],
// brand colors
primary: storytellerColors.azureRadiance,
secondary: storytellerColors.gunpowder,
primary: {
DEFAULT: "#2d81ff",
50: "#eef6ff",
100: "#d9ebff",
200: "#bcdcff",
300: "#8ec7ff",
400: "#59a7ff",
500: "#2d81ff",
600: "#1b63f5",
700: "#144ee1",
800: "#173fb6",
900: "#19398f",
950: "#142457",
},
secondary: {
DEFAULT: "#3E3E41",
50: "#f6f6f6",
100: "#e7e7e7",
200: "#d1d1d1",
300: "#b0b0b0",
400: "#888888",
500: "#6d6d6d",
600: "#5d5d5d",
700: "#4f4f4f",
800: "#454545",
900: "#3d3d3d",
950: "#3E3E41",
},
tertiary: storytellerColors.aquamarineBlue,
ui: {
background: "#101014",
panel: "#3E3E41",
border: "#FFFFFF0D",
"panel-border": "#3F3F3F",
controls: "#3E3E41",
},
},
+11 -1
View File
@@ -24,5 +24,15 @@
"@ApiManager/*": ["./src/Classes/ApiManager/*"]
}
},
"include": ["src"]
"include": [
"src"
],
"references": [
{
"path": "../../libs/components/settings-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/button/tsconfig.lib.json"
}
]
}
+12 -2
View File
@@ -1,7 +1,17 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{
"path": "../../libs/components/settings-modal"
},
{
"path": "../../libs/components/button"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}
-1
View File
@@ -1 +0,0 @@
_
-4
View File
@@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
-23
View File
@@ -1,23 +0,0 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../**/*.mdx", "../**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
],
framework: {
name: "@storybook/react-vite",
options: {
builder: {
viteConfigPath: "sb-vite.config.ts",
},
},
},
docs: {
autodocs: "tag",
},
};
export default config;
@@ -1,34 +0,0 @@
const { createProxyMiddleware } = require("http-proxy-middleware");
module.exports = function expressMiddleware(router) {
router.use(
"/v1",
createProxyMiddleware({
target: "https://api.storyteller.ai/v1",
changeOrigin: true,
on: {
proxyReq: async (req, res) => {
req.setHeader("Access-Control-Allow-Origin", "*");
req.setHeader("Origin", "http://localhost:5173");
req.setHeader("Referer", "http://localhost:5173");
},
},
logger: console,
}),
);
router.use(
"/cdn-cgi",
createProxyMiddleware({
target: "https://cdn.storyteller.ai/cdn-cgi",
changeOrigin: true,
on: {
proxyReq: async (req, res) => {
req.setHeader("Access-Control-Allow-Origin", "*");
req.setHeader("Origin", "http://localhost:5173");
req.setHeader("Referer", "http://localhost:5173");
},
},
logger: console,
}),
);
};
@@ -1,7 +0,0 @@
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
Roboto, "Helvetica Neue", Arial, sans-serif;
color: white;
}
</style>
@@ -1,6 +0,0 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
rel="stylesheet"
/>
@@ -1,23 +0,0 @@
import type { Preview } from "@storybook/react";
import "tailwindcss/tailwind.css";
import "../app/styles/tailwind.css";
import "../app/styles/normalize.css?url";
import "../app//styles/tailwind.css?url";
import "../app//styles/base.css?url";
import { MemoryRouter } from "react-router";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
decorators: [
(story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>,
],
};
export default preview;
@@ -1,61 +0,0 @@
import { Transition } from '@headlessui/react';
import { twMerge } from 'tailwind-merge';
import { H4 } from '.';
interface LoadingDotsProps {
className?: string;
isShowing?: boolean;
};
interface LoadingDotsInnerProps {
className?: string;
isShowing?: boolean;
type?: 'typing'|'bricks';
message?: string;
};
export const LoadingDotsTyping = (props: LoadingDotsProps)=>{
return (
<LoadingDots {...props} />
)
}
export const LoadingDotsBricks = (props: LoadingDotsProps)=>{
return (
<LoadingDots {...props} type="bricks"/>
)
}
export function LoadingDots({
className,
isShowing=true,
type = 'typing',
message,
}: LoadingDotsInnerProps){
const classNames = twMerge("w-full h-full flex flex-col justify-center items-center bg-ui-background gap-6", className);
return(
<Transition
className={classNames}
show={isShowing}
enter="transition-opacity duration-150"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-1000"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
{ type==='typing' &&
<div className="dot-typing"></div>
}
{ type==='bricks' &&
<div className="dot-bricks"></div>
}
{
message &&
<H4>{message}</H4>
}
</Transition>
);
};
@@ -1,76 +0,0 @@
import { Fragment } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { faCheck, faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { MediaFileAnimationType } from "~/enums";
export type SelectValue = string | number;
export type SelectOption = { label: string; value: SelectValue };
interface ListDropdownProps {
options: SelectOption[];
onChange: (val: MediaFileAnimationType) => void;
placeholder?: string;
value?: SelectValue;
}
export const Select = ({
onChange,
options,
placeholder,
value,
}: ListDropdownProps) => {
const selectedOption = options.find(
(option: SelectOption) => option.value === value,
) || { label: placeholder || "", value: "" };
return (
<Listbox value={value} onChange={onChange}>
<div className="relative mt-1">
<Listbox.Button className="relative h-10 w-full cursor-pointer rounded-md bg-brand-secondary py-2 pl-3 pr-10 text-left outline-none outline-offset-0 transition-all duration-150 ease-in-out focus:outline-brand-primary sm:text-sm">
<span className={`block truncate${value ? "" : " opacity-50"}`}>
{selectedOption.label}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<FontAwesomeIcon icon={faChevronDown} aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-brand-secondary py-1 text-base shadow-lg focus:outline-none sm:text-sm">
{options.map((option, itemIdx) => (
<Listbox.Option
key={itemIdx}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 text-white ${
active ? "text-white" : "text-gray-400"
}`
}
value={option.value}
>
{({ selected }) => (
<>
<span
className={`block truncate ${
selected ? "font-medium" : "font-normal"
}`}
>
{option.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
<FontAwesomeIcon icon={faCheck} aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
);
};
@@ -1,114 +0,0 @@
import { useEffect, useState } from "react";
import { TransitionDialogue, LoadingDots } from "~/components";
import { UploadAssetError } from "../UploadModal/UploadAssetError";
import { UploadSuccess } from "../UploadModal/UploadSuccess";
import { UploadFiles3D } from "./UploadFiles3D";
import { FilterEngineCategories, UploaderStates } from "~/enums";
import { initialUploaderState, UploaderState } from "~/models";
interface Props {
onClose: () => void;
onSuccess: () => void;
isOpen: boolean;
title: string;
fileTypes: string[];
engineCategory: FilterEngineCategories;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
hasThumbnailUpload?: boolean;
};
}
export function UploadModal3D({
isOpen,
onClose,
onSuccess,
title,
fileTypes,
engineCategory,
options,
}: Props) {
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
const updateUploaderState = (newState: UploaderState) => {
setUploaderState(newState);
};
const resetModalState = () => {
setUploaderState(initialUploaderState);
};
useEffect(() => {
if (isOpen) {
resetModalState();
}
}, [isOpen]);
const UploaderModalContent = () => {
switch (uploaderState.status) {
case UploaderStates.ready:
return (
<UploadFiles3D
title={title}
engineCategory={engineCategory}
fileTypes={fileTypes}
options={options}
onClose={onClose}
onUploadProgress={updateUploaderState}
/>
);
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={title}
onOk={() => {
onSuccess();
onClose();
}}
/>
);
case UploaderStates.assetError:
return (
<UploadAssetError
onCancel={onClose}
onRetry={() => {
resetModalState();
}}
type={FilterEngineCategories.OBJECT}
errorMessage={uploaderState.errorMessage}
/>
);
case UploaderStates.coverCreateError:
case UploaderStates.coverSetError:
return (
<UploadAssetError
onCancel={onClose}
onRetry={() => {
resetModalState();
}}
type={"Thumbnail"}
errorMessage={uploaderState.errorMessage}
/>
);
}
};
return (
<TransitionDialogue isOpen={isOpen} onClose={onClose} title={title}>
<UploaderModalContent />
</TransitionDialogue>
);
}
@@ -1,26 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import { AUTH_STATUS } from "~/enums";
import { authentication } from "~/signals";
import { ButtonLink } from "~/components";
import ProfileDropdown from "./ProfileDropdown";
export const AuthButtons = () => {
useSignals();
const { status } = authentication;
if (status.value === AUTH_STATUS.LOGGED_IN) {
return <ProfileDropdown />;
} else {
return (
<>
<ButtonLink variant="secondary" to="/login">
Login
</ButtonLink>
<ButtonLink to="/signup">Sign Up</ButtonLink>
</>
);
}
};
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ArtCraft 3D</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<!-- <link rel="stylesheet" href="/src/styles.css" /> -->
</head>
<body className="overflow-hidden bg-ui-background">
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Login | ArtCraft 3D</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<!-- <link rel="stylesheet" href="/src/styles.css" /> -->
</head>
<body className="overflow-hidden bg-ui-background">
<div id="root"></div>
<script type="module" src="/src/login.tsx"></script>
</body>
</html>
@@ -1,389 +0,0 @@
import { TransitionDialogue } from "~/components/reusable/TransitionDialogue";
import {
faPlus,
faSearch,
faChevronLeft,
faLayerGroup,
faUser,
faSun,
faCube,
faChevronRight,
faMountainCity,
faDog,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, CloseButton, Input, Tooltip } from "~/components";
import { TabSelector } from "~/components/reusable/TabSelector";
import { useState, useEffect, useMemo, useRef } from "react";
import { ItemElements } from "../SidePanelTabs/sharedComps/ItemElements";
import {
demoSkyboxItems,
demoShapeItems,
demoCharacterItems,
assetModalVisibleDuringDrag,
reopenAfterDragSignal,
assetModalVisible,
} from "../../signals";
import { MediaItem } from "../../models";
import { useUserObjects, useFeaturedObjects } from "../SidePanelTabs/hooks";
import { FilterEngineCategories } from "~/enums";
import { twMerge } from "tailwind-merge";
import { useSignals } from "@preact/signals-react/runtime";
interface AssetModalProps {
isOpen: boolean;
onClose: () => void;
}
type AssetTab = {
id: string;
label: string;
icon: typeof faLayerGroup;
engineCategory?: FilterEngineCategories;
items: MediaItem[];
};
const AllTabSection = ({
label,
items,
onViewAll,
}: {
label: string;
items: MediaItem[];
onViewAll: () => void;
}) => (
<div className="mb-0">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-md ml-2 font-semibold opacity-90">{label}</h3>
<Button
variant="secondary"
className="mr-3 flex items-center gap-1 px-2 py-1 text-xs"
onClick={onViewAll}
>
View all
<FontAwesomeIcon icon={faChevronRight} className="text-xs opacity-70" />
</Button>
</div>
<div className="h-[170px]">
<ItemElements
items={items.slice(0, 4)}
busy={false}
debug={`all-tab-section-${label}`}
/>
</div>
</div>
);
export const AssetModal = () => {
useSignals();
const [activeLibraryTab, setActiveLibraryTab] = useState("library");
const [activeAssetTab, setActiveAssetTab] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [reopenAfterAdd, setReopenAfterAdd] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const handleClose = () => {
assetModalVisible.value = false;
};
// Update the signal when the preference changes
useEffect(() => {
reopenAfterDragSignal.value = reopenAfterAdd;
}, [reopenAfterAdd]);
useEffect(() => {
if (assetModalVisible.value) {
// Small delay to ensure the modal is fully mounted
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [assetModalVisible.value]);
// Fetch objects for different categories
const { userObjects: userCharacters } = useUserObjects({
filterEngineCategories: [FilterEngineCategories.CHARACTER],
defaultErrorMessage: "Error fetching user characters",
});
const { userObjects: userObjects } = useUserObjects({
filterEngineCategories: [FilterEngineCategories.OBJECT],
defaultErrorMessage: "Error fetching user objects",
});
const { featuredObjects: featuredCharacters } = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.CHARACTER],
defaultErrorMessage: "Error fetching featured characters",
});
const { featuredObjects: featuredObjects } = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.OBJECT],
defaultErrorMessage: "Error fetching featured objects",
});
const { userObjects: userSets } = useUserObjects({
filterEngineCategories: [FilterEngineCategories.LOCATION],
defaultErrorMessage: "Error fetching user sets",
});
const { featuredObjects: featuredSets } = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.LOCATION],
defaultErrorMessage: "Error fetching featured sets",
});
const { userObjects: userCreatures } = useUserObjects({
filterEngineCategories: [FilterEngineCategories.CREATURE],
defaultErrorMessage: "Error fetching user creatures",
});
const { featuredObjects: featuredCreatures } = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.CREATURE],
defaultErrorMessage: "Error fetching featured creatures",
});
const libraryTabs = [
{ id: "library", label: "Library" },
{ id: "mine", label: "Mine" },
];
// eslint-disable-next-line react-hooks/exhaustive-deps
const assetTabs: AssetTab[] = [
{ id: "all", label: "All", icon: faLayerGroup, items: [] },
{
id: "character",
label: "Character",
icon: faUser,
engineCategory: FilterEngineCategories.CHARACTER,
items:
activeLibraryTab === "library"
? [...demoCharacterItems.value, ...(featuredCharacters ?? [])]
: (userCharacters ?? []),
},
{
id: "objects",
label: "Objects",
icon: faCube,
engineCategory: FilterEngineCategories.OBJECT,
items:
activeLibraryTab === "library"
? [...demoShapeItems.value, ...(featuredObjects ?? [])]
: (userObjects ?? []),
},
{
id: "sets",
label: "Sets",
icon: faMountainCity,
engineCategory: FilterEngineCategories.LOCATION,
items:
activeLibraryTab === "library"
? (featuredSets ?? [])
: (userSets ?? []),
},
{
id: "creatures",
label: "Creatures",
icon: faDog,
engineCategory: FilterEngineCategories.CREATURE,
items:
activeLibraryTab === "library"
? (featuredCreatures ?? [])
: (userCreatures ?? []),
},
{
id: "skybox",
label: "Skybox",
icon: faSun,
items: demoSkyboxItems.value,
},
];
// Combine all items for the "All" tab
const allItems = useMemo(() => {
return assetTabs
.filter((tab) => tab.id !== "all")
.flatMap((tab) => tab.items);
}, [assetTabs]);
// Update the items array for the "All" tab
assetTabs[0].items = allItems;
const currentTab =
assetTabs.find((tab) => tab.id === activeAssetTab) || assetTabs[0];
// Filter items based on search term
const displayedItems = useMemo(() => {
if (!searchTerm) {
return currentTab.items;
}
const searchLower = searchTerm.toLowerCase();
return currentTab.items.filter(
(item) =>
item.name?.toLowerCase().includes(searchLower) ||
item.description?.toLowerCase().includes(searchLower),
);
}, [currentTab.items, searchTerm]);
// Switch to "All" tab when searching
useEffect(() => {
if (searchTerm && activeAssetTab !== "all") {
setActiveAssetTab("all");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm]);
// Clear search term when tab changes
useEffect(() => {
setSearchTerm("");
}, [activeAssetTab]);
const renderContent = () => {
if (activeAssetTab === "all" && !searchTerm) {
return (
<div className="h-full space-y-2 overflow-y-auto">
{assetTabs.slice(1).map((tab) => (
<AllTabSection
key={tab.id}
label={tab.label}
items={tab.items}
onViewAll={() => setActiveAssetTab(tab.id)}
/>
))}
</div>
);
}
return (
<ItemElements
items={displayedItems}
busy={false}
debug={`asset-modal-${currentTab.id}`}
/>
);
};
const handleAddAsset = () => {
if (reopenAfterAdd) {
// Small delay to allow the modal to close and reopen
setTimeout(() => {
handleClose();
}, 100);
} else {
handleClose();
}
};
const clearSearch = () => setSearchTerm("");
return (
<TransitionDialogue
isOpen={assetModalVisible.value && assetModalVisibleDuringDrag.value}
onClose={handleClose}
className="h-[640px] max-w-4xl"
childPadding={false}
>
<div className="grid h-full grid-cols-12 gap-3">
<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:dark: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</h2>
<Tooltip content="Upload model" position="top" delay={200}>
<Button
className="h-6 w-6 rounded-full border-none bg-transparent text-white/70 transition-colors hover:bg-transparent hover:text-white/100"
onClick={handleAddAsset}
>
<FontAwesomeIcon icon={faPlus} className="text-xl" />
</Button>
</Tooltip>
</div>
<hr className="my-2 w-full border-white/10" />
<div className="space-y-2">
{assetTabs.map((tab) => (
<Button
key={tab.id}
variant={activeAssetTab === tab.id ? "primary" : "secondary"}
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",
activeAssetTab === tab.id &&
"border-brand-primary bg-brand-primary/10 hover:bg-brand-primary/10",
)}
onClick={() => setActiveAssetTab(tab.id)}
>
<FontAwesomeIcon icon={tab.icon} className="mr-2 opacity-70" />
{tab.label}
</Button>
))}
</div>
<div className="mt-auto flex items-center gap-2 pt-3">
<input
type="checkbox"
id="reopen-after-add"
checked={reopenAfterAdd}
onChange={(e) => setReopenAfterAdd(e.target.checked)}
className="h-4 w-4 rounded-lg border-gray-300 bg-gray-700 text-brand-primary focus:ring-brand-primary"
/>
<label htmlFor="reopen-after-add" className="text-sm text-white/70">
Reopen after adding
</label>
</div>
</div>
<div className="col-span-9 p-3 pb-0 ps-0 pt-2">
<div className="flex h-full flex-col">
<div className="h-full">
<div className="flex items-center gap-4">
<TabSelector
tabs={libraryTabs}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
className="w-auto"
/>
<div className="relative grow">
<Input
ref={searchInputRef}
placeholder="Search"
className="grow"
inputClassName="pr-2.5"
icon={faSearch}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
iconClassName="text-white/60"
/>
{searchTerm && (
<CloseButton
onClick={clearSearch}
className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 bg-white/10 text-[10px] hover:bg-white/20"
/>
)}
</div>
<CloseButton onClick={handleClose} />
</div>
<div
className={twMerge(
"overflow-auto-y mt-4 h-[574px]",
activeAssetTab !== "all" && "h-[552px]",
)}
>
{activeAssetTab !== "all" && !searchTerm && (
<div className="mb-2 flex items-center font-semibold">
<Button
variant="secondary"
className="flex items-center gap-2 border-none bg-transparent px-3 py-1.5 text-sm text-white/70 hover:bg-transparent hover:text-white/100"
onClick={() => setActiveAssetTab("all")}
>
<FontAwesomeIcon
icon={faChevronLeft}
className="text-sm font-semibold opacity-70"
/>
</Button>
{currentTab.label}
</div>
)}
{renderContent()}
</div>
</div>
</div>
</div>
</div>
</TransitionDialogue>
);
};
@@ -1,230 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import { faBell, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverMenu } from "~/components/reusable/Popover/Popover";
import { CompletedCard } from "./CompletedCard";
import { InProgressCard } from "./InProgressCard";
import { H3, P, Tooltip } from "~/components";
import { activeImageGenerationJobs, toasts, userMovies } from "~/signals";
import { useEffect, useState } from "react";
import { JobsApi } from "~/Classes/ApiManager/JobsApi";
import { toast } from "sonner";
// TODO ensure we de-dupe all this extra code.
interface CompletedItem {
token: string;
maybe_title: string;
public_bucket_path: string;
updated_at: string;
}
interface ActiveJob {
job_token: string;
request: {
maybe_model_title: string;
};
status: {
status: string;
progress_percentage: number;
};
}
// {
// "job_token": "jinf_421nnr6rj8wy2sj0s11v9fns32s",
// "request": {
// "inference_category": "image_generation",
// "maybe_model_type": "image_gen_api",
// "maybe_model_token": null,
// "maybe_model_title": "Image Generation",
// "maybe_raw_inference_text": null,
// "maybe_style_name": null,
// "maybe_live_portrait_details": null,
// "maybe_lipsync_details": null
// },
// "status": {
// "status": "complete_success",
// "maybe_extra_status_description": null,
// "maybe_assigned_worker": "inference-job-4o-image-848c55d5dd-555kn",
// "maybe_assigned_cluster": "storyteller-eks-1",
// "maybe_first_started_at": "2025-04-15T04:41:01Z",
// "maybe_current_execution_duration_seconds": null,
// "attempt_count": 1,
// "requires_keepalive": false,
// "maybe_failure_category": null,
// "progress_percentage": 100
// },
// "maybe_result": {
// "entity_type": "media_file",
// "entity_token": "m_ytrjp1fcd5tb43ebcs3ewhq6y05tfb",
// "maybe_public_bucket_media_path": "/media/p/t/7/y/g/pt7yg2xfv4ynemssfv1mwb7yxe26e2nb/image_pt7yg2xfv4ynemssfv1mwb7yxe26e2nb.png",
// "media_links": {
// "cdn_url": "https://cdn-2.fakeyou.com/media/p/t/7/y/g/pt7yg2xfv4ynemssfv1mwb7yxe26e2nb/image_pt7yg2xfv4ynemssfv1mwb7yxe26e2nb.png",
// "maybe_thumbnail_template": "https://cdn-2.fakeyou.com/cdn-cgi/image/width={WIDTH},quality=95/media/p/t/7/y/g/pt7yg2xfv4ynemssfv1mwb7yxe26e2nb/image_pt7yg2xfv4ynemssfv1mwb7yxe26e2nb.png",
// "maybe_video_previews": null
// },
// "maybe_successfully_completed_at": "2025-04-15T04:42:14Z"
// },
// "created_at": "2025-04-15T04:41:01Z",
// "updated_at": "2025-04-15T04:42:14Z"
// }
export function Activity() {
useSignals();
const [completedItems, setCompletedItems] = useState<CompletedItem[]>([]);
const [loading, setLoading] = useState(true);
const [jobs, setJobs] = useState<ActiveJob[]>([]);
useEffect(() => {
const pollData = async () => {
const fetchJobs = async () => {
try {
const jobsApi = new JobsApi();
const jobsResponse = await jobsApi.ListRecentJobs();
if (jobsResponse.success && jobsResponse.data) {
// Process active jobs
const activeJobs = jobsResponse.data
.filter(job => {
const status = job.status?.status?.toLowerCase();
return status && status !== "complete_success" && status !== "failed";
})
.map(job => ({
job_token: job.job_token,
request: {
maybe_model_title: job.request?.maybe_model_title || "Image Generation"
},
status: {
status: job.status?.status?.toUpperCase() || "",
progress_percentage: job.status?.progress_percentage || 0
}
}));
// Process completed items
// Get completed items and filter out any that are already in our list
const successfulJobs = jobsResponse.data
.filter(job => job.status?.status?.toLowerCase() === "complete_success");
// Create a set of existing tokens for faster lookup
const existingTokens = new Set(completedItems.map(item => item.token));
// Filter out jobs we already have in our list
const newCompletedItems = successfulJobs
.filter(job => !existingTokens.has(job.job_token))
.map(job => ({
token: job.job_token,
maybe_title: job.request?.maybe_model_title || "Image Generation",
public_bucket_path: job.maybe_result?.maybe_public_bucket_media_path || "",
updated_at: job.updated_at || new Date().toISOString()
}));
// Show toast notification for newly completed items
if (newCompletedItems.length > 0) {
const count = newCompletedItems.length;
const message = count === 1
? `${newCompletedItems[0].maybe_title} completed successfully`
: `${count} images completed successfully`;
toast.success(message);
}
// Update completed items with a maximum limit of 50 items
setCompletedItems(prev => {
const combined = [...newCompletedItems, ...prev];
// Remove duplicates based on token
const uniqueItems = Array.from(
new Map(combined.map(item => [item.token, item])).values()
);
// Sort by updated_at in descending order and limit to 50 items
return uniqueItems
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, 50);
});
setJobs(activeJobs);
setLoading(false);
}
} catch (error) {
toast.error("Error Fetching Recent Jobs");
console.error("Failed to fetch recent jobs:", error);
}
};
await fetchJobs();
};
// Initial load
// Initial call to pollData
pollData();
// Set up polling interval (every 5 seconds)
const interval = setInterval(async () => {
await pollData();
}, 5000);
return () => clearInterval(interval);
}, []);
return (
<Tooltip content="Activity" position="bottom" closeOnClick={true}>
<PopoverMenu
mode="default"
buttonClassName="h-[38px] w-[38px] !p-0"
panelClassName="w-[360px] p-2"
position="bottom"
align="end"
triggerIcon={
<div>
<FontAwesomeIcon icon={faBell} />
{activeImageGenerationJobs.value &&
activeImageGenerationJobs.value.length > 0 && (
<div className="bg-blue-500 absolute -right-1 -top-1 h-3 w-3 animate-pulse rounded-full" />
)}
</div>
}
>
<div className="max-h-[480px] overflow-y-auto">
{/* In progress */}
{jobs && jobs.length > 0 && (
<div>
{jobs.map((job) => (
<InProgressCard key={job.job_token} movie={job} />
))}
</div>
)}
{!userMovies.value && !activeImageGenerationJobs.value ? (
<div className="flex h-48 w-full flex-col justify-center gap-4 p-4 text-center align-middle">
<FontAwesomeIcon
icon={faSpinnerThird}
spin
size="2x"
className="text-gray-400"
/>
<H3 className="text-gray-300">Retrieving Activities</H3>
</div>
) : userMovies.value &&
userMovies.value.length === 0 &&
!activeImageGenerationJobs.value ? (
<div className="flex h-48 w-full flex-col justify-center gap-4 p-4 text-center align-middle">
<H3 className="text-gray-300">No activities yet</H3>
<P className="text-gray-400">
Try generating something new from the featured scenes
</P>
</div>
) : (
<div>
{/* Completed */}
<div className="flex flex-col">
{completedItems.map((item) => (
<CompletedCard
key={item.token}
movie={item}
setMovieId={(item_id) => {
console.log("item_id", item_id);
}}
/>
))}
</div>
</div>
)}
</div>
</PopoverMenu>
</Tooltip>
);
}

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