mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 09:22:41 -04:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c96f21bd7 | ||
|
|
245409de08 | ||
|
|
90d0f15176 | ||
|
|
257db9b892 | ||
|
|
70348a922a | ||
|
|
95e724e4bd | ||
|
|
df0b98ec63 | ||
|
|
f214577af1 | ||
|
|
3efe9db4ca | ||
|
|
21ea7d05e2 | ||
|
|
e8305bd112 | ||
|
|
8208206e5a | ||
|
|
4f33e74e3a | ||
|
|
f75b210dcd | ||
|
|
7a07f196d1 |
+3
-3
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
+179
@@ -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)]
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
+69
@@ -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;
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+1
-4
@@ -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)]
|
||||
|
||||
-5
@@ -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,
|
||||
|
||||
-1
@@ -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;
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum FilterMediaClasses {
|
||||
AUDIO = "audio",
|
||||
IMAGE = "image",
|
||||
VIDEO = "video",
|
||||
DIMENSIONAL = "dimensional",
|
||||
}
|
||||
|
||||
export enum FilterMediaType {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface NodeProps {
|
||||
scaleY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export class Node {
|
||||
@@ -42,6 +43,7 @@ export class Node {
|
||||
scaleY: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
zIndex: number;
|
||||
|
||||
constructor({
|
||||
id,
|
||||
@@ -62,7 +64,8 @@ export class Node {
|
||||
scaleX = 1,
|
||||
scaleY = 1,
|
||||
offsetX = 0,
|
||||
offsetY = 0
|
||||
offsetY = 0,
|
||||
zIndex = 0
|
||||
}: NodeProps) {
|
||||
this.id = id;
|
||||
this.x = x;
|
||||
@@ -83,6 +86,7 @@ export class Node {
|
||||
this.scaleY = scaleY;
|
||||
this.offsetX = offsetX;
|
||||
this.offsetY = offsetY;
|
||||
this.zIndex = zIndex;
|
||||
}
|
||||
|
||||
setPosition(x: number, y: number): void {
|
||||
|
||||
@@ -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 { 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,6 +271,42 @@ const PageDraw = () => {
|
||||
activeToolId={store.activeTool}
|
||||
/>
|
||||
<div className="relative z-0">
|
||||
<ContextMenuContainer
|
||||
onAction={(e, action) => {
|
||||
|
||||
if (action === "contextMenu") {
|
||||
const hasSelection = store.selectedNodeIds.length > 0;
|
||||
if (hasSelection) {
|
||||
console.log("An item is selected.");
|
||||
// You can add additional actions here based on the selection
|
||||
return true
|
||||
} else {
|
||||
console.log("No item is selected.");
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}}
|
||||
onMenuAction={(action) => {
|
||||
|
||||
switch (action) {
|
||||
case 'BRING_TO_FRONT':
|
||||
store.bringToFront(store.selectedNodeIds);
|
||||
break;
|
||||
case 'BRING_FORWARD':
|
||||
store.bringForward(store.selectedNodeIds);
|
||||
break;
|
||||
case 'SEND_BACKWARD':
|
||||
store.sendBackward(store.selectedNodeIds);
|
||||
break;
|
||||
case 'SEND_TO_BACK':
|
||||
store.sendToBack(store.selectedNodeIds);
|
||||
break;
|
||||
default:
|
||||
// No action needed for unhandled cases
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PaintSurface
|
||||
nodes={store.nodes}
|
||||
selectedNodeIds={store.selectedNodeIds}
|
||||
@@ -196,6 +322,7 @@ const PageDraw = () => {
|
||||
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"
|
||||
@@ -764,6 +774,7 @@ export const PaintSurface = ({
|
||||
rotation={lineNode.rotation || 0}
|
||||
offsetX={lineNode.offsetX || 0}
|
||||
offsetY={lineNode.offsetY || 0}
|
||||
zIndex={lineNode.zIndex}
|
||||
/>
|
||||
{renderTransformer()}
|
||||
</React.Fragment>
|
||||
@@ -781,12 +792,13 @@ export const PaintSurface = ({
|
||||
height={node.height}
|
||||
fill={node.fill}
|
||||
stroke={node.stroke}
|
||||
strokeWidth={node.strokeWidth}
|
||||
strokeWidth={0}
|
||||
rotation={node.rotation || 0}
|
||||
scaleX={node.scaleX || 1}
|
||||
scaleY={node.scaleY || 1}
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
zIndex={node.zIndex}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable)}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onTap={(e) => handleNodeMouseDown(e, node.id)}
|
||||
@@ -812,12 +824,13 @@ export const PaintSurface = ({
|
||||
height={node.height}
|
||||
fill={node.fill}
|
||||
stroke={node.stroke}
|
||||
strokeWidth={node.strokeWidth}
|
||||
strokeWidth={0}
|
||||
rotation={node.rotation || 0}
|
||||
scaleX={node.scaleX || 1}
|
||||
scaleY={node.scaleY || 1}
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
zIndex={node.zIndex}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable)}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onTap={(e) => handleNodeMouseDown(e, node.id)}
|
||||
@@ -843,12 +856,13 @@ export const PaintSurface = ({
|
||||
radius={Math.min(node.width, node.height) / 2}
|
||||
fill={node.fill}
|
||||
stroke={node.stroke}
|
||||
strokeWidth={node.strokeWidth}
|
||||
strokeWidth={0}
|
||||
rotation={node.rotation || 0}
|
||||
scaleX={node.scaleX || 1}
|
||||
scaleY={node.scaleY || 1}
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
zIndex={node.zIndex}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable)}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onTap={(e) => handleNodeMouseDown(e, node.id)}
|
||||
@@ -882,6 +896,7 @@ export const PaintSurface = ({
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
listening={false}
|
||||
zIndex={node.zIndex}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
@@ -989,7 +1004,12 @@ export const PaintSurface = ({
|
||||
/>
|
||||
|
||||
{/* Render all nodes including line nodes */}
|
||||
{[...nodes, ...store.lineNodes].map((node) => renderNode(node))}
|
||||
{[...nodes, ...store.lineNodes]
|
||||
.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0))
|
||||
.map((node, index) => {
|
||||
// console.log(`Node ${index}:`, node);
|
||||
return renderNode(node);
|
||||
})}
|
||||
|
||||
{/* Render selection rectangle */}
|
||||
{selectionRect && (
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export interface MenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
action: string; // Instead of onClick, we use an action identifier
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
items: MenuItem[];
|
||||
onAction: (action: string) => void; // Single callback for all menu items
|
||||
onClose: () => void;
|
||||
position: MenuPosition;
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({ items, onAction, 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={() => {
|
||||
onAction(item.action); // Pass the action identifier up
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContextMenuContainerProps {
|
||||
children: React.ReactNode;
|
||||
items?: MenuItem[];
|
||||
onAction?: (e: React.MouseEvent, actionName: string) => boolean | void;
|
||||
onMenuAction?: (action: string) => void; // New prop for menu item actions
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ContextMenuContainer: React.FC<ContextMenuContainerProps> = ({
|
||||
children,
|
||||
items = defaultMenuItems,
|
||||
onAction,
|
||||
onMenuAction,
|
||||
className = ''
|
||||
}) => {
|
||||
const [showMenu, setShowMenu] = React.useState(false);
|
||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0 });
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const newPosition = { x: e.clientX, y: e.clientY };
|
||||
setPosition(newPosition);
|
||||
|
||||
const shouldShowMenu = onAction?.(e, 'contextMenu');
|
||||
if (shouldShowMenu !== undefined) {
|
||||
setShowMenu(shouldShowMenu);
|
||||
} else {
|
||||
setShowMenu(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
onAction?.(e, 'click');
|
||||
};
|
||||
|
||||
const handleMenuAction = (action: string) => {
|
||||
onMenuAction?.(action);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={`w-full h-full ${className}`}
|
||||
>
|
||||
{children}
|
||||
{showMenu && (
|
||||
<ContextMenu
|
||||
items={items}
|
||||
onAction={handleMenuAction}
|
||||
position={position}
|
||||
onClose={() => setShowMenu(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Default menu items now use action identifiers instead of callbacks
|
||||
const defaultMenuItems: MenuItem[] = [
|
||||
{
|
||||
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',
|
||||
action: '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',
|
||||
action: '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',
|
||||
action: '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',
|
||||
action: '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',
|
||||
action: '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',
|
||||
action: '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',
|
||||
action: 'DUPLICATE'
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
@@ -16,6 +17,7 @@ export type LineNode = {
|
||||
scaleY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
export type ActiveTool = 'select' | 'draw' | 'eraser' | 'backgroundColor' | 'shape';
|
||||
@@ -33,6 +35,7 @@ interface SceneState {
|
||||
activeTool: ActiveTool;
|
||||
brushColor: string;
|
||||
brushSize: number;
|
||||
brushOpacity: number;
|
||||
fillColor: string;
|
||||
|
||||
// Cursor state
|
||||
@@ -84,6 +87,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;
|
||||
|
||||
@@ -100,6 +104,12 @@ interface SceneState {
|
||||
// Add these two properties
|
||||
serializeSceneToString: () => Promise<string>;
|
||||
loadSceneFromString: (jsonString: string) => boolean;
|
||||
|
||||
// New functions
|
||||
bringToFront: (nodeIds: string[]) => void;
|
||||
sendToBack: (nodeIds: string[]) => void;
|
||||
bringForward: (nodeIds: string[]) => void;
|
||||
sendBackward: (nodeIds: string[]) => void;
|
||||
}
|
||||
|
||||
const generateId = (): string => {
|
||||
@@ -109,6 +119,22 @@ const generateId = (): string => {
|
||||
// Add a flag to prevent saving state during restoration
|
||||
let isRestoring = false;
|
||||
|
||||
// Add this helper function at the top of the store (before create<SceneState>)
|
||||
const getNextZIndex = (nodes: Node[], lineNodes: LineNode[]): number => {
|
||||
const allZIndices = [
|
||||
...nodes.map((n, index) => {
|
||||
//console.log(`Node: ${JSON.stringify(n)}, Index: ${index}`);
|
||||
return n.zIndex || 0;
|
||||
}),
|
||||
...lineNodes.map((n, index) => {
|
||||
//console.log(`LineNode: ${JSON.stringify(n)}, Index: ${index}`);
|
||||
return n.zIndex || 0;
|
||||
})
|
||||
];
|
||||
|
||||
return allZIndices.length > 0 ? Math.max(...allZIndices) + 1 : 0;
|
||||
};
|
||||
|
||||
export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
// Initial state
|
||||
nodes: [],
|
||||
@@ -122,6 +148,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
activeTool: 'select',
|
||||
brushColor: '#000000',
|
||||
brushSize: 5,
|
||||
brushOpacity: 1,
|
||||
fillColor: 'white',
|
||||
|
||||
// Cursor initial state
|
||||
@@ -131,8 +158,17 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
// Actions
|
||||
addNode: (node: Node) => {
|
||||
set((state) => {
|
||||
const newNodes = [...state.nodes, node];
|
||||
return { nodes: newNodes };
|
||||
const nextZ = getNextZIndex(state.nodes, state.lineNodes);
|
||||
console.log(`NextZ for node ID ${node.id}: ${nextZ}`);
|
||||
const newNode = new Node({
|
||||
...node,
|
||||
zIndex: nextZ
|
||||
});
|
||||
// console.log("New Node with ID:", newNode.id);
|
||||
let nodes = [...state.nodes, newNode]
|
||||
console.log("Nodes after update")
|
||||
console.log(nodes)
|
||||
return { nodes: nodes };
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
@@ -157,9 +193,17 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
|
||||
updateNode: (id: string, updates: Partial<Node>, shouldSaveState: boolean = true) => {
|
||||
set((state) => {
|
||||
|
||||
const newNodes = state.nodes.map(node => {
|
||||
let newZIndex = updates.zIndex !== undefined ? updates.zIndex : node.zIndex
|
||||
|
||||
|
||||
if (node.id === id) {
|
||||
return new Node({ ...node, ...updates });
|
||||
return new Node({
|
||||
...node,
|
||||
...updates,
|
||||
zIndex: newZIndex
|
||||
});
|
||||
}
|
||||
return node;
|
||||
});
|
||||
@@ -447,8 +491,13 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
// Add new line node actions
|
||||
addLineNode: (lineNode: LineNode) => {
|
||||
set((state) => {
|
||||
const newLineNodes = [...state.lineNodes, lineNode];
|
||||
return { lineNodes: newLineNodes };
|
||||
const nextZ = getNextZIndex(state.nodes, state.lineNodes);
|
||||
|
||||
const newLineNode = {
|
||||
...lineNode,
|
||||
zIndex: nextZ
|
||||
};
|
||||
return { lineNodes: [...state.lineNodes, newLineNode] };
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
@@ -475,7 +524,11 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
set((state) => {
|
||||
const newLineNodes = state.lineNodes.map(node => {
|
||||
if (node.id === id) {
|
||||
return { ...node, ...updates };
|
||||
return {
|
||||
...node,
|
||||
...updates,
|
||||
zIndex: updates.zIndex !== undefined ? updates.zIndex : node.zIndex
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
@@ -515,7 +568,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,10 +634,11 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
// Load the image asynchronously
|
||||
node.setImage(source).then(() => {
|
||||
// Update the node in the store after image loads
|
||||
get().updateNode(node.id, node, false);
|
||||
}).catch(console.error);
|
||||
get().addNode(node);
|
||||
//get().updateNode(node.id, node, false);
|
||||
}).catch((error)=>console.error('Error loading image:', error));
|
||||
|
||||
|
||||
get().addNode(node);
|
||||
},
|
||||
|
||||
// Action for deleting selected items
|
||||
@@ -713,6 +772,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 }),
|
||||
@@ -877,5 +937,198 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
},
|
||||
loadSceneFromString: (jsonString: string): boolean => {
|
||||
return get().importSceneFromJson(jsonString);
|
||||
}
|
||||
},
|
||||
|
||||
// New functions
|
||||
bringToFront: (nodeIds: string[]) => {
|
||||
set(state => {
|
||||
// Get highest zIndex from all nodes and line nodes
|
||||
const allZIndices = [
|
||||
...state.nodes.map(n => n.zIndex || 0),
|
||||
...state.lineNodes.map(n => n.zIndex || 0)
|
||||
];
|
||||
const maxZ = Math.max(...allZIndices, 0) + 1;
|
||||
|
||||
return {
|
||||
nodes: state.nodes.map(node =>
|
||||
nodeIds.includes(node.id)
|
||||
? new Node({ ...node, zIndex: maxZ })
|
||||
: node
|
||||
),
|
||||
lineNodes: state.lineNodes.map(node =>
|
||||
nodeIds.includes(node.id)
|
||||
? { ...node, zIndex: maxZ }
|
||||
: node
|
||||
)
|
||||
};
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
|
||||
sendToBack: (nodeIds: string[]) => {
|
||||
set(state => {
|
||||
// Get lowest zIndex from all nodes and line nodes
|
||||
const allZIndices = [
|
||||
...state.nodes.map(n => n.zIndex || 0),
|
||||
...state.lineNodes.map(n => n.zIndex || 0)
|
||||
];
|
||||
const minZ = Math.min(...allZIndices, 0) - 1;
|
||||
|
||||
return {
|
||||
nodes: state.nodes.map(node =>
|
||||
nodeIds.includes(node.id)
|
||||
? new Node({ ...node, zIndex: minZ })
|
||||
: node
|
||||
),
|
||||
lineNodes: state.lineNodes.map(node =>
|
||||
nodeIds.includes(node.id)
|
||||
? { ...node, zIndex: minZ }
|
||||
: node
|
||||
)
|
||||
};
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
|
||||
bringForward: (nodeIds: string[]) => {
|
||||
set(state => {
|
||||
// Combine all items with their current zIndex
|
||||
const allItems = [
|
||||
...state.nodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) })),
|
||||
...state.lineNodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) }))
|
||||
].sort((a, b) => a.z - b.z);
|
||||
|
||||
// Find the highest z-index among selected items
|
||||
const selectedItems = allItems.filter(item => item.selected);
|
||||
if (selectedItems.length === 0) return state;
|
||||
|
||||
const highestSelectedZ = Math.max(...selectedItems.map(item => item.z));
|
||||
|
||||
// Find the closest item above our selection
|
||||
const nextItem = allItems.find(item => !item.selected && item.z > highestSelectedZ);
|
||||
|
||||
// If no item above, swap with the highest non-selected item
|
||||
if (!nextItem) {
|
||||
const highestNonSelected = allItems.filter(item => !item.selected)
|
||||
.sort((a, b) => b.z - a.z)[0];
|
||||
if (!highestNonSelected) return state;
|
||||
|
||||
// Swap positions
|
||||
return {
|
||||
nodes: state.nodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return new Node({ ...node, zIndex: highestNonSelected.z });
|
||||
}
|
||||
if (node.id === highestNonSelected.id) {
|
||||
return new Node({ ...node, zIndex: highestSelectedZ });
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
lineNodes: state.lineNodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return { ...node, zIndex: highestNonSelected.z };
|
||||
}
|
||||
if (node.id === highestNonSelected.id) {
|
||||
return { ...node, zIndex: highestSelectedZ };
|
||||
}
|
||||
return node;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const newZ = nextItem.z;
|
||||
return {
|
||||
nodes: state.nodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return new Node({ ...node, zIndex: newZ });
|
||||
}
|
||||
if (node.id === nextItem.id) {
|
||||
return new Node({ ...node, zIndex: highestSelectedZ });
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
lineNodes: state.lineNodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return { ...node, zIndex: newZ };
|
||||
}
|
||||
if (node.id === nextItem.id) {
|
||||
return { ...node, zIndex: highestSelectedZ };
|
||||
}
|
||||
return node;
|
||||
})
|
||||
};
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
|
||||
sendBackward: (nodeIds: string[]) => {
|
||||
set(state => {
|
||||
// Combine all items with their current zIndex
|
||||
const allItems = [
|
||||
...state.nodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) })),
|
||||
...state.lineNodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) }))
|
||||
].sort((a, b) => b.z - a.z);
|
||||
|
||||
// Find the lowest z-index among selected items
|
||||
const selectedItems = allItems.filter(item => item.selected);
|
||||
if (selectedItems.length === 0) return state;
|
||||
|
||||
const lowestSelectedZ = Math.min(...selectedItems.map(item => item.z));
|
||||
|
||||
// Find the closest item below our selection
|
||||
const nextItem = allItems.find(item => !item.selected && item.z < lowestSelectedZ);
|
||||
|
||||
// If no item below, swap with the lowest non-selected item
|
||||
if (!nextItem) {
|
||||
const lowestNonSelected = allItems.filter(item => !item.selected)
|
||||
.sort((a, b) => a.z - b.z)[0];
|
||||
if (!lowestNonSelected) return state;
|
||||
|
||||
// Swap positions
|
||||
return {
|
||||
nodes: state.nodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return new Node({ ...node, zIndex: lowestNonSelected.z });
|
||||
}
|
||||
if (node.id === lowestNonSelected.id) {
|
||||
return new Node({ ...node, zIndex: lowestSelectedZ });
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
lineNodes: state.lineNodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return { ...node, zIndex: lowestNonSelected.z };
|
||||
}
|
||||
if (node.id === lowestNonSelected.id) {
|
||||
return { ...node, zIndex: lowestSelectedZ };
|
||||
}
|
||||
return node;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const newZ = nextItem.z;
|
||||
return {
|
||||
nodes: state.nodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return new Node({ ...node, zIndex: newZ });
|
||||
}
|
||||
if (node.id === nextItem.id) {
|
||||
return new Node({ ...node, zIndex: lowestSelectedZ });
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
lineNodes: state.lineNodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return { ...node, zIndex: newZ };
|
||||
}
|
||||
if (node.id === nextItem.id) {
|
||||
return { ...node, zIndex: lowestSelectedZ };
|
||||
}
|
||||
return node;
|
||||
})
|
||||
};
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */}
|
||||
@@ -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>
|
||||
|
||||
@@ -173,8 +173,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 +196,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 +213,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()
|
||||
@@ -480,7 +490,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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Generated
+1
-1
@@ -28691,4 +28691,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user