Compare commits

..
Author SHA1 Message Date
Brandon Thomas 2f767ef655 update download links 2025-07-17 21:53:54 -04:00
68 changed files with 274 additions and 2285 deletions
@@ -1,12 +0,0 @@
{
"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"
}
@@ -21,8 +21,6 @@ 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.
@@ -1,4 +1,3 @@
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;
@@ -181,19 +180,6 @@ 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)?)
}
@@ -2,7 +2,6 @@ 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;
@@ -152,8 +151,10 @@ 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: GenericInferenceArgs::from_json_optional(
record.maybe_inference_args.as_deref())?,
maybe_inference_args: record.maybe_inference_args
.as_deref()
.map(|args| GenericInferenceArgs::from_json(args))
.transpose()?,
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,4 +5,3 @@ 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;
@@ -1,52 +0,0 @@
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))
}
}
}
@@ -40,8 +40,6 @@ 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()
@@ -53,7 +53,6 @@ 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};
@@ -62,7 +61,6 @@ 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)
@@ -165,8 +163,6 @@ 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()?;
@@ -349,14 +345,6 @@ 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);
@@ -1 +0,0 @@
pub mod reap_jobs_thread;
@@ -1,33 +0,0 @@
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(())
}
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 438 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 873 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 651 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 888 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -12,10 +12,14 @@ import { Button } from "@storyteller/ui-button";
import { AuthButtons } from "./AuthButtons";
import { SceneTitleInput } from "./SceneTitleInput";
import { Activity } from "~/pages/PageEnigma/comps/GenerateModals/Activity";
import { useGalleryModal } from "@storyteller/ui-gallery-modal";
import {
GalleryModal,
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
} from "@storyteller/ui-gallery-modal";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
// import { downloadFileFromUrl, FilterMediaClasses } from "@storyteller/api";
import { downloadFileFromUrl, FilterMediaClasses } from "@storyteller/api";
import {
MenuIconSelector,
MenuIconItem,
@@ -32,8 +36,7 @@ import {
set3DPageMounted,
} from "~/pages/PageEnigma/Editor/editor";
// import { usePricingModalStore } from "@storyteller/ui-pricing-modal"; - Uncomment for pricing modal - BFlat
// import toast from "react-hot-toast";
import toast from "react-hot-toast";
interface Props {
pageName: string;
loginSignUpPressed: () => void;
@@ -78,10 +81,9 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const { openView } = useGalleryModal();
const handleOpenGalleryModal = () => {
openView();
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
};
const tabStore = useTabStore();
@@ -100,6 +102,19 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
);
};
const downloadFile = async (url: string, mediaClass?: string) => {
try {
await downloadFileFromUrl(url);
if (mediaClass === FilterMediaClasses.DIMENSIONAL) {
toast.success(`Downloaded 3D model`);
} else {
toast.success(`Downloaded ${mediaClass}`);
}
} catch (error) {
toast.error("Failed to download file");
}
};
// const { toggleModal } = usePricingModalStore(); - Uncomment for pricing modal - BFlat
return (
@@ -202,7 +217,7 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
globalAccountLogoutCallback={() => setLogoutStates()}
/>
{/* GalleryModalRoot is now mounted higher in the tree (PageEnigma). */}
<GalleryModal mode="view" onDownloadClicked={downloadFile} />
</>
);
};
@@ -15,7 +15,6 @@ 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,
@@ -147,18 +146,14 @@ const PageDraw = () => {
const modelList: PopoverItem[] = [
{
label: "GPT-4o",
icon: getCreatorIcon(ModelCreator.OpenAi) || (
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
),
icon: <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: getCreatorIcon(ModelCreator.BlackForestLabs) || (
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
),
icon: <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,8 +1035,6 @@ 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
@@ -64,18 +64,18 @@ import {
GalleryItem,
onImageDrop,
removeImageDropListener,
onShapeDrop,
removeShapeDropListener,
} from "@storyteller/ui-gallery-modal";
import {
instructiveImageEditModels,
ModelCategory,
// ModelCategory,
ModelSelector,
// videoGenerationModels,
// useModelSelectorStore,
} from "@storyteller/ui-model-selector";
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
import PageDraw from "../PageDraw/PageDraw";
import { useTabStore } from "../Stores/TabState";
import { addShape } from "./signals/shape";
export const PageEditor = () => {
useSignals();
@@ -275,12 +275,11 @@ export const PageEditor = () => {
// MOVE THIS don't throw this in here
// Image drop from gallery/library modal logic
useEffect(() => {
let imageDropHandler: unknown;
let shapeDropHandler: unknown;
let handler: unknown;
// 3D Drag and Drop Logic
if (tabStore.activeTabId === "3D") {
imageDropHandler = onImageDrop(
handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
console.log("3D Drop debug (event):", {
editorEngine,
@@ -354,43 +353,10 @@ export const PageEditor = () => {
})();
},
);
shapeDropHandler = onShapeDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
// Calculate world position from cursor immediately on drop
let worldPosition = undefined;
if (editorEngine?.camera && editorEngine?.renderer && position) {
const rect =
editorEngine.renderer.domElement.getBoundingClientRect();
// Convert client coordinates to canvas coordinates
const canvasX = position.x - rect.left;
const canvasY = position.y - rect.top;
const ndcX = (canvasX / rect.width) * 2 - 1;
const ndcY = -(canvasY / rect.height) * 2 + 1;
const vector = new THREE.Vector3(ndcX, ndcY, 0.5);
vector.unproject(editorEngine.camera);
worldPosition = vector;
}
const mediaItem: MediaItem = {
version: 1,
type: AssetType.SHAPE,
media_id: item.id, // This is "Box", "Cylinder" etc.
name: item.name || item.label || "Shape",
...(worldPosition && {
position: {
x: worldPosition.x,
y: worldPosition.y,
z: worldPosition.z,
},
}),
};
addShape(mediaItem);
},
);
// 2D Drag and Drop Logic
} else if (tabStore.activeTabId === "2D") {
imageDropHandler = onImageDrop(
handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
console.log("2D Drop debug (event):", {
item,
@@ -443,10 +409,8 @@ export const PageEditor = () => {
}
return () => {
if (imageDropHandler)
removeImageDropListener(imageDropHandler as (e: CustomEvent) => void);
if (shapeDropHandler)
removeShapeDropListener(shapeDropHandler as (e: CustomEvent) => void);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (handler) removeImageDropListener(handler as any);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabStore.activeTabId, editorEngine]);
@@ -25,10 +25,7 @@ import { useGenerationEnqueueSuccessEvent } from "./TauriEvents/generations/useG
import { useGenerationFailedEvent } from "./TauriEvents/generations/useGenerationFailedEvent";
import { useGenerationCompleteEvent } from "./TauriEvents/generations/useGenerationCompleteEvent";
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import {
GalleryDragComponent,
GalleryModalRoot,
} from "@storyteller/ui-gallery-modal";
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
import { LoadingDots } from "@storyteller/ui-loading";
import { PricingModal } from "@storyteller/ui-pricing-modal";
@@ -106,7 +103,6 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
return (
<EngineProvider sceneToken={sceneToken}>
<GalleryModalRoot />
<PageEditor />
<DragComponent />
<GalleryDragComponent />
@@ -17,9 +17,11 @@ import {
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { EngineContext } from "../../contexts/EngineContext";
import { useContext, useEffect, useState, useRef, useCallback } from "react";
import { assetModalVisibleDuringDrag, assetModalVisible } from "../../signals";
import {
Create3dModal,
useCreate3dModalStore,
// eslint-disable-next-line import/no-unresolved
} from "@storyteller/ui-create-3d-modal";
// import { v4 as uuidv4 } from "uuid";
// import { addObject } from "../../signals/objectGroup/addObject";
@@ -27,13 +29,13 @@ import {
import { GetFalApiKey } from "@storyteller/tauri-api"; // Fix import path
// eslint-disable-next-line import/no-unresolved
// import { AssetType } from "~/enums";
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 { useGalleryModal } from "@storyteller/ui-gallery-modal";
import { UploadModal3D } from "../../../../components/reusable/UploadModal3D/UploadModal3D";
export const Controls3D = () => {
@@ -47,12 +49,12 @@ export const Controls3D = () => {
// Track processed 3D models by their media token to prevent duplicates
const processedModelsRef = useRef<Record<string, boolean>>({});
const { openView, isOpen: isGalleryOpen } = useGalleryModal();
useEffect(() => {
// Check if scene is empty and onboarding helper is not visible
const checkSceneEmpty = () => {
const isSceneEmpty =
outlinerState.items.value.length === 0 && !isGalleryOpen;
outlinerState.items.value.length === 0 && !assetModalVisible.value;
setShowEmptySceneTooltip(isSceneEmpty);
};
@@ -65,7 +67,8 @@ export const Controls3D = () => {
return () => {
unsubscribe();
};
}, [isGalleryOpen]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assetModalVisible.value]);
const handleModeChange = (value: string) => {
selectedMode.value = value;
@@ -129,7 +132,8 @@ export const Controls3D = () => {
// }, []);
const handleOpenModal = () => {
openView("3d");
assetModalVisibleDuringDrag.value = true;
assetModalVisible.value = true;
};
const handleOpenCreate3dModal = async () => {
@@ -279,6 +283,8 @@ export const Controls3D = () => {
</div>
</div>
<AssetModal />
<Create3dModal onModelComplete={handleModelComplete} />
<SettingsModal
@@ -1,5 +1,7 @@
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";
@@ -39,9 +41,7 @@ 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,18 +57,17 @@ 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,8 +24,7 @@ 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,8 +24,7 @@ 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: [],
-3
View File
@@ -1,8 +1,5 @@
export * from "./lib/enums/index.js";
export * from "./lib/enums/UploaderStates.js";
export * from "./lib/models/Users.js";
export * from "./lib/models/MediaFile.js";
export * from "./lib/models/UploaderState.js";
export * from "./lib/AnalyticsApi.js";
export * from "./lib/BillingApi.js";
export * from "./lib/EngineApi.js";
@@ -1,12 +0,0 @@
export enum UploaderStates {
ready,
uploadingAsset,
uploadingImage,
uploadingCover,
settingCover,
success,
assetError,
coverCreateError,
coverSetError,
imageCreateError,
}
@@ -1,11 +0,0 @@
import { UploaderStates } from "../enums/UploaderStates";
export interface UploaderState {
status: UploaderStates;
errorMessage?: string;
data?: string;
}
export const initialUploaderState = {
status: UploaderStates.ready,
};
@@ -1,6 +1,4 @@
export * from "./lib/gallery-modal";
export * from "./lib/galleryModalStore";
export * from "./lib/useGalleryModal";
export * from "./lib/GalleryModalRoot";
export * from "./lib/galleryModalSignals";
export * from "./lib/GalleryDragComponent";
export * from "./lib/galleryDnd";
@@ -1,43 +0,0 @@
import { downloadFileFromUrl } from "@storyteller/api";
import React from "react";
import { GalleryModal } from "./gallery-modal";
import { useGalleryModalStore } from "./galleryModalStore";
import { LightboxModal } from "@storyteller/ui-lightbox-modal";
/**
* GalleryModalRoot subscribes to the zustand store and renders the GalleryModal
* component with the current state. Mount this once at a high level (e.g. PageEnigma)
* so that other components can open/close the gallery via the useGalleryModal hook.
*/
export const GalleryModalRoot: React.FC = () => {
const { isOpen, mode, forceFilter, close, lightbox, closeLightbox, open } =
useGalleryModalStore();
return (
<>
<GalleryModal
mode={mode}
isOpen={isOpen}
forceFilter={forceFilter}
onClose={close}
onDownloadClicked={downloadFileFromUrl}
/>
{lightbox.isOpen && lightbox.item && (
<LightboxModal
isOpen={true}
onClose={closeLightbox}
onCloseGallery={close}
imageUrl={lightbox.item.fullImage}
imageAlt={lightbox.item.label}
title={lightbox.item.label}
createdAt={lightbox.item.createdAt}
downloadUrl={lightbox.item.fullImage || undefined}
mediaId={lightbox.item.id}
mediaClass={lightbox.item.mediaClass}
onDownloadClicked={downloadFileFromUrl}
/>
)}
</>
);
};
@@ -1,74 +0,0 @@
import { FilterEngineCategories } from "@storyteller/api";
import { GalleryItem } from "./types";
const now = new Date().toISOString();
export const demoCharacters: GalleryItem[] = [
{
id: "m_gff67btr810vg3ng9szj85zskztcgy",
label: "Story Girl",
thumbnail: "/resources/characters/img13.png",
fullImage: "/resources/characters/img13.png",
name: "Story Girl",
mediaClass: "dimensional",
engineCategory: FilterEngineCategories.CHARACTER,
createdAt: now,
},
{
id: "m_ja13tz27cqqvy366tsed4z4kh4gg0m",
label: "Roko",
thumbnail: "/resources/characters/roko.png",
fullImage: "/resources/characters/roko.png",
name: "Roko",
mediaClass: "dimensional",
engineCategory: FilterEngineCategories.CHARACTER,
createdAt: now,
},
];
export const demoShapes: GalleryItem[] = [
{
id: "Box",
label: "Cube",
thumbnail: "/resources/shapes/cube.png",
fullImage: "/resources/shapes/cube.png",
name: "Cube",
mediaClass: "dimensional",
engineCategory: FilterEngineCategories.OBJECT,
createdAt: now,
assetType: "shape",
},
{
id: "Cylinder",
label: "Cylinder",
thumbnail: "/resources/shapes/cylinder.png",
fullImage: "/resources/shapes/cylinder.png",
name: "Cylinder",
mediaClass: "dimensional",
engineCategory: FilterEngineCategories.OBJECT,
createdAt: now,
assetType: "shape",
},
{
id: "Donut",
label: "Donut",
thumbnail: "/resources/shapes/donut.png",
fullImage: "/resources/shapes/donut.png",
name: "Donut",
mediaClass: "dimensional",
engineCategory: FilterEngineCategories.OBJECT,
createdAt: now,
assetType: "shape",
},
{
id: "Sphere",
label: "Sphere",
thumbnail: "/resources/shapes/sphere.png",
fullImage: "/resources/shapes/sphere.png",
name: "Sphere",
mediaClass: "dimensional",
engineCategory: FilterEngineCategories.OBJECT,
createdAt: now,
assetType: "shape",
},
];
@@ -1,4 +1,5 @@
import { Modal } from "@storyteller/ui-modal";
import { LightboxModal } from "@storyteller/ui-lightbox-modal";
import { Button } from "@storyteller/ui-button";
import { CloseButton } from "@storyteller/ui-close-button";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
@@ -18,7 +19,11 @@ import {
import { twMerge } from "tailwind-merge";
import { GalleryDraggableItem } from "./GalleryDraggableItem";
import { useSignals } from "@preact/signals-react/runtime";
import { useGalleryModalStore } from "./galleryModalStore";
import {
galleryModalVisibleDuringDrag,
galleryReopenAfterDragSignal,
galleryModalVisibleViewMode,
} from "./galleryModalSignals";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faFilter,
@@ -30,34 +35,24 @@ import {
faExpand,
faCompress,
faArrowsRotate,
faUser,
faSun,
faMountainCity,
faDog,
faUpFromLine,
faChevronRight,
} from "@fortawesome/pro-solid-svg-icons";
import { PopoverMenu } from "@storyteller/ui-popover";
import { SliderV2 } from "@storyteller/ui-sliderv2";
import { Tooltip } from "@storyteller/ui-tooltip";
import { UploadModal3D } from "@storyteller/ui-upload-modal-3d";
import {
MediaFilesApi,
FilterEngineCategories,
MediaFile,
} from "@storyteller/api";
import { Switch } from "@storyteller/ui-switch";
import { GalleryItem } from "./types";
import { demoCharacters, demoShapes } from "./demoData";
export interface GalleryItem {
id: string;
label: string;
thumbnail: string | null;
fullImage?: string | null;
createdAt: string;
mediaClass?: string;
}
interface GroupedItems {
[date: string]: GalleryItem[];
}
interface Grouped3DItems {
[categoryName: string]: GalleryItem[];
}
type ModalMode = "select" | "view";
interface GalleryModalProps {
@@ -90,48 +85,21 @@ export const GalleryModal = React.memo(
forceFilter,
}: GalleryModalProps) => {
const [loading, setLoading] = useState(false);
const [lightboxImage, setLightboxImage] = useState<GalleryItem | null>(
null
);
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
const [failedImageUrls] = useState<Set<string>>(new Set());
const [username, setUsername] = useState<string>("");
const { initialFilter } = useGalleryModalStore();
const [activeFilter, setActiveFilter] = useState(
initialFilter || forceFilter || "all"
);
const [active3DCategory, setActive3DCategory] = useState("all");
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
const [selectedCategory, setSelectedCategory] =
useState<FilterEngineCategories | null>(null);
const [isSelectVisible, setIsSelectVisible] = useState(true);
const [showArtCraftOnly, setShowArtCraftOnly] = useState(false);
const {
visibleDuringDrag,
reopenAfterDrag,
setReopenAfterDrag,
openLightbox,
} = useGalleryModalStore();
const [featuredItems, setFeaturedItems] = useState<GalleryItem[]>([]);
const [grouped3dItems, setGrouped3dItems] = useState<Grouped3DItems>({});
const requestCounter = useRef(0);
const cache = useRef<{
user: { [category: string]: GalleryItem[] };
featured: { [category: string]: GalleryItem[] };
userPagination: {
[category: string]: { pageIndex: number; hasMore: boolean };
};
}>({ user: {}, featured: {}, userPagination: {} }).current;
const forceFilterRef = useRef(forceFilter);
const [activeFilter, setActiveFilter] = useState(forceFilter || "all");
// If forceFilter is provided, always use it
useEffect(() => {
if (initialFilter) {
setActiveFilter(initialFilter);
} else if (forceFilter) {
setActiveFilter(forceFilter);
if (forceFilterRef.current) {
setActiveFilter(forceFilterRef.current);
}
}, [initialFilter, forceFilter]);
}, [forceFilterRef]);
const minColumns = 3;
const maxColumns = 12;
// Default gridColumns to 5
@@ -146,8 +114,9 @@ export const GalleryModal = React.memo(
const [hasMore, setHasMore] = useState(true);
const pageSize = 25;
const imageUrl = lightboxImage?.fullImage || "";
const api = useMemo(() => new GalleryModalApi(), []);
const mediaFilesApi = useMemo(() => new MediaFilesApi(), []);
const usersApi = useMemo(() => new UsersApi(), []);
const formatDate = useCallback((date: string) => {
@@ -197,10 +166,10 @@ export const GalleryModal = React.memo(
setUsername(session.data.user.username);
}
};
if (typeof isOpen === "boolean" ? isOpen : true) {
if (isOpen || (mode === "view" && galleryModalVisibleViewMode.value)) {
getUsername();
}
}, [usersApi, isOpen]);
}, [usersApi, mode, galleryModalVisibleViewMode.value, isOpen]);
// filter state
const FILTERS = [
@@ -235,201 +204,9 @@ export const GalleryModal = React.memo(
}
};
const asset3DCategories = useMemo(
() => [
{ id: "all", label: "All", icon: faBorderAll },
{
id: "character",
label: "Characters",
icon: faUser,
engineCategory: FilterEngineCategories.CHARACTER,
},
{
id: "objects",
label: "Objects",
icon: faCube,
engineCategory: FilterEngineCategories.OBJECT,
},
{
id: "sets",
label: "Sets",
icon: faMountainCity,
engineCategory: FilterEngineCategories.LOCATION,
},
{
id: "creatures",
label: "Creatures",
icon: faDog,
engineCategory: FilterEngineCategories.CREATURE,
},
{
id: "skybox",
label: "Skybox",
icon: faSun,
engineCategory: FilterEngineCategories.SKYBOX,
},
],
[]
);
const loadItems = async (reset = false) => {
const currentRequest = ++requestCounter.current;
if (!username) return;
setLoading(true);
if (activeFilter === "3d") {
const categoryId = active3DCategory;
const isAllTab = categoryId === "all";
if (reset) {
setAllItems([]);
setPageIndex(0);
setHasMore(true);
if (isAllTab) {
setGrouped3dItems({});
}
}
const category = asset3DCategories.find((c) => c.id === categoryId);
// Step 1: Fetch featured items
let featured: GalleryItem[] = [];
if (reset) {
if (isAllTab) {
const categoriesToFetch = asset3DCategories.filter(
(c) => c.id !== "all" && c.engineCategory
);
const promises = categoriesToFetch.map(async (cat) => {
const response = await mediaFilesApi.ListFeaturedMediaFiles({
filter_engine_categories: [cat.engineCategory!],
filter_media_classes: [FilterMediaClasses.DIMENSIONAL],
page_size: 4,
});
return {
category: cat.label,
items:
response.success && response.data
? response.data
.filter(
(item) => !item.media_links.cdn_url?.endsWith(".pmx")
)
.map((item) => mapToGalleryItem(item, true))
: [],
};
});
const results = await Promise.all(promises);
if (currentRequest === requestCounter.current) {
const newGroupedItems: Grouped3DItems = {};
results.forEach((result) => {
if (result.items.length > 0) {
newGroupedItems[result.category] = result.items;
}
});
// Special handling for demo items
newGroupedItems["Characters"] = [
...demoCharacters,
...(newGroupedItems["Characters"] || []),
].slice(0, 4);
newGroupedItems["Objects"] = [
...demoShapes,
...(newGroupedItems["Objects"] || []),
].slice(0, 4);
setGrouped3dItems(newGroupedItems);
}
// For the "All" tab, we still need to proceed to fetch user items below.
} else {
const featuredResponse = await mediaFilesApi.ListFeaturedMediaFiles(
{
filter_engine_categories: category?.engineCategory
? [category.engineCategory]
: undefined,
filter_media_classes: [FilterMediaClasses.DIMENSIONAL],
}
);
if (
currentRequest === requestCounter.current &&
featuredResponse.success &&
featuredResponse.data
) {
featured = featuredResponse.data
.filter((item) => !item.media_links.cdn_url?.endsWith(".pmx"))
.map((item) => mapToGalleryItem(item, true));
}
}
if (!isAllTab) setFeaturedItems(featured);
} else {
featured = featuredItems;
}
// Step 2: Fetch user items (paginated)
const userResponse = showArtCraftOnly
? { success: false, data: [], pagination: undefined }
: await mediaFilesApi.ListUserMediaFiles({
username,
include_user_uploads: true,
filter_engine_categories:
!isAllTab && category?.engineCategory
? [category.engineCategory]
: undefined,
filter_media_classes: [FilterMediaClasses.DIMENSIONAL],
page_index: reset ? 0 : pageIndex,
page_size: pageSize,
});
if (currentRequest !== requestCounter.current) return setLoading(false);
let user: GalleryItem[] = [];
if (userResponse.success && userResponse.data) {
user = userResponse.data
.filter((item) => !item.media_links.cdn_url?.endsWith(".pmx"))
.map((item) => mapToGalleryItem(item, false));
}
// Step 3: Combine, sort, and set state
setAllItems((prev) => {
if (isAllTab) {
// For 'All' tab, allItems is only user items.
return reset ? user : [...prev, ...user];
}
// For specific category tabs:
let initialItems: GalleryItem[] = [];
if (reset) {
if (categoryId === "character")
initialItems.push(...demoCharacters);
if (categoryId === "objects") initialItems.push(...demoShapes);
initialItems.push(...featured);
}
const combined = reset
? [...initialItems, ...user]
: [...prev, ...user];
// De-duplicate
const seen = new Set();
return combined.filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
});
const pagination = (userResponse as any).pagination;
setPageIndex((pagination?.current ?? 0) + 1);
setHasMore(
!showArtCraftOnly &&
(pagination?.current ?? 0) + 1 < (pagination?.total_page_count ?? 1)
);
setLoading(false);
return;
}
try {
let response = null;
const filterMediaClasses = getFilterMediaClass(activeFilter);
@@ -451,9 +228,7 @@ export const GalleryModal = React.memo(
const thumbnail_size = 250;
const newItems = response.data
.filter(
(item: any) =>
item.media_type !== FilterMediaType.SCENE_JSON &&
!item.media_links.cdn_url?.endsWith(".pmx")
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON
)
.map((item: any) => ({
id: item.token,
@@ -462,13 +237,7 @@ export const GalleryModal = React.memo(
item.media_class === "video"
? item.media_links.maybe_video_previews.animated
: item.media_class === "dimensional"
? (item.cover_image as any)
?.maybe_cover_image_public_bucket_url ||
item.cover_image?.maybe_cover_image_public_bucket_path ||
item.media_links.maybe_thumbnail_template?.replace(
"{WIDTH}",
thumbnail_size.toString()
)
? item.cover_image?.maybe_cover_image_public_bucket_url
: item.media_links.maybe_thumbnail_template?.replace(
"{WIDTH}",
thumbnail_size.toString()
@@ -481,21 +250,12 @@ export const GalleryModal = React.memo(
? item.filter_media_classes[0]
: "image"),
}));
setAllItems((prev) => {
if (reset) return newItems;
const existingIds = new Set(prev.map((i) => i.id));
const deduped = newItems.filter((i) => !existingIds.has(i.id));
return [...prev, ...deduped];
});
setAllItems((prev) => (reset ? newItems : [...prev, ...newItems]));
// Pagination logic
const current = response.pagination?.current ?? 0;
const total = response.pagination?.total_page_count ?? 1;
setPageIndex(current + 1);
if (showArtCraftOnly) {
setHasMore(false);
} else {
setHasMore(current + 1 < total);
}
setHasMore(current + 1 < total);
}
} catch (error) {
console.error("Failed to fetch library items:", error);
@@ -503,72 +263,52 @@ export const GalleryModal = React.memo(
setLoading(false);
};
// Helper function to be defined inside the component
const mapToGalleryItem = (
item: MediaFile,
isFeatured: boolean
): GalleryItem => ({
id: item.token,
label: item.maybe_title || "3D Asset",
thumbnail:
(item.cover_image as any)?.maybe_cover_image_public_bucket_url ||
item.cover_image?.maybe_cover_image_public_bucket_path ||
item.media_links.thumbnail_template?.replace("{WIDTH}", "250"),
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
mediaClass: "dimensional",
engineCategory: item.origin_category as FilterEngineCategories,
name: item.maybe_title ?? undefined,
assetType: "object",
isFeatured,
});
// refresh logic
const refreshGallery = useCallback(() => {
cache.user = {};
cache.featured = {};
cache.userPagination = {};
setAllItems([]);
setGrouped3dItems({});
setPageIndex(0);
setHasMore(true);
loadItems(true);
}, [loadItems]);
}, [setAllItems, setPageIndex, setHasMore, loadItems]);
useEffect(() => {
// Refresh every time the modal is opened
const modalIsOpen = typeof isOpen === "boolean" ? isOpen : true;
const modalIsOpen =
mode === "view"
? galleryModalVisibleViewMode.value
: typeof isOpen === "boolean"
? isOpen
: true;
if (modalIsOpen && username) {
refreshGallery();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, isOpen, username, activeFilter]);
useEffect(() => {
// Refresh when active 3D category changes
if (activeFilter === "3d") {
refreshGallery();
}
}, [active3DCategory]);
useEffect(() => {
if (activeFilter === "3d") {
refreshGallery();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showArtCraftOnly]);
}, [
mode,
isOpen,
galleryModalVisibleViewMode.value,
username,
activeFilter,
]);
const handleItemClick = useCallback(
(item: GalleryItem) => {
if (mode === "select" && onSelectItem) {
onSelectItem(item.id);
} else {
openLightbox(item);
setLightboxImage(item);
setIsLightboxVisible(true);
}
},
[mode, onSelectItem, openLightbox]
[mode, onSelectItem]
);
const handleCloseLightbox = useCallback(() => {
setIsLightboxVisible(false);
// Wait for animation to complete before removing the image
setTimeout(() => setLightboxImage(null), 300);
}, []);
const handleDeselectAll = useCallback(() => {
selectedItemIds.forEach((id: any) => onSelectItem?.(id));
}, [selectedItemIds, onSelectItem]);
@@ -598,40 +338,24 @@ export const GalleryModal = React.memo(
}
};
const handleAddAsset = () => {
setIsUploadModalOpen(true);
setSelectedCategory(null);
setIsSelectVisible(true);
};
const handleUploadSuccess = (category: FilterEngineCategories) => {
setIsUploadModalOpen(false);
// Optional: set the category filter to the newly uploaded type
const categoryId = asset3DCategories.find(
(c) => c.engineCategory === category
)?.id;
if (categoryId) {
setActive3DCategory(categoryId);
} else {
setActive3DCategory("all");
}
refreshGallery();
};
return (
<>
<Modal
resizable={mode === "view"}
isOpen={
mode === "view"
? (typeof isOpen === "boolean" ? isOpen : true) &&
visibleDuringDrag
? galleryModalVisibleViewMode.value &&
galleryModalVisibleDuringDrag.value
: typeof isOpen === "boolean"
? isOpen
: true
}
onClose={() => {
onClose?.();
if (mode === "view") {
onClose?.() || (galleryModalVisibleViewMode.value = false);
} else {
onClose?.();
}
}}
className={twMerge(
"h-[620px] max-w-4xl",
@@ -661,8 +385,11 @@ export const GalleryModal = React.memo(
<input
type="checkbox"
id="gallery-reopen-after-drag"
checked={reopenAfterDrag}
onChange={(e) => setReopenAfterDrag(e.target.checked)}
checked={galleryReopenAfterDragSignal.value}
onChange={(e) =>
(galleryReopenAfterDragSignal.value =
e.target.checked)
}
className="h-4 w-4 cursor-pointer rounded-lg border-gray-300 bg-gray-700 text-primary focus:ring-primary"
/>
<label
@@ -675,20 +402,7 @@ export const GalleryModal = React.memo(
)}
</div>
<div className="flex justify-end gap-2 items-center">
{activeFilter === "3d" && (
<Tooltip position="top" content="Upload 3D Asset">
<Button
variant="action"
onClick={handleAddAsset}
className="relative z-[51] h-9 w-9 bg-primary/80 hover:bg-primary"
>
<FontAwesomeIcon
icon={faUpFromLine}
className="text-lg text-white"
/>
</Button>
</Tooltip>
)}
{/* Refresh button */}
<Tooltip
position="top"
content="Refresh list"
@@ -707,6 +421,7 @@ export const GalleryModal = React.memo(
/>
</Button>
</Tooltip>
{/* Image fit toggle button */}
<Tooltip
position="top"
content="Toggle image fit"
@@ -728,6 +443,7 @@ export const GalleryModal = React.memo(
</Button>
</Tooltip>
{/* Slider */}
<div className="w-48 mx-3 relative z-[51] flex items-center gap-2">
<SliderV2
min={minColumns}
@@ -744,9 +460,12 @@ export const GalleryModal = React.memo(
} columns`}
/>
</div>
{/* Filter popover */}
<Tooltip
position="top"
content={forceFilter ? "Filter locked" : "Filter"}
content={
forceFilterRef.current ? "Filter locked" : "Filter"
}
closeOnClick={true}
>
<PopoverMenu
@@ -754,19 +473,23 @@ export const GalleryModal = React.memo(
position="bottom"
align="end"
buttonClassName={`relative z-[51] mr-3 ${
forceFilter ? "opacity-70 pointer-events-none" : ""
forceFilterRef.current
? "opacity-70 pointer-events-none"
: ""
}`}
panelClassName="min-w-36"
items={FILTERS.map((f) => ({
label: f.label,
selected: activeFilter === f.id,
icon: f.icon,
// Use a custom property that will be passed through but not cause type errors
customProps: {
disabled: forceFilter !== undefined,
disabled: forceFilterRef.current !== undefined,
},
}))}
onSelect={(item) => {
if (!forceFilter) {
// Only allow filter changes if no forceFilter was provided
if (!forceFilterRef.current) {
const filter = FILTERS.find(
(f) => f.label === item.label
);
@@ -784,7 +507,11 @@ export const GalleryModal = React.memo(
{mode === "view" && <Modal.ExpandButton />}
<CloseButton
onClick={() => {
onClose?.();
if (mode === "view") {
galleryModalVisibleViewMode.value = false;
} else {
onClose?.();
}
}}
className="relative z-[51]"
/>
@@ -792,204 +519,69 @@ export const GalleryModal = React.memo(
</div>
</div>
{activeFilter === "3d" && (
<div className="px-4 py-2.5 flex items-center gap-2 border-white/10">
{asset3DCategories.map((cat) => (
<Button
key={cat.id}
variant={
active3DCategory === cat.id ? "primary" : "secondary"
}
onClick={() => setActive3DCategory(cat.id)}
className="px-3 py-1.5 text-xs"
>
<FontAwesomeIcon icon={cat.icon} />
{cat.label}
</Button>
))}
<div className="ml-auto flex items-center">
<Tooltip content="ArtCraft assets only" position="top">
<Switch
enabled={showArtCraftOnly}
setEnabled={(v) => setShowArtCraftOnly(v)}
/>
</Tooltip>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto" onScroll={handleScroll}>
{loading &&
allItems.length === 0 &&
!Object.keys(grouped3dItems).length ? (
{loading && allItems.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-white/60">
<LoadingSpinner className="h-12 w-12" />
</div>
</div>
) : activeFilter === "3d" && active3DCategory === "all" ? (
<div className="space-y-6 p-4">
{Object.entries(grouped3dItems).map(
([categoryName, items]) => (
<div key={categoryName}>
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-lg">
{categoryName}
</h3>
<Button
variant="secondary"
onClick={() => {
const catId = asset3DCategories.find(
(c) => c.label === categoryName
)?.id;
if (catId) setActive3DCategory(catId);
}}
className="text-sm text-white/60 hover:text-white bg-transparent hover:bg-white/10"
>
View all{" "}
<FontAwesomeIcon
icon={faChevronRight}
className="ml-2"
/>
</Button>
</div>
<div
className={twMerge("grid", gapClass)}
style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
}}
>
{items.map((item) => (
<GalleryDraggableItem
key={item.id}
item={item}
mode={mode}
activeFilter={activeFilter}
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={() =>
handleImageError(item.thumbnail!)
}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
/>
))}
</div>
</div>
)
)}
{/* User's Library Section */}
{allItems.length > 0 && (
<div>
<div className="mb-2 mt-8 flex items-center justify-between border-t border-white/10 pt-4">
<h3 className="font-semibold text-lg">Your Library</h3>
</div>
<div
className={twMerge("grid", gapClass)}
style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
}}
>
{allItems.map((item) => (
<GalleryDraggableItem
key={item.id}
item={item}
mode={mode}
activeFilter={activeFilter}
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={() =>
handleImageError(item.thumbnail!)
}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
/>
))}
</div>
</div>
)}
{loading && allItems.length > 0 && (
<div className="flex justify-center py-4">
<LoadingSpinner className="h-8 w-8" />
</div>
)}
{!hasMore && allItems.length > 0 && (
<div className="flex justify-center py-4 text-white/40 text-xs">
No more items
</div>
)}
</div>
) : activeFilter === "3d" && active3DCategory !== "all" ? (
<div className="space-y-6 p-4">
<div className="mb-2 flex items-center justify-between font-semibold">
<Button
variant="secondary"
onClick={() => setActive3DCategory("all")}
>
Back
</Button>
<h3>
{
asset3DCategories.find((c) => c.id === active3DCategory)
?.label
}
</h3>
</div>
<div
className={twMerge("grid", gapClass)}
style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
}}
>
{allItems.map((item) => (
<GalleryDraggableItem
key={item.id}
item={item}
mode={mode}
activeFilter={activeFilter}
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={() => handleImageError(item.thumbnail!)}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
/>
))}
</div>
{loading && allItems.length > 0 && (
<div className="flex justify-center py-4">
<LoadingSpinner className="h-8 w-8" />
</div>
)}
{!hasMore && allItems.length > 0 && (
<div className="flex justify-center py-4 text-white/40 text-xs">
No more items
</div>
)}
</div>
) : (
<div className="space-y-6 p-4">
<div
className={twMerge("grid", gapClass)}
style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
}}
>
{allItems.map((item) => (
<GalleryDraggableItem
key={item.id}
item={item}
mode={mode}
activeFilter={activeFilter}
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={() => handleImageError(item.thumbnail!)}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
/>
))}
</div>
{Object.entries(groupItemsByDate(allItems)).map(
([date, dateItems]) => {
const filteredItems = dateItems.filter((item) => {
if ((item as any).mediaType === "scene_json")
return false;
if (activeFilter === "3d") {
return item.mediaClass === "dimensional";
}
if (activeFilter === "image") {
return item.mediaClass === "image";
}
if (activeFilter === "video") {
return item.mediaClass === "video";
}
if (activeFilter === "all") {
return (
item.mediaClass !== "audio" &&
(item as any).mediaType !== "scene_json"
);
}
return true;
});
if (filteredItems.length === 0) return null;
return (
<div key={date}>
<h3 className="text-md mb-2 font-medium text-white/60">
{date}
</h3>
<div
className={twMerge("grid", gapClass)}
style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
}}
>
{filteredItems.map((item) => (
<GalleryDraggableItem
key={item.id}
item={item}
mode={mode}
activeFilter={activeFilter}
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={() =>
handleImageError(item.thumbnail!)
}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
/>
))}
</div>
</div>
);
}
)}
{loading && allItems.length > 0 && (
<div className="flex justify-center py-4">
<LoadingSpinner className="h-8 w-8" />
@@ -1033,20 +625,24 @@ export const GalleryModal = React.memo(
</div>
</Modal>
<UploadModal3D
onClose={() => {
setIsUploadModalOpen(false);
}}
onSuccess={(category: FilterEngineCategories) =>
handleUploadSuccess(category)
<LightboxModal
isOpen={isLightboxVisible}
onClose={handleCloseLightbox}
onCloseGallery={() =>
mode === "view"
? (galleryModalVisibleViewMode.value = false)
: onClose?.()
}
isOpen={isUploadModalOpen}
title={`Upload 3D Asset`}
titleIcon={faUpFromLine}
preselectedCategory={
selectedCategory || FilterEngineCategories.OBJECT
}
isSelectVisible={isSelectVisible}
imageUrl={imageUrl}
imageAlt={lightboxImage?.label || ""}
onImageError={() => imageUrl && handleImageError(imageUrl)}
title={lightboxImage?.label}
createdAt={lightboxImage?.createdAt}
downloadUrl={imageUrl}
mediaId={lightboxImage?.id}
onDownloadClicked={onDownloadClicked}
onAddToSceneClicked={onAddToSceneClicked}
mediaClass={lightboxImage?.mediaClass}
/>
</>
);
@@ -1,5 +1,8 @@
import { GalleryItem } from "./gallery-modal";
import { useGalleryModalStore } from "./galleryModalStore";
import {
galleryModalVisibleDuringDrag,
galleryReopenAfterDragSignal,
} from "./galleryModalSignals";
interface DragState {
item: GalleryItem | null;
@@ -29,7 +32,7 @@ function onPointerDown(event: React.PointerEvent, item: GalleryItem) {
dragState.currX = event.pageX;
dragState.currY = event.pageY;
dragState.isDragging = false;
useGalleryModalStore.setState({ visibleDuringDrag: false });
galleryModalVisibleDuringDrag.value = false;
document.body.style.cursor = "grabbing";
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
@@ -50,7 +53,6 @@ function onPointerMove(event: PointerEvent) {
}
export const IMAGE_DROP_EVENT = "gallery-image-drop";
export const SHAPE_DROP_EVENT = "gallery-shape-drop";
export function emitImageDrop(
item: GalleryItem,
@@ -61,15 +63,6 @@ export function emitImageDrop(
);
}
export function emitShapeDrop(
item: GalleryItem,
position: { x: number; y: number }
) {
window.dispatchEvent(
new CustomEvent(SHAPE_DROP_EVENT, { detail: { item, position } })
);
}
export function onImageDrop(
callback: (item: GalleryItem, position: { x: number; y: number }) => void
) {
@@ -80,40 +73,22 @@ export function onImageDrop(
return handler;
}
export function onShapeDrop(
callback: (item: GalleryItem, position: { x: number; y: number }) => void
) {
const handler = (e: any) => {
callback(e.detail.item, e.detail.position);
};
window.addEventListener(SHAPE_DROP_EVENT, handler);
return handler;
}
export function removeImageDropListener(handler: (e: any) => void) {
window.removeEventListener(IMAGE_DROP_EVENT, handler);
}
export function removeShapeDropListener(handler: (e: any) => void) {
window.removeEventListener(SHAPE_DROP_EVENT, handler);
}
function onPointerUp(event: PointerEvent) {
if (dragState.item && dragState.isDragging) {
if (dragState.item.assetType === "shape") {
emitShapeDrop(dragState.item, { x: event.pageX, y: event.pageY });
} else if (
dragState.item.mediaClass === "image" ||
dragState.item.mediaClass === "dimensional"
) {
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
}
if (
dragState.item &&
dragState.isDragging &&
(dragState.item.mediaClass === "image" ||
dragState.item.mediaClass === "dimensional")
) {
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
}
dragState.item = null;
dragState.isDragging = false;
const { reopenAfterDrag } = useGalleryModalStore.getState();
useGalleryModalStore.setState({ visibleDuringDrag: reopenAfterDrag });
galleryModalVisibleDuringDrag.value = galleryReopenAfterDragSignal.value;
document.body.style.cursor = "";
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
@@ -1,62 +0,0 @@
import { create } from "zustand";
import { GalleryItem } from "./gallery-modal";
export type GalleryModalMode = "view" | "select";
interface GalleryModalState {
isOpen: boolean;
mode: GalleryModalMode;
/**
* When set, the gallery will lock the filter to this value.
* Possible values: "all", "image", "video", "3d", "uploaded"
*/
forceFilter?: string;
initialFilter?: string;
/** internal flag used to temporarily hide modal during drag */
visibleDuringDrag: boolean;
/** user preference: whether modal should re-appear after a drag */
reopenAfterDrag: boolean;
/** Open the gallery modal. */
open: (options?: {
mode?: GalleryModalMode;
forceFilter?: string;
initialFilter?: string;
}) => void;
/** Close the gallery modal. */
close: () => void;
/** Set temporary visibility during drag */
setVisibleDuringDrag: (visible: boolean) => void;
/** Toggle reopen preference */
setReopenAfterDrag: (value: boolean) => void;
/** Lightbox state */
lightbox: { isOpen: boolean; item: GalleryItem | null };
openLightbox: (item: GalleryItem) => void;
closeLightbox: () => void;
}
export const useGalleryModalStore = create<GalleryModalState>((set) => ({
isOpen: false,
mode: "view",
forceFilter: undefined,
initialFilter: undefined,
visibleDuringDrag: true,
reopenAfterDrag: false,
lightbox: { isOpen: false, item: null },
open: (options) => {
const { mode = "view", forceFilter, initialFilter } = options || {};
set({
isOpen: true,
mode,
forceFilter,
initialFilter,
visibleDuringDrag: true,
});
},
close: () =>
set({ isOpen: false, visibleDuringDrag: false, initialFilter: undefined }),
setVisibleDuringDrag: (visible) => set({ visibleDuringDrag: visible }),
setReopenAfterDrag: (value) => set({ reopenAfterDrag: value }),
openLightbox: (item) => set({ lightbox: { isOpen: true, item } }),
closeLightbox: () =>
set((state) => ({ lightbox: { isOpen: false, item: null } })),
}));
@@ -1,15 +0,0 @@
import { FilterEngineCategories } from "@storyteller/api";
export interface GalleryItem {
id: string;
label: string;
thumbnail: string | null;
fullImage?: string | null;
createdAt: string;
mediaClass?: string;
name?: string;
description?: string;
engineCategory?: FilterEngineCategories;
assetType?: "shape" | "object";
isFeatured?: boolean;
}
@@ -1,44 +0,0 @@
import { useCallback } from "react";
import { useGalleryModalStore, GalleryModalMode } from "./galleryModalStore";
export const useGalleryModal = () => {
const { isOpen, mode, forceFilter, open, close, setVisibleDuringDrag } =
useGalleryModalStore();
const openView = useCallback(
(filter?: string, lock = false) => {
setVisibleDuringDrag(true);
if (lock && filter) {
open({ mode: "view", forceFilter: filter });
} else if (filter) {
open({ mode: "view", initialFilter: filter });
} else {
open({ mode: "view" });
}
},
[open, setVisibleDuringDrag]
);
const openSelect = useCallback(
(forceFilter?: string) => {
setVisibleDuringDrag(true);
open({ mode: "select", forceFilter });
},
[open, setVisibleDuringDrag]
);
const safeClose = useCallback(() => {
setVisibleDuringDrag(false);
close();
}, [close, setVisibleDuringDrag]);
return {
isOpen,
mode,
forceFilter,
open,
close: safeClose,
openView,
openSelect,
} as const;
};
@@ -2,9 +2,6 @@
"files": [],
"include": [],
"references": [
{
"path": "../upload-modal-3d"
},
{
"path": "../slider-v2"
},
@@ -43,9 +43,6 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../upload-modal-3d/tsconfig.lib.json"
},
{
"path": "../slider-v2/tsconfig.lib.json"
},
@@ -1,3 +1,7 @@
/**
* Defines the unique categories for each product or context where a model
* selection is required. Using an enum prevents typos and ensures type safety.
*/
export enum ModelCategory {
TextToImage = "text-to-image",
ImageToVideo = "image-to-video",
@@ -1,12 +1,7 @@
import type { PopoverItem } from "@storyteller/ui-popover";
import { faClock, faFilm, faImage } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
IMAGE_MODELS,
VIDEO_MODELS,
getCreatorIcon,
ModelCreator,
} from "@storyteller/model-list";
import { IMAGE_MODELS } from "@storyteller/model-list";
export type ModelList = Omit<PopoverItem, "selected">[];
@@ -14,92 +9,67 @@ export const allModels = {
video: [
{
label: "Kling 1.6 Pro",
icon: getCreatorIcon(ModelCreator.Kling) || (
<FontAwesomeIcon icon={faFilm} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Good quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: VIDEO_MODELS.kling_1_6_pro,
},
{
label: "Kling 2.1 Pro",
icon: getCreatorIcon(ModelCreator.Kling) || (
<FontAwesomeIcon icon={faFilm} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: VIDEO_MODELS.kling_2_1_pro,
},
{
label: "Kling 2.1 Master",
icon: getCreatorIcon(ModelCreator.Kling) || (
<FontAwesomeIcon icon={faFilm} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Master quality model ($$)",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: VIDEO_MODELS.kling_2_1_master,
},
{
label: "Seedance 1.0 Lite",
icon: getCreatorIcon(ModelCreator.Bytedance) || (
<FontAwesomeIcon icon={faFilm} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Fast and high-quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: VIDEO_MODELS.seedance_1_0_lite,
},
{
label: "Google Veo 2",
icon: getCreatorIcon(ModelCreator.Google) || (
<FontAwesomeIcon icon={faFilm} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Fast and high-quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: VIDEO_MODELS.veo_2,
},
],
image: [
{
label: "Flux Pro 1.1 Ultra",
icon: getCreatorIcon(ModelCreator.BlackForestLabs) || (
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.flux_pro_1_1_ultra,
},
{
label: "Flux Pro 1.1",
icon: getCreatorIcon(ModelCreator.BlackForestLabs) || (
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.flux_pro_1_1,
},
{
label: "Flux 1 Dev",
icon: getCreatorIcon(ModelCreator.BlackForestLabs) || (
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.flux_1_dev,
},
{
label: "Flux 1 Schnell",
icon: getCreatorIcon(ModelCreator.BlackForestLabs) || (
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.flux_1_schnell,
},
{
label: "GPT Image 1 (GPT-4o)",
icon: getCreatorIcon(ModelCreator.OpenAi) || (
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "Slow, ultra instructive model",
badges: [{ label: "45 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.gpt_image_1,
@@ -123,18 +93,14 @@ export const allModels = {
instructiveImageEdits: [
{
label: "GPT-4o",
icon: getCreatorIcon(ModelCreator.OpenAi) || (
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.gpt_image_1,
},
{
label: "FLUX.1 Kontext",
icon: getCreatorIcon(ModelCreator.BlackForestLabs) || (
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
),
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "Fast and high-quality model",
badges: [{ label: "20 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.gpt_image_1, // TODO: Wrong metadata!
@@ -144,5 +110,4 @@ export const allModels = {
export const videoGenerationModels: ModelList = allModels.video;
export const imageGenerationModels: ModelList = allModels.image;
export const instructiveImageEditModels: ModelList =
allModels.instructiveImageEdits;
export const instructiveImageEditModels: ModelList = allModels.instructiveImageEdits;
@@ -8,6 +8,9 @@
{
"path": "../camera-settings-modal"
},
{
"path": "../../soundboard"
},
{
"path": "../action-reminder-modal"
},
@@ -49,6 +49,9 @@
{
"path": "../camera-settings-modal/tsconfig.lib.json"
},
{
"path": "../../soundboard/tsconfig.lib.json"
},
{
"path": "../action-reminder-modal/tsconfig.lib.json"
},
@@ -1,12 +0,0 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -1,7 +0,0 @@
# @storyteller/ui-upload-modal-3d
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-upload-modal-3d` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -1,17 +0,0 @@
{
"name": "@storyteller/ui-upload-modal-3d",
"version": "0.0.1",
"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"
}
}
}
@@ -1 +0,0 @@
export * from "./lib/upload-modal-3d";
@@ -1,195 +0,0 @@
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";
import { UploaderState } from "~/models";
import { FilterEngineCategories, MediaFileAnimationType } from "~/enums";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCube } from "@fortawesome/pro-solid-svg-icons";
interface Props {
title: string;
fileTypes: string[];
engineCategory: FilterEngineCategories;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
hasThumbnailUpload?: boolean;
};
onClose: () => void;
onUploadProgress: (newState: UploaderState) => void;
}
export const UploadFiles3D = ({
fileTypes,
engineCategory,
options,
onClose,
onUploadProgress,
}: Props) => {
const canvasRef = useRef<HTMLCanvasElement | undefined>(undefined);
const canvasCallbackRef = useCallback((node: HTMLCanvasElement) => {
if (node !== null) {
canvasRef.current = node;
}
}, []);
const fileSubtypes = options?.fileSubtypes;
// const hasLength = options?.hasLength;
// const hasThumbnailUpload = options?.hasThumbnailUpload;
const [subtype, setSubtype] = useState<MediaFileAnimationType | undefined>(
fileSubtypes
? (Object.values(fileSubtypes[0])[0] as MediaFileAnimationType)
: undefined
);
const [uploadTitle, setUploadTitle] = useState<{
value: string;
error?: string;
}>({
value: "",
});
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
const [previewStatus, setPreviewStatus] = useState<{
type: string;
message?: string;
}>({ type: "init" });
const [thumbnailFile, setThumbnailFile] = useState<Blob | undefined>(
undefined
);
const handleSubmit = () => {
if (!uploadTitle.value) {
setUploadTitle((curr) => ({
...curr,
error: "Please enter a title.",
}));
return;
}
if (!assetFile.value) {
setAssetFile((curr) => ({
...curr,
error: "Please select a file to upload.",
}));
return;
}
upload3DObjects({
title: uploadTitle.value,
assetFile: assetFile.value,
thumbnailSnapshot: thumbnailFile,
engineCategory: engineCategory,
animationType: subtype,
progressCallback: onUploadProgress,
});
};
useEffect(() => {
if (canvasRef.current && assetFile.value) {
loadPreviewOnCanvas({
file: assetFile.value,
canvas: canvasRef.current,
statusCallback: (statusObject: { type: string; message?: string }) => {
setPreviewStatus(statusObject);
},
});
}
}, [assetFile.value]);
useEffect(() => {
if (previewStatus.type === "OK" && canvasRef.current) {
snapshotCanvasAsThumbnail({
targetNode: canvasRef.current!,
resultCallback: (snapshotBlob) => {
if (snapshotBlob) {
setThumbnailFile(snapshotBlob);
}
},
});
}
}, [previewStatus]);
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>
<FileUploader
title="Upload 3D Model"
fileTypes={fileTypes}
file={assetFile.value}
setFile={(file: File | null) => {
setAssetFile({
value: file,
});
}}
/>
{fileSubtypes && fileSubtypes.length > 1 && (
<ListDropdown
list={fileSubtypes}
onSelect={(value) => setSubtype(value as MediaFileAnimationType)}
/>
)}
{assetFile.error && (
<h6 className="z-10 text-red">{assetFile.error}</h6>
)}
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
<canvas
className="pointer-events-none h-full w-full"
ref={canvasCallbackRef}
/>
{!assetFile.value && (
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
<FontAwesomeIcon icon={faCube} />
Your model preview will appear here
</h6>
)}
{previewStatus.type.includes("Error") && (
<>
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
{previewStatus.type}
{previewStatus.message && <br />}
{previewStatus.message}
</h6>
</>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={!uploadTitle.value || !assetFile.value}
>
Upload
</Button>
</div>
</div>
</>
);
};
@@ -1,7 +0,0 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -1,12 +0,0 @@
import { render } from '@testing-library/react';
import UploadModal3d from './upload-modal-3d';
describe('UploadModal3d', () => {
it('should render successfully', () => {
const { baseElement } = render(<UploadModal3d />);
expect(baseElement).toBeTruthy();
});
});
@@ -1,219 +0,0 @@
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 "./upload-files-3d";
import {
initialUploaderState,
UploaderState,
} from "../../../../../apps/editor3d/app/src/models";
import { Select } from "@storyteller/ui-select";
import {
FilterEngineCategories,
UploaderStates,
OBJECT_FILE_TYPE,
CHARACTER_MIXAMO_FILE_TYPE,
IMAGEPLANE_FILE_TYPE,
} from "../../../../../apps/editor3d/app/src/enums";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
interface Props {
onClose: () => void;
onSuccess: (category: FilterEngineCategories) => void;
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
options?: {
fileSubtypes?: { [key: string]: string }[];
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",
"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,
}));
export function UploadModal3D({
isOpen,
onClose,
onSuccess,
title,
titleIcon,
options,
preselectedCategory,
isSelectVisible,
}: Props & { preselectedCategory?: FilterEngineCategories }) {
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
const [selectedCategory, setSelectedCategory] =
useState<FilterEngineCategories>(
preselectedCategory !== undefined
? preselectedCategory
: FilterEngineCategories.OBJECT
);
const updateUploaderState = (newState: UploaderState) => {
setUploaderState(newState);
};
const resetModalState = () => {
setUploaderState(initialUploaderState);
};
useEffect(() => {
if (isOpen) {
resetModalState();
}
}, [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) {
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">
{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] || []}
options={options}
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="3D model"
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>
);
}
export default UploadModal3D;
@@ -1,3 +0,0 @@
export * from "./loadPreviewOnCanvas";
export * from "./thumbnailHelpers";
export * from "./uploadAsset";
@@ -1,229 +0,0 @@
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
interface LoaderInterface {
file: File;
camera: THREE.PerspectiveCamera;
scene: THREE.Scene;
renderer: THREE.WebGLRenderer;
statusCallback: (statusObject: { type: string; message?: string }) => void;
}
export const loadPreviewOnCanvas = ({
file,
canvas,
statusCallback,
}: {
file: File;
canvas: HTMLCanvasElement;
statusCallback: (error: { type: string; message?: string }) => void;
}) => {
// Setup of scene, camera, and renderer in the canvas
const scene = new THREE.Scene();
// Calculate proper aspect ratio from canvas dimensions
const width = canvas.getBoundingClientRect().width || 0;
const height = canvas.getBoundingClientRect().height || 0;
const aspectRatio = width / height;
// Reduce FOV to make objects appear larger
const camera = new THREE.PerspectiveCamera(35, aspectRatio, 0.1, 1000);
camera.position.z = 2;
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvas,
preserveDrawingBuffer: true,
});
renderer.setSize(width, height);
const color = 0xfcece7;
const light = new THREE.HemisphereLight(color, 0x8d8d8d, 3.0);
// Add some additional lighting to better show the model
const frontLight = new THREE.DirectionalLight(0xffffff, 2);
frontLight.position.set(0, 0, 10);
scene.add(frontLight);
scene.add(light);
// load the file into the preview mini-scene depending of the file's type
if (file.name.includes(".glb")) {
glbLoader({ file, scene, camera, renderer, statusCallback });
} else if (file.name.includes(".pmd")) {
pmdLoader({ file, scene, camera, renderer, statusCallback });
} else if (
file.name.includes(".png") ||
file.name.includes(".jpg") ||
file.name.includes(".jpeg") ||
file.name.includes(".gif")
) {
imagePlaneLoader({ file, scene, camera, renderer, statusCallback });
} else if (file.name.includes(".vmd")) {
statusCallback({
type: "Preview Error",
message: "Sorry, Preview is not available to VMD files yet",
});
} else {
statusCallback({
type: "Preview Error",
message: "Unknown file type for loader",
});
}
// Render the loaded data
const animate = function () {
renderer.render(scene, camera);
};
renderer.setAnimationLoop(animate);
};
const glbLoader = ({
file,
camera,
scene,
renderer,
statusCallback,
}: LoaderInterface) => {
const loader = new GLTFLoader();
loader.load(
URL.createObjectURL(file),
(data) => {
data.scene.children.forEach((child) => {
child.userData["color"] = "#FFFFFF";
scene.add(child);
// Calculate bounding box for all objects in the scene
const box = new THREE.Box3();
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.computeBoundingBox();
box.expandByObject(object);
}
});
// Get the center and size of the bounding box
const center = new THREE.Vector3();
const size = new THREE.Vector3();
box.getCenter(center);
box.getSize(size);
// Calculate the radius of the bounding sphere
const radius = Math.max(size.x, size.y, size.z) * 0.5;
// Position camera to fit the object in view with minimal padding
const fov = camera.fov * (Math.PI / 180);
const distance = (radius * 1.2) / Math.tan(fov * 0.5);
// Position camera at a better angle for preview
camera.position.set(
center.x + distance * 0.6,
center.y + distance * 0.4,
center.z + distance * 0.6,
);
camera.lookAt(center);
// Adjust the camera's near and far planes to ensure the model is visible
camera.near = distance * 0.01;
camera.far = distance * 100;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
statusCallback({
type: "OK",
message: "Preview should be available",
});
});
},
undefined, // nothing to-do onProgress
(loaderError) => {
/*onError*/
statusCallback({
type: "GLB Loader Error",
message: String(loaderError),
});
},
);
};
const pmdLoader = ({
camera,
scene,
renderer,
statusCallback,
}: LoaderInterface) => {
camera.position.z = 30;
const loader = new FontLoader();
loader.load(
"https://threejs.org/examples/fonts/helvetiker_regular.typeface.json",
(font) => {
const textGeometry = new TextGeometry("MMD", {
font: font,
size: 100,
depth: 5,
curveSegments: 12,
bevelEnabled: true,
bevelThickness: 1,
bevelSize: 1,
bevelOffset: 0,
bevelSegments: 5,
});
textGeometry.computeBoundingBox();
const textMaterial = new THREE.MeshPhongMaterial({
color: 0xffffff,
});
const textMesh = new THREE.Mesh(textGeometry, textMaterial);
textMesh.scale.set(0.15, 0.15, 0.01);
textMesh.position.set(-22, -5, 0);
scene.add(textMesh);
renderer.render(scene, camera);
statusCallback({
type: "OK",
message: "Preview should be available",
});
},
undefined, // nothing on Progress
(loaderError) => {
/*onError*/
statusCallback({
type: "PMD Loader Error",
message: String(loaderError),
});
},
);
};
const imagePlaneLoader = ({ file, scene, statusCallback }: LoaderInterface) => {
const geometry = new THREE.PlaneGeometry(1, 1);
const loader = new THREE.TextureLoader();
const texture = loader.load(
URL.createObjectURL(file),
undefined, // nothing to-do onLoad
undefined, // nothing to-do onProgress
(loaderError) => {
/*onError*/
statusCallback({
type: "Image Plane Loader Error",
message: String(loaderError),
});
},
);
texture.colorSpace = THREE.SRGBColorSpace;
const image_material = new THREE.MeshBasicMaterial({
color: 0xffffff,
map: texture,
});
const obj = new THREE.Mesh(geometry, image_material);
obj.receiveShadow = true;
obj.castShadow = true;
scene.add(obj);
statusCallback({
type: "OK",
message: "Preview should be available",
});
};
@@ -1,54 +0,0 @@
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
import { v4 as uuidv4 } from "uuid";
import { getFileName } from "./utilities";
export const snapshotCanvasAsThumbnail = async ({
targetNode,
resultCallback,
}: {
targetNode: HTMLCanvasElement | undefined;
resultCallback: (blob: Blob | undefined) => void;
}) => {
if (!targetNode) {
//TODO: Verbose Error
return undefined;
}
targetNode.toBlob((blob: Blob | null) => {
resultCallback(blob || undefined);
});
};
export const uploadSnapshotAsThumbnail = async ({
assetTitle = "unknown_asset",
blob,
}: {
assetTitle?: string;
blob: Blob;
}) => {
const thumbnailFile = new File([blob], "storyteller-cap.png");
const thumbnailTitle = "thumbnail_" + assetTitle;
const mediaUploadApi = new MediaUploadApi();
return await mediaUploadApi.UploadImage({
uuid: uuidv4(),
blob: thumbnailFile,
fileName: getFileName(thumbnailFile),
maybe_title: thumbnailTitle,
});
};
export const setThumbnail = async ({
assetToken,
thumbnailToken,
}: {
assetToken: string;
thumbnailToken: string;
}) => {
const mediaFilesApi = new MediaFilesApi();
return await mediaFilesApi.UpdateCoverImage({
mediaFileToken: assetToken,
imageToken: thumbnailToken,
});
};
@@ -1,71 +0,0 @@
import {
FilterEngineCategories,
UploaderState,
UploaderStates,
} from "@storyteller/api";
import { setThumbnail, uploadSnapshotAsThumbnail } from "./thumbnailHelpers";
import { uploadAsset } from "./uploadAsset";
export const upload3DObjects = async ({
title,
assetFile,
engineCategory,
thumbnailSnapshot,
progressCallback,
}: {
title: string;
assetFile: File;
engineCategory: FilterEngineCategories;
thumbnailSnapshot: Blob | undefined;
progressCallback: (newState: UploaderState) => void;
}) => {
progressCallback({ status: UploaderStates.uploadingAsset });
const assetReponse = await uploadAsset({
file: assetFile,
title: title,
engineCategory: engineCategory,
});
if (!assetReponse.success || !assetReponse.data) {
progressCallback({
status: UploaderStates.assetError,
errorMessage: assetReponse.errorMessage,
});
return;
}
const assetToken = assetReponse.data;
if (!thumbnailSnapshot) {
progressCallback({ status: UploaderStates.success });
return;
}
progressCallback({ status: UploaderStates.uploadingCover });
const thumbnailResponse = await uploadSnapshotAsThumbnail({
assetTitle: title,
blob: thumbnailSnapshot,
});
if (!thumbnailResponse.success || !thumbnailResponse.data) {
progressCallback({
status: UploaderStates.coverCreateError,
errorMessage: thumbnailResponse.errorMessage,
});
return;
}
progressCallback({ status: UploaderStates.settingCover });
const thumbnailToken = thumbnailResponse.data;
const setThumbnailResponse = await setThumbnail({
assetToken: assetToken,
thumbnailToken: thumbnailToken,
});
if (!setThumbnailResponse.success) {
progressCallback({
status: UploaderStates.coverSetError,
errorMessage: setThumbnailResponse.errorMessage,
});
return;
}
progressCallback({ status: UploaderStates.success });
};
@@ -1,39 +0,0 @@
import { MediaUploadApi } from "@storyteller/api";
import { FilterEngineCategories } from "@storyteller/api";
import { getFileExtension } from "./utilities";
import { v4 as uuidv4 } from "uuid";
export const uploadAsset = async ({
file,
title,
engineCategory,
}: {
file: File;
title: string;
engineCategory: FilterEngineCategories;
}) => {
const mediaUploadApi = new MediaUploadApi();
const fileExtension = getFileExtension(file);
const assetReponse = await (async () => {
switch (fileExtension) {
case ".zip":
return mediaUploadApi.UploadPmx({
file: file,
fileName: file.name,
engine_category: engineCategory,
maybe_title: title,
uuid: uuidv4(),
});
default:
return mediaUploadApi.UploadNewEngineAsset({
file: file,
fileName: file.name,
engine_category: engineCategory,
maybe_title: title,
uuid: uuidv4(),
});
}
})();
return assetReponse;
};
@@ -1,10 +0,0 @@
export const getFileName = (file: File) => {
return file.name.substring(0, file.name.lastIndexOf("."));
};
export const getFileExtension = (file: File) => {
return file.name.substring(file.name.lastIndexOf("."));
};
export const getFileTypesFromEnum = (enumOfTypes: object) => {
return Object.keys(enumOfTypes);
};
@@ -1,46 +0,0 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../../api"
},
{
"path": "../select"
},
{
"path": "../upload-modal"
},
{
"path": "../modal"
},
{
"path": "../loading"
},
{
"path": "../../../apps/editor3d"
},
{
"path": "../file-uploader"
},
{
"path": "../label"
},
{
"path": "../input"
},
{
"path": "../button"
},
{
"path": "../list-dropdown"
},
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -1,80 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
],
"references": [
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../select/tsconfig.lib.json"
},
{
"path": "../upload-modal/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../loading/tsconfig.lib.json"
},
{
"path": "../../../apps/editor3d"
},
{
"path": "../file-uploader/tsconfig.lib.json"
},
{
"path": "../label/tsconfig.lib.json"
},
{
"path": "../input/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../list-dropdown/tsconfig.lib.json"
}
]
}
@@ -1,34 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -1,49 +0,0 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/upload-modal-3d',
plugins: [react(), 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/ui-upload-modal-3d',
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: ['react','react-dom','react/jsx-runtime']
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));
+6 -6
View File
@@ -1,6 +1,6 @@
export * from "./lib/model-list.js";
export * from "./lib/ModelInfo.js";
export * from "./lib/ModelCreator.js";
export * from "./lib/ImageModels.js";
export * from "./lib/VideoModels.js";
export * from "./lib/ModelCreatorIcons.js";
export * from './lib/model-list.js';
export * from './lib/ModelInfo.js';
export * from './lib/ModelCreator.js';
export * from './lib/ImageModels.js';
export * from './lib/VideoModels.js';
@@ -26,10 +26,10 @@ export const IMAGE_MODELS: Record<string, ModelInfo> = {
name: "GPT Image 1 (GPT-4o)",
tauri_id: "gpt_image_1",
creator: ModelCreator.OpenAi,
},
};
}
}
// This is a hack to get the old model selector code to work since it's keyed off
// This is a hack to get the old model selector code to work since it's keyed off
// of the human-readable label of the model. Long term, that should be fixed.
export const IMAGE_MODELS_BY_LABEL: Record<string, ModelInfo> = {
"Flux Pro 1.1 Ultra": IMAGE_MODELS.flux_pro_1_1_ultra,
@@ -37,4 +37,5 @@ export const IMAGE_MODELS_BY_LABEL: Record<string, ModelInfo> = {
"Flux 1 Dev": IMAGE_MODELS.flux_1_dev,
"Flux 1 Schnell": IMAGE_MODELS.flux_1_schnell,
"GPT Image 1 (GPT-4o)": IMAGE_MODELS.gpt_image_1,
};
}
@@ -1,28 +0,0 @@
import React, { ReactNode } from "react";
import { ModelCreator } from "./ModelCreator.js";
export const CREATOR_ICON_PATHS: Partial<Record<ModelCreator, string>> = {
[ModelCreator.BlackForestLabs]:
"/resources/images/services/blackforestlabs.svg",
[ModelCreator.Kling]: "/resources/images/services/kling.svg",
[ModelCreator.Midjourney]: "/resources/images/services/midjourney.svg",
[ModelCreator.OpenAi]: "/resources/images/services/openai.svg",
[ModelCreator.Bytedance]: "/resources/images/services/bytedance.svg",
[ModelCreator.Google]: "/resources/images/services/google.svg",
};
export const getCreatorIconPath = (creator: ModelCreator): string | undefined =>
CREATOR_ICON_PATHS[creator];
export const getCreatorIcon = (
creator: ModelCreator,
className = "h-4 w-4 invert"
): ReactNode | null => {
const path = getCreatorIconPath(creator);
if (!path) return null;
return React.createElement("img", {
src: path,
alt: `${creator} logo`,
className,
});
};
@@ -3,11 +3,11 @@ import { ModelCreator } from "./ModelCreator.js";
export interface ModelInfo {
// Human-readible name
// Other components may want to rename the model, eg. to fit in smaller width elements
name: string;
name: string,
// The identifier that Tauri uses (it may send different signals downstream)
tauri_id: string;
tauri_id: string,
// Creator of the model type
creator: ModelCreator;
creator: ModelCreator,
}
@@ -26,10 +26,10 @@ export const VIDEO_MODELS: Record<string, ModelInfo> = {
name: "Google Veo 2",
tauri_id: "veo_2",
creator: ModelCreator.Google,
},
};
}
}
// This is a hack to get the old model selector code to work since it's keyed off
// This is a hack to get the old model selector code to work since it's keyed off
// of the human-readable label of the model. Long term, that should be fixed.
export const VIDEO_MODELS_BY_LABEL: Record<string, ModelInfo> = {
"Kling 1.6 Pro": VIDEO_MODELS.kling_1_6_pro,
@@ -37,4 +37,5 @@ export const VIDEO_MODELS_BY_LABEL: Record<string, ModelInfo> = {
"Kling 2.1 Master": VIDEO_MODELS.kling_2_1_master,
"Seedance 1.0 Lite": VIDEO_MODELS.seedance_1_0_lite,
"Google Veo 2": VIDEO_MODELS.veo_2,
};
}
@@ -1,3 +1,3 @@
export function modelList(): string {
return "model-list";
return 'model-list';
}
-8
View File
@@ -1341,10 +1341,6 @@
"name": "@storyteller/ui-upload-modal",
"version": "0.0.1"
},
"libs/components/upload-modal-3d": {
"name": "@storyteller/ui-upload-modal-3d",
"version": "0.0.1"
},
"libs/login": {
"name": "@frontend/login",
"version": "0.0.1"
@@ -9585,10 +9581,6 @@
"resolved": "libs/components/upload-modal",
"link": true
},
"node_modules/@storyteller/ui-upload-modal-3d": {
"resolved": "libs/components/upload-modal-3d",
"link": true
},
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
-3
View File
@@ -146,9 +146,6 @@
},
{
"path": "./libs/model-list"
},
{
"path": "./libs/components/upload-modal-3d"
}
]
}