Compare commits

...
Author SHA1 Message Date
bflatastic b76ec47c9d removed add object to scene for now 2025-06-12 22:08:02 +07:00
bflatastic 340741a5ef gallery modal forced filters on open 2025-06-12 21:34:55 +07:00
bflatastic 9bb7a4be4d feat: implement 3D model generation from images with FAL API integration 2025-06-12 14:43:31 +07:00
bflatastic 0f65bc8fac feat: add create 3D modal and image input components with supporting files 2025-06-12 14:06:37 +07:00
BFlat df0b98ec63 drag and drop gallery 3d objects to 3d scene feature (#666)
- can now drag and drop 3d models onto the editor scene from the gallery
modal
2025-06-10 12:48:44 +07:00
BFlat f214577af1 2D canvas gallery image drop feature (#665)
- now you can drag and drop onto the 2D canvas from the gallery modal
2025-06-10 12:24:28 +07:00
ArEnScandCrossPr0duct 3efe9db4ca Create Menus and Bug Fixes (#664)
Removed:
Save and Load Scene for later.
Added undo and redo as button.
Hide Generate Image
Added a menu ( still needs logic code )
Fixed Opacity on brush.

---------

Co-authored-by: CrossPr0duct <alicextensor@gmail.com>
2025-06-09 17:36:16 -04:00
Brandon Thomas 8208206e5a missing 2025-06-09 12:20:21 -04:00
Tanish Manku 4f33e74e3a Make 3D state save and load on tab change (#661) 2025-06-06 22:50:47 -07:00
Brandon Thomas f75b210dcd end-to-end fal in artcraft backend (#663) 2025-06-06 21:37:06 -04:00
Brandon ThomasandMichael Chung 7a07f196d1 Fix Safari issue (#662)
Co-authored-by: Michael Chung <michaelchung@mac.home>
2025-06-06 20:26:30 -04:00
84 changed files with 2578 additions and 295 deletions
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?, \n origin_product_category = ?, \n maybe_origin_model_type = ?, \n maybe_origin_model_token = ?, \n maybe_origin_filename = ?,\n\n is_batch_generated = ?,\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?, \n maybe_video_encoding = ?, \n maybe_frame_width = ?, \n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?, \n maybe_public_bucket_prefix = ?, \n maybe_public_bucket_extension = ?, \n\n maybe_creator_user_token = ?, \n maybe_creator_anonymous_visitor_token = ?, \n\n creator_ip_address = ?, \n creator_set_visibility = ?, \n\n maybe_creator_file_synthetic_id = ?, \n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_mod_user_token = ?, \n is_generated_on_prem = ?, \n generated_by_worker = ?, \n generated_by_cluster = ?\n ",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?, \n origin_product_category = ?, \n maybe_origin_model_type = ?, \n maybe_origin_model_token = ?, \n maybe_origin_filename = ?,\n\n is_batch_generated = ?,\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?, \n maybe_video_encoding = ?, \n maybe_frame_width = ?, \n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n \n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?, \n maybe_public_bucket_prefix = ?, \n maybe_public_bucket_extension = ?, \n\n maybe_creator_user_token = ?, \n maybe_creator_anonymous_visitor_token = ?, \n\n creator_ip_address = ?, \n creator_set_visibility = ?, \n\n maybe_creator_file_synthetic_id = ?, \n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_mod_user_token = ?, \n is_generated_on_prem = ?, \n generated_by_worker = ?, \n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 38
"Right": 39
},
"nullable": []
},
"hash": "c58f414b5e4d0f0420d1894e2f40311e5613919f49f3ac91f81f5fc2bef1bea8"
"hash": "b4470e607c005421dd8504b9f1d695080a581936ac6ce00b6f437b2764e741be"
}
Generated
+3
View File
@@ -648,6 +648,7 @@ name = "artcraft"
version = "0.1.0"
dependencies = [
"anyhow",
"artcraft_api_defs",
"base64 0.22.1",
"build_metadata",
"bytes 1.10.1",
@@ -659,6 +660,7 @@ dependencies = [
"fal_client",
"filesys",
"futures 0.3.31",
"idempotency",
"image",
"indexmap 2.9.0",
"log",
@@ -9994,6 +9996,7 @@ name = "storyteller_client"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_defs",
"backoff",
"chrono",
"cookie 0.18.1",
+2
View File
@@ -23,10 +23,12 @@ tauri-build = { version = "2.1.1", features = [] }
workspace-build-acceleration = { workspace = true }
# Internal
artcraft_api_defs = { workspace = true }
build_metadata = { workspace = true }
errors = { workspace = true }
fal_client = { workspace = true }
filesys = { workspace = true }
idempotency = { workspace = true }
memory_store = { workspace = true }
mimetypes = { workspace = true }
openai_sora_client = { workspace = true }
@@ -1 +1,2 @@
pub mod image;
pub mod image;
pub mod object;
@@ -0,0 +1,179 @@
use crate::core::commands::enqueue::image::handle_fal::handle_fal;
use crate::core::commands::enqueue::image::handle_sora::handle_sora;
use crate::core::commands::enqueue::object::handle_object_fal::handle_object_fal;
use crate::core::commands::enqueue::object::internal_object_error::InternalObjectError;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::Response;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::get_url_file_extension::get_url_file_extension;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::core::utils::simple_http_download::simple_http_download;
use crate::core::utils::simple_http_download_to_tempfile::simple_http_download_to_tempfile;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::read_sora_credentials_from_disk::read_sora_credentials_from_disk;
use crate::services::sora::state::sora_credential_holder::SoraCredentialHolder;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use base64::prelude::BASE64_STANDARD;
use base64::{DecodeError, Engine};
use errors::{AnyhowError, AnyhowResult};
use fal_client::error::fal_error_plus::FalErrorPlus;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_ultra_text_to_image::{enqueue_flux_pro_ultra_text_to_image, FluxProUltraTextToImageArgs};
use fal_client::requests::queue::image_gen::enqueue_recraft3_text_to_image::{enqueue_recraft3_text_to_image, Recraft3TextToImageArgs};
use filesys::file_read_bytes::file_read_bytes;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, EncodableLayout, ImageReader};
use log::{debug, error, info, warn};
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::creds::credential_migration::CredentialMigrationRef;
use openai_sora_client::recipes::image_remix_with_session_auto_renew::{image_remix_with_session_auto_renew, ImageRemixAutoRenewRequest};
use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use openai_sora_client::requests::upload::upload_media_from_file::sora_media_upload_from_file;
use openai_sora_client::sora_error::SoraError;
use reqwest::Url;
use serde_derive::{Deserialize, Serialize};
use std::fs::read_to_string;
use std::io::{Cursor, Read, Write};
use std::ops::Add;
use std::path::PathBuf;
use std::time::Duration;
use storyteller_client::error::api_error::ApiError;
use storyteller_client::error::api_error::ApiError::InternalServerError;
use storyteller_client::media_files::get_media_file::{get_media_file, GetMediaFileSuccessResponse};
use storyteller_client::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Emitter, Manager, State};
use tempfile::NamedTempFile;
use tokens::tokens::media_files::MediaFileToken;
use crate::core::commands::enqueue::object::handle_object_artcraft::handle_object_artcraft;
#[derive(Deserialize)]
pub struct EnqueueImageTo3dObjectRequest {
/// Image media file; the image to remove the background from.
/// TODO: In the future we may support base64 images, URLs, or file paths here.
pub image_media_token: Option<MediaFileToken>,
/// The model to use.
pub model: Option<EnqueueImageTo3dObjectModel>,
}
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum EnqueueImageTo3dObjectModel {
#[serde(rename = "hunyuan_3d_2")]
Hunyuan3d2,
}
#[derive(Serialize)]
pub struct EnqueueImageTo3dObjectSuccessResponse {
}
impl SerializeMarker for EnqueueImageTo3dObjectSuccessResponse {}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum EnqueueImageTo3dObjectErrorType {
/// Caller didn't specify a model
ModelNotSpecified,
/// Generic server error
ServerError,
/// No Fal API key available
NeedsFalApiKey,
/// Fal had an API error
FalError,
/// Needs to be logged into Artcraft
NeedsStorytellerCredentials,
}
#[tauri::command]
pub async fn enqueue_image_to_3d_object_command(
app: AppHandle,
request: EnqueueImageTo3dObjectRequest,
app_data_root: State<'_, AppDataRoot>,
fal_creds_manager: State<'_, FalCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
sora_task_queue: State<'_, SoraTaskQueue>,
) -> Response<EnqueueImageTo3dObjectSuccessResponse, EnqueueImageTo3dObjectErrorType, ()> {
info!("enqueue_image_to_3d_object_command called");
let result = handle_request(
&app,
request,
&app_data_root,
&fal_creds_manager,
&storyteller_creds_manager,
&fal_task_queue,
).await;
match result {
Err(err) => {
error!("error: {:?}", err);
let mut status = CommandErrorStatus::ServerError;
let mut error_type = EnqueueImageTo3dObjectErrorType::ServerError;
let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.";
match err {
InternalObjectError::NoModelSpecified => {
status = CommandErrorStatus::BadRequest;
error_type = EnqueueImageTo3dObjectErrorType::ModelNotSpecified;
error_message = "No model specified for image generation";
}
InternalObjectError::NeedsFalApiKey => {
status = CommandErrorStatus::Unauthorized;
error_type = EnqueueImageTo3dObjectErrorType::NeedsFalApiKey;
error_message = "You need to set a FAL api key";
},
InternalObjectError::NeedsStorytellerCredentials => {
status = CommandErrorStatus::Unauthorized;
error_type = EnqueueImageTo3dObjectErrorType::NeedsStorytellerCredentials;
error_message = "You need to be logged into Artcraft.";
}
_ => {}, // Fall-through
}
Err(CommandErrorResponseWrapper {
status,
error_message: Some(error_message.to_string()),
error_type: Some(error_type),
error_details: None,
})
}
Ok(()) => {
Ok(EnqueueImageTo3dObjectSuccessResponse {}.into())
}
}
}
pub async fn handle_request(
app: &AppHandle,
request: EnqueueImageTo3dObjectRequest,
app_data_root: &AppDataRoot,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<(), InternalObjectError> {
if fal_creds_manager.has_apparent_api_token()? {
handle_object_fal(&app, app_data_root, request, fal_creds_manager, fal_task_queue).await?;
} else {
handle_object_artcraft(request, &app, app_data_root, storyteller_creds_manager).await?;
}
Ok(())
}
@@ -0,0 +1,129 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageModel, EnqueueTextToImageRequest};
use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::{EnqueueImageTo3dObjectModel, EnqueueImageTo3dObjectRequest};
use crate::core::commands::enqueue::object::internal_object_error::InternalObjectError;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::GenerateHunyuan2ImageTo3dRequest;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::queue::enqueue_hunyuan2_image_to_3d::{enqueue_hunyuan2_image_to_3d, Hunyuan2Args};
use fal_client::requests::queue::image_gen::enqueue_flux_pro_ultra_text_to_image::{enqueue_flux_pro_ultra_text_to_image, FluxProUltraTextToImageArgs};
use fal_client::requests::queue::image_gen::enqueue_recraft3_text_to_image::{enqueue_recraft3_text_to_image, Recraft3TextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info, warn};
use storyteller_client::generate::object::generate_hunyuan2_image_to_3d::generate_hunyuan2_image_to_3d;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, State};
pub async fn handle_object_artcraft(
request: EnqueueImageTo3dObjectRequest,
app: &AppHandle,
app_data_root: &AppDataRoot,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<(), InternalObjectError> {
let creds = match storyteller_creds_manager.get_credentials()? {
Some(creds) => creds,
None => {
error!("No Artcraft credentials are set. Can't perform action.");
let event =
GenerationEnqueueFailureEvent::no_artcraft_credentials(GenerationAction::ImageTo3d);
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
return Err(InternalObjectError::NeedsStorytellerCredentials);
},
};
//let api_key = fal_creds_manager.get_key_required()
// .map_err(|err| {
// error!("EnqueueTextToImage FAL api key required: {:?}", err);
// InternalObjectError::NeedsFalApiKey
// })?;
info!("Calling Artcraft image to 3d ...");
let uuid_idempotency_token = generate_random_uuid();
let job_token = match request.model {
None => {
return Err(InternalObjectError::NoModelSpecified);
}
Some(EnqueueImageTo3dObjectModel::Hunyuan3d2) => {
info!("enqueue Hunyuan 3D 2.0");
let request = GenerateHunyuan2ImageTo3dRequest {
uuid_idempotency_token,
media_file_token: request.image_media_token,
};
let result = generate_hunyuan2_image_to_3d(
&ApiHost::Storyteller,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft Hunyuan 3D 2.0");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Hunyuan 3D 2.0: {:?}", err);
return Err(InternalObjectError::StorytellerError(err));
}
}
}
};
info!("Successfully enqueued 3D generation");
let event = GenerationEnqueueSuccessEvent {
action: GenerationAction::ImageTo3d,
service: GenerationServiceProvider::Artcraft,
model: None,
};
if let Err(err) = event.send(app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
//if let Err(err) = fal_task_queue.insert(&enqueued) {
// error!("Failed to enqueue task: {:?}", err);
// return Err(InternalObjectError::AnyhowError(anyhow!("Failed to enqueue task: {:?}", err)));
//}
// match result {
// Ok(enqueued) => {
// }
// Err(err) => {
// error!("Failed to enqueue image to 3d: {:?}", err);
//
// let event = GenerationEnqueueFailureEvent {
// action: GenerationAction::ImageTo3d,
// service: GenerationServiceProvider::Fal,
// model: None,
// reason: None,
// };
//
// if let Err(err) = event.send(app) {
// error!("Failed to emit event: {:?}", err); // Fail open.
// }
//
// return Err(InternalObjectError::FalError(err));
// }
// }
Ok(())
}
@@ -0,0 +1,117 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageModel, EnqueueTextToImageRequest};
use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::{EnqueueImageTo3dObjectModel, EnqueueImageTo3dObjectRequest};
use crate::core::commands::enqueue::object::internal_object_error::InternalObjectError;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::queue::enqueue_hunyuan2_image_to_3d::{enqueue_hunyuan2_image_to_3d, Hunyuan2Args};
use fal_client::requests::queue::image_gen::enqueue_flux_pro_ultra_text_to_image::{enqueue_flux_pro_ultra_text_to_image, FluxProUltraTextToImageArgs};
use fal_client::requests::queue::image_gen::enqueue_recraft3_text_to_image::{enqueue_recraft3_text_to_image, Recraft3TextToImageArgs};
use log::{error, info, warn};
use tauri::AppHandle;
pub async fn handle_object_fal(
app: &AppHandle,
app_data_root: &AppDataRoot,
request: EnqueueImageTo3dObjectRequest,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<(), InternalObjectError> {
let api_key = match fal_creds_manager.get_key()? {
Some(key) => key,
None => {
error!("No FAL API key is set. Can't perform action.");
let event =
GenerationEnqueueFailureEvent::no_fal_api_key(GenerationAction::GenerateImage);
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
return Err(InternalObjectError::NeedsFalApiKey);
},
};
//let api_key = fal_creds_manager.get_key_required()
// .map_err(|err| {
// error!("EnqueueTextToImage FAL api key required: {:?}", err);
// InternalObjectError::NeedsFalApiKey
// })?;
let mut temp_download;
if let Some(media_token) = request.image_media_token {
temp_download = download_media_file_to_temp_dir(&app_data_root, &media_token).await?;
} else {
return Err(InternalObjectError::AnyhowError(anyhow!("No image media token provided")));
}
info!("Calling FAL image to 3d ...");
let filename = temp_download.path().to_path_buf();
let result = match request.model {
None => {
return Err(InternalObjectError::NoModelSpecified);
}
Some(EnqueueImageTo3dObjectModel::Hunyuan3d2) => {
info!("enqueue Hunyuan 3D 2.0");
enqueue_hunyuan2_image_to_3d(Hunyuan2Args {
image_path: filename,
api_key: &api_key,
}).await
}
};
match result {
Ok(enqueued) => {
info!("Successfully enqueued text to image");
let event = GenerationEnqueueSuccessEvent {
action: GenerationAction::ImageTo3d,
service: GenerationServiceProvider::Fal,
model: None,
};
if let Err(err) = event.send(app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
if let Err(err) = fal_task_queue.insert(&enqueued) {
error!("Failed to enqueue task: {:?}", err);
return Err(InternalObjectError::AnyhowError(anyhow!("Failed to enqueue task: {:?}", err)));
}
}
Err(err) => {
error!("Failed to enqueue image to 3d: {:?}", err);
let event = GenerationEnqueueFailureEvent {
action: GenerationAction::ImageTo3d,
service: GenerationServiceProvider::Fal,
model: None,
reason: None,
};
if let Err(err) = event.send(app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
return Err(InternalObjectError::FalError(err));
}
}
Ok(())
}
@@ -0,0 +1,49 @@
use base64::DecodeError;
use errors::AnyhowError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use storyteller_client::error::api_error::ApiError;
use storyteller_client::error::storyteller_error::StorytellerError;
#[derive(Debug)]
pub enum InternalObjectError {
NoModelSpecified,
NeedsFalApiKey,
NeedsSoraCredentials,
NeedsStorytellerCredentials,
FalError(FalErrorPlus),
AnyhowError(AnyhowError),
StorytellerError(StorytellerError),
StorytellerApiError(ApiError),
DecodeError(DecodeError),
IoError(std::io::Error),
}
impl From<AnyhowError> for InternalObjectError {
fn from(value: AnyhowError) -> Self {
Self::AnyhowError(value)
}
}
impl From<FalErrorPlus> for InternalObjectError {
fn from(value: FalErrorPlus) -> Self {
Self::FalError(value)
}
}
impl From<ApiError> for InternalObjectError {
fn from(value: ApiError) -> Self {
Self::StorytellerApiError(value)
}
}
impl From<DecodeError> for InternalObjectError {
fn from(value: DecodeError) -> Self {
Self::DecodeError(value)
}
}
impl From<std::io::Error> for InternalObjectError {
fn from(value: std::io::Error) -> Self {
Self::IoError(value)
}
}
@@ -0,0 +1,4 @@
pub mod enqueue_image_to_3d_object_command;
pub mod handle_object_artcraft;
pub mod handle_object_fal;
pub mod internal_object_error;
@@ -3,8 +3,9 @@ use serde::Serialize;
#[derive(Debug, Copy, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GenerationServiceProvider {
Sora,
Artcraft,
Fal,
Sora,
}
#[derive(Debug, Copy, Clone, Serialize)]
@@ -19,6 +19,15 @@ impl BasicSendableEvent for GenerationEnqueueFailureEvent {
}
impl GenerationEnqueueFailureEvent {
pub fn no_artcraft_credentials(action: GenerationAction) -> Self {
Self {
action,
service: GenerationServiceProvider::Artcraft,
model: None,
reason: Some("Please log into Artcraft.".to_string()),
}
}
pub fn no_fal_api_key(action: GenerationAction) -> Self {
Self {
action,
+2
View File
@@ -6,6 +6,7 @@ use tauri::Manager;
use crate::core::commands::app_preferences::get_app_preferences_command::get_app_preferences_command;
use crate::core::commands::app_preferences::update_app_preference_command::update_app_preferences_command;
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::enqueue_text_to_image_command;
use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::enqueue_image_to_3d_object_command;
use crate::core::commands::flip_image::flip_image;
use crate::core::commands::get_build_info_command::get_build_info_command;
use crate::core::commands::platform_info_command::platform_info_command;
@@ -161,6 +162,7 @@ pub fn run() {
.manage(storyteller_creds_manager_3)
.invoke_handler(tauri::generate_handler![
check_sora_session_command,
enqueue_image_to_3d_object_command,
enqueue_text_to_image_command,
fal_background_removal_command,
fal_hunyuan_image_to_3d_command,
@@ -196,7 +196,6 @@ pub async fn image_to_3d(
) -> Result<(), InnerError> {
let api_key = fal_creds_manager.get_key_required()?;
let creds = storyteller_creds_manager.get_credentials_required()?;
let mut temp_download;
+3 -2
View File
@@ -16,7 +16,8 @@
"width": 2400,
"height": 1300,
"resizable": true,
"fullscreen": false
"fullscreen": false,
"dragDropEnabled": false
}
],
"security": {
@@ -33,7 +34,7 @@
"https://www.googletagmanager.com",
"ipc://localhost",
"tauri://localhost"
],
],
"connect-src": [
"'self'",
"'unsafe-eval'",
@@ -17,6 +17,7 @@ path = "src/lib.rs"
workspace-build-acceleration = { workspace = true }
# Internal
artcraft_api_defs = { workspace = true }
enums = { workspace = true }
enums_public = { workspace = true }
errors = { workspace = true }
@@ -2,6 +2,7 @@ use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
use std::path::PathBuf;
use crate::error::api_error::ApiError;
/// An error arising from the client, either before or after the request is sent.
#[derive(Debug)]
@@ -14,6 +15,12 @@ pub enum ClientError {
/// An error doing file I/O (on our side)
IoError(io::Error),
/// An error setting up the client
ReqwestError(reqwest::Error),
/// A serialization error with the request.
SerializationError(serde_json::Error),
}
impl Error for ClientError {}
@@ -24,6 +31,8 @@ impl Display for ClientError {
Self::FileTypeNotKnown(path) => write!(f, "Could not determine the filetype for the file: {:?}", path),
Self::FileTypeNotHandled(path) => write!(f, "Could not handle the file type for the file: {:?}", path),
Self::IoError(err) => write!(f, "IO error: {}", err),
Self::ReqwestError(err) => write!(f, "Reqwest client error: {}", err),
Self::SerializationError(err) => write!(f, "Request serialization error: {}", err),
}
}
}
@@ -33,3 +42,15 @@ impl From<io::Error> for ClientError {
ClientError::IoError(error)
}
}
impl From<serde_json::Error> for ClientError {
fn from(error: serde_json::Error) -> Self {
ClientError::SerializationError(error)
}
}
impl From<reqwest::Error> for ClientError {
fn from(error: reqwest::Error) -> Self {
ClientError::ReqwestError(error)
}
}
@@ -0,0 +1 @@
pub mod object;
@@ -0,0 +1,69 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::{GenerateHunyuan2ImageTo3dRequest, GenerateHunyuan2ImageTo3dResponse, GENERATE_HUNYUAN_2_IMAGE_TO_3D_URL_PATH};
use log::{debug, info};
use reqwest::Client;
/// Upload an image media file from a file.
pub async fn generate_hunyuan2_image_to_3d(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateHunyuan2ImageTo3dRequest,
) -> Result<GenerateHunyuan2ImageTo3dResponse, StorytellerError> {
let url = get_route(api_host);
info!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON)
.header("Content-Type", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
}
let request_body = serde_json::to_string(&request)
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
debug!("Request body: {:?}", request_body);
let response = request_builder
.body(request_body)
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response_body = &response.text()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
debug!("Response body: {:?}", response_body);
let response = serde_json::from_str(&response_body)
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
Ok(response)
}
fn get_route(api_host: &ApiHost) -> String {
let api_hostname = api_host.to_api_hostname();
format!("https://{}{}", api_hostname, GENERATE_HUNYUAN_2_IMAGE_TO_3D_URL_PATH)
}
@@ -0,0 +1 @@
pub mod generate_hunyuan2_image_to_3d;
@@ -2,6 +2,7 @@
pub mod credentials;
pub mod error;
pub mod generate;
pub mod media_files;
pub mod recipes;
pub mod shared_response_types;
@@ -4,6 +4,7 @@ use sqlx::MySqlPool;
use enums::by_table::generic_synthetic_ids::id_category::IdCategory;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
@@ -51,6 +52,9 @@ pub struct InsertArgs<'a> {
pub maybe_frame_width: Option<u32>,
pub maybe_frame_height: Option<u32>,
pub checksum_sha2: &'a str,
// Media info for certain product areas
pub maybe_engine_category: Option<MediaFileEngineCategory>, // TODO: Deprecate
// User text information
pub maybe_title: Option<&'a str>,
@@ -152,6 +156,8 @@ pub async fn insert_media_file_generic(
maybe_frame_height = ?,
maybe_prompt_token = ?,
checksum_sha2 = ?,
maybe_engine_category = ?,
maybe_title = ?,
maybe_text_transcript = ?,
@@ -205,6 +211,8 @@ pub async fn insert_media_file_generic(
args.maybe_prompt_token.map(|t| t.as_str()),
args.checksum_sha2,
args.maybe_engine_category.map(|e| e.to_str()),
args.maybe_title,
args.maybe_text_transcript,
@@ -107,6 +107,7 @@ pub async fn insert_media_file_generic_from_job(
maybe_extra_media_info: args.maybe_extra_media_info,
maybe_frame_height: args.maybe_frame_height,
maybe_frame_width: args.maybe_frame_width,
maybe_engine_category: None,
maybe_mime_type: args.maybe_mime_type,
maybe_mod_user_token: args.maybe_mod_user_token,
maybe_origin_filename: args.maybe_origin_filename,
@@ -9,6 +9,7 @@ use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use sqlx::MySqlPool;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
@@ -51,6 +52,9 @@ pub struct MediaFileInsertBuilder {
maybe_frame_height: Option<u32>,
checksum_sha2: Option<String>, // NB: Non-nullable field
// Media info for certain product areas
pub maybe_engine_category: Option<MediaFileEngineCategory>, // TODO: Deprecate
// // User text information
// maybe_title: Option<&'a str>,
// maybe_text_transcript: Option<&'a str>,
@@ -94,6 +98,7 @@ impl MediaFileInsertBuilder {
maybe_frame_width: None,
maybe_frame_height: None,
checksum_sha2: None,
maybe_engine_category: None,
public_bucket_directory_hash: None,
}
}
@@ -207,6 +212,11 @@ impl MediaFileInsertBuilder {
self
}
pub fn maybe_engine_category(mut self, maybe_engine_category: Option<MediaFileEngineCategory>) -> Self {
self.maybe_engine_category = maybe_engine_category;
self
}
// TODO: maybe_title
// TODO: maybe_text_transcript
// TODO: maybe_scene_source_media_file_token
@@ -258,6 +268,7 @@ impl MediaFileInsertBuilder {
maybe_video_encoding: None, // TODO
maybe_frame_width: self.maybe_frame_width,
maybe_frame_height: self.maybe_frame_height,
maybe_engine_category: self.maybe_engine_category,
checksum_sha2: &checksum_sha2,
maybe_title: None, // TODO
maybe_text_transcript: None, // TODO
@@ -111,6 +111,7 @@ pub async fn insert_media_file_from_studio_scene_render(
maybe_batch_token: None,
maybe_prompt_token: None,
maybe_mod_user_token: None,
maybe_engine_category: None,
}).await?;
Ok(new_media_token)
@@ -3,7 +3,7 @@ use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
const GENERATE_HUNYUAN_2_IMAGE_TO_3D_URL_PATH: &str = "/v1/generate/object/hunyuan_2_image_to_3d";
pub const GENERATE_HUNYUAN_2_IMAGE_TO_3D_URL_PATH: &str = "/v1/generate/object/hunyuan_2_image_to_3d";
#[derive(Serialize, Deserialize, ToSchema)]
pub struct GenerateHunyuan2ImageTo3dRequest {
@@ -13,9 +13,6 @@ pub struct GenerateHunyuan2ImageTo3dRequest {
/// Source of the file to convert to 3D
/// It must be an image.
pub media_file_token: Option<MediaFileToken>,
/// Optional text prompt.
pub prompt: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
@@ -167,11 +167,6 @@ pub async fn generate_hunyuan_2_image_to_3d_handler(
info!("Fal webhook URL: {}", server_state.fal.webhook_url);
let prompt = request.prompt
.as_deref()
.map(|prompt| prompt.trim())
.unwrap_or_else(|| "");
let args = Hunyuan2Args {
image_url: media_links.cdn_url,
webhook_url: &server_state.fal.webhook_url,
@@ -32,7 +32,6 @@ use mysql_queries::queries::generic_inference::web::get_inference_job_status::{g
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_cancelled_by_user::mark_generic_inference_job_cancelled_by_user;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
use mysql_queries::queries::media_files::create::generic_insert::insert_media_file_generic::{insert_media_file_generic, InsertArgs};
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
use mysql_queries::queries::tts::tts_inference_jobs::mark_tts_inference_job_permanently_dead::mark_tts_inference_job_permanently_dead;
@@ -18,6 +18,7 @@ use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id:
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use serde_json::{Map, Value};
use std::sync::Arc;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use tokens::tokens::media_files::MediaFileToken;
const PREFIX : Option<&str> = Some("artcraft_");
@@ -90,6 +91,7 @@ pub async fn handle_model_mesh_payload(
.media_file_class(MediaFileClass::Dimensional)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.maybe_engine_category(Some(MediaFileEngineCategory::Object))
//.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState } from "react";
import {
faGear,
faImages,
@@ -23,19 +23,21 @@ import {
MenuIconSelector,
MenuIconItem,
} from "@storyteller/ui-menu-icon-selector";
import { signal } from "@preact/signals-react";
import { signal } from "@preact/signals-react";
import { useSignals } from "@preact/signals-react/runtime";
import { setLogoutStates } from "~/signals/authentication/utilities";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useTabStore } from "~/pages/Stores/TabState";
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
import {
is3DEditorInitialized,
is3DSceneLoaded,
set3DPageMounted,
} from "~/pages/PageEnigma/Editor/editor";
interface Props {
pageName: string;
}
const appMenuTabs: MenuIconItem[] = [
{
id: "2D",
@@ -62,9 +64,7 @@ const appMenuTabs: MenuIconItem[] = [
export const topNavMediaId = signal<string>("");
export const topNavMediaUrl = signal<string>("");
export const TopBar = ({
pageName
}: Props) => {
export const TopBar = ({ pageName }: Props) => {
useSignals();
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
@@ -73,9 +73,19 @@ export const TopBar = ({
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
};
const tabStore = useTabStore();
const is3DSceneReady = is3DSceneLoaded.value;
const is3DEditorReady = is3DEditorInitialized.value;
const disableTabSwitcher = () => {
return (
useTabStore.getState().activeTabId === "3D" &&
!is3DEditorReady &&
!is3DSceneReady
);
};
return (
<>
<header className="fixed left-0 top-0 z-[60] w-full border-b border-white/5 bg-ui-background">
@@ -95,7 +105,7 @@ export const TopBar = ({
<MenuIconSelector
menuItems={appMenuTabs}
activeMenu={tabStore.activeTabId}
disabled={false}
disabled={disableTabSwitcher()}
onMenuChange={(tabId) => {
// Disable 3d engine to prevent memory leak.
if (tabId === "3D") {
@@ -103,9 +113,7 @@ export const TopBar = ({
} else {
set3DPageMounted(false);
}
useTabStore
.getState()
.setActiveTab(tabId);
useTabStore.getState().setActiveTab(tabId);
}}
className="w-fit"
/>
@@ -158,10 +166,7 @@ export const TopBar = ({
globalAccountLogoutCallback={() => setLogoutStates()}
/>
<GalleryModal
mode="view"
onDownloadClicked={downloadFileFromUrl}
/>
<GalleryModal mode="view" onDownloadClicked={downloadFileFromUrl} />
</>
);
};
@@ -2,6 +2,7 @@ export enum FilterMediaClasses {
AUDIO = "audio",
IMAGE = "image",
VIDEO = "video",
DIMENSIONAL = "dimensional",
}
export enum FilterMediaType {
@@ -1,4 +1,4 @@
import React, { useState, useRef } from "react";
import { useState, useRef, useEffect } from "react";
import { PaintSurface } from "./PaintSurface";
// https://github.com/SaladTechnologies/comfyui-api
import "./App.css";
@@ -12,23 +12,22 @@ import { useCopyPasteHotkeys } from "./hooks/useCopyPasteHotkeys"; // Import the
import { PopoverItem, PopoverMenu } from "@storyteller/ui-popover";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClock, faImage } from "@fortawesome/pro-solid-svg-icons";
import Konva from 'konva';
import Konva from "konva"; // just for types
import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap"
import { captureStageImageBitmap } from "./hooks/useUpdateSnapshot"
import { useStateSceneLoader } from "./hooks/useStateSceneLoader";
import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap";
import { captureStageImageBitmap } from "./hooks/useUpdateSnapshot";
import { ContextMenuContainer } from "./components/ui/ContextMenu";
const PageDraw = () => {
//useStateSceneLoader();
// State for canvas dimensions
const canvasWidth = React.useRef<number>(1024);
const canvasHeight = React.useRef<number>(1024);
const canvasWidth = useRef<number>(1024);
const canvasHeight = useRef<number>(1024);
// Add new state to track if user is selecting
const [isSelecting, setIsSelecting] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("GPT-4o");
// Create refs for stage and image
const stageRef = useRef<Konva.Stage>({});
const stageRef = useRef<Konva.Stage>({} as Konva.Stage);
const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({});
// Use the Zustand store
@@ -42,17 +41,88 @@ const PageDraw = () => {
onPaste: store.pasteItems,
});
// Listen for gallery drag and drop events
useEffect(() => {
const handleGallery2DDrop = (event: CustomEvent) => {
const { item, canvasPosition } = event.detail;
console.log("Received 2D gallery drop:", { item, canvasPosition });
// Get the stage to transform coordinates properly
const stage = stageRef.current;
if (!stage) {
console.error(
"Stage reference not available for coordinate transformation",
);
return;
}
// Transform canvas coordinates to stage coordinates
// Account for stage position, scale, and transformations
const stageX = stage.x();
const stageY = stage.y();
const scaleX = stage.scaleX();
const scaleY = stage.scaleY();
const stagePoint = {
x: (canvasPosition.x - stageX) / scaleX,
y: (canvasPosition.y - stageY) / scaleY,
};
console.log("Transformed stage coordinates:", stagePoint);
// Use the direct URL for the image
const imageUrl = item.fullImage || item.thumbnail;
if (!imageUrl) {
console.error("No image URL available for dropped item");
return;
}
console.log("Creating image from URL:", imageUrl);
// Use the store's createImageFromUrl method directly
store.createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl);
console.log(
`Created image "${item.label}" at stage position:`,
stagePoint,
);
};
// Add event listener
window.addEventListener(
"gallery-2d-drop",
handleGallery2DDrop as EventListener,
);
// Cleanup
return () => {
window.removeEventListener(
"gallery-2d-drop",
handleGallery2DDrop as EventListener,
);
};
}, [store]);
const handleImageUpload = (files: File[]): void => {
// Place images at center of viewport with offset for multiple images
const centerX = 512; // leftPanelWidth / 2
const centerY = 512; // leftPanelHeight / 2
console.log("Image upload started with files:", files);
console.log("Center coordinates:", { centerX, centerY });
files.forEach((file, index) => {
console.log(`Processing file ${index}:`, file.name);
store.createImageFromFile(
centerX + index * 60, // Offset each image
centerY + index * 60,
file,
);
console.log(`Created image at position:`, {
x: centerX + index * 60,
y: centerY + index * 60,
});
});
};
@@ -79,14 +149,14 @@ const PageDraw = () => {
const onEnqueuedPressed = async () => {
// takes snap shot and then a global variable in the engine will invoke the inference.
const image = await captureStageImageBitmap(stageRef,transformerRefs)
const image = await captureStageImageBitmap(stageRef, transformerRefs);
if (!image) {
console.error('Failed to capture stage image');
console.error("Failed to capture stage image");
return;
} else {
setCanvasRenderBitmap(image);
}
}
};
return (
<>
@@ -114,7 +184,7 @@ const PageDraw = () => {
// Handle aspect ratio changes here
}}
onEnqueuePressed={onEnqueuedPressed}
/>
/>
</div>
<SideToolbar
className="fixed left-0 top-1/2 z-10 -translate-y-1/2 transform"
@@ -133,10 +203,11 @@ const PageDraw = () => {
store.createTriangle(centerX, centerY);
}
}}
onPaintBrush={(hex: string, size: number): void => {
onPaintBrush={(hex: string, size: number, opacity: number): void => {
store.setActiveTool("draw");
store.setBrushColor(hex);
store.setBrushSize(size);
store.setBrushOpacity(opacity);
}}
onEraser={(size: number): void => {
store.setActiveTool("eraser");
@@ -155,23 +226,42 @@ const PageDraw = () => {
}}
onUploadImage={(): void => {
// Create input element dynamically like in PromptEditor
console.log("Upload image activated");
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true; // Allow multiple file selection
input.multiple = true;
input.style.display = "none"; // Hide the input
// Attach to DOM
document.body.appendChild(input);
input.onchange = (e: Event) => {
console.log("File change event triggered");
const target = e.target as HTMLInputElement;
if (target.files) {
const files = Array.from(target.files);
console.log("Selected files:", files);
const imageFiles = files.filter((file) =>
file.type.startsWith("image/"),
);
console.log("Filtered image files:", imageFiles);
if (imageFiles.length > 0) {
console.log("Uploading images:", imageFiles);
handleImageUpload(imageFiles);
} else {
console.log("No valid image files selected");
}
} else {
console.log("No files selected");
}
// Clean up: remove input from DOM after use
document.body.removeChild(input);
};
// Reset value before click (for same file selection)
input.value = "";
input.click();
}}
onDelete={(): void => {
@@ -181,21 +271,23 @@ const PageDraw = () => {
activeToolId={store.activeTool}
/>
<div className="relative z-0">
<PaintSurface
nodes={store.nodes}
selectedNodeIds={store.selectedNodeIds}
onCanvasSizeChange={(width: number, height: number): void => {
canvasWidth.current = width;
canvasHeight.current = height;
}}
fillColor={store.fillColor}
activeTool={store.activeTool}
brushColor={store.brushColor}
brushSize={store.brushSize}
onSelectionChange={setIsSelecting}
stageRef={stageRef}
transformerRefs={transformerRefs}
/>
<ContextMenuContainer>
<PaintSurface
nodes={store.nodes}
selectedNodeIds={store.selectedNodeIds}
onCanvasSizeChange={(width: number, height: number): void => {
canvasWidth.current = width;
canvasHeight.current = height;
}}
fillColor={store.fillColor}
activeTool={store.activeTool}
brushColor={store.brushColor}
brushSize={store.brushSize}
onSelectionChange={setIsSelecting}
stageRef={stageRef}
transformerRefs={transformerRefs}
/>
</ContextMenuContainer>
</div>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
<PopoverMenu
@@ -185,6 +185,14 @@ export const PaintSurface = ({
isWithinLeftPanel(stagePoint)
) {
const lineId = `line-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
let opacity;
if (activeTool === "draw") {
opacity = store.brushOpacity
} else {
opacity = 1
}
const newLineNode: LineNode = {
id: lineId,
type: "line",
@@ -192,6 +200,7 @@ export const PaintSurface = ({
stroke: activeTool === "draw" ? brushColor : fillColor || "#ffffff",
strokeWidth: brushSize / stage.scaleX(),
draggable: true,
opacity: opacity // Add opacity to the line node
};
store.selectNode(null);
store.addLineNode(newLineNode);
@@ -738,11 +747,12 @@ export const PaintSurface = ({
id={lineNode.id}
points={lineNode.points}
stroke={lineNode.stroke}
opacity={lineNode.opacity ?? 1} // Use the line node's opacity or default to 1
strokeWidth={
isSelected
? (lineNode.strokeWidth || 0) + 2
: lineNode.strokeWidth
} // Ensure strokeWidth is number
}
tension={0.5}
lineCap="round"
lineJoin="round"
@@ -0,0 +1,174 @@
import React, { useEffect, useRef } from 'react';
interface MenuPosition {
x: number;
y: number;
}
interface MenuItem {
icon: React.ReactNode;
label: string;
onClick: () => void;
divider?: boolean;
}
interface ContextMenuProps {
items: MenuItem[];
onClose: () => void;
position: MenuPosition;
}
export const ContextMenu: React.FC<ContextMenuProps> = ({ items, onClose, position }) => {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);
return (
<div
ref={menuRef}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
}}
className="fixed z-50 min-w-[220px] bg-[#1A1A1A] rounded-lg shadow-xl border border-[#333333] py-1 select-none"
>
{items.map((item, index) => (
<React.Fragment key={index}>
<button
onClick={() => {
item.onClick();
onClose();
}}
className="w-full px-4 py-2.5 flex items-center gap-3 hover:bg-[#333333] text-[#ECECEC] text-[13px] transition-colors duration-75 ease-in-out"
>
<span className="w-5 h-5 flex items-center justify-center text-[#ECECEC]">
{item.icon}
</span>
<span className="font-normal">{item.label}</span>
</button>
{item.divider && <div className="h-[1px] bg-[#333333] mx-2 my-1" />}
</React.Fragment>
))}
</div>
);
};
// Example usage with icons
const menuItems: MenuItem[] = [
{
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 3L4 7.5L12 12L20 7.5L12 3Z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M4 16.5L12 21L20 16.5" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M4 12L12 16.5L20 12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>,
label: 'Convert to 3D',
onClick: () => console.log('Convert to 3D')
},
{
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M4 4v16h16M4 20l16-16" strokeWidth="2" strokeLinecap="round"/>
</svg>,
label: 'Remove background',
onClick: () => console.log('Remove background')
},
{
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 10V14M8 6H16C17.1046 6 18 6.89543 18 8V16C18 17.1046 17.1046 18 16 18H8C6.89543 18 6 17.1046 6 16V8C6 6.89543 6.89543 6 8 6Z" strokeWidth="2" strokeLinecap="round"/>
</svg>,
label: 'Lock',
onClick: () => console.log('Lock'),
divider: true
},
{
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M5 15L12 8L19 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>,
label: 'Bring to front',
onClick: () => console.log('Bring to front')
},
{
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M8 14L12 10L16 14" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>,
label: 'Bring forward',
onClick: () => console.log('Bring forward')
},
{
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M8 10L12 14L16 10" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>,
label: 'Send backward',
onClick: () => console.log('Send backward')
},
{
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M5 9L12 16L19 9" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>,
label: 'Send to back',
onClick: () => console.log('Send to back'),
divider: true
},
{
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M16 8V6M16 8H8M16 8V16M8 8V6M8 8V16M8 16H6M8 16H16M16 16H18M6 6H8M8 6H16M16 6H18" strokeWidth="2" strokeLinecap="round"/>
</svg>,
label: 'Duplicate',
onClick: () => console.log('Duplicate')
},
{
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M7 16L17 8M7 8L17 16" strokeWidth="2" strokeLinecap="round"/>
</svg>,
label: 'Flip Horizontal',
onClick: () => console.log('Flip Horizontal')
},
{
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M8 7L16 17M8 17L16 7" strokeWidth="2" strokeLinecap="round"/>
</svg>,
label: 'Flip Vertical',
onClick: () => console.log('Flip Vertical')
},
{
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M4 12V6C4 4.89543 4.89543 4 6 4H18C19.1046 4 20 4.89543 20 6V18C20 19.1046 19.1046 20 18 20H12" strokeWidth="2" strokeLinecap="round"/>
<path d="M9 15H4V20" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>,
label: 'Fit to screen',
onClick: () => console.log('Fit to screen')
}
];
// Container component that handles the right-click
export const ContextMenuContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [showMenu, setShowMenu] = React.useState(false);
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0 });
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setPosition({ x: e.clientX, y: e.clientY });
setShowMenu(true);
};
return (
<div onContextMenu={handleContextMenu} className="w-full h-full">
{children}
{showMenu && (
<ContextMenu
items={menuItems}
position={position}
onClose={() => setShowMenu(false)}
/>
)}
</div>
);
};
@@ -13,6 +13,8 @@ import {
faSparkles,
faFileImport,
faFileExport,
faUndo,
faRedo
} from "@fortawesome/pro-solid-svg-icons";
import "../../App.css";
import { HsvaColorPicker, HsvaColor } from "react-colorful";
@@ -40,7 +42,7 @@ function useDebounced<T extends (...args: A) => void, A extends unknown[]>(
export interface SideToolbarProps {
onSelect: () => void;
onAddShape: (shape: "rectangle" | "circle" | "triangle") => void;
onPaintBrush: (hex: string, size: number) => void;
onPaintBrush: (hex: string, size: number,opacity: number) => void;
onEraser: (size: number) => void;
onCanvasBackground: (hex: string) => void;
onGenerateImage: () => void;
@@ -72,6 +74,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
v: 100,
a: 1,
});
const [bgHsva, setBgHsva] = useState<HsvaColor>({
h: 0,
s: 100,
@@ -81,9 +84,11 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
/* debounced parent calls */
const sendPaint = useDebounced<
(hex: string, size: number) => void,
[string, number]
>(onPaintBrush, 75);
(hex: string, size: number, opacity: number) => void,
[string, number, number]
>(onPaintBrush, 75, 1);
const sendBg = useDebounced<(hex: string) => void, [string]>(
onCanvasBackground,
75,
@@ -117,7 +122,8 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
const BrushPopout = makePicker(
brushHsva,
setBrushHsva,
(hex) => sendPaint(hex, brushSize),
(hex) => sendPaint(hex, brushSize, brushHsva.a),
<>
<div className="relative">
<p className="mb-2 text-sm font-medium text-white">Brush Size</p>
@@ -125,7 +131,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
value={brushSize}
onChange={(size) => {
setBrushSize(size);
sendPaint(hsvaToHex(brushHsva), size);
sendPaint(hsvaToHex(brushHsva), size, brushHsva.a);
}}
min={1}
max={64}
@@ -179,14 +185,14 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
</div>
),
},
{
id: "generate",
label: "Generate Image",
icon: <FontAwesomeIcon icon={faSparkles} className="h-5 w-5" />,
onClick: () => {
onGenerateImage();
},
},
// {
// id: "generate",
// label: "Generate Image",
// icon: <FontAwesomeIcon icon={faSparkles} className="h-5 w-5" />,
// onClick: () => {
// onGenerateImage();
// },
// },
{
id: "upload",
label: "Upload Image",
@@ -206,7 +212,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
/>
),
onClick: () => {
sendPaint(hsvaToHex(brushHsva), brushSize);
sendPaint(hsvaToHex(brushHsva), brushSize, brushHsva.a);
},
popout: BrushPopout,
},
@@ -251,35 +257,51 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
popout: BgPopout,
},
{
id: "save-scene",
label: "Save Scene",
icon: <FontAwesomeIcon icon={faFileExport} className="h-5 w-5" />,
id: "undo",
label: "Undo",
icon: <FontAwesomeIcon icon={faUndo} className="h-5 w-5" />,
onClick: () => {
store.saveSceneToFile();
store.undo(); // Assuming store has an undo method
},
},
{
id: "load-scene",
label: "Load Scene",
icon: <FontAwesomeIcon icon={faFileImport} className="h-5 w-5" />,
id: "redo",
label: "Redo",
icon: <FontAwesomeIcon icon={faRedo} className="h-5 w-5" />,
onClick: () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = async (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.files && target.files[0]) {
const success = await store.loadSceneFromFile(target.files[0]);
if (success) {
console.log("Scene loaded successfully");
} else {
console.error("Failed to load scene");
}
}
};
input.click();
store.redo(); // Assuming store has a redo method
},
},
// {
// id: "save-scene",
// label: "Save Scene",
// icon: <FontAwesomeIcon icon={faFileExport} className="h-5 w-5" />,
// onClick: () => {
// store.saveSceneToFile();
// },
// },
// {
// id: "load-scene",
// label: "Load Scene",
// icon: <FontAwesomeIcon icon={faFileImport} className="h-5 w-5" />,
// onClick: () => {
// const input = document.createElement("input");
// input.type = "file";
// input.accept = ".json";
// input.onchange = async (e: Event) => {
// const target = e.target as HTMLInputElement;
// if (target.files && target.files[0]) {
// const success = await store.loadSceneFromFile(target.files[0]);
// if (success) {
// console.log("Scene loaded successfully");
// } else {
// console.error("Failed to load scene");
// }
// }
// };
// input.click();
// },
// },
];
/* ------------------------------------------------ render ---------- */
@@ -9,6 +9,7 @@ export type LineNode = {
stroke: string;
strokeWidth: number;
draggable: boolean;
opacity?: number; // Add opacity property
x?: number;
y?: number;
rotation?: number;
@@ -33,6 +34,7 @@ interface SceneState {
activeTool: ActiveTool;
brushColor: string;
brushSize: number;
brushOpacity: number;
fillColor: string;
// Cursor state
@@ -84,6 +86,7 @@ interface SceneState {
// Toolbar actions
setActiveTool: (tool: ActiveTool) => void;
setBrushColor: (color: string) => void;
setBrushOpacity: (opacity: number) => void;
setBrushSize: (size: number) => void;
setFillColor: (color: string) => void;
@@ -122,6 +125,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
activeTool: 'select',
brushColor: '#000000',
brushSize: 5,
brushOpacity: 1,
fillColor: 'white',
// Cursor initial state
@@ -515,7 +519,12 @@ export const useSceneStore = create<SceneState>((set, get) => ({
const aspectRatio = img.naturalWidth / img.naturalHeight;
const finalWidth = width || Math.min(img.naturalWidth, 300);
const finalHeight = height || finalWidth / aspectRatio;
console.log('Image loaded with dimensions:', {
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
finalWidth: finalWidth,
finalHeight: finalHeight
});
get().createImage(x, y, file, finalWidth, finalHeight);
};
img.src = dataUrl;
@@ -576,8 +585,9 @@ export const useSceneStore = create<SceneState>((set, get) => ({
// Load the image asynchronously
node.setImage(source).then(() => {
// Update the node in the store after image loads
console.log('Image loaded for node:', node.id);
get().updateNode(node.id, node, false);
}).catch(console.error);
}).catch((error)=>console.error('Error loading image:', error));
get().addNode(node);
},
@@ -713,6 +723,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
setBrushColor: (color: string) => set({ brushColor: color }),
setBrushSize: (size: number) => set({ brushSize: size }),
setFillColor: (color: string) => set({ fillColor: color }),
setBrushOpacity: (opacity: number) => set({ brushOpacity: opacity }),
// Cursor actions
setCursorPosition: (position: { x: number; y: number } | null) => set({ cursorPosition: position }),
@@ -0,0 +1,32 @@
import { getArtStyle } from "~/enums/ArtStyle";
import {
cameraAspectRatio,
cinematic,
enginePreProcessing,
faceDetail,
globalIPAMediaToken,
lipSync,
styleStrength,
upscale,
} from "~/pages/PageEnigma/signals";
import { SceneGenereationMetaData as SceneGenerationMetaData } from "~/pages/PageEnigma/models/sceneGenerationMetadata";
import Editor from "./editor";
export const getSceneGenerationMetaData = (
editorEngine: Editor,
): SceneGenerationMetaData => {
// when this is called, editor engine is guarunteed by it's caller
return {
positivePrompt: editorEngine.positive_prompt,
negativePrompt: editorEngine.negative_prompt,
artisticStyle: getArtStyle(editorEngine.art_style.toString()),
cameraAspectRatio: cameraAspectRatio.value,
globalIPAMediaToken: globalIPAMediaToken.value || undefined,
upscale: upscale.value,
faceDetail: faceDetail.value,
styleStrength: styleStrength.value,
lipSync: lipSync.value,
cinematic: cinematic.value,
enginePreProcessing: enginePreProcessing.value,
};
};
@@ -50,29 +50,37 @@ import { SceneManager } from "./scene_manager_api";
import { CustomOutlinePass } from "./CustomOutlinePass.js";
import FindSurfaces from "./FindSurfaces.js";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { CharacterAnimationEngine } from "./Engines/CharacterAnimationEngine";
import { cameras, selectedCameraId } from "~/pages/PageEnigma/signals/camera";
export type EditorInitializeConfig = {
sceneToken: string;
editorCanvasEl: HTMLCanvasElement;
camViewCanvasEl: HTMLCanvasElement;
sceneContainerEl: HTMLDivElement;
cacheJsonString?: string;
};
// ONLY USED IN THIS CLASS
// ONLY USED IN THIS CLASS
// Need this one to ensure the canvas is inflated and non-zero sized
const is3DPageMounted = signal(false);
export const set3DPageMounted = (isMounted: boolean) => {
is3DPageMounted.value = isMounted;
}
};
// Need this one to know when the engine has initialized (but not necessarily loaded saves)
export const is3DEditorInitialized = signal(false);
export const setIs3DEditorInitialized = (isInitialized: boolean) => {
is3DEditorInitialized.value = isInitialized;
};
// Need this one to know when the engine is done loading stuff into the scene for display
export const is3DSceneLoaded = signal(false);
export const setIs3DSceneLoaded = (isLoaded: boolean) => {
is3DSceneLoaded.value = isLoaded;
};
class Editor {
version: number;
activeScene: Scene;
@@ -333,9 +341,7 @@ class Editor {
// set image type at this stage
this.renderIndex = 0;
}
// Add helper method to convert focal length to FOV
@@ -400,7 +406,7 @@ class Editor {
}
updateSceneContainer(newContainer: HTMLDivElement) {
this.container = newContainer;
this.containerResizeObserver?.disconnect()
this.containerResizeObserver?.disconnect();
this.containerResizeObserver?.observe(this.container);
}
engineCanvasMayReset() {
@@ -462,6 +468,7 @@ class Editor {
editorCanvasEl,
camViewCanvasEl,
sceneContainerEl,
cacheJsonString: cacheJson = "",
}: EditorInitializeConfig) {
if (!this.can_initialize) {
console.log("Reinitializing 3D editor");
@@ -662,8 +669,15 @@ class Editor {
// Attach freecam state controller
this.mouseOverEventHandler = this.mouseOverEventHandler.bind(this);
if (!this.utils.isEmpty(sceneToken)) {
this.loadScene(sceneToken);
const onloadCallback = () => {
console.log("Setting Scene is loaded");
setIs3DSceneLoaded(true);
};
if (!this.utils.isEmpty(cacheJson)) {
this.loadCache(cacheJson).then(onloadCallback);
} else if (!this.utils.isEmpty(sceneToken)) {
this.loadScene(sceneToken).then(onloadCallback);
} else {
signalScene({
title: "Untitled New Scene",
@@ -671,6 +685,7 @@ class Editor {
ownerToken: authentication.userInfo.value?.user_token,
isModified: false,
});
onloadCallback();
}
this._configurePostProcessingRaw();
@@ -917,6 +932,10 @@ class Editor {
}
}
public async loadCache(cacheJson: string) {
await this.save_manager.loadCache(cacheJson);
}
public async loadScene(scene_media_token: string) {
await this.save_manager.loadScene(scene_media_token);
@@ -981,6 +1000,20 @@ class Editor {
});
}
public cacheScene({
sceneTitle,
sceneToken,
sceneGenerationMetadata,
}: {
sceneTitle: string;
sceneToken: string;
sceneGenerationMetadata: SceneGenereationMetaData;
}) {
return this.save_manager.getSceneJson({
sceneGenerationMetadata: sceneGenerationMetadata,
});
}
get_camera_person_mode(): boolean {
return this.camera_person_mode;
}
@@ -1183,10 +1216,10 @@ class Editor {
: this.render_camera_aspect_ratio === meraAspectRatio.VERTICAL_9_16
? 576
: this.render_camera_aspect_ratio ===
meraAspectRatio.HORIZONTAL_3_2
meraAspectRatio.HORIZONTAL_3_2
? 900
: this.render_camera_aspect_ratio ===
meraAspectRatio.VERTICAL_2_3
meraAspectRatio.VERTICAL_2_3
? 600
: 1000;
const height =
@@ -1195,10 +1228,10 @@ class Editor {
: this.render_camera_aspect_ratio === meraAspectRatio.VERTICAL_9_16
? 1024
: this.render_camera_aspect_ratio ===
meraAspectRatio.HORIZONTAL_3_2
meraAspectRatio.HORIZONTAL_3_2
? 600
: this.render_camera_aspect_ratio ===
meraAspectRatio.VERTICAL_2_3
meraAspectRatio.VERTICAL_2_3
? 900
: 1000;
@@ -1261,7 +1294,6 @@ class Editor {
console.log(`COLLECTING COLOR FRAMES COUNTED: ${this.renderIndex}`);
await this.engineFrameBuffers.collectColorFrames(this.renderIndex);
this.renderIndex = 0;
console.time("Stop Playback And Upload Video Time");
await this.stopPlaybackAndUploadVideo();
@@ -1467,11 +1499,20 @@ class Editor {
}
unmountEngine() {
setIs3DSceneLoaded(false);
this.stopRenderLoop();
this.sceneManager?.detachEventListeners();
this.disableFreeCamControls();
this.cameraViewControls?.detachEventListeners();
// Fix: dispose 3D contexts
this.renderer?.dispose();
this.composer?.dispose();
this.render_composer?.dispose();
this.rawRenderer?.dispose();
this.isMounted = false;
setIs3DEditorInitialized(false);
console.log("3D Editor Engine unmounted");
}
@@ -53,8 +53,6 @@ export class SaveManager {
);
const scene_json = await proxyScene.saveToScene(this.editor.version);
const check_sum_data = {
scene: scene_json,
timeline: "",
@@ -81,8 +79,6 @@ export class SaveManager {
);
const scene_json = await proxyScene.saveToScene(this.editor.version);
// Save all cameras data
const camerasData = cameras.value.map((cam: Camera) => ({
id: cam.id,
@@ -112,28 +108,18 @@ export class SaveManager {
return jsonString;
}
// TODO Move this function into scene manager.
public async saveScene({
sceneTitle,
sceneToken,
public getSceneJson({
sceneGenerationMetadata,
}: {
sceneTitle: string;
sceneToken?: string;
sceneGenerationMetadata: SceneGenereationMetaData;
}): Promise<string> {
this.editor.generating_preview = true; // Set this to true to stop control panel from flipping out.
// remove controls when saving scene.
this.editor.utils.removeTransformControls();
showEditorLoader();
}) {
const proxyScene = new StoryTellerProxyScene(
this.editor.version,
this.editor.activeScene,
);
const scene_json = await proxyScene.saveToScene(this.editor.version);
const scene_json = proxyScene.saveToScene(this.editor.version);
console.log(scene_json);
// Save all cameras data
const camerasData = cameras.value.map((cam: Camera) => ({
id: cam.id,
@@ -159,6 +145,28 @@ export class SaveManager {
selectedCameraId: selectedCameraId.value,
};
return save_data;
}
// TODO Move this function into scene manager.
public async saveScene({
sceneTitle,
sceneToken,
sceneGenerationMetadata,
}: {
sceneTitle: string;
sceneToken?: string;
sceneGenerationMetadata: SceneGenereationMetaData;
}): Promise<string> {
this.editor.generating_preview = true; // Set this to true to stop control panel from flipping out.
// remove controls when saving scene.
this.editor.utils.removeTransformControls();
showEditorLoader();
const sceneJson = this.getSceneJson({
sceneGenerationMetadata,
});
// TODO turn scene information into and object ...
let sceneThumbnail = undefined;
@@ -169,7 +177,7 @@ export class SaveManager {
}
const result = await this.editor.api_manager.saveSceneState({
saveJson: JSON.stringify(save_data),
saveJson: JSON.stringify(sceneJson),
sceneTitle,
sceneToken,
sceneThumbnail,
@@ -186,19 +194,7 @@ export class SaveManager {
return result; // if this is an empty string it is an error. need to migrate to api manager.
}
// TODO Refactor remove editor.
public async loadScene(scene_media_token: string) {
showEditorLoader();
this.editor.current_scene_media_token = scene_media_token;
const scene_json = await this.editor.api_manager
.loadSceneState(this.editor.current_scene_media_token)
.catch((err) => {
hideEditorLoader();
throw err;
});
private async loadFromJson(scene_json: any) {
const proxyScene = new StoryTellerProxyScene(
this.editor.version,
this.editor.activeScene,
@@ -273,6 +269,32 @@ export class SaveManager {
console.debug("Loading Timeline", scene_json["timeline"]);
this.editor.timeline.checkEditorCanPlay();
}
public async loadCache(cacheJson: string) {
showEditorLoader();
const scene_json = JSON.parse(cacheJson);
await this.loadFromJson(scene_json);
hideEditorLoader();
this.editor.timeline.scrub({ currentTime: 0 });
}
// TODO Refactor remove editor.
public async loadScene(scene_media_token: string) {
showEditorLoader();
this.editor.current_scene_media_token = scene_media_token;
const scene_json = await this.editor.api_manager
.loadSceneState(this.editor.current_scene_media_token)
.catch((err) => {
hideEditorLoader();
throw err;
});
await this.loadFromJson(scene_json);
hideEditorLoader();
// TODO figure out if this is a bug.
@@ -32,7 +32,6 @@ import { LoadingDots } from "@storyteller/ui-loading";
import { OnboardingHelper } from "./comps/OnboardingHelper";
import { FocalLengthDisplay } from "./comps/FocalLengthDisplay/FocalLengthDisplay";
import {
addCamera,
cameras,
@@ -76,7 +75,6 @@ import {
import PageDraw from "../PageDraw/PageDraw";
import { useTabStore } from "../Stores/TabState";
export const PageEditor = () => {
useSignals();
@@ -269,7 +267,7 @@ export const PageEditor = () => {
useEffect(() => {
let handler: unknown;
// 3D Drag and Drop Logic
if (tabStore.activeTabId === "3D") {
handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
@@ -297,31 +295,50 @@ export const PageEditor = () => {
(async () => {
try {
// Place the object immediately to avoid upload delay affecting placement
const mediaItem: MediaItem = {
version: 1,
type: AssetType.OBJECT,
media_id: item.id || uuidv4(),
name: item.label || "Image Plane",
...(worldPosition && {
position: {
x: worldPosition.x,
y: worldPosition.y,
z: worldPosition.z,
},
}),
};
addObject(mediaItem);
if (item.mediaClass === "dimensional") {
// Handle 3D models
const mediaItem: MediaItem = {
version: 1,
type: AssetType.OBJECT,
media_id: item.id || uuidv4(),
name: item.label || "3D Object",
...(worldPosition && {
position: {
x: worldPosition.x,
y: worldPosition.y,
z: worldPosition.z,
},
}),
};
addObject(mediaItem);
} else {
// Handle images (create image planes)
const mediaItem: MediaItem = {
version: 1,
type: AssetType.OBJECT,
media_id: item.id || uuidv4(),
name: item.label || "Image Plane",
...(worldPosition && {
position: {
x: worldPosition.x,
y: worldPosition.y,
z: worldPosition.z,
},
}),
};
addObject(mediaItem);
await uploadPlaneFromMediaToken({
title: item.label || "Image Plane",
mediaToken: item.id,
progressCallback: (state: UploaderState) => {
if (state.status) console.log("Upload status:", state.status);
},
});
await uploadPlaneFromMediaToken({
title: item.label || "Image Plane",
mediaToken: item.id,
progressCallback: (state: UploaderState) => {
if (state.status)
console.log("Upload status:", state.status);
},
});
}
} catch (err) {
console.error("Failed to add image plane:", err);
console.error("Failed to add object to scene:", err);
}
})();
},
@@ -331,8 +348,52 @@ export const PageEditor = () => {
} else if (tabStore.activeTabId === "2D") {
handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
// ...2D drop logic... - TODO FOR MICHAEL
console.log("2D drop logic here", item, position);
console.log("2D Drop debug (event):", {
item,
position,
});
// Find the main Konva canvas element - get the first canvas (left panel)
const canvasElements = document.querySelectorAll("canvas");
const canvasElement = canvasElements[0]; // Get the main drawing canvas (left panel)
if (!canvasElement) {
console.error("Could not find canvas element for 2D drop");
return;
}
const rect = canvasElement.getBoundingClientRect();
// Convert screen coordinates to canvas coordinates
const canvasX = position.x - rect.left;
const canvasY = position.y - rect.top;
// Ensure the drop position is within canvas bounds
if (
canvasX < 0 ||
canvasY < 0 ||
canvasX > rect.width ||
canvasY > rect.height
) {
console.log("Drop position outside canvas bounds");
return;
}
console.log("Canvas drop position:", { canvasX, canvasY });
(async () => {
try {
// event that PageDraw listens for
const dropEvent = new CustomEvent("gallery-2d-drop", {
detail: {
item,
canvasPosition: { x: canvasX, y: canvasY },
},
});
window.dispatchEvent(dropEvent);
} catch (err) {
console.error("Failed to add image to 2D canvas:", err);
}
})();
},
);
}
@@ -344,14 +405,9 @@ export const PageEditor = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabStore.activeTabId, editorEngine]);
return (
<div className="w-screen">
<TopBar
pageName="Edit Scene"
/>
<TopBar pageName="Edit Scene" />
{tabStore.activeTabId == "3D" && (
<div>
<OnboardingHelper />
@@ -504,4 +560,4 @@ export const PageEditor = () => {
/> */}
</div>
);
};
};
@@ -8,19 +8,42 @@ import {
import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
showActionReminder,
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { EngineContext } from "../../contexts/EngineContext";
import { useContext, useEffect, useState } from "react";
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";
// import { MediaItem } from "../../models/assets";
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";
export const Controls3D = () => {
useSignals();
const editorEngine = useContext(EngineContext);
const [showEmptySceneTooltip, setShowEmptySceneTooltip] = useState(false);
// Action reminder is now handled through signals
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
// Track processed 3D models by their media token to prevent duplicates
const processedModelsRef = useRef<Record<string, boolean>>({});
useEffect(() => {
// Check if scene is empty and onboarding helper is not visible
@@ -79,6 +102,85 @@ export const Controls3D = () => {
editorEngine.change_mode("scale");
};
// ----- TODO LATER - BFlat for auto add 3d model to scene -----
// Function to add the generated 3D model to the scene
// const addGeneratedModelToScene = useCallback((mediaToken: string) => {
// console.log("[DEBUG] addGeneratedModelToScene called with token:", mediaToken);
// // Create a MediaItem object for the 3D model
// const mediaItem: MediaItem = {
// version: 1,
// type: AssetType.OBJECT,
// media_id: mediaToken,
// name: "3D Model",
// object_uuid: uuidv4(),
// };
// // Add the object to the scene
// console.log("[DEBUG] Calling addObject with:", mediaItem);
// try {
// addObject(mediaItem);
// console.log("[DEBUG] addObject called successfully");
// } catch (error) {
// console.error("[DEBUG] Error in addObject:", error);
// }
// }, []);
const handleOpenModal = () => {
assetModalVisibleDuringDrag.value = true;
assetModalVisible.value = true;
};
const handleOpenCreate3dModal = async () => {
try {
const falApiKeyResult = await GetFalApiKey();
if (
falApiKeyResult.status === "success" &&
"payload" in falApiKeyResult &&
falApiKeyResult.payload?.key
) {
// API key exists, open the create 3D modal
useCreate3dModalStore.getState().open();
} else {
// No API key, show the action reminder modal
showActionReminder({
reminderType: "default",
title: "FAL API Key Required",
message:
"To generate 3D models, you need to add a valid FAL API key in your settings.",
primaryActionText: "Add now",
secondaryActionText: "Cancel",
onPrimaryAction: () => {
// Close the action reminder modal first
isActionReminderOpen.value = false;
// Then open the settings modal
openSettingsModal();
},
});
}
} catch (error) {
console.error("Error checking FAL API key:", error);
}
};
const openSettingsModal = () => {
setIsSettingsModalOpen(true);
};
// Handle completed models from the Create3dModal component
const handleModelComplete = useCallback((mediaToken: string) => {
// Check if we've already processed this model
if (mediaToken && !processedModelsRef.current[mediaToken]) {
// Mark this model as processed to avoid duplicates
processedModelsRef.current[mediaToken] = true;
// Add the generated 3D model to the scene
// addGeneratedModelToScene(mediaToken);
}
}, []);
const modes = [
{
value: "move",
@@ -100,11 +202,6 @@ export const Controls3D = () => {
},
];
const handleOpenModal = () => {
assetModalVisibleDuringDrag.value = true;
assetModalVisible.value = true;
};
return (
<>
<div className="flex justify-center">
@@ -148,10 +245,9 @@ export const Controls3D = () => {
>
<Button
icon={faMagicWandSparkles}
className="text-md h-9 w-9 rounded-[10px] bg-white/10"
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
variant="secondary"
disabled={true}
onClick={handleOpenModal}
onClick={handleOpenCreate3dModal}
/>
</Tooltip>
</div>
@@ -167,6 +263,15 @@ export const Controls3D = () => {
</div>
<AssetModal />
<Create3dModal onModelComplete={handleModelComplete} />
<SettingsModal
isOpen={isSettingsModalOpen}
onClose={() => setIsSettingsModalOpen(false)}
globalAccountLogoutCallback={() => setLogoutStates()}
initialSection="accounts"
/>
</>
);
};
@@ -7,11 +7,8 @@ import {
faQuestion,
faSquare,
} from "@fortawesome/pro-solid-svg-icons";
import {
EditorExpandedI,
EngineContext,
} from "~/pages/PageEnigma/contexts/EngineContext";
import { ToastTypes, getArtStyle } from "~/enums";
import { EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
import { ToastTypes } from "~/enums";
import { scene, signalScene, authentication, addToast } from "~/signals";
import { outlinerIsShowing } from "~/pages/PageEnigma/signals/outliner/outliner";
import { ButtonDropdown } from "@storyteller/ui-button-dropdown";
@@ -20,22 +17,12 @@ import { Button } from "@storyteller/ui-button";
import { TestFeaturesButtons } from "./TestFeaturesButtons";
import { LoadUserScenes } from "./LoadUserScenes";
import { getCurrentLocationWithoutParams, isNumberString } from "~/utilities";
import { SceneGenereationMetaData as SceneGenerationMetaData } from "~/pages/PageEnigma/models/sceneGenerationMetadata";
import {
cameraAspectRatio,
cinematic,
enginePreProcessing,
faceDetail,
globalIPAMediaToken,
lipSync,
resetSceneGenerationMetadata,
styleStrength,
upscale,
} from "~/pages/PageEnigma/signals";
import { resetSceneGenerationMetadata } from "~/pages/PageEnigma/signals";
import { CameraAspectRatio } from "~/pages/PageEnigma/enums";
import { twMerge } from "tailwind-merge";
import { Help } from "./Help";
import { Modal } from "@storyteller/ui-modal";
import { getSceneGenerationMetaData } from "../../Editor/SceneMetadata";
export const ControlsTopButtons = () => {
useSignals();
@@ -61,32 +48,12 @@ export const ControlsTopButtons = () => {
);
};
const getSceneGenereationMetaData = useCallback(
(editorEngine: EditorExpandedI): SceneGenerationMetaData => {
// when this is called, editor engine is guarunteed by it's caller
return {
positivePrompt: editorEngine.positive_prompt,
negativePrompt: editorEngine.negative_prompt,
artisticStyle: getArtStyle(editorEngine.art_style.toString()),
cameraAspectRatio: cameraAspectRatio.value,
globalIPAMediaToken: globalIPAMediaToken.value || undefined,
upscale: upscale.value,
faceDetail: faceDetail.value,
styleStrength: styleStrength.value,
lipSync: lipSync.value,
cinematic: cinematic.value,
enginePreProcessing: enginePreProcessing.value,
};
},
[],
);
const handleButtonSave = async () => {
if (!editorEngine) {
addToast(ToastTypes.ERROR, "No Engine Error in Saving Scenes");
return;
}
const sceneGenerationMetadata = getSceneGenereationMetaData(editorEngine);
const sceneGenerationMetadata = getSceneGenerationMetaData(editorEngine);
const retSceneMediaToken = await editorEngine.saveScene({
sceneTitle: scene.value.title || "",
@@ -114,7 +81,7 @@ export const ControlsTopButtons = () => {
addToast(ToastTypes.ERROR, "No Engine Error in Saving Scenes");
return;
}
const sceneGenerationMetadata = getSceneGenereationMetaData(editorEngine);
const sceneGenerationMetadata = getSceneGenerationMetaData(editorEngine);
const retSceneMediaToken = await editorEngine.saveScene({
sceneTitle: sceneTitleInput,
sceneToken: undefined,
@@ -129,7 +96,7 @@ export const ControlsTopButtons = () => {
title: sceneTitleInput,
});
}
}, [sceneTitleInput, editorEngine, getSceneGenereationMetaData]);
}, [sceneTitleInput, editorEngine, getSceneGenerationMetaData]);
const handleButtonLoadScene = () => {
handleResetScene();
@@ -1,11 +1,12 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { EngineContext } from "./EngineContext";
import Editor from "~/pages/PageEnigma/Editor/editor";
import Editor, { is3DSceneLoaded } from "~/pages/PageEnigma/Editor/editor";
import { useSignals } from "@preact/signals-react/runtime";
import { signal } from "@preact/signals-react";
import { useTabStore } from "../../../Stores/TabState"
import { useTabStore } from "../../../Stores/TabState";
import { getSceneGenerationMetaData } from "../../Editor/SceneMetadata";
interface Props {
sceneToken?: string;
children: ReactNode;
@@ -23,10 +24,10 @@ export const EngineProvider = ({ sceneToken, children }: Props) => {
const createEditor = () => {
return new Editor();
}
};
const tabStore = useTabStore();
const tab = tabStore.activeTabId;
const sceneContainer = sceneContainerSignal.value;
const editorCanvas = editorCanvasSignal.value;
const camViewCanvas = camViewCanvasSignal.value;
@@ -36,19 +37,44 @@ export const EngineProvider = ({ sceneToken, children }: Props) => {
if (activeEditorRef.current) {
return;
}
const newEditor = createEditor();
console.warn("Creating new Editor instance", newEditor);
activeEditorRef.current = newEditor;
// Check if we have a cached state
const cacheString = tabStore.getTabData("3D") as string;
newEditor.initialize({
sceneToken: sceneToken || "",
sceneContainerEl: sceneContainer,
editorCanvasEl: editorCanvas,
camViewCanvasEl: camViewCanvas,
})
cacheJsonString: cacheString,
});
setEditor(newEditor);
} else if (tab !== "3D") {
// If the tab is changed, we unmount the editor and clear all input params
if (!activeEditorRef.current) {
return;
}
// If the tab is changed, cache state, unmount the editor and clear all input params
// This condition makes sure the editor saves a fully loaded scene so it doesn't lose data
// If the load was incomplete, we'll preserve the last cache anyway.
if (is3DSceneLoaded.peek()) {
const sceneGenerationMetadata = getSceneGenerationMetaData(
activeEditorRef.current,
);
const cacheJson = activeEditorRef.current.cacheScene({
sceneTitle: "",
sceneToken: "",
sceneGenerationMetadata: sceneGenerationMetadata,
});
const cacheString = JSON.stringify(cacheJson);
tabStore.updateTabData("3D", cacheString);
}
// Unmount/Destructor flow
activeEditorRef.current?.unmountEngine();
activeEditorRef.current = null;
sceneContainerSignal.value = null;
@@ -105,7 +105,7 @@ export class StoryTellerProxy3DObject {
this.locked = object.userData["locked"];
}
public async toJSON(): Promise<ObjectJSON> {
public toJSON(): ObjectJSON {
const json: ObjectJSON = {
version: this.version,
position: {
@@ -27,7 +27,7 @@ export class StoryTellerProxyScene {
this.sceneItemProxy = [];
}
async getChildren(child: THREE.Object3D) {
getChildren(child: THREE.Object3D) {
if (this.lookUpDictionary[child.uuid] == null) {
this.lookUpDictionary[child.uuid] = new StoryTellerProxy3DObject(
this.version,
@@ -54,11 +54,11 @@ export class StoryTellerProxyScene {
const boneHelper = new BoneJSONHelper(child);
proxyObject3D.rigData = boneHelper.toJSON();
const json_data = await proxyObject3D.toJSON();
const json_data = proxyObject3D.toJSON();
return json_data;
}
public async saveToSceneOlder(): Promise<any> {
public saveToSceneOlder(): ObjectJSON[] {
const results: ObjectJSON[] = [];
if (this.scene.scene != null) {
for (const child of this.scene.scene.children) {
@@ -82,7 +82,7 @@ export class StoryTellerProxyScene {
proxyObject3D.shininess = child.userData["shininess"];
proxyObject3D.specular = child.userData["specular"];
proxyObject3D.locked = child.userData["locked"];
const json_data = await proxyObject3D.toJSON();
const json_data = proxyObject3D.toJSON();
results.push(json_data);
}
}
@@ -93,7 +93,7 @@ export class StoryTellerProxyScene {
return results;
}
public async saveToScene(version: number): Promise<any> {
public saveToScene(version: number): ObjectJSON[] {
this.version = version;
console.log("Saving with version:", this.version);
const results: ObjectJSON[] = [];
@@ -101,8 +101,8 @@ export class StoryTellerProxyScene {
for (const pchild of this.scene.scene.children) {
if (this.version >= 1.0) {
if (pchild.userData["media_id"] != undefined) {
console.debug("Object JSON:", pchild.toJSON())
results.push(await this.getChildren(pchild));
console.debug("Object JSON:", pchild.toJSON());
results.push(this.getChildren(pchild));
}
} else {
console.log("Saving older.");
+15 -15
View File
@@ -28,9 +28,6 @@
"noEmit": true
},
"references": [
{
"path": "../../libs/components/model-selector"
},
{
"path": "../../libs/components/pagination"
},
@@ -49,9 +46,15 @@
{
"path": "../../libs/components/loading-spinner"
},
{
"path": "../../libs/components/create-3d-modal"
},
{
"path": "../../libs/components/button-icon-select"
},
{
"path": "../../libs/components/slider-v2"
},
{
"path": "../../libs/components/tab-selector"
},
@@ -61,32 +64,29 @@
{
"path": "../../libs/components/button-dropdown"
},
{
"path": "../../libs/tauri-api"
},
{
"path": "../../libs/components/action-reminder-modal"
},
{
"path": "../../libs/components/login-modal"
},
{
"path": "../../libs/soundboard"
},
{
"path": "../../libs/components/slider-v2"
},
{
"path": "../../libs/components/toaster"
},
{
"path": "../../libs/components/popover"
"path": "../../libs/components/model-selector"
},
{
"path": "../../libs/soundboard"
},
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/common"
"path": "../../libs/components/popover"
},
{
"path": "../../libs/tauri-api"
"path": "../../libs/common"
},
{
"path": "../../libs/components/menu-icon-selector"
+1
View File
@@ -14,6 +14,7 @@ rm -rf ./apps/editor2d/node_modules/
rm -rf ./apps/editor3d/node_modules/
echo "Removing Build directories..."
rm -rf ./apps/artcraft-website/dist/
rm -rf ./apps/editor2d/dist/
rm -rf ./apps/editor3d/dist/
rm -rf ./libs/api/dist/
@@ -2,6 +2,7 @@ export enum FilterMediaClasses {
AUDIO = "audio",
IMAGE = "image",
VIDEO = "video",
DIMENSIONAL = "dimensional",
}
export enum FilterMediaType {
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -0,0 +1,7 @@
# @storyteller/ui-create-3d-modal
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-create-3d-modal` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -0,0 +1,17 @@
{
"name": "@storyteller/ui-create-3d-modal",
"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"
}
}
}
@@ -0,0 +1,2 @@
export * from "./lib/create-3d-modal";
export * from "./lib/create-3d-modal-store";
@@ -0,0 +1,16 @@
import { create } from 'zustand';
// Simplified store that only handles the modal open state
interface Create3dModalState {
isOpen: boolean;
open: () => void;
close: () => void;
toggle: () => void;
}
export const useCreate3dModalStore = create<Create3dModalState>((set) => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
}));
@@ -0,0 +1,7 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -0,0 +1,12 @@
import { render } from '@testing-library/react';
import Create3dModal from './create-3d-modal';
describe('Create3dModal', () => {
it('should render successfully', () => {
const { baseElement } = render(<Create3dModal />);
expect(baseElement).toBeTruthy();
});
});
@@ -0,0 +1,275 @@
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { twMerge } from "tailwind-merge";
import { useCreate3dModalStore } from "./create-3d-modal-store";
import { useState, useCallback, useEffect, useRef } from "react";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { ImageInput, ImageFile } from "@storyteller/ui-image-input";
import { faCube } from "@fortawesome/pro-solid-svg-icons";
import { MediaUploadApi } from "@storyteller/api";
import {
EnqueueImageTo3dObject,
EnqueueImageTo3dObjectModel,
} from "@storyteller/tauri-api";
import { toast } from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { listen } from "@tauri-apps/api/event";
import type { UnlistenFn } from "@tauri-apps/api/event";
enum GenerationAction {
ImageTo3d = "image_to_3d",
}
interface Create3dModalProps {
onModelComplete?: (mediaToken: string) => void;
}
export const Create3dModal = ({ onModelComplete }: Create3dModalProps = {}) => {
const { isOpen, close } = useCreate3dModalStore();
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[]
);
const [selectedImage, setSelectedImage] = useState<ImageFile | null>(null);
// Store active toast IDs for 3D model generation
const activeToastIds = useRef<Record<string, string>>({});
// Listen for generation complete events
useEffect(() => {
let isUnmounted = false;
let unlisten: Promise<UnlistenFn>;
const setup = async () => {
unlisten = listen("generation-complete-event", (event: any) => {
console.log("[DEBUG] Generation complete event received:", event);
// Check if this is an ImageTo3d completion event
const eventData = event.payload?.data;
console.log("[DEBUG] eventData:", eventData);
console.log("[DEBUG] Current activeToastIds:", {...activeToastIds.current});
if (eventData?.action === GenerationAction.ImageTo3d) {
console.log("[DEBUG] This is an ImageTo3d completion event");
// Find any active toasts for 3D model generation and update them
const entries = Object.entries(activeToastIds.current);
console.log("[DEBUG] Processing", entries.length, "active toast entries");
entries.forEach(
([mediaToken, toastId]) => {
console.log("[DEBUG] Processing toast for mediaToken:", mediaToken);
// Dismiss the loading toast
toast.dismiss(toastId);
console.log("[DEBUG] Toast dismissed");
// Call the callback if provided
if (onModelComplete) {
console.log("[DEBUG] Calling onModelComplete with mediaToken:", mediaToken);
onModelComplete(mediaToken);
} else {
console.log("[DEBUG] onModelComplete callback is not provided");
}
// Remove from active toasts
delete activeToastIds.current[mediaToken];
console.log("[DEBUG] Removed mediaToken from activeToastIds");
}
);
} else {
console.log("[DEBUG] Not an ImageTo3d event, action:", eventData?.action);
}
});
if (isUnmounted) {
unlisten.then((f) => f()); // Unsubscribe if unmounted early.
}
};
setup();
return () => {
isUnmounted = true;
if (unlisten) {
unlisten.then((f) => f());
}
};
}, []);
const handleOpenGallery = () => {
setIsGalleryModalOpen(true);
};
const handleGalleryClose = () => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleImageSelect = (id: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) {
return prev.filter((imageId) => imageId !== id);
}
// Only allow single selection for 3D model creation
return [id];
});
};
const handleGalleryImages = (selectedItems: GalleryItem[]) => {
if (selectedItems && selectedItems.length > 0) {
const item = selectedItems[0];
if (!item.fullImage) return;
const newImage: ImageFile = {
id: item.id,
url: item.fullImage,
file: new File([], "gallery-image"),
mediaToken: item.id,
};
setSelectedImage(newImage);
}
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
// Process image for 3D model creation
const processImageFor3D = useCallback(async (imageToProcess: ImageFile) => {
let mediaToken = imageToProcess.mediaToken;
let toastId: string;
try {
// toast that won't disappear until we manually dismiss it
toastId = toast.loading("Generating 3D model...", {
duration: 300000, // max five minutes
});
// If the image is from a local file and doesn't have a media token yet, upload it first
if (!mediaToken && imageToProcess.file) {
const mediaUploadApi = new MediaUploadApi();
const uuid = uuidv4();
const uploadResult = await mediaUploadApi.UploadImage({
blob: imageToProcess.file,
fileName: imageToProcess.file.name,
uuid: uuid,
});
if (!uploadResult.success || !uploadResult.data) {
throw new Error("Failed to upload image");
}
mediaToken = uploadResult.data;
}
if (!mediaToken) {
throw new Error("No media token available");
}
// Update toast message to show progress
toast.loading("Uploading and preparing image...", { id: toastId });
// Enqueue the image for 3D model creation using the Hunyuan 3D 2 model
const result = await EnqueueImageTo3dObject({
image_media_token: mediaToken,
model: EnqueueImageTo3dObjectModel.Hunyuan3d2,
});
if ("error_type" in result) {
throw new Error(result.error_message || result.error_type);
}
toast.loading("Generating 3D model...", {
id: toastId,
});
// Store the toast ID with the media token so we can update it when generation completes
if (mediaToken) {
activeToastIds.current[mediaToken] = toastId;
}
return true;
} catch (error) {
// Handle errors
const errorMessage =
error instanceof Error ? error.message : "An unexpected error occurred";
toast.error(`Failed to create 3D model: ${errorMessage}`, {
id: toastId!, // Use the non-null assertion as we know toastId is defined
duration: 5000,
});
return false;
}
}, []);
const handleCreate3DModel = () => {
if (!selectedImage) return;
// Store the selected image information before closing the modal
const imageToProcess = selectedImage;
// Clear the image field after starting the process
setSelectedImage(null);
// Close the modal immediately
close();
// Start the processing in the background
processImageFor3D(imageToProcess);
};
return (
<>
<Modal
isOpen={isOpen}
onClose={close}
className={twMerge("max-w-xl duration-200")}
backdropClassName={twMerge("duration-200")}
childPadding={true}
>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold">
Create 3D Model from Image
</h2>
</div>
<div className="flex-grow space-y-6 py-4">
<div className="text-center">
{/* Image Input Component */}
<ImageInput
value={selectedImage}
onChange={setSelectedImage}
onGalleryOpen={handleOpenGallery}
placeholderText="Drag and drop an image here"
className="mb-2"
/>
</div>
</div>
<Button
disabled={!selectedImage}
onClick={handleCreate3DModel}
className="w-full"
icon={faCube}
>
Create 3D Model
</Button>
</div>
</Modal>
<GalleryModal
isOpen={isGalleryModalOpen}
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={1}
forceFilter="image"
onUseSelected={handleGalleryImages}
/>
</>
);
};
export default Create3dModal;
@@ -0,0 +1,22 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../gallery-modal"
},
{
"path": "../button"
},
{
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -0,0 +1,56 @@
{
"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": "../gallery-modal/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
}
]
}
@@ -0,0 +1,34 @@
{
"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"
}
]
}
@@ -0,0 +1,49 @@
/// <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/create-3d-modal',
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-create-3d-modal',
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,
}
},
}));
@@ -80,7 +80,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{/* Media class badge on hover */}
{!disableTooltipAndBadge && item.mediaClass && (
<div className="pointer-events-none absolute right-2 top-2 z-20 rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
{item.mediaClass}
{item.mediaClass === "dimensional" ? "3D" : item.mediaClass}
</div>
)}
{/* Tooltip on hover */}
@@ -89,12 +89,12 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
type="button"
tabIndex={0}
className={twMerge(
"w-full group relative overflow-visible rounded-md border-[3px] transition-all focus:outline-none focus:ring-2 focus:ring-primary",
"w-full group relative overflow-visible rounded-md border-transparent border-[3px] transition-all",
activeFilter === "video" ? "aspect-square" : "aspect-square",
selected
? "border-primary"
: "border-transparent hover:border-primary",
item.mediaClass === "video"
: "border-transparent hover:border-primary/80",
mode === "select" || item.mediaClass === "video"
? "cursor-pointer"
: "cursor-grab hover:cursor-grab active:cursor-grabbing"
)}
@@ -138,7 +138,11 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{item.mediaClass !== "video" && (
<span>
<span className="font-bold">Drag</span>
<span className="opacity-50"> to add</span>
<span className="opacity-50">
{item.mediaClass === "dimensional"
? " to add to scene"
: " to add"}
</span>
</span>
)}
<span>
@@ -157,7 +161,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
selected
? "border-primary"
: "border-transparent hover:border-primary",
item.mediaClass === "video"
mode === "select" || item.mediaClass === "video"
? "cursor-pointer"
: "cursor-grab hover:cursor-grab active:cursor-grabbing"
)}
@@ -3,7 +3,13 @@ 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";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import React, {
useState,
useEffect,
useCallback,
useMemo,
useRef,
} from "react";
import {
FilterMediaClasses,
GalleryModalApi,
@@ -61,6 +67,11 @@ interface GalleryModalProps {
media_id: string | undefined
) => Promise<void>;
isOpen?: boolean;
/**
* Optional filter to force when opening the modal. When set, users cannot change the filter.
* Possible values: "all", "image", "video", "3d", "uploaded"
*/
forceFilter?: string;
}
export const GalleryModal = React.memo(
@@ -74,6 +85,7 @@ export const GalleryModal = React.memo(
onDownloadClicked,
onAddToSceneClicked,
isOpen,
forceFilter,
}: GalleryModalProps) => {
const [loading, setLoading] = useState(false);
const [lightboxImage, setLightboxImage] = useState<GalleryItem | null>(
@@ -82,7 +94,15 @@ export const GalleryModal = React.memo(
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
const [failedImageUrls] = useState<Set<string>>(new Set());
const [username, setUsername] = useState<string>("");
const [activeFilter, setActiveFilter] = useState("all");
const forceFilterRef = useRef(forceFilter);
const [activeFilter, setActiveFilter] = useState(forceFilter || "all");
// If forceFilter is provided, always use it
useEffect(() => {
if (forceFilterRef.current) {
setActiveFilter(forceFilterRef.current);
}
}, [forceFilterRef]);
const minColumns = 3;
const maxColumns = 12;
// Default gridColumns to 5
@@ -173,8 +193,14 @@ export const GalleryModal = React.memo(
return [FilterMediaClasses.IMAGE];
case "video":
return [FilterMediaClasses.VIDEO];
case "3d":
return [FilterMediaClasses.DIMENSIONAL];
case "uploaded":
return [FilterMediaClasses.IMAGE, FilterMediaClasses.VIDEO];
return [
FilterMediaClasses.IMAGE,
FilterMediaClasses.VIDEO,
FilterMediaClasses.DIMENSIONAL,
];
default:
return undefined;
}
@@ -190,7 +216,9 @@ export const GalleryModal = React.memo(
filter_media_classes: filterMediaClasses,
username: username,
include_user_uploads:
activeFilter === "uploaded" || activeFilter === "all",
activeFilter === "uploaded" ||
activeFilter === "all" ||
activeFilter === "3d",
user_uploads_only: activeFilter === "uploaded",
page_index: reset ? 0 : pageIndex,
page_size: pageSize,
@@ -205,6 +233,8 @@ export const GalleryModal = React.memo(
thumbnail:
item.media_class === "video"
? item.media_links.maybe_video_previews.still
: item.media_class === "dimensional"
? item.cover_image?.maybe_cover_image_public_bucket_url
: item.media_links.maybe_thumbnail_template?.replace(
"{WIDTH}",
thumbnail_size.toString()
@@ -426,23 +456,40 @@ export const GalleryModal = React.memo(
/>
</div>
{/* Filter popover */}
<Tooltip position="top" content="Filter" closeOnClick={true}>
<Tooltip
position="top"
content={
forceFilterRef.current ? "Filter locked" : "Filter"
}
closeOnClick={true}
>
<PopoverMenu
panelTitle="Filter"
position="bottom"
align="end"
buttonClassName="relative z-[51] mr-3"
buttonClassName={`relative z-[51] mr-3 ${
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: forceFilterRef.current !== undefined,
},
}))}
onSelect={(item) => {
const filter = FILTERS.find(
(f) => f.label === item.label
);
if (filter) setActiveFilter(filter.id);
// Only allow filter changes if no forceFilter was provided
if (!forceFilterRef.current) {
const filter = FILTERS.find(
(f) => f.label === item.label
);
if (filter) setActiveFilter(filter.id);
}
}}
triggerIcon={<FontAwesomeIcon icon={faFilter} />}
triggerLabel={
@@ -480,7 +527,7 @@ export const GalleryModal = React.memo(
([date, dateItems]) => {
const filteredItems = dateItems.filter((item) => {
if (activeFilter === "3d")
return item.mediaClass === "3d";
return item.mediaClass === "dimensional";
if (activeFilter === "all")
return item.mediaClass !== "audio";
return true;
@@ -81,7 +81,8 @@ function onPointerUp(event: PointerEvent) {
if (
dragState.item &&
dragState.isDragging &&
dragState.item.mediaClass === "image"
(dragState.item.mediaClass === "image" ||
dragState.item.mediaClass === "dimensional")
) {
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
}
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -0,0 +1,7 @@
# @storyteller/ui-image-input
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-image-input` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -0,0 +1,17 @@
{
"name": "@storyteller/ui-image-input",
"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"
}
}
}
@@ -0,0 +1,2 @@
export * from './lib/image-input';
@@ -0,0 +1,7 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -0,0 +1,12 @@
import { render } from '@testing-library/react';
import ImageInput from './image-input';
describe('ImageInput', () => {
it('should render successfully', () => {
const { baseElement } = render(<ImageInput />);
expect(baseElement).toBeTruthy();
});
});
@@ -0,0 +1,265 @@
import { useState, useRef, useCallback } from "react";
import { Button } from "@storyteller/ui-button";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUpload, faImages, faTrash } from "@fortawesome/pro-solid-svg-icons";
import { toast } from "react-hot-toast";
export interface ImageFile {
id: string;
url: string;
file: File;
mediaToken?: string;
}
export interface ImageInputProps {
/** Selected image */
value?: ImageFile | null;
/** Callback when image is selected or removed */
onChange: (image: ImageFile | null) => void;
/** Handler for opening gallery modal */
onGalleryOpen?: () => void;
/** Show gallery button */
showGalleryButton?: boolean;
/** Custom placeholder text */
placeholderText?: string;
/** Additional class names */
className?: string;
}
export function ImageInput({
value,
onChange,
onGalleryOpen,
showGalleryButton = true,
placeholderText = "Drag and drop an image here",
className,
}: ImageInputProps) {
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const processImageFile = (file: File) => {
// Only accept image files
if (!file.type.startsWith("image/")) {
toast.error("Please upload an image file");
return;
}
const reader = new FileReader();
reader.onloadend = () => {
const newImage: ImageFile = {
id: Math.random().toString(36).substring(7),
url: reader.result as string,
file: file,
};
onChange(newImage);
};
reader.readAsDataURL(file);
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files && files[0]) {
const file = files[0];
processImageFile(file);
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
// Drag and drop handlers
// Enhanced drag and drop handlers for better desktop compatibility
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging) setIsDragging(true);
// Explicitly set effectAllowed to make sure the browser shows the correct drop effect
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "copy";
}
},
[isDragging]
);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// Only set isDragging to false if we're leaving the drop target
// and not entering a child element
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
setIsDragging(false);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
// Debug logging
console.log("Files dropped:", e.dataTransfer.files);
console.log("DataTransfer types:", e.dataTransfer.types);
try {
// First try the files collection
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0]; // Just take the first file
console.log("Processing file:", file.name, file.type, file.size);
processImageFile(file);
return;
}
// Then try the items collection
const items = e.dataTransfer.items;
if (items && items.length > 0) {
for (let i = 0; i < items.length; i++) {
if (items[i].kind === "file") {
const file = items[i].getAsFile();
if (file) {
console.log("Processing item as file:", file.name);
processImageFile(file);
return;
}
}
}
}
// If we got here, we couldn't process the drop
console.log("Could not process dropped content");
toast.error(
"Could not process the dropped file. Please try uploading instead."
);
} catch (error) {
console.error("Error processing drop:", error);
toast.error(
"Error processing the dropped file. Please try uploading instead."
);
}
}, []);
return (
<div className={twMerge("relative", className)}>
{/* Drag & drop area or image preview */}
<div
className={twMerge(
"mx-auto flex h-60 w-full flex-col items-center justify-center rounded-lg border border-dashed border-white/20 bg-white/5 p-4 transition-colors",
isDragging && "border-blue-400 bg-blue-500/10"
)}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragStart={(e) => e.preventDefault()}
>
{value ? (
<div className="h-full w-full overflow-hidden">
<img
src={value.url}
alt="Selected image"
className="h-full w-full object-contain"
/>
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center text-white/60">
<FontAwesomeIcon icon={faImages} className="text-2xl mb-2" />
<p>{placeholderText}</p>
<div className="flex items-center justify-center gap-2 my-4 w-full">
<div className="h-[1px] w-12 bg-white/10" />
<p className="text-sm px-3">or</p>
<div className="h-[1px] w-12 bg-white/10" />
</div>
<div className="flex flex-col items-center justify-center gap-3 sm:flex-row">
{showGalleryButton && onGalleryOpen && (
<Button
onClick={onGalleryOpen}
variant="secondary"
className="flex items-center gap-2 px-2.5 py-1.5 text-sm bg-white/15"
>
<FontAwesomeIcon icon={faImages} />
Choose from Library
</Button>
)}
<Button
onClick={handleUploadClick}
variant="secondary"
className="flex items-center gap-2 px-2.5 py-1.5 text-sm bg-white/15"
>
<FontAwesomeIcon icon={faUpload} />
Upload Image
</Button>
</div>
</div>
)}
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>
{/* Action buttons when image is selected */}
{value && (
<div className="mt-4 flex flex-wrap items-center justify-center gap-3">
{showGalleryButton && onGalleryOpen && (
<Button
onClick={onGalleryOpen}
variant="secondary"
className="flex items-center gap-2 px-2.5 py-1.5 text-sm"
icon={faImages}
>
Choose Different Image
</Button>
)}
<Button
onClick={handleUploadClick}
variant="secondary"
className="flex items-center gap-2 px-2.5 py-1.5 text-sm"
icon={faUpload}
>
Upload Different Image
</Button>
<Button
onClick={() => {
onChange(null);
// Reset file input value to allow selecting the same file again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}}
variant="destructive"
className="flex items-center gap-2 px-2.5 py-1.5 text-sm"
icon={faTrash}
>
Remove Image
</Button>
</div>
)}
</div>
);
}
export default ImageInput;
@@ -0,0 +1,13 @@
{
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -0,0 +1,45 @@
{
"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"
]
}
@@ -0,0 +1,34 @@
{
"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"
}
]
}
@@ -0,0 +1,49 @@
/// <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/image-input',
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-image-input',
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,
}
},
}));
@@ -5,8 +5,9 @@ import { createPortal } from "react-dom";
import dayjs from "dayjs";
import { faDownToLine } from "@fortawesome/pro-solid-svg-icons";
import {
FalKlingImageToVideo,
FalHunyuanImageTo3d,
EnqueueImageTo3dObject,
EnqueueImageTo3dObjectModel,
} from "@storyteller/tauri-api";
interface LightboxModalProps {
@@ -129,9 +130,13 @@ export function LightboxModal({
<div className="mt-15 mb-15 flex justify-end gap-2">
<Button
onClick={async (e) => {
let _result = await FalHunyuanImageTo3d({
//let _result = await FalHunyuanImageTo3d({
// image_media_token: mediaId,
// //base64_image: downloadUrl,
//});
let _result = await EnqueueImageTo3dObject({
image_media_token: mediaId,
//base64_image: downloadUrl,
model: EnqueueImageTo3dObjectModel.Hunyuan3d2,
});
//e.stopPropagation();
//await onAddToSceneClicked(downloadUrl, mediaId);
@@ -625,6 +625,7 @@ export const PromptBox2D = ({
maxSelections={4}
onUseSelected={handleGalleryImages}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
);
@@ -58,7 +58,7 @@ import {
SoraSessionState,
waitForSoraLogin,
} from "@storyteller/tauri-api";
import { showActionReminder } from "libs/components/action-reminder-modal/src/lib/action-reminder-modal-signals";
import { showActionReminder } from "@storyteller/ui-action-reminder-modal";
interface ReferenceImage {
id: string;
@@ -821,6 +821,7 @@ export const PromptBox3D = ({
maxSelections={4}
onUseSelected={handleGalleryImages}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
);
@@ -6,7 +6,10 @@ import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button, ToggleButton } from "@storyteller/ui-button";
import { Modal } from "@storyteller/ui-modal";
import { EnqueueTextToImage, EnqueueTextToImageModel } from "@storyteller/tauri-api";
import {
EnqueueTextToImage,
EnqueueTextToImageModel,
} from "@storyteller/tauri-api";
import {
faMessageXmark,
faMessageCheck,
@@ -221,7 +224,7 @@ export const PromptBoxImage = ({
return iconElement.props.icon;
};
const getModelByName = (name: string) : EnqueueTextToImageModel => {
const getModelByName = (name: string): EnqueueTextToImageModel => {
switch (name) {
case "Flux Pro Ultra":
return EnqueueTextToImageModel.FluxProUltra;
@@ -401,6 +404,7 @@ export const PromptBoxImage = ({
maxSelections={1}
onUseSelected={handleGalleryImages}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
);
@@ -375,6 +375,7 @@ export const PromptBoxVideo = ({
maxSelections={1}
onUseSelected={handleGalleryImages}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
);
@@ -17,6 +17,7 @@ interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
globalAccountLogoutCallback: () => void;
initialSection?: SettingsSection;
}
//type SettingsSection = "misc" | "audio" | "accounts" | "video" | "image";
@@ -26,9 +27,10 @@ export const SettingsModal = ({
isOpen,
onClose,
globalAccountLogoutCallback,
initialSection = "general",
}: SettingsModalProps) => {
const [selectedSection, setSelectedSection] =
useState<SettingsSection>("general");
useState<SettingsSection>(initialSection);
const sections = [
{ id: "general" as const, label: "General", icon: faCog },
+1
View File
@@ -1,5 +1,6 @@
export * from "./lib/common/CommandStatus.js";
export * from "./lib/enqueue/EnqueueTextToImage.js";
export * from "./lib/enqueue/EnqueueImageTo3dObject.js";
export * from "./lib/fal/FalBackgroundRemoval.js";
export * from "./lib/fal/FalHunyuanImageTo3d.js";
export * from "./lib/fal/FalKlingImageToVideo.js";
@@ -0,0 +1,48 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
export enum EnqueueImageTo3dObjectErrorType {
/// Caller didn't specify a model
ModelNotSpecified = "model_not_specified",
/// Generic server error
ServerError = "server_error",
/// No Fal API key available
NeedsFalApiKey = "needs_fal_api_key",
/// Fal had an API error
FalError = "fal_error",
/// Need tobe logged into Artcraft
NeedsStorytellerCredentials = "needs_storyteller_credentials",
}
export interface EnqueueImageTo3dObjectRequest {
image_media_token?: string;
model?: EnqueueImageTo3dObjectModel;
}
export enum EnqueueImageTo3dObjectModel {
Hunyuan3d2 = "hunyuan_3d_2",
}
export interface EnqueueImageTo3dObjectError extends CommandResult {
error_type: EnqueueImageTo3dObjectErrorType;
error_message?: string;
}
export interface EnqueueImageTo3dObjectPayload {
}
export interface EnqueueImageTo3dObjectSuccess extends CommandResult {
payload: EnqueueImageTo3dObjectPayload;
}
export type EnqueueImageTo3dObjectResult = EnqueueImageTo3dObjectSuccess | EnqueueImageTo3dObjectError;
export const EnqueueImageTo3dObject = async (request: EnqueueImageTo3dObjectRequest) : Promise<EnqueueImageTo3dObjectResult> => {
let result = await invoke("enqueue_image_to_3d_object_command", {
request: {
image_media_token: request.image_media_token,
model: request.model,
}
});
return (result as EnqueueImageTo3dObjectResult);
}
@@ -4,12 +4,12 @@ import { CommandResult } from "../common/CommandStatus";
export interface GetFalApiKeySuccessResult extends CommandResult {
payload: {
key: string;
}
};
}
export type GetFalApiKeyResult = GetFalApiKeySuccessResult | CommandResult;
export const GetFalApiKey = async () : Promise<GetFalApiKeyResult> => {
export const GetFalApiKey = async (): Promise<GetFalApiKeyResult> => {
let result = await invoke("get_fal_api_key_command");
return (result as GetFalApiKeyResult);
}
return result as GetFalApiKeyResult;
};
+21 -1
View File
@@ -1390,6 +1390,10 @@
"name": "@storyteller/ui-close-button",
"version": "0.0.1"
},
"libs/components/create-3d-modal": {
"name": "@storyteller/ui-create-3d-modal",
"version": "0.0.1"
},
"libs/components/demo-modal": {
"name": "@storyteller/ui-demo-modal",
"version": "0.0.1"
@@ -1406,6 +1410,14 @@
"name": "@storyteller/ui-gravatar",
"version": "0.0.1"
},
"libs/components/image-field": {
"version": "0.0.1",
"extraneous": true
},
"libs/components/image-input": {
"name": "@storyteller/ui-image-input",
"version": "0.0.1"
},
"libs/components/input": {
"name": "@storyteller/ui-input",
"version": "0.0.1"
@@ -9681,6 +9693,10 @@
"resolved": "libs/components/close-button",
"link": true
},
"node_modules/@storyteller/ui-create-3d-modal": {
"resolved": "libs/components/create-3d-modal",
"link": true
},
"node_modules/@storyteller/ui-demo-modal": {
"resolved": "libs/components/demo-modal",
"link": true
@@ -9697,6 +9713,10 @@
"resolved": "libs/components/gravatar",
"link": true
},
"node_modules/@storyteller/ui-image-input": {
"resolved": "libs/components/image-input",
"link": true
},
"node_modules/@storyteller/ui-input": {
"resolved": "libs/components/input",
"link": true
@@ -28691,4 +28711,4 @@
}
}
}
}
}
+6
View File
@@ -134,6 +134,12 @@
},
{
"path": "./libs/components/model-selector"
},
{
"path": "./libs/components/create-3d-modal"
},
{
"path": "./libs/components/image-input"
}
]
}