Compare commits

..
Author SHA1 Message Date
Brandon Thomas 105eee5bdb handle fal image download and bucket upload 2025-06-03 14:44:11 -04:00
Brandon Thomas 9e32196ca2 decode image 2025-06-03 12:59:47 -04:00
Brandon Thomas c8c857e3d0 read webhook request json 2025-06-03 12:12:02 -04:00
Brandon Thomas f480bc032e webhook debugging 2025-06-03 10:36:32 -04:00
Brandon Thomas c3e41a1a3e fal webhook endpoint 2025-06-03 10:26:47 -04:00
Brandon Thomas 377acb3e4a add routes 2025-06-03 09:31:42 -04:00
Brandon Thomas bfd864c082 idempotency token 2025-06-03 09:23:04 -04:00
Brandon Thomas bd059643f0 enum type, wip 2025-06-03 01:16:44 -04:00
Brandon Thomas d39c6c94e5 storyteller-web builds 2025-06-03 00:53:39 -04:00
Brandon Thomas 0d6ec74611 wip 2025-06-02 14:25:02 -04:00
Brandon Thomas 4c26309d4e refactor queue vs webhook endpoints 2025-06-02 14:25:02 -04:00
Brandon Thomas c6d3b92d42 revert changes to old fal methods 2025-06-02 14:25:02 -04:00
Brandon Thomas 3f85ef0884 enqueuing to webhook works 2025-06-02 14:25:02 -04:00
Brandon Thomas d730ffb7bd wip 2025-06-02 14:25:02 -04:00
Brandon Thomas 15bc87e025 server impl wip 2025-06-02 14:25:01 -04:00
Brandon Thomas 0af30604a6 endpoint wip 2025-06-02 14:25:01 -04:00
Brandon Thomas 01dfb1e301 crate for shared api defs 2025-06-02 14:25:01 -04:00
Brandon Thomas 3ea554f322 wip fal backend 2025-06-02 14:25:01 -04:00
316 changed files with 13137 additions and 8622 deletions
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "0fa824822cd94024dce21243f41e69e4a3e8685c4de676834a7c0ee14982c976"
}
@@ -1,113 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n \n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "status: enums::common::job_status_plus::JobStatusPlus",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 2,
"name": "external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "external_third_party_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
true,
true,
true,
true,
false,
false,
false
]
},
"hash": "64dfacc1f0552d539f009fea91e902a2fe296c06ad539808596bbf2c8fc9eaa0"
}
@@ -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_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 ",
"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 ",
"describe": {
"columns": [],
"parameters": {
"Right": 39
"Right": 38
},
"nullable": []
},
"hash": "b4470e607c005421dd8504b9f1d695080a581936ac6ce00b6f437b2764e741be"
"hash": "c58f414b5e4d0f0420d1894e2f40311e5613919f49f3ac91f81f5fc2bef1bea8"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n on_success_result_entity_type = ?,\n on_success_result_entity_token = ?,\n failure_reason = NULL,\n internal_debugging_failure_reason = NULL,\n success_execution_millis = NULL,\n success_inference_execution_millis = NULL,\n retry_at = NULL,\n successfully_completed_at = NOW()\nWHERE token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "f68a8917e639418a0bb952bef606c96299001e143c745ee30df99b72d1235d35"
}
Generated
-3
View File
@@ -648,7 +648,6 @@ name = "artcraft"
version = "0.1.0"
dependencies = [
"anyhow",
"artcraft_api_defs",
"base64 0.22.1",
"build_metadata",
"bytes 1.10.1",
@@ -660,7 +659,6 @@ dependencies = [
"fal_client",
"filesys",
"futures 0.3.31",
"idempotency",
"image",
"indexmap 2.9.0",
"log",
@@ -9996,7 +9994,6 @@ name = "storyteller_client"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_defs",
"backoff",
"chrono",
"cookie 0.18.1",
-2
View File
@@ -23,12 +23,10 @@ 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,2 +1 @@
pub mod image;
pub mod object;
pub mod image;
@@ -1,179 +0,0 @@
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(())
}
@@ -1,129 +0,0 @@
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(())
}
@@ -1,117 +0,0 @@
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(())
}
@@ -1,49 +0,0 @@
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)
}
}
@@ -1,4 +0,0 @@
pub mod enqueue_image_to_3d_object_command;
pub mod handle_object_artcraft;
pub mod handle_object_fal;
pub mod internal_object_error;
@@ -3,9 +3,8 @@ use serde::Serialize;
#[derive(Debug, Copy, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GenerationServiceProvider {
Artcraft,
Fal,
Sora,
Fal,
}
#[derive(Debug, Copy, Clone, Serialize)]
@@ -19,15 +19,6 @@ 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,7 +6,6 @@ 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;
@@ -162,7 +161,6 @@ 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,6 +196,7 @@ 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;
@@ -1,3 +1 @@
pub mod image;
pub mod object;
pub mod video;
pub mod image;
@@ -1,42 +0,0 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use fal::endpoints::fal_ai::hunyuan3d::v2::{v2, Hunyuan3DInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
pub struct Hunyuan2Args<'a, U: IntoUrl, V: IntoUrl> {
pub image_url: U,
pub webhook_url: V,
pub api_key: &'a FalApiKey,
}
#[derive(Copy, Clone, Debug)]
pub enum Kling16Duration {
Default,
FiveSeconds,
TenSeconds,
}
pub async fn enqueue_hunyuan2_image_to_3d_webhook<U: IntoUrl, V: IntoUrl>(
args: Hunyuan2Args<'_, U, V>
) -> Result<WebhookResponse, FalErrorPlus> {
let image_url = args.image_url.as_str().to_string();
let request = Hunyuan3DInput {
input_image_url: image_url,
textured_mesh: Some(true),
guidance_scale: None,
num_inference_steps: None,
octree_resolution: None,
seed: None,
};
let result = v2(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
@@ -1 +0,0 @@
pub mod enqueue_hunyuan2_image_to_3d_webhook;
@@ -1,83 +0,0 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use fal::endpoints::fal_ai::kling_video::v1_6::pro::image_to_video::{image_to_video, ProImageToVideoRequest};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
pub struct Kling16Args<'a, U: IntoUrl, V: IntoUrl> {
pub image_url: U,
pub webhook_url: V,
pub prompt: &'a str,
pub api_key: &'a FalApiKey,
pub duration: Kling16Duration,
}
#[derive(Copy, Clone, Debug)]
pub enum Kling16Duration {
Default,
FiveSeconds,
TenSeconds,
}
pub async fn enqueue_kling_16_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
args: Kling16Args<'_, U, V>
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Kling16Duration::Default => None,
Kling16Duration::FiveSeconds => Some("5".to_string()), // Gross...
Kling16Duration::TenSeconds => Some("10".to_string()),
};
let image_url = args.image_url.as_str().to_string();
let request = ProImageToVideoRequest {
image_url,
prompt: args.prompt.to_string(),
aspect_ratio: None,
cfg_scale: None,
duration,
negative_prompt: None,
tail_image_url: None,
};
let result = image_to_video(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
/*
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::enqueue_kling_16_image_to_video_webhook::{enqueue_kling_16_image_to_video_webhook, Kling16Args, Kling16Duration};
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
#[tokio::test]
#[ignore]
async fn test_kling16_video() -> AnyhowResult<()> {
let image = test_file_path("test_data/image/juno.jpg")?;
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Kling16Args {
image_path: image,
prompt: "a corgi looks out over the water",
api_key: &api_key,
duration: Kling16Duration::Default,
};
let result = enqueue_kling_16_image_to_video_webhook(args).await?;
Ok(())
}
}
*/
@@ -1 +0,0 @@
pub mod enqueue_kling_16_image_to_video_webhook;
@@ -6,10 +6,9 @@ use reqwest::Url;
use serde_derive::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::{fs::File, io::Write, path::Path};
use log::{debug, error, warn};
use log::{debug, warn};
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::utils::classify_general_http_status_code_and_body::classify_general_http_status_code_and_body;
const SORA_STATUS_URL: &str = "https://sora.com/backend/video_gen";
@@ -242,10 +241,9 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
debug!("response_body: {}", response_body);
if !status.is_success() {
error!("The sora status request failed; status = {:?} ; response body = {}", status, response_body);
let error = classify_general_http_status_code_and_body(status, &response_body).await;
return Err(error);
if status != reqwest::StatusCode::OK {
// TODO: Handle non-200 status codes, esp. JWT errors and credential errors
return Err(SoraError::AnyhowError(anyhow::anyhow!("The physical status request failed; status = {:?}, message = {:?}", status, response_body)));
}
let maybe_response = serde_json::from_str::<VideoGenStatusResponse>(response_body);
@@ -17,7 +17,6 @@ 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,7 +2,6 @@ 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)]
@@ -15,12 +14,6 @@ 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 {}
@@ -31,8 +24,6 @@ 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),
}
}
}
@@ -42,15 +33,3 @@ 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)
}
}
@@ -1 +0,0 @@
pub mod object;
@@ -1,69 +0,0 @@
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)
}
@@ -1 +0,0 @@
pub mod generate_hunyuan2_image_to_3d;
@@ -2,7 +2,6 @@
pub mod credentials;
pub mod error;
pub mod generate;
pub mod media_files;
pub mod recipes;
pub mod shared_response_types;
@@ -1,48 +0,0 @@
use anyhow::anyhow;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
use enums::common::job_status_plus::JobStatusPlus;
use errors::AnyhowResult;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
// TODO: Maybe in the future we set a batch_token instead of media token
pub async fn mark_fal_generic_inference_job_successfully_done(
pool: &MySqlPool,
job_token: &InferenceJobToken,
media_file_token: MediaFileToken,
) -> AnyhowResult<()>
{
const STATUS : JobStatusPlus = JobStatusPlus::CompleteSuccess;
const RESULT_TYPE : InferenceResultType = InferenceResultType::MediaFile;
let query_result = sqlx::query!(
r#"
UPDATE generic_inference_jobs
SET
status = ?,
on_success_result_entity_type = ?,
on_success_result_entity_token = ?,
failure_reason = NULL,
internal_debugging_failure_reason = NULL,
success_execution_millis = NULL,
success_inference_execution_millis = NULL,
retry_at = NULL,
successfully_completed_at = NOW()
WHERE token = ?
"#,
STATUS.to_str(),
RESULT_TYPE.to_str(),
media_file_token,
job_token,
)
.execute(pool)
.await;
match query_result {
Err(err) => Err(anyhow!("error with query: {:?}", err)),
Ok(_r) => Ok(()),
}
}
@@ -1,3 +0,0 @@
pub mod get_inference_job_by_fal_id;
pub mod insert_generic_inference_job_for_fal_queue;
pub mod mark_fal_generic_inference_job_successfully_done;
@@ -1,3 +1,2 @@
pub mod fal;
pub mod job;
pub mod web;
@@ -175,7 +175,6 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
InferenceCategory::VideoFilter => Some("Video Filter"),
InferenceCategory::ImageGeneration => Some("Image Generation"),
InferenceCategory::VideoGeneration => Some("Video Generation"),
InferenceCategory::ObjectGeneration => Some("Object Generation"),
InferenceCategory::BackgroundRemoval => Some("Background Removal"),
InferenceCategory::Mocap => Some("Mocap"),
InferenceCategory::Workflow => Some("Workflow"),
@@ -198,7 +197,6 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
InferenceCategory::VideoFilter => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::ImageGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::VideoGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::ObjectGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::BackgroundRemoval => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::Mocap => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::Workflow => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
@@ -151,7 +151,6 @@ fn raw_record_to_public_result(record: RawGenericInferenceJobStatus) -> GenericI
InferenceCategory::VideoFilter => Some("Video Filter"),
InferenceCategory::ImageGeneration => Some("Image Generation"),
InferenceCategory::VideoGeneration => Some("Video Generation"),
InferenceCategory::ObjectGeneration => Some("Object Generation"),
InferenceCategory::BackgroundRemoval => Some("Background Removal"),
InferenceCategory::Mocap => Some("Mocap"),
InferenceCategory::Workflow => Some("Workflow"),
@@ -174,7 +173,6 @@ fn raw_record_to_public_result(record: RawGenericInferenceJobStatus) -> GenericI
InferenceCategory::VideoFilter => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::ImageGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::VideoGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::ObjectGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::BackgroundRemoval => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::Mocap => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::Workflow => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
@@ -24,7 +24,6 @@ pub enum FalCategory {
ImageGeneration,
VideoGeneration,
BackgroundRemoval,
ObjectGeneration,
}
pub struct InsertGenericInferenceForFalArgs<'a> {
@@ -70,10 +69,6 @@ pub async fn insert_generic_inference_job_for_fal_queue(args: InsertGenericInfer
InferenceCategory::BackgroundRemoval,
InferenceJobProductCategory::FalBgRemoval
),
FalCategory::ObjectGeneration => (
InferenceCategory::ObjectGeneration,
InferenceJobProductCategory::FalObject,
),
};
@@ -242,7 +242,6 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
InferenceCategory::VideoFilter => Some("Video Filter"),
InferenceCategory::ImageGeneration => Some("Image Generation"),
InferenceCategory::VideoGeneration => Some("Video Generation"),
InferenceCategory::ObjectGeneration => Some("Object Generation"),
InferenceCategory::BackgroundRemoval => Some("Background Removal"),
InferenceCategory::Mocap => Some("Mocap"),
InferenceCategory::Workflow => Some("Workflow"),
@@ -264,7 +263,6 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
InferenceCategory::VideoFilter => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::ImageGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::VideoGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::ObjectGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::BackgroundRemoval => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::Mocap => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
InferenceCategory::Workflow => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
@@ -3,9 +3,11 @@ pub mod dismiss_finished_jobs_for_user;
pub mod get_inference_job_status;
pub mod get_pending_inference_job_count;
pub mod insert_generic_inference_job;
pub mod insert_generic_inference_job_for_fal_queue;
pub mod job_status;
pub mod kill_generic_inference_jobs;
pub mod kill_jobs_in_development;
pub mod list_session_jobs;
pub mod mark_generic_inference_job_cancelled_by_user;
pub mod mark_generic_inference_job_successfully_done_by_token;
pub mod mark_generic_inference_job_successfully_done_by_token;
pub mod get_inference_job_by_fal_id;
@@ -4,7 +4,6 @@ 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;
@@ -52,9 +51,6 @@ 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>,
@@ -156,8 +152,6 @@ pub async fn insert_media_file_generic(
maybe_frame_height = ?,
maybe_prompt_token = ?,
checksum_sha2 = ?,
maybe_engine_category = ?,
maybe_title = ?,
maybe_text_transcript = ?,
@@ -211,8 +205,6 @@ 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,7 +107,6 @@ 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,7 +9,6 @@ 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;
@@ -45,16 +44,13 @@ pub struct MediaFileInsertBuilder {
// Media info
maybe_mime_type: Option<String>,
file_size_bytes: u64, // NB: Non-nullable field
maybe_duration_millis: Option<u64>,
// maybe_duration_millis: Option<u64>,
// maybe_audio_encoding: Option<&'a str>,
// maybe_video_encoding: Option<&'a str>,
maybe_frame_width: Option<u32>,
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,11 +90,9 @@ impl MediaFileInsertBuilder {
origin_product_category: MediaFileOriginProductCategory::Unknown,
maybe_mime_type: None,
file_size_bytes: 0,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
checksum_sha2: None,
maybe_engine_category: None,
public_bucket_directory_hash: None,
}
}
@@ -179,44 +173,25 @@ impl MediaFileInsertBuilder {
self
}
pub fn maybe_duration_millis(mut self, maybe_duration_millis: Option<u64>) -> Self {
self.maybe_duration_millis = maybe_duration_millis;
self
}
// TODO: maybe_duration_millis
// TODO: maybe_audio_encoding
// TODO: maybe_video_encoding
pub fn frame_width(mut self, frame_width: u32) -> Self {
self.maybe_frame_width = Some(frame_width);
self
}
pub fn maybe_frame_width(mut self, maybe_frame_width: Option<u32>) -> Self {
self.maybe_frame_width = maybe_frame_width;
self
}
pub fn frame_height(mut self, frame_height: u32) -> Self {
self.maybe_frame_height = Some(frame_height);
self
}
pub fn maybe_frame_height(mut self, maybe_frame_height: Option<u32>) -> Self {
self.maybe_frame_height = maybe_frame_height;
self
}
pub fn checksum_sha2(mut self, checksum_sha2: &str) -> Self {
self.checksum_sha2 = Some(checksum_sha2.to_string());
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
@@ -263,12 +238,11 @@ impl MediaFileInsertBuilder {
maybe_origin_filename: None, // TODO
maybe_mime_type: self.maybe_mime_type.as_deref(),
file_size_bytes: self.file_size_bytes,
maybe_duration_millis: self.maybe_duration_millis,
maybe_duration_millis: None, // TODO
maybe_audio_encoding: None, // TODO
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,7 +111,6 @@ 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,3 +1 @@
pub mod image;
pub mod object;
pub mod video;
pub mod image;
@@ -1,22 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
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 {
/// Idempotency token to prevent duplicate requests.
pub uuid_idempotency_token: String,
/// Source of the file to convert to 3D
/// It must be an image.
pub media_file_token: Option<MediaFileToken>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct GenerateHunyuan2ImageTo3dResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
}
@@ -1 +0,0 @@
pub mod generate_hunyuan_2_image_to_3d;
@@ -1,25 +0,0 @@
use serde::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
const GENERATE_KLING_1_6_IMAGE_TO_VIDEO_URL_PATH: &str = "/v1/generate/video/kling_16_image_to_video";
#[derive(Serialize, Deserialize, ToSchema)]
pub struct GenerateKling16ImageToVideoRequest {
/// Idempotency token to prevent duplicate requests.
pub uuid_idempotency_token: String,
/// Source of the image file to convert to video.
/// It must be an image.
pub media_file_token: Option<MediaFileToken>,
/// Optional text prompt.
pub prompt: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct GenerateKling16ImageToVideoResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
}
@@ -1 +0,0 @@
pub mod generate_kling_1_6_image_to_video;
@@ -43,10 +43,6 @@ pub enum InferenceCategory {
#[serde(rename = "video_generation")]
VideoGeneration,
/// FAL-powered 3D object generation
#[serde(rename = "object_generation")]
ObjectGeneration,
/// FAL-powered image background removal
#[serde(rename = "background_removal")]
BackgroundRemoval,
@@ -102,7 +98,6 @@ impl InferenceCategory {
Self::VoiceConversion => "voice_conversion",
Self::ImageGeneration => "image_generation",
Self::VideoGeneration => "video_generation",
Self::ObjectGeneration => "object_generation",
Self::BackgroundRemoval => "background_removal",
Self::Mocap => "mocap",
Self::Workflow => "workflow",
@@ -123,7 +118,6 @@ impl InferenceCategory {
"voice_conversion" => Ok(Self::VoiceConversion),
"image_generation" => Ok(Self::ImageGeneration),
"video_generation" => Ok(Self::VideoGeneration),
"object_generation" => Ok(Self::ObjectGeneration),
"background_removal" => Ok(Self::BackgroundRemoval),
"f5_tts" => Ok(Self::F5TTS),
"mocap" => Ok(Self::Mocap),
@@ -146,7 +140,6 @@ impl InferenceCategory {
Self::TextToSpeech,
Self::VoiceConversion,
Self::ImageGeneration,
Self::ObjectGeneration,
Self::VideoGeneration,
Self::BackgroundRemoval,
Self::Mocap,
@@ -177,7 +170,6 @@ mod tests {
assert_serialization(InferenceCategory::VoiceConversion, "voice_conversion");
assert_serialization(InferenceCategory::ImageGeneration, "image_generation");
assert_serialization(InferenceCategory::VideoGeneration, "video_generation");
assert_serialization(InferenceCategory::ObjectGeneration, "object_generation");
assert_serialization(InferenceCategory::BackgroundRemoval, "background_removal");
assert_serialization(InferenceCategory::Mocap, "mocap");
assert_serialization(InferenceCategory::F5TTS, "f5_tts");
@@ -197,7 +189,6 @@ mod tests {
assert_eq!(InferenceCategory::VoiceConversion.to_str(), "voice_conversion");
assert_eq!(InferenceCategory::ImageGeneration.to_str(), "image_generation");
assert_eq!(InferenceCategory::VideoGeneration.to_str(), "video_generation");
assert_eq!(InferenceCategory::ObjectGeneration.to_str(), "object_generation");
assert_eq!(InferenceCategory::BackgroundRemoval.to_str(), "background_removal");
assert_eq!(InferenceCategory::F5TTS.to_str(), "f5_tts");
assert_eq!(InferenceCategory::SeedVc.to_str(), "seed_vc");
@@ -217,7 +208,6 @@ mod tests {
assert_eq!(InferenceCategory::from_str("voice_conversion").unwrap(), InferenceCategory::VoiceConversion);
assert_eq!(InferenceCategory::from_str("image_generation").unwrap(), InferenceCategory::ImageGeneration);
assert_eq!(InferenceCategory::from_str("video_generation").unwrap(), InferenceCategory::VideoGeneration);
assert_eq!(InferenceCategory::from_str("object_generation").unwrap(), InferenceCategory::ObjectGeneration);
assert_eq!(InferenceCategory::from_str("background_removal").unwrap(), InferenceCategory::BackgroundRemoval);
assert_eq!(InferenceCategory::from_str("f5_tts").unwrap(), InferenceCategory::F5TTS);
assert_eq!(InferenceCategory::from_str("seed_vc").unwrap(), InferenceCategory::SeedVc);
@@ -232,7 +222,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 16;
const EXPECTED_COUNT : usize = 15;
assert_eq!(InferenceCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceCategory::iter().len(), EXPECTED_COUNT);
@@ -26,8 +26,6 @@ pub enum InferenceJobProductCategory {
FalImage,
FalVideo,
/// Fal: 3D Object Generation
FalObject,
FalBgRemoval,
// =============== TEXT TO SPEECH ===============
@@ -123,7 +121,6 @@ impl InferenceJobProductCategory {
Self::DownloadGptSoVits => "download_gpt_so_vits",
Self::FalImage => "fal_image",
Self::FalVideo => "fal_video",
Self::FalObject => "fal_object",
Self::FalBgRemoval => "fal_bg_removal",
Self::TtsGptSoVits => "tts_gpt_so_vits",
Self::TtsStyleTts2 => "tts_style_tts2",
@@ -155,7 +152,6 @@ impl InferenceJobProductCategory {
"download_gpt_so_vits" => Ok(Self::DownloadGptSoVits),
"fal_image" => Ok(Self::FalImage),
"fal_video" => Ok(Self::FalVideo),
"fal_object" => Ok(Self::FalObject),
"fal_bg_removal" => Ok(Self::FalBgRemoval),
"tts_gpt_so_vits" => Ok(Self::TtsGptSoVits),
"tts_style_tts2" => Ok(Self::TtsStyleTts2),
@@ -190,7 +186,6 @@ impl InferenceJobProductCategory {
Self::DownloadGptSoVits,
Self::FalImage,
Self::FalVideo,
Self::FalObject,
Self::FalBgRemoval,
Self::TtsGptSoVits,
Self::TtsStyleTts2,
@@ -231,7 +226,6 @@ mod tests {
assert_serialization(InferenceJobProductCategory::DownloadGptSoVits, "download_gpt_so_vits");
assert_serialization(InferenceJobProductCategory::FalImage, "fal_image");
assert_serialization(InferenceJobProductCategory::FalVideo, "fal_video");
assert_serialization(InferenceJobProductCategory::FalObject, "fal_object");
assert_serialization(InferenceJobProductCategory::FalBgRemoval, "fal_bg_removal");
assert_serialization(InferenceJobProductCategory::TtsGptSoVits, "tts_gpt_so_vits");
assert_serialization(InferenceJobProductCategory::TtsStyleTts2, "tts_style_tts2");
@@ -262,7 +256,6 @@ mod tests {
assert_eq!(InferenceJobProductCategory::DownloadGptSoVits.to_str(), "download_gpt_so_vits");
assert_eq!(InferenceJobProductCategory::FalImage.to_str(), "fal_image");
assert_eq!(InferenceJobProductCategory::FalVideo.to_str(), "fal_video");
assert_eq!(InferenceJobProductCategory::FalObject.to_str(), "fal_object");
assert_eq!(InferenceJobProductCategory::FalBgRemoval.to_str(), "fal_bg_removal");
assert_eq!(InferenceJobProductCategory::TtsGptSoVits.to_str(), "tts_gpt_so_vits");
assert_eq!(InferenceJobProductCategory::TtsStyleTts2.to_str(), "tts_style_tts2");
@@ -292,7 +285,6 @@ mod tests {
assert_eq!(InferenceJobProductCategory::from_str("download_gpt_so_vits").unwrap(), InferenceJobProductCategory::DownloadGptSoVits);
assert_eq!(InferenceJobProductCategory::from_str("fal_image").unwrap(), InferenceJobProductCategory::FalImage);
assert_eq!(InferenceJobProductCategory::from_str("fal_video").unwrap(), InferenceJobProductCategory::FalVideo);
assert_eq!(InferenceJobProductCategory::from_str("fal_object").unwrap(), InferenceJobProductCategory::FalObject);
assert_eq!(InferenceJobProductCategory::from_str("fal_bg_removal").unwrap(), InferenceJobProductCategory::FalBgRemoval);
assert_eq!(InferenceJobProductCategory::from_str("tts_gpt_so_vits").unwrap(), InferenceJobProductCategory::TtsGptSoVits);
assert_eq!(InferenceJobProductCategory::from_str("tts_style_tts2").unwrap(), InferenceJobProductCategory::TtsStyleTts2);
@@ -320,7 +312,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 27;
const EXPECTED_COUNT : usize = 26;
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
@@ -111,12 +111,12 @@ impl MediaFileType {
match mime_type {
"image/jpeg" => Some(Self::Jpg),
"image/png" => Some(Self::Png),
"video/mp4" => Some(Self::Mp4),
"model/gltf-binary" => Some(Self::Glb),
//"audio/wav" => Some(Self::Wav), // NB: Not sure if this is correct
//"audio/mpeg" => Some(Self::Mp3), // NB: Not sure if this is correct
//"image/gif" => Some(Self::Gif), // NB: Not sure if this is correct
//"model/gltf+json" => Some(Self::Gltf), // NB: Not sure if this is correct
//"audio/wav" => Some(Self::Wav),
//"audio/mpeg" => Some(Self::Mp3),
//"image/gif" => Some(Self::Gif),
//"video/mp4" => Some(Self::Mp4),
//"model/gltf-binary" => Some(Self::Glb),
//"model/gltf+json" => Some(Self::Gltf),
_ => None,
}
}
@@ -360,9 +360,7 @@ async fn old_dispatch(
return Err(ProcessSingleJobError::InvalidJob(
anyhow!("invalid job category for dispatch: {:?}", job.inference_category)))
}
InferenceCategory::VideoGeneration
| InferenceCategory::BackgroundRemoval
| InferenceCategory::ObjectGeneration => {
InferenceCategory::VideoGeneration | InferenceCategory::BackgroundRemoval => {
// These are handled by the new dispatch
return Err(ProcessSingleJobError::InvalidJob(
anyhow!("these job types do not support the old-style dispatch: {:?}", job.inference_category)))
@@ -22,15 +22,15 @@ use http_server_common::request::get_request_ip::get_request_ip;
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
use idempotency::uuid::generate_random_uuid;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::InsertGenericInferenceForFalArgs;
use mysql_queries::queries::generic_inference::web::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
use mysql_queries::queries::generic_inference::web::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::web::insert_generic_inference_job_for_fal_queue::InsertGenericInferenceForFalArgs;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::media_files::get::get_media_file::{get_media_file, MediaFile};
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
// =============== Error Response ===============
@@ -116,10 +116,6 @@ pub async fn remove_image_background_handler(
return Err(RemoveImageBackgroundError::BadInput("No media file token provided".to_string()));
}
};
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(RemoveImageBackgroundError::BadInput(reason));
}
insert_idempotency_token(&request.uuid_idempotency_token, &server_state.mysql_pool)
.await
@@ -188,8 +184,10 @@ pub async fn remove_image_background_handler(
let ip_address = get_request_ip(&http_request);
let uuid_idempotency_token = generate_random_uuid();
let db_result = insert_generic_inference_job_for_fal_queue(InsertGenericInferenceForFalArgs {
uuid_idempotency_token: &request.uuid_idempotency_token,
uuid_idempotency_token: &uuid_idempotency_token,
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::BackgroundRemoval,
maybe_inference_args: None,
@@ -1,3 +1,2 @@
pub mod image;
pub mod object;
pub mod video;
@@ -1,217 +0,0 @@
use std::fmt;
use std::sync::Arc;
use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo;
use crate::http_server::common_responses::media::media_links::MediaLinks;
use crate::http_server::deprecated_endpoints::engine::create_scene_handler::CreateSceneError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
use crate::state::server_state::ServerState;
use crate::util::delete_role_disambiguation::{delete_role_disambiguation, DeleteRole};
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::Json;
use actix_web::web::Path;
use actix_web::{web, HttpRequest, HttpResponse};
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::GenerateHunyuan2ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::GenerateHunyuan2ImageTo3dResponse;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::common::visibility::Visibility;
use fal_client::requests::webhook::object::enqueue_hunyuan2_image_to_3d_webhook::enqueue_hunyuan2_image_to_3d_webhook;
use fal_client::requests::webhook::object::enqueue_hunyuan2_image_to_3d_webhook::Hunyuan2Args;
use http_server_common::request::get_request_ip::get_request_ip;
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
use idempotency::uuid::generate_random_uuid;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::InsertGenericInferenceForFalArgs;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::media_files::get::get_media_file::{get_media_file, MediaFile};
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
// =============== Error Response ===============
#[derive(Debug, Serialize, ToSchema)]
pub enum GenerateHunyuan2ImageTo3dError {
BadInput(String),
NotFound,
NotAuthorized,
ServerError,
}
impl ResponseError for GenerateHunyuan2ImageTo3dError {
fn status_code(&self) -> StatusCode {
match *self {
GenerateHunyuan2ImageTo3dError::BadInput(_) => StatusCode::BAD_REQUEST,
GenerateHunyuan2ImageTo3dError::NotFound => StatusCode::NOT_FOUND,
GenerateHunyuan2ImageTo3dError::NotAuthorized => StatusCode::UNAUTHORIZED,
GenerateHunyuan2ImageTo3dError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
serialize_as_json_error(self)
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for GenerateHunyuan2ImageTo3dError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
// =============== Handler ===============
/// Background removal
#[utoipa::path(
post,
tag = "Generate Objects",
path = "/v1/generate/object/hunyuan_2_image_to_3d",
responses(
(status = 200, description = "Success", body = GenerateHunyuan2ImageTo3dResponse),
(status = 400, description = "Bad input", body = GenerateHunyuan2ImageTo3dError),
(status = 401, description = "Not authorized", body = GenerateHunyuan2ImageTo3dError),
(status = 500, description = "Server error", body = GenerateHunyuan2ImageTo3dError),
),
params(
("request" = GenerateHunyuan2ImageTo3dRequest, description = "Payload for Request"),
)
)]
pub async fn generate_hunyuan_2_image_to_3d_handler(
http_request: HttpRequest,
request: Json<GenerateHunyuan2ImageTo3dRequest>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<GenerateHunyuan2ImageTo3dResponse>, GenerateHunyuan2ImageTo3dError> {
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
GenerateHunyuan2ImageTo3dError::ServerError
})?;
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// TODO: Limit usage for new accounts. Billing, free credits metering, etc.
//let user_session = match maybe_user_session {
// Some(session) => session,
// None => {
// warn!("not logged in");
// return Err(GenerateHunyuan2ImageTo3dError::NotAuthorized);
// }
//};
let media_file_token = match &request.media_file_token {
Some(token) => token,
None => {
warn!("No media file token provided");
return Err(GenerateHunyuan2ImageTo3dError::BadInput("No media file token provided".to_string()));
}
};
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(GenerateHunyuan2ImageTo3dError::BadInput(reason));
}
insert_idempotency_token(&request.uuid_idempotency_token, &server_state.mysql_pool)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
GenerateHunyuan2ImageTo3dError::BadInput("invalid idempotency token".to_string())
})?;
const IS_MOD : bool = false;
let media_file_lookup_result = get_media_file(
media_file_token,
IS_MOD,
&server_state.mysql_pool,
).await;
let media_file = match media_file_lookup_result {
Ok(Some(media_file)) => media_file,
Ok(None) => {
warn!("MediaFile not found: {:?}", media_file_token);
return Err(GenerateHunyuan2ImageTo3dError::NotFound);
},
Err(err) => {
warn!("Error looking up media_file: {:?}", err);
return Err(GenerateHunyuan2ImageTo3dError::ServerError);
}
};
if !media_file.media_type.is_jpg_or_png_or_legacy_image() {
return Err(GenerateHunyuan2ImageTo3dError::BadInput("Media file must be a JPG or PNG image".to_string()));
}
let media_domain = get_media_domain(&http_request);
let bucket_path = MediaFileBucketPath::from_object_hash(
&media_file.public_bucket_directory_hash,
media_file.maybe_public_bucket_prefix.as_deref(),
media_file.maybe_public_bucket_extension.as_deref());
let media_links = MediaLinks::from_media_path_and_env(
media_domain,
server_state.server_environment,
&bucket_path);
info!("Fal webhook URL: {}", server_state.fal.webhook_url);
let args = Hunyuan2Args {
image_url: media_links.cdn_url,
webhook_url: &server_state.fal.webhook_url,
api_key: &server_state.fal.api_key,
};
let fal_result = enqueue_hunyuan2_image_to_3d_webhook(args)
.await
.map_err(|err| {
warn!("Error calling enqueue_hunyuan2_image_to_3d_webhook: {:?}", err);
GenerateHunyuan2ImageTo3dError::ServerError
})?;
let external_job_id = fal_result.request_id
.ok_or_else(|| {
warn!("Fal request_id is None");
GenerateHunyuan2ImageTo3dError::ServerError
})?;
info!("Fal request_id: {}", external_job_id);
let ip_address = get_request_ip(&http_request);
let db_result = insert_generic_inference_job_for_fal_queue(InsertGenericInferenceForFalArgs {
uuid_idempotency_token: &request.uuid_idempotency_token,
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::ObjectGeneration,
maybe_inference_args: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
}).await;
let job_token = match db_result {
Ok(token) => token,
Err(err) => {
warn!("Error inserting generic inference job for FAL queue: {:?}", err);
return Err(GenerateHunyuan2ImageTo3dError::ServerError);
}
};
Ok(Json(GenerateHunyuan2ImageTo3dResponse {
success: true,
inference_job_token: job_token,
}))
}
@@ -1 +0,0 @@
pub mod generate_hunyuan2_image_to_3d_handler;
@@ -1,225 +0,0 @@
use std::fmt;
use std::sync::Arc;
use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo;
use crate::http_server::common_responses::media::media_links::MediaLinks;
use crate::http_server::deprecated_endpoints::engine::create_scene_handler::CreateSceneError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
use crate::state::server_state::ServerState;
use crate::util::delete_role_disambiguation::{delete_role_disambiguation, DeleteRole};
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::Json;
use actix_web::web::Path;
use actix_web::{web, HttpRequest, HttpResponse};
use artcraft_api_defs::generate::video::generate_kling_1_6_image_to_video::GenerateKling16ImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_kling_1_6_image_to_video::GenerateKling16ImageToVideoResponse;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::common::visibility::Visibility;
use fal_client::requests::webhook::video::enqueue_kling_16_image_to_video_webhook::enqueue_kling_16_image_to_video_webhook;
use fal_client::requests::webhook::video::enqueue_kling_16_image_to_video_webhook::Kling16Args;
use fal_client::requests::webhook::video::enqueue_kling_16_image_to_video_webhook::Kling16Duration;
use http_server_common::request::get_request_ip::get_request_ip;
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
use idempotency::uuid::generate_random_uuid;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::InsertGenericInferenceForFalArgs;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::media_files::get::get_media_file::{get_media_file, MediaFile};
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
// =============== Error Response ===============
#[derive(Debug, Serialize, ToSchema)]
pub enum GenerateKling16VideoError {
BadInput(String),
NotFound,
NotAuthorized,
ServerError,
}
impl ResponseError for GenerateKling16VideoError {
fn status_code(&self) -> StatusCode {
match *self {
GenerateKling16VideoError::BadInput(_) => StatusCode::BAD_REQUEST,
GenerateKling16VideoError::NotFound => StatusCode::NOT_FOUND,
GenerateKling16VideoError::NotAuthorized => StatusCode::UNAUTHORIZED,
GenerateKling16VideoError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
serialize_as_json_error(self)
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for GenerateKling16VideoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
// =============== Handler ===============
/// Background removal
#[utoipa::path(
post,
tag = "Generate Videos",
path = "/v1/generate/video/kling_16_image_to_video",
responses(
(status = 200, description = "Success", body = RemoveImageBackgroundResponse),
(status = 400, description = "Bad input", body = RemoveImageBackgroundError),
(status = 401, description = "Not authorized", body = RemoveImageBackgroundError),
(status = 500, description = "Server error", body = RemoveImageBackgroundError),
),
params(
("request" = RemoveImageBackgroundRequest, description = "Payload for Request"),
)
)]
pub async fn generate_kling_1_6_video_handler(
http_request: HttpRequest,
request: Json<GenerateKling16ImageToVideoRequest>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<GenerateKling16ImageToVideoResponse>, GenerateKling16VideoError> {
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
GenerateKling16VideoError::ServerError
})?;
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// TODO: Limit usage for new accounts. Billing, free credits metering, etc.
//let user_session = match maybe_user_session {
// Some(session) => session,
// None => {
// warn!("not logged in");
// return Err(RemoveImageBackgroundError::NotAuthorized);
// }
//};
let media_file_token = match &request.media_file_token {
Some(token) => token,
None => {
warn!("No media file token provided");
return Err(GenerateKling16VideoError::BadInput("No media file token provided".to_string()));
}
};
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(GenerateKling16VideoError::BadInput(reason));
}
insert_idempotency_token(&request.uuid_idempotency_token, &server_state.mysql_pool)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
GenerateKling16VideoError::BadInput("invalid idempotency token".to_string())
})?;
const IS_MOD : bool = false;
let media_file_lookup_result = get_media_file(
media_file_token,
IS_MOD,
&server_state.mysql_pool,
).await;
let media_file = match media_file_lookup_result {
Ok(Some(media_file)) => media_file,
Ok(None) => {
warn!("MediaFile not found: {:?}", media_file_token);
return Err(GenerateKling16VideoError::NotFound);
},
Err(err) => {
warn!("Error looking up media_file: {:?}", err);
return Err(GenerateKling16VideoError::ServerError);
}
};
if !media_file.media_type.is_jpg_or_png_or_legacy_image() {
return Err(GenerateKling16VideoError::BadInput("Media file must be a JPG or PNG image".to_string()));
}
let media_domain = get_media_domain(&http_request);
let bucket_path = MediaFileBucketPath::from_object_hash(
&media_file.public_bucket_directory_hash,
media_file.maybe_public_bucket_prefix.as_deref(),
media_file.maybe_public_bucket_extension.as_deref());
let media_links = MediaLinks::from_media_path_and_env(
media_domain,
server_state.server_environment,
&bucket_path);
info!("Fal webhook URL: {}", server_state.fal.webhook_url);
let prompt = request.prompt
.as_deref()
.map(|prompt| prompt.trim())
.unwrap_or_else(|| "");
let args = Kling16Args {
image_url: media_links.cdn_url,
webhook_url: &server_state.fal.webhook_url,
duration: Kling16Duration::Default,
prompt: prompt,
api_key: &server_state.fal.api_key,
};
let fal_result = enqueue_kling_16_image_to_video_webhook(args)
.await
.map_err(|err| {
warn!("Error calling remove_background_rembg_webhook: {:?}", err);
GenerateKling16VideoError::ServerError
})?;
let external_job_id = fal_result.request_id
.ok_or_else(|| {
warn!("Fal request_id is None");
GenerateKling16VideoError::ServerError
})?;
info!("Fal request_id: {}", external_job_id);
let ip_address = get_request_ip(&http_request);
let db_result = insert_generic_inference_job_for_fal_queue(InsertGenericInferenceForFalArgs {
uuid_idempotency_token: &request.uuid_idempotency_token,
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::VideoGeneration,
maybe_inference_args: None,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_pool: &server_state.mysql_pool,
}).await;
let job_token = match db_result {
Ok(token) => token,
Err(err) => {
warn!("Error inserting generic inference job for FAL queue: {:?}", err);
return Err(GenerateKling16VideoError::ServerError);
}
};
Ok(Json(GenerateKling16ImageToVideoResponse {
success: true,
inference_job_token: job_token,
}))
}
@@ -1 +0,0 @@
pub mod generate_kling_1_6_video_handler;
@@ -32,6 +32,7 @@ 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;
@@ -73,7 +73,6 @@ pub fn estimate_job_progress(job: &GenericInferenceJobStatus, maybe_args: Option
// TODO: Better estimates for FAL, etc.
InferenceCategory::VideoGeneration => percent(duration_seconds, 60*5),
InferenceCategory::BackgroundRemoval => percent(duration_seconds, 10),
InferenceCategory::ObjectGeneration => percent(duration_seconds, 60*2),
// TODO: Better estimate using video duration, params, etc.
InferenceCategory::Workflow => comfy_workflow_estimate(maybe_args, duration_seconds),
@@ -2,8 +2,6 @@ use std::fmt;
use std::sync::Arc;
use crate::http_server::endpoints::webhooks::handle_image_payload::handle_image_payload;
use crate::http_server::endpoints::webhooks::handle_model_mesh_payload::handle_model_mesh_payload;
use crate::http_server::endpoints::webhooks::handle_video_payload::handle_video_payload;
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
@@ -14,11 +12,9 @@ use errors::AnyhowResult;
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
use log::{info, warn};
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id;
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::mark_fal_generic_inference_job_successfully_done;
use serde_json::Value;
use utoipa::ToSchema;
use mysql_queries::queries::generic_inference::web::get_inference_job_by_fal_id::get_inference_job_by_fal_id;
// 1. tauri --> hit endpoint to enqueue
//
// 2. webhook
@@ -140,34 +136,12 @@ pub async fn fal_webhook_handler(
return Err(FalWebhookError::ServerError);
}
};
let mut maybe_media_token = None;
if let Some(payload_obj) = payload.as_object() {
if payload_obj.contains_key("image") {
let token = handle_image_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("video") {
let token = handle_video_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("model_mesh") {
let token = handle_model_mesh_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
handle_image_payload(payload_obj, &job, &server_state).await?;
}
}
if let Some(media_token) = maybe_media_token {
info!("Media file token: {:?}", media_token);
// TODO: Update job metadata.
mark_fal_generic_inference_job_successfully_done(
&server_state.mysql_pool,
&job.job_token,
media_token,
).await.map_err(|err| {
warn!("Error marking job as successfully done: {:?}", err);
FalWebhookError::ServerError
})?;
}
Ok(SimpleGenericJsonSuccess::wrapped(true))
}
@@ -14,13 +14,12 @@ use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use images::image_info::image_info::ImageInfo;
use log::{info, warn};
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::generic_inference::web::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use serde_json::{Map, Value};
use std::sync::Arc;
use tokens::tokens::media_files::MediaFileToken;
const PREFIX : Option<&str> = Some("artcraft_");
const PREFIX : Option<&str> = Some("image_");
#[derive(Deserialize, Debug)]
pub struct FalWebhookImage {
@@ -36,15 +35,15 @@ pub async fn handle_image_payload(
payload: &Map<String, Value>,
job: &FalJobDetails,
server_state: &ServerState,
) -> AnyhowResult<MediaFileToken> {
) -> AnyhowResult<()> {
let image_value = payload.get("image")
.ok_or_else(|| anyhow!("no `image` key in payload"))?;
info!("Fal Image Payload: {:?}", image_value);
let image: FalWebhookImage = serde_json::from_value(image_value.clone())?;
info!("Image payload received: {:?}", image);
let image_url = image.url
.as_deref()
.ok_or_else(|| anyhow!("no `url` in image payload"))?;
@@ -100,7 +99,7 @@ pub async fn handle_image_payload(
.insert_pool(&server_state.mysql_pool)
.await?;
info!("Image media file uploaded with token: {}", media_token);
info!("Media file uploaded with token: {}", media_token);
Ok(media_token)
Ok(())
}
@@ -1,105 +0,0 @@
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use crate::util::http_download_url_to_tempfile::http_download_url_to_tempfile;
use actix_web::web;
use anyhow::anyhow;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use images::image_info::image_info::ImageInfo;
use log::{info, warn};
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
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_");
#[derive(Deserialize, Debug)]
pub struct FalWebhookModelMesh {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub url: Option<String>,
}
pub async fn handle_model_mesh_payload(
payload: &Map<String, Value>,
job: &FalJobDetails,
server_state: &ServerState,
) -> AnyhowResult<MediaFileToken> {
let model_mesh_value = payload.get("model_mesh")
.ok_or_else(|| anyhow!("no `model_mesh` key in payload"))?;
info!("Fal Model Mesh Payload: {:?}", model_mesh_value);
let mesh: FalWebhookModelMesh = serde_json::from_value(model_mesh_value.clone())?;
let mesh_url = mesh.url
.as_deref()
.ok_or_else(|| anyhow!("no `url` in image payload"))?;
//let mime_type = mesh.content_type
// .as_deref()
// .ok_or_else(|| anyhow!("no `content_type` in mesh payload"))?;
let file_bytes = http_download_url_to_bytes(mesh_url)
.await
.map_err(|e| anyhow!("Failed to download mesh: {:?}", e))?;
let mimetype_info = MimetypeInfo::get_for_bytes(&file_bytes)
.ok_or_else(|| anyhow!("Failed to get mimetype info"))?;
let mime_type = mimetype_info.mime_type();
info!("Mime type of mesh: {}", mime_type);
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
.ok_or_else(|| anyhow!("Unsupported media file type: {}", mime_type))?;
let extension_with_period = mimetype_info.file_extension()
.map(|ext| ext.extension_with_period())
.ok_or_else(|| anyhow!("Failed to get file extension from mimetype info"))?;
let file_size_bytes = file_bytes.len();
let file_hash = sha256_hash_bytes(&file_bytes)?;
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension_with_period));
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
server_state.public_bucket_client.upload_file_with_content_type_process(
public_upload_path.get_full_object_path_str(),
file_bytes.as_ref(),
&mime_type)
.await?;
let media_token = MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&job.creator_ip_address)
.public_bucket_directory_hash(&public_upload_path)
.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)
.checksum_sha2(&file_hash)
.insert_pool(&server_state.mysql_pool)
.await?;
info!("Mesh media file uploaded with token: {}", media_token);
Ok(media_token)
}
@@ -1,133 +0,0 @@
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use crate::util::http_download_url_to_tempfile::http_download_url_to_tempfile;
use actix_multipart::form::tempfile::TempFile;
use actix_web::web;
use anyhow::anyhow;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use images::image_info::image_info::ImageInfo;
use log::{info, warn};
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use serde_json::{Map, Value};
use std::io::Write;
use std::sync::Arc;
use tempfile::NamedTempFile;
use tokens::tokens::media_files::MediaFileToken;
use videos::ffprobe_get_info::ffprobe_get_info;
const PREFIX : Option<&str> = Some("artcraft_");
#[derive(Deserialize, Debug)]
pub struct FalWebhookVideo {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub height: Option<usize>, // NB: Might not be on videos
pub width: Option<usize>, // NB: Might not be on videos
pub url: Option<String>,
}
pub async fn handle_video_payload(
payload: &Map<String, Value>,
job: &FalJobDetails,
server_state: &ServerState,
) -> AnyhowResult<MediaFileToken> {
let video_value = payload.get("video")
.ok_or_else(|| anyhow!("no `image` key in payload"))?;
info!("Fal Video Payload: {:?}", video_value);
let video: FalWebhookVideo = serde_json::from_value(video_value.clone())?;
let video_url = video.url
.as_deref()
.ok_or_else(|| anyhow!("no `url` in video payload"))?;
//let mime_type = image.content_type
// .as_deref()
// .ok_or_else(|| anyhow!("no `content_type` in image payload"))?;
let file_bytes = http_download_url_to_bytes(video_url)
.await
.map_err(|e| anyhow!("Failed to download image: {:?}", e))?;
let mimetype_info = MimetypeInfo::get_for_bytes(&file_bytes)
.ok_or_else(|| anyhow!("Failed to get mimetype info"))?;
let mime_type = mimetype_info.mime_type();
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
.ok_or_else(|| anyhow!("Unsupported media file type: {}", mime_type))?;
let extension_with_period = mimetype_info.file_extension()
.map(|ext| ext.extension_with_period())
.ok_or_else(|| anyhow!("Failed to get file extension from mimetype info"))?;
let file_size_bytes = file_bytes.len();
let file_hash = sha256_hash_bytes(&file_bytes)?;
let mut maybe_duration_millis = None;
let mut maybe_frame_width = None;
let mut maybe_frame_height = None;
if let Ok(mut file) = NamedTempFile::new() {
// NB: Fallible
let _r = file.write_all(&file_bytes);
if let Ok(video_info) = ffprobe_get_info(&file.path()) {
maybe_duration_millis = video_info.duration
.as_ref()
.map(|duration| duration.millis as u64);
maybe_frame_width = video_info.dimensions
.as_ref()
.map(|ref dims| dims.width as u32);
maybe_frame_height = video_info.dimensions
.as_ref()
.map(|dims| dims.height as u32);
}
}
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension_with_period));
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
server_state.public_bucket_client.upload_file_with_content_type_process(
public_upload_path.get_full_object_path_str(),
file_bytes.as_ref(),
&mime_type)
.await?;
let media_token = MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&job.creator_ip_address)
.public_bucket_directory_hash(&public_upload_path)
.media_file_class(MediaFileClass::Image)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
//.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.maybe_frame_width(maybe_frame_width)
.maybe_frame_height(maybe_frame_height)
.maybe_duration_millis(maybe_duration_millis)
.checksum_sha2(&file_hash)
.insert_pool(&server_state.mysql_pool)
.await?;
info!("Video media file uploaded with token: {}", media_token);
Ok(media_token)
}
@@ -1,4 +1,2 @@
pub mod fal_webhook_handler;
pub mod handle_image_payload;
pub mod handle_model_mesh_payload;
pub mod handle_video_payload;
pub mod handle_image_payload;
@@ -1,6 +1,4 @@
use crate::http_server::endpoints::generate::image::remove_image_background_handler::remove_image_background_handler;
use crate::http_server::endpoints::generate::object::generate_hunyuan2_image_to_3d_handler::generate_hunyuan_2_image_to_3d_handler;
use crate::http_server::endpoints::generate::video::generate_kling_1_6_video_handler::generate_kling_1_6_video_handler;
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
use actix_web::dev::{ServiceRequest, ServiceResponse};
@@ -24,17 +22,5 @@ where
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/video")
.service(web::resource("/kling_16_image_to_video")
.route(web::post().to(generate_kling_1_6_video_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/object")
.service(web::resource("/hunyuan_2_image_to_3d")
.route(web::post().to(generate_hunyuan_2_image_to_3d_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
)
}
@@ -8,10 +8,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Footer from "../../components/footer";
import { useState, useRef, useEffect } from "react";
const MAC_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_2025.06.04.dmg";
const WINDOWS_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/windows/ArtCraft_0.0.1_x64-setup_2025.06.04.exe";
const fadeUpVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
@@ -91,8 +87,8 @@ const Landing = () => {
animate="visible"
variants={fadeUpVariants}
>
Text prompting sucks. Bring your true vision to life with
unparalleled control and precision.
Your vision, amplified. Create stunning art with unparalleled AI
control and precision.
</motion.p>
{/* CTA Buttons */}
@@ -114,7 +110,7 @@ const Landing = () => {
<Button
className="text-md px-4 py-3 font-semibold rounded-xl shadow-lg gap-3"
as="link"
href={WINDOWS_LINK}
href="/"
>
<FontAwesomeIcon icon={faWindows} />
Download Windows
@@ -122,7 +118,7 @@ const Landing = () => {
<Button
className="text-md px-4 py-3 font-semibold rounded-xl shadow-lg gap-3"
as="link"
href={MAC_LINK}
href="/"
>
<FontAwesomeIcon icon={faApple} />
Download Mac
@@ -263,7 +259,7 @@ const Landing = () => {
<div className="max-w-2xl lg:mx-0 mb-10 md:mb-16">
<h2 className="relative font-bold text-4xl md:text-5xl lg:text-6xl !leading-tight">
Our tool lets you design, control, and achieve{" "}
<span className="text-primary">perfect consistency</span>
<span className="text-primary">great consistency.</span>
</h2>
</div>
<DiscordButton />
@@ -321,7 +317,7 @@ const Landing = () => {
<div className=" max-w-2xl lg:mx-0 mb-10 md:mb-16">
<h2 className="relative font-bold text-4xl md:text-5xl lg:text-6xl xl:text-[3.8rem] !leading-tight">
<span className="text-primary">Patented</span> Brain to Art
Interface lets you Sketch and Composite
Interface
</h2>
</div>
<DiscordButton />
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useContext, useEffect, useState } from "react";
import {
faGear,
faImages,
@@ -23,21 +23,22 @@ import {
MenuIconSelector,
MenuIconItem,
} from "@storyteller/ui-menu-icon-selector";
import { signal } from "@preact/signals-react";
import { Signal, signal } from "@preact/signals-react";
import { useSignals } from "@preact/signals-react/runtime";
import { setLogoutStates } from "~/signals/authentication/utilities";
import { EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useTabStore } from "~/pages/Stores/TabState";
import {
is3DEditorInitialized,
is3DSceneLoaded,
set3DPageMounted,
} from "~/pages/PageEnigma/Editor/editor";
interface Props {
pageName: string;
appTabIdSignal: Signal<string>;
setAppTabId: (id: string) => void;
is3DInitSignal: Signal<boolean>;
}
// This is the tab we start with.
export const STARTING_APP_TAB_ID = "2D";
const appMenuTabs: MenuIconItem[] = [
{
id: "2D",
@@ -64,28 +65,75 @@ const appMenuTabs: MenuIconItem[] = [
export const topNavMediaId = signal<string>("");
export const topNavMediaUrl = signal<string>("");
export const TopBar = ({ pageName }: Props) => {
export const TopBar = ({
pageName,
appTabIdSignal,
setAppTabId,
is3DInitSignal,
}: Props) => {
useSignals();
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [url, setUrl] = useState<string>("");
const [mediaId, setMediaId] = useState<string>("");
const engine3D = useContext(EngineContext);
const handleTabChange = (tabId: string) => {
if (appTabIdSignal.peek() == "3D") {
engine3D?.unmountEngine();
}
setAppTabId(tabId);
};
const currentAppTabId = appTabIdSignal.value;
const is3DInit = is3DInitSignal.value;
useEffect(() => {
if (currentAppTabId == "3D" && is3DInit) {
engine3D?.remountEngine();
}
}, [currentAppTabId, engine3D, is3DInit]);
const handleAddToScene = async (
url: string,
media_id: string | undefined,
) => {
console.log("Items to add to scene", currentAppTabId);
console.log("url", url);
console.log("media_id", media_id);
setUrl(url);
setMediaId(media_id ?? "");
if (currentAppTabId === "2D") {
console.log("Adding to 2D scene");
// from the uploaded image url.
} else if (currentAppTabId === "3D") {
console.log("Adding to 3D scene");
// media id from the image selected from gallery.
if (media_id) {
engine3D?.activeScene.loadObject(media_id, "image", true);
} else {
console.warn("No media id provided");
}
} else if (currentAppTabId === "VIDEO") {
console.log("Adding to Video scene");
topNavMediaId.value = media_id ?? "";
topNavMediaUrl.value = url;
} else if (currentAppTabId === "IMAGE") {
console.log("Adding to Image scene");
topNavMediaId.value = media_id ?? "";
topNavMediaUrl.value = url;
} else {
console.warn(`Unknown tab type: ${currentAppTabId}`);
}
};
const handleOpenGalleryModal = () => {
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">
@@ -104,29 +152,21 @@ export const TopBar = ({ pageName }: Props) => {
</a>
<MenuIconSelector
menuItems={appMenuTabs}
activeMenu={tabStore.activeTabId}
disabled={disableTabSwitcher()}
onMenuChange={(tabId) => {
// Disable 3d engine to prevent memory leak.
if (tabId === "3D") {
set3DPageMounted(true);
} else {
set3DPageMounted(false);
}
useTabStore.getState().setActiveTab(tabId);
}}
activeMenu={appTabIdSignal.value}
disabled={false}
onMenuChange={handleTabChange}
className="w-fit"
/>
</div>
<div className="flex items-center justify-center gap-2 font-medium">
{tabStore.activeTabId === "3D" ? (
{currentAppTabId === "3D" ? (
<SceneTitleInput pageName={pageName} />
) : (
<h1>
{tabStore.activeTabId === "2D"
{currentAppTabId === "2D"
? "Canvas"
: tabStore.activeTabId === "VIDEO"
: currentAppTabId === "VIDEO"
? "Generate Video"
: "Generate Image"}
</h1>
@@ -166,7 +206,11 @@ export const TopBar = ({ pageName }: Props) => {
globalAccountLogoutCallback={() => setLogoutStates()}
/>
<GalleryModal mode="view" onDownloadClicked={downloadFileFromUrl} />
<GalleryModal
mode="view"
onDownloadClicked={downloadFileFromUrl}
onAddToSceneClicked={handleAddToScene}
/>
</>
);
};
@@ -2,7 +2,6 @@ export enum FilterMediaClasses {
AUDIO = "audio",
IMAGE = "image",
VIDEO = "video",
DIMENSIONAL = "dimensional",
}
export enum FilterMediaType {
@@ -0,0 +1,97 @@
import { MouseEventHandler, useState, useEffect } from "react";
import { Transition } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { uiAccess } from "./signals/uiAccess";
import { dispatchUiEvents } from "./signals/uiEvents";
import { ToolbarNode } from "./components/features/ToolbarNode";
import { ToolbarNodeButtonNames } from "./components/features/ToolbarNode/enums";
import { transitionTimingStyles } from "./components/styles";
import { useSignals } from "@preact/signals-react/runtime";
export const ContextualToolbarNode = () => {
const [winSize, setWinSize] = useState<{ width: number; height: number }>({
width: window.innerWidth,
height: window.innerHeight,
});
const { isShowing, position, ...rest } = uiAccess.toolbarNode.signal.value;
const buttonsProps = Object.values(ToolbarNodeButtonNames).reduce(
(acc, buttonName) => {
acc[buttonName] = {
onClick: dispatchUiEvents.toolbarNode[buttonName],
disabled:
uiAccess.toolbarNode.signal.value.buttonStates[buttonName].disabled,
hidden:
uiAccess.toolbarNode.signal.value.buttonStates[buttonName].hidden,
active:
uiAccess.toolbarNode.signal.value.buttonStates[buttonName].active,
};
return acc;
},
{} as {
[key in ToolbarNodeButtonNames]: {
onClick: MouseEventHandler<HTMLButtonElement>;
disabled: boolean;
hidden: boolean;
active: boolean;
};
},
);
useEffect(() => {
function resizeHandler() {
setWinSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
window.addEventListener("resize", resizeHandler);
return () => {
window.removeEventListener("resize", resizeHandler);
};
}, []);
const handleColorChange = (color: string) => {
// Don't do anything if no node is known to be bound
if (uiAccess.toolbarNode.signal.value.knodeIds.length < 1) {
return;
}
// Update the UI component
uiAccess.toolbarNode.setColor(color);
// Signal the engine with the node ID (just grab the first -and expected only- node)
const nodeID = uiAccess.toolbarNode.signal.value.knodeIds[0];
dispatchUiEvents.toolbarNode.setSelectedColor({ kNodeId: nodeID, color });
}
useSignals();
const color = uiAccess.toolbarNode.signal.value.color;
const boundedTopPosition = Math.min(position.y, winSize.height - 164);
return (
<Transition
as="div"
show={isShowing}
className={twMerge(
transitionTimingStyles,
"fixed -translate-x-1/2 translate-y-5 data-[closed]:opacity-0",
)}
style={{
top: boundedTopPosition,
left: position.x,
}}
>
<ToolbarNode
{...rest}
buttonsProps={buttonsProps}
locked={uiAccess.toolbarNode.signal.value.locked}
onLockClicked={(e) => {
dispatchUiEvents.toolbarNode.lock(e);
}}
color={color}
onColorChange={handleColorChange}
/>
</Transition>
);
};
@@ -0,0 +1,767 @@
import Konva from "konva";
import { uiAccess, uiEvents } from "./signals";
import {
ImageNode,
ShapeNode,
ShapeType,
TextNode,
PaintNode,
PreviewCopyNode,
} from "./KonvaApp/Nodes";
import { UndoStackManager } from "./KonvaApp/UndoRedo";
import {
CommandManager,
MatteBox,
SceneManager,
} from "./KonvaApp/EngineUtitlities";
import {
NodeIsolator,
NodesManager,
NodesTranslationEventDetails,
NodeTransformationEventDetails,
NodeTransformer,
SelectionManager,
SelectionManagerEvents,
SelectorSquare,
} from "./KonvaApp/NodesManagers";
import { EngineOptions, TextNodeData, VideoNodeData } from "./KonvaApp/types";
import { AppModes, VideoResolutions } from "./KonvaApp/constants";
import { ToolbarMainButtonNames } from "./components/features/ToolbarMain/enum";
import { ToolbarNodeButtonNames } from "./components/features/ToolbarNode/enums";
import { NavigateFunction } from "react-router-dom";
import { RealTimeDrawEngine } from "./KonvaApp/RenderingPrimitives/RealTimeDrawEngine";
import { NodeColor } from "./signals/uiEvents/toolbarNode";
import { invoke } from "@tauri-apps/api/core";
import { DecodeBase64ToImage } from "./utilities/DecodeBase64ToImage";
import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64";
import {
FalBackgroundRemoval,
FalBackgroundRemovalRequest,
} from "@storyteller/tauri-api";
export interface RenderingOptions {
artstyle: string;
positivePrompt: string;
negativePrompt: string;
cinematic: boolean;
enginePreProcessing: boolean;
faceDetail: boolean;
lipSync: boolean;
upscale: boolean;
styleStrength: number;
}
export class Engine {
private navigateRef: NavigateFunction;
private appMode: AppModes = AppModes.INIT;
private boardCanvasRef: HTMLDivElement;
private stage: Konva.Stage;
private bgLayer: Konva.Layer;
private mediaLayer: Konva.Layer;
private nodeIsolationLayer: Konva.Layer;
private uiLayer: Konva.Layer;
private previewLayer: Konva.Layer;
private offScreenCanvas: OffscreenCanvas;
public realTimeDrawEngine: RealTimeDrawEngine;
private nodesManager: NodesManager;
private nodeIsolator: NodeIsolator;
private nodeTransformer: NodeTransformer;
private selectionManager: SelectionManager;
private selectorSquare: SelectorSquare;
private loadingVideosProvider: LoadingVideosProvider;
private matteBox: MatteBox;
private backgroundNode: ShapeNode | null = null;
private sceneManager: SceneManager;
public undoStackManager: UndoStackManager;
public commandManager: CommandManager;
public segmentationButtonCanBePressed: boolean = true;
// signal reference
constructor(boardCanvasRef: HTMLDivElement, options: EngineOptions) {
if (import.meta.env.DEV) {
console.log("Engine Created");
}
this.navigateRef = options.navigate;
this.appMode = AppModes.SELECT;
this.boardCanvasRef = boardCanvasRef;
this.stage = new Konva.Stage({
container: this.boardCanvasRef,
width: window.innerWidth,
height: window.innerHeight,
});
this.bgLayer = new Konva.Layer();
this.mediaLayer = new Konva.Layer();
this.previewLayer = new Konva.Layer();
this.nodeIsolationLayer = new Konva.Layer();
this.uiLayer = new Konva.Layer();
this.stage.add(this.bgLayer);
this.stage.add(this.previewLayer);
this.stage.add(this.mediaLayer);
this.stage.add(this.nodeIsolationLayer);
this.stage.add(this.uiLayer);
// Konva Transformer
this.nodeTransformer = new NodeTransformer();
this.uiLayer.add(this.nodeTransformer.getKonvaNode());
// Loading Placeholders
// Node Isolator
this.nodeIsolator = new NodeIsolator({
mediaLayerRef: this.mediaLayer,
nodeIsolationLayerRef: this.nodeIsolationLayer,
});
// core layer for all the work done.
this.offScreenCanvas = new OffscreenCanvas(0, 0);
// Collection of all Nodes
this.nodesManager = new NodesManager();
// Partial Collection of selected Nodes
this.selectionManager = new SelectionManager({
nodeTransformerRef: this.nodeTransformer,
mediaLayerRef: this.mediaLayer,
});
this.realTimeDrawEngine = new RealTimeDrawEngine({
width: VideoResolutions.SQUARE_1024.width,
height: VideoResolutions.SQUARE_1024.height,
mediaLayerRef: this.mediaLayer,
offScreenCanvas: this.offScreenCanvas,
onDraw: async (canvas, lineBounds) => {
await this.addPaintNode(canvas, lineBounds);
},
onPreviewCopy: async (image) => {
await this.addPreviewCopy(image);
},
});
// Selector Square to select Nodes
this.selectorSquare = new SelectorSquare({
captureCanvasRef: this.realTimeDrawEngine.captureCanvas,
mediaLayerRef: this.mediaLayer,
nodesManagerRef: this.nodesManager,
selectionManagerRef: this.selectionManager,
stageRef: this.stage,
});
this.uiLayer.add(this.selectorSquare.getKonvaNode());
//Collection of commands for undo-redo
this.undoStackManager = new UndoStackManager(() => {
//this.realTimeDrawEngine.render();
});
this.commandManager = new CommandManager({
mediaLayerRef: this.mediaLayer,
nodesManagerRef: this.nodesManager,
nodeTransformerRef: this.nodeTransformer,
selectionManagerRef: this.selectionManager,
renderEngineRef: this.realTimeDrawEngine,
undoStackManagerRef: this.undoStackManager,
});
// set up secene manager
this.sceneManager = new SceneManager({
navigateRef: this.navigateRef,
loadingVideosProviderRef: this.loadingVideosProvider,
mediaLayerRef: this.mediaLayer,
nodesManagerRef: this.nodesManager,
selectionManagerRef: this.selectionManager,
renderEngineRef: this.realTimeDrawEngine,
});
this.matteBox = new MatteBox({
boardCanvasSize: {
width: this.boardCanvasRef.clientWidth,
height: this.boardCanvasRef.clientHeight,
},
captureCanvasSize: this.realTimeDrawEngine.captureCanvas.getSize(),
uiLayerRef: this.uiLayer,
});
// some of the managers has events
// hence, lastly, setup these events
this.setupEventSystem();
this.setAppMode(this.appMode);
}
private setAppMode(newAppMode: AppModes) {
this.appMode = newAppMode;
switch (this.appMode) {
case AppModes.PAINT: {
console.log("APPMODE: PAINT");
this.selectorSquare.disable(); // this breaks the paint if you use it in the wrong spot
this.selectionManager.disable(); // this prevents selection.
this.realTimeDrawEngine.enablePaintMode();
uiAccess.toolbarMain.enable();
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
active: false,
});
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.PAINT, {
active: true,
});
this.matteBox.disable();
return;
}
case AppModes.SELECT: {
console.log("APPMODE: SELECT");
this.realTimeDrawEngine.disablePaintMode();
this.selectorSquare.enable();
this.selectionManager.enable();
uiAccess.toolbarMain.enable();
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
active: true,
});
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.PAINT, {
active: false,
});
this.matteBox.disable();
document.body.style.cursor = "default";
return;
}
case AppModes.PREVIEW: {
console.log("APPMODE: RENDER");
this.selectorSquare.disable();
this.selectionManager.disable();
uiAccess.toolbarMain.disable();
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
active: false,
});
this.matteBox.enable();
document.body.style.cursor = "not-allowed";
return;
}
case AppModes.RENDERING: {
console.log("APPMODE: RENDER");
this.selectorSquare.disable();
this.selectionManager.disable();
uiAccess.toolbarMain.disable();
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
active: false,
});
this.matteBox.enable(true);
document.body.style.cursor = "wait";
return;
}
case AppModes.INIT:
default: {
console.log("APPMODE: INIT");
this.selectorSquare.disable();
uiAccess.toolbarMain.disable();
this.selectionManager.disable();
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
active: false,
});
document.body.style.cursor = "wait";
this.matteBox.disable();
}
}
}
private setupEventSystem() {
// Listen to changes in container size
const resizeObserver = new ResizeObserver(() => {
this.onBoardCanvasResize();
});
resizeObserver.observe(this.boardCanvasRef);
this.onBoardCanvasResize();
// Listen to Nodes
this.selectionManager.eventTarget.addEventListener(
SelectionManagerEvents.NODES_TRANSLATIONS,
((event: CustomEvent<NodesTranslationEventDetails>) => {
//console.log("Event: SelectionManager -> Engine", event);
this.commandManager.translateNodes(event.detail);
}) as EventListener,
);
this.selectionManager.eventTarget.addEventListener(
SelectionManagerEvents.NODES_TRANSFORMATION,
((event: CustomEvent<NodeTransformationEventDetails>) => {
//console.log("Event: SelectionManager -> Engine", event);
this.commandManager.transformNodes(event.detail);
}) as EventListener,
);
// Listen to Tooolbar Node
uiEvents.toolbarNode.lock.onClick(() => {
this.commandManager.toggleLockNodes();
});
uiEvents.toolbarNode.DOWNLOAD.onClick(() => {
const nodes = this.selectionManager.getSelectedNodes();
if (nodes.size > 1) {
uiAccess.dialogError.show({
title: "Error: Download Node Content",
message:
"Please do not select more than 1 item for the Download Node Content feature, you can only download 1 item at a time",
});
return;
}
const node = nodes.values().next().value;
try {
if (node instanceof VideoNode && node.currentUrl) {
downloadURI(node.currentUrl, `Download Video Node-${node.kNode.id}`);
} else {
throw new Error();
}
} catch {
uiAccess.dialogError.show({
title: "Error: Download Node Content",
message: "This item does not have content for download.",
});
}
});
uiEvents.toolbarNode.DELETE.onClick(() =>
this.commandManager.deleteNodes(),
);
uiEvents.toolbarNode.MOVE_LAYER_DOWN.onClick(() =>
this.commandManager.moveNodesDown(),
);
uiEvents.toolbarNode.MOVE_LAYER_UP.onClick(() =>
this.commandManager.moveNodesUp(),
);
uiEvents.toolbarNode.REMOVE_BACKGROUND.onClick(() =>
this.removeBackground(),
);
// Listen to Toolbar Main
uiEvents.toolbarMain.UNDO.onClick(() => {
this.undoStackManager.undo();
});
uiEvents.toolbarMain.REDO.onClick(() => {
this.undoStackManager.redo();
//this.realTimeDrawEngine.render();
});
uiEvents.toolbarMain.SAVE.onClick(async (/*event*/) => {
//await this.realTimeDrawEngine.saveOutput(); // This works.
await invoke("open_login_command");
});
uiEvents.toolbarMain.GENERATE.onClick(async (/*event*/) => {
console.log("generate clicked");
await this.realTimeDrawEngine.generateImage();
});
uiEvents.toolbarMain.SELECT.onClick(() => {
console.log("Toolbar Main >> Select");
this.setAppMode(AppModes.SELECT);
});
uiEvents.toolbarMain.PAINT.onClick(() => {
console.log("Toolbar Main >> Paint");
this.setAppMode(AppModes.PAINT);
});
uiEvents.toolbarMain.ERASER.onClick(() => {
console.log("Toolbar Main >> Eraser");
});
uiEvents.toolbarMain.PREVIEW.onClick(async () => {
console.log("Toolbar Main >> Preview");
this.setAppMode(AppModes.PREVIEW);
await this.handlePreview();
this.setAppMode(AppModes.SELECT);
});
uiEvents.toolbarMain.DOWNLOAD.onClick(async () => {
console.log("Toolbar Main >> Render Download");
try {
this.setAppMode(AppModes.RENDERING);
} catch (error) {
// throw error to retry
uiAccess.dialogError.show({
title: "Generation Error",
message: error?.toString() || "Unknown Error",
});
this.setAppMode(AppModes.SELECT);
}
});
// Listen to other toolbars
// VideoExtraction Toolbar
// Listen to other requests coming from the UI
uiEvents.onGetStagedImage((image) => {
this.addImage(image);
});
uiEvents.onAddTextToEngine((textdata) => {
this.addText(textdata);
});
let renderTimeout: NodeJS.Timeout;
uiEvents.promptEvents.onPromptStrengthChanged(async (strength) => {
this.realTimeDrawEngine.currentStrength = strength / 100.0;
clearTimeout(renderTimeout);
renderTimeout = setTimeout(async () => {
//await this.realTimeDrawEngine.render();
}, 1000);
});
uiEvents.promptEvents.onPromptTextChanged(async (prompt) => {
this.realTimeDrawEngine.currentPrompt = prompt;
clearTimeout(renderTimeout);
renderTimeout = setTimeout(async () => {
//await this.realTimeDrawEngine.render();
}, 1000);
});
uiEvents.onAddShapeToEngine((shapeData) => {
switch (shapeData.shape) {
case "circle":
this.addShape(ShapeType.CIRCLE, 100);
break;
case "square":
this.addShape(ShapeType.SQUARE, 100);
break;
case "triangle":
this.addShape(ShapeType.TRIANGLE, 100);
break;
}
});
uiEvents.toolbarMain.onBgColorChanged((data) => {
this.realTimeDrawEngine.updateBackground(data);
});
uiEvents.toolbarNode.color.onConfirmChanged((nodeColor) => {
console.log("Color change event triggered with:", nodeColor);
if (!nodeColor) {
console.warn("nodeColor is undefined or null");
return;
}
if (!nodeColor.kNodeId) {
console.warn("nodeColor.kNodeId is undefined or null");
return;
}
this.changeNodeColor(nodeColor);
});
uiEvents.toolbarMain.onPaintColorChanged((color) => {
this.realTimeDrawEngine.paintColor = color;
});
uiEvents.toolbarMain.onEraseBrushSizeChanged((size: number) => {
this.realTimeDrawEngine.paintBrushSize = size;
});
}
removeBackground() {
console.log("Remove background");
const selectedNodeSet = this.selectionManager.getSelectedNodes();
if (selectedNodeSet.size !== 1) {
return; // Can only remove background from a single image at a time.
}
const [selectedNode] = selectedNodeSet;
const removeBgInvocation = async (
selectedNode: ImageNode,
callback: (
existingImageNode: ImageNode,
createdImage: ImageBitmap,
) => void,
) => {
const image: HTMLImageElement = selectedNode.kNode.image();
const base64Bitmap = await EncodeImageBitmapToBase64(image);
const falRemoveBgRequest: FalBackgroundRemovalRequest = {
base64_image: base64Bitmap,
};
const response = await FalBackgroundRemoval(falRemoveBgRequest);
if (response.status !== "success") {
console.error("Failed to remove background", response);
// TODO
return;
}
console.log("Remove background success", response);
if ("payload" in response) {
const base64BitmapResponse = response.payload?.base64_bytes;
const createdImage = await DecodeBase64ToImage(
base64BitmapResponse as string,
);
callback(selectedNode, createdImage);
}
};
const _promise = removeBgInvocation(
selectedNode,
(selectedNode: ImageNode, createdImage: ImageBitmap) => {
if (!this.nodesManager.hasNode(selectedNode)) {
return;
}
this.commandManager.deleteSpecificNodes([selectedNode]);
this.addImageFromImageBitmap(createdImage);
},
);
}
disableAllButtons() {
const buttonNames = Object.values(ToolbarNodeButtonNames);
for (const name of buttonNames) {
uiAccess.toolbarNode.changeButtonState(name, { disabled: true });
}
}
async enableAllButtons() {
const buttonNames = Object.values(ToolbarNodeButtonNames);
for (const name of buttonNames) {
await uiAccess.toolbarNode.changeButtonState(name, { disabled: false });
}
}
sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private onBoardCanvasResize() {
console.log("onBoardCanvasResize Event called");
// Print window dimensions for debugging
console.log("Window dimensions:", {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
outerWidth: window.outerWidth,
outerHeight: window.outerHeight,
});
this.realTimeDrawEngine.updateCaptureCanvas(
window.innerWidth,
window.innerHeight,
);
this.matteBox.updateSize({
boardCanvasSize: {
width: this.boardCanvasRef.offsetWidth,
height: this.boardCanvasRef.offsetHeight,
},
});
this.uiLayer.draw();
this.stage.width(this.boardCanvasRef.offsetWidth);
this.stage.height(this.boardCanvasRef.offsetHeight);
this.stage.draw(); // Redraw the canvas
}
// Sandbox is quickly a way to test your idea.
public async sandbox() {}
public onMessage(event: MessageEvent) {
console.log("Message From Shared Worker");
console.log(event);
}
public initializeStage(sceneToken?: string) {
// load canvas that was originaly saved TODO Save manager for resharing.
uiAccess.toolbarNode.hide();
uiAccess.loadingBar.hide();
// load the scene if there's a scenetoken
if (sceneToken) {
this.sceneManager.loadScene(sceneToken);
}
this.setupStage();
// this.populateWithDebugItems();
}
public isInitialized() {
return this.stage !== null;
}
public async setupStage() {
// Frame rate inicator
// const textNode = new Konva.Text({
// x: 10,
// y: 80,
// text: "",
// fontSize: 18,
// fontFamily: "Source Sans 3",
// fill: "white",
// });
// const anim = new Konva.Animation((frame) => {
// if (frame && import.meta.env.DEV) {
// const timeDiff = frame.timeDiff;
// const frameRate = frame.frameRate;
// textNode.setText(
// `FrameTime: ${timeDiff.toFixed(0)} ms\nFrameRate: ${frameRate.toFixed(0)} fps`,
// );
// }
// }, this.mediaLayer);
// anim.start();
//this.uiLayer.add(textNode);
this.addKeyboardShortcuts();
}
private changeNodeColor(nodeColor: NodeColor) {
console.log("Changing node color:", nodeColor);
// Find the node that is selected with the id
const idSelector = "#" + nodeColor.kNodeId;
const selectedNode = this.mediaLayer.findOne(idSelector);
console.log("Selected node:", selectedNode);
console.log("ID selector:", idSelector);
console.log("Media layer:", this.mediaLayer);
// TODO: Change the color of the node
// Turns out shapes are image nodes... how do we change the image's fill??
if (selectedNode) {
// Get the shape node from the real time drawing engine's image nodes
const shapeNode = this.realTimeDrawEngine.findImageNodeById(
nodeColor.kNodeId,
);
if (shapeNode && shapeNode instanceof ShapeNode) {
// Create new shape node with updated color
const newShapeNode = new ShapeNode({
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
shapeType: shapeNode.shapeType,
size: shapeNode.kNode.size(),
color: nodeColor.color,
mediaLayerRef: this.mediaLayer,
selectionManagerRef: this.selectionManager,
loaded: async () => {
//this.realTimeDrawEngine.render();
},
});
newShapeNode.kNode.position(shapeNode.kNode.position());
newShapeNode.kNode.zIndex(shapeNode.kNode.zIndex());
// Remove old node and add new one
shapeNode.kNode.destroy();
this.commandManager.createNode(newShapeNode);
}
}
}
public addText(textNodeData: TextNodeData) {
const textNode = new TextNode({
textNodeData: textNodeData,
mediaLayerRef: this.mediaLayer,
selectionManagerRef: this.selectionManager,
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
});
this.commandManager.createNode(textNode);
}
public addShape(type: ShapeType, size: number, color?: string) {
const shapeNode = new ShapeNode({
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
shapeType: type,
size: { width: size, height: size },
color: color,
mediaLayerRef: this.mediaLayer,
selectionManagerRef: this.selectionManager,
loaded: async () => {
//await this.realTimeDrawEngine.render();
this.setAppMode(AppModes.SELECT);
},
});
this.commandManager.createNode(shapeNode);
}
public addPreviewCopy(image: Image.Konva) {
// Start of Selection
const copyNode = new PreviewCopyNode({
image: image,
mediaLayerRef: this.mediaLayer,
selectionManagerRef: this.selectionManager,
loaded: async () => {
//this.realTimeDrawEngine.render();
this.setAppMode(AppModes.SELECT);
},
});
this.commandManager.createNode(copyNode);
}
public addImage(imageFile: File) {
const imageNode = new ImageNode({
mediaLayerRef: this.mediaLayer,
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
imageFile: imageFile,
selectionManagerRef: this.selectionManager,
loaded: async () => {
//await this.realTimeDrawEngine.render();
this.setAppMode(AppModes.SELECT);
},
});
this.commandManager.createNode(imageNode);
}
public addImageFromImageBitmap(imageBitmap: ImageBitmap) {
const imageNode = new ImageNode({
mediaLayerRef: this.mediaLayer,
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
imageBitmap: imageBitmap,
selectionManagerRef: this.selectionManager,
loaded: async () => {
//await this.realTimeDrawEngine.render();
this.setAppMode(AppModes.SELECT);
},
});
this.commandManager.createNode(imageNode);
}
public addPaintNode(
canvas: HTMLCanvasElement,
lineBounds: {
width: number;
height: number;
x: number;
y: number;
},
) {
const node = new PaintNode({
canvasElement: canvas,
lineBounds: lineBounds,
mediaLayerRef: this.mediaLayer,
selectionManagerRef: this.selectionManager,
loaded: async () => {
//await this.realTimeDrawEngine.render();
},
});
this.commandManager.createNode(node);
}
// Events for Undo and Redo
private addKeyboardShortcuts() {
window.addEventListener("keydown", (event) => {
if (event.ctrlKey && event.key === "z") {
this.undoStackManager.undo();
} else if (
(event.ctrlKey && event.key === "y") ||
(event.ctrlKey && event.shiftKey && event.key === "Z")
) {
this.undoStackManager.redo();
} else if (event.key === "Delete") {
this.commandManager.deleteNodes();
}
});
}
}
// TODO: Move to utilities?
function downloadURI(uri: string, name: string) {
const link = document.createElement("a");
link.download = name;
link.target = "_blank";
link.href = uri;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
@@ -0,0 +1,371 @@
import Konva from "konva";
import { uiAccess } from "../signals";
import { CommandManager, MatteBox } from "./EngineUtitlities";
import { ImageNode } from "./Nodes";
import { PaintNode } from "./Nodes/PaintNode";
import {
NodesManager,
NodesTranslationEventDetails,
NodeTransformationEventDetails,
NodeTransformer,
SelectionManager,
SelectionManagerEvents,
SelectorSquare,
} from "./NodesManagers";
import { UndoStackManager } from "./UndoRedo";
import { ToolbarMainButtonNames } from "../components/features/ToolbarMain/enum";
import { EditModes, VideoResolutions } from "./constants";
import { ToolbarNodeButtonNames } from "../components/features/ToolbarNode/enums";
export interface RenderingOptions {
artstyle: string;
positivePrompt: string;
negativePrompt: string;
cinematic: boolean;
enginePreProcessing: boolean;
faceDetail: boolean;
lipSync: boolean;
upscale: boolean;
styleStrength: number;
}
export class EditEngine {
private editMode: EditModes = EditModes.SELECT;
private boardCanvasRef: HTMLDivElement;
private stage: Konva.Stage;
private mediaLayer: Konva.Layer;
private nodeIsolationLayer: Konva.Layer;
private uiLayer: Konva.Layer;
private offScreenCanvas: OffscreenCanvas;
private realTimeDrawEngine: RealTimeDrawEngine;
private nodesManager: NodesManager;
private nodeTransformer: NodeTransformer;
private selectionManager: SelectionManager;
private selectorSquare: SelectorSquare;
private matteBox: MatteBox;
private undoStackManager: UndoStackManager;
private commandManager: CommandManager;
public segmentationButtonCanBePressed: boolean = true;
// signal reference
constructor(boardCanvasRef: HTMLDivElement) {
if (import.meta.env.DEV) {
console.log("Engine Created");
}
this.editMode = EditModes.SELECT;
this.boardCanvasRef = boardCanvasRef;
this.stage = new Konva.Stage({
container: this.boardCanvasRef,
width: window.innerWidth,
height: window.innerHeight,
});
//this.bgLayer = new Konva.Layer();
this.mediaLayer = new Konva.Layer();
this.nodeIsolationLayer = new Konva.Layer();
this.uiLayer = new Konva.Layer();
//this.stage.add(this.bgLayer);
this.stage.add(this.mediaLayer);
this.stage.add(this.nodeIsolationLayer);
this.stage.add(this.uiLayer);
// Konva Transformer
this.nodeTransformer = new NodeTransformer();
this.uiLayer.add(this.nodeTransformer.getKonvaNode());
// core layer for all the work done.
this.offScreenCanvas = new OffscreenCanvas(0, 0);
// Collection of all Nodes
this.nodesManager = new NodesManager();
// Partial Collection of selected Nodes
this.selectionManager = new SelectionManager({
nodeTransformerRef: this.nodeTransformer,
mediaLayerRef: this.mediaLayer,
});
this.realTimeDrawEngine = new RealTimeDrawEngine({
width: VideoResolutions.SQUARE_1024.width,
height: VideoResolutions.SQUARE_1024.height,
mediaLayerRef: this.mediaLayer,
offScreenCanvas: this.offScreenCanvas,
onDraw: async (canvas, lineBounds) => {
await this.addPaintNode(canvas, lineBounds);
},
});
// Selector Square to select Nodes
this.selectorSquare = new SelectorSquare({
captureCanvasRef: this.realTimeDrawEngine.captureCanvas,
mediaLayerRef: this.mediaLayer,
nodesManagerRef: this.nodesManager,
selectionManagerRef: this.selectionManager,
stageRef: this.stage,
});
this.uiLayer.add(this.selectorSquare.getKonvaNode());
//Collection of commands for undo-redo
this.undoStackManager = new UndoStackManager(() => {
this.realTimeDrawEngine.render();
});
this.commandManager = new CommandManager({
mediaLayerRef: this.mediaLayer,
nodesManagerRef: this.nodesManager,
nodeTransformerRef: this.nodeTransformer,
selectionManagerRef: this.selectionManager,
renderEngineRef: this.realTimeDrawEngine,
undoStackManagerRef: this.undoStackManager,
});
this.matteBox = new MatteBox({
boardCanvasSize: {
width: this.boardCanvasRef.clientWidth,
height: this.boardCanvasRef.clientHeight,
},
captureCanvasSize: this.realTimeDrawEngine.captureCanvas.getSize(),
uiLayerRef: this.uiLayer,
});
// some of the managers has events
// hence, lastly, setup these events
this.setupEventSystem();
this.setEditMode(this.editMode);
}
private setEditMode(newEditMode: EditModes) {
this.editMode = newEditMode;
switch (this.editMode) {
case EditModes.SELECT:
default: {
console.log("EDITMODE: SELECT");
this.realTimeDrawEngine.disablePaintMode();
this.selectorSquare.enable();
this.selectionManager.enable();
uiAccess.toolbarMain.enable();
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
active: true,
});
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.PAINT, {
active: false,
});
this.matteBox.disable();
document.body.style.cursor = "default";
return;
}
case EditModes.EDIT: {
console.log("EDITMODE: EDIT");
this.realTimeDrawEngine.enablePaintMode();
this.selectorSquare.disable();
this.selectionManager.disable();
}
}
}
private setupEventSystem() {
// Listen to changes in container size
const resizeObserver = new ResizeObserver(() => {
this.onBoardCanvasResize();
});
resizeObserver.observe(this.boardCanvasRef);
this.onBoardCanvasResize();
// Listen to Nodes
this.selectionManager.eventTarget.addEventListener(
SelectionManagerEvents.NODES_TRANSLATIONS,
((event: CustomEvent<NodesTranslationEventDetails>) => {
//console.log("Event: SelectionManager -> Engine", event);
this.commandManager.translateNodes(event.detail);
}) as EventListener,
);
this.selectionManager.eventTarget.addEventListener(
SelectionManagerEvents.NODES_TRANSFORMATION,
((event: CustomEvent<NodeTransformationEventDetails>) => {
//console.log("Event: SelectionManager -> Engine", event);
this.commandManager.transformNodes(event.detail);
}) as EventListener,
);
// Listen to Toolbar Main
// uiEvents.toolbarMain.UNDO.onClick(() => {
// this.undoStackManager.undo();
// });
// uiEvents.toolbarMain.REDO.onClick(() => {
// this.undoStackManager.redo();
// this.realTimeDrawEngine.render();
// });
// uiEvents.toolbarMain.SAVE.onClick(async (/*event*/) => {
// await this.realTimeDrawEngine.saveOutput();
// });
// uiEvents.toolbarMain.SELECT.onClick(() => {
// console.log("Toolbar Main >> Select");
// this.setAppMode(AppModes.SELECT);
// });
// uiEvents.toolbarMain.PAINT.onClick(() => {
// console.log("Toolbar Main >> Paint");
// this.setAppMode(AppModes.PAINT);
// });
}
disableAllButtons() {
const buttonNames = Object.values(ToolbarNodeButtonNames);
for (const name of buttonNames) {
uiAccess.toolbarNode.changeButtonState(name, { disabled: true });
}
}
async enableAllButtons() {
const buttonNames = Object.values(ToolbarNodeButtonNames);
for (const name of buttonNames) {
await uiAccess.toolbarNode.changeButtonState(name, { disabled: false });
}
}
sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private onBoardCanvasResize() {
this.realTimeDrawEngine.updateCaptureCanvas(0, 0);
console.log("onBoardCanvasResize Event called");
console.log("Board Canvas values below");
console.log(this.boardCanvasRef.offsetWidth);
console.log(this.boardCanvasRef.offsetHeight);
this.matteBox.updateSize({
boardCanvasSize: {
width: this.boardCanvasRef.offsetWidth,
height: this.boardCanvasRef.offsetHeight,
},
});
this.uiLayer.draw();
this.stage.width(this.boardCanvasRef.offsetWidth);
this.stage.height(this.boardCanvasRef.offsetHeight);
this.stage.draw(); // Redraw the canvas
}
// Sandbox is quickly a way to test your idea.
public async sandbox() {}
public onMessage(event: MessageEvent) {
console.log("Message From Shared Worker");
console.log(event);
}
public initializeStage() {
// load canvas that was originaly saved TODO Save manager for resharing.
uiAccess.toolbarNode.hide();
uiAccess.loadingBar.hide();
this.setupStage();
}
public isInitialized() {
return this.stage !== null;
}
public async setupStage() {
// Frame rate inicator
// const textNode = new Konva.Text({
// x: 10,
// y: 80,
// text: "",
// fontSize: 18,
// fontFamily: "Source Sans 3",
// fill: "white",
// });
// const anim = new Konva.Animation((frame) => {
// if (frame && import.meta.env.DEV) {
// const timeDiff = frame.timeDiff;
// const frameRate = frame.frameRate;
// textNode.setText(
// `FrameTime: ${timeDiff.toFixed(0)} ms\nFrameRate: ${frameRate.toFixed(0)} fps`,
// );
// }
// }, this.mediaLayer);
// anim.start();
// this.uiLayer.add(textNode);
this.addKeyboardShortcuts();
}
public addImage(imageFile: File) {
const imageNode = new ImageNode({
mediaLayerRef: this.mediaLayer,
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
imageFile: imageFile,
selectionManagerRef: this.selectionManager,
loaded: async () => {
await this.realTimeDrawEngine.render();
this.setEditMode(EditModes.SELECT);
},
});
this.commandManager.createNode(imageNode);
}
public addImageFromImageBitmap(imageBitmap: ImageBitmap) {
const imageNode = new ImageNode({
mediaLayerRef: this.mediaLayer,
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
imageBitmap: imageBitmap,
selectionManagerRef: this.selectionManager,
loaded: async () => {
await this.realTimeDrawEngine.render();
this.setEditMode(EditModes.SELECT);
},
});
this.commandManager.createNode(imageNode);
}
public addPaintNode(
canvas: HTMLCanvasElement,
lineBounds: {
width: number;
height: number;
x: number;
y: number;
},
) {
var node = new PaintNode({
canvasElement: canvas,
lineBounds: lineBounds,
mediaLayerRef: this.mediaLayer,
selectionManagerRef: this.selectionManager,
loaded: async () => {
await this.realTimeDrawEngine.render();
},
});
this.commandManager.createNode(node);
}
// Events for Undo and Redo
private addKeyboardShortcuts() {
window.addEventListener("keydown", (event) => {
if (event.ctrlKey && event.key === "z") {
this.undoStackManager.undo();
} else if (
(event.ctrlKey && event.key === "y") ||
(event.ctrlKey && event.shiftKey && event.key === "Z")
) {
this.undoStackManager.redo();
} else if (event.key === "Delete") {
this.commandManager.deleteNodes();
}
});
}
}
@@ -0,0 +1,7 @@
const ensureBase64Prefix = (base64String: string) => {
return base64String.startsWith("data:")
? base64String
: `data:image/png;base64,${base64String}`;
}
export { ensureBase64Prefix };
@@ -0,0 +1,157 @@
import Konva from "konva";
import { MediaNode, Position, RGBColor, Transformation } from "../types";
import {
CreateCommand,
DeleteCommand,
LockNodesCommand,
MoveLayerDown,
MoveLayerUp,
TransformCommand,
TranslateCommand,
UndoStackManager,
UnlockNodesCommand,
} from "../UndoRedo";
import {
NodesManager,
NodeTransformer,
SelectionManager,
} from "../NodesManagers";
import { RealTimeDrawEngine } from "../RenderingPrimitives/RealTimeDrawEngine";
interface EngineReferences {
mediaLayerRef: Konva.Layer;
nodesManagerRef: NodesManager;
nodeTransformerRef: NodeTransformer;
selectionManagerRef: SelectionManager;
renderEngineRef: RealTimeDrawEngine;
undoStackManagerRef: UndoStackManager;
}
export class CommandManager {
private mediaLayerRef: Konva.Layer;
private nodesManagerRef: NodesManager;
private nodeTransformerRef: NodeTransformer;
private selectionManagerRef: SelectionManager;
private renderEngineRef: RealTimeDrawEngine;
private undoStackManagerRef: UndoStackManager;
constructor(engineRefs: EngineReferences) {
this.mediaLayerRef = engineRefs.mediaLayerRef;
this.nodesManagerRef = engineRefs.nodesManagerRef;
this.nodeTransformerRef = engineRefs.nodeTransformerRef;
this.selectionManagerRef = engineRefs.selectionManagerRef;
this.renderEngineRef = engineRefs.renderEngineRef;
this.undoStackManagerRef = engineRefs.undoStackManagerRef;
}
createNode(node: MediaNode) {
const command = new CreateCommand({
nodes: new Set<MediaNode>([node]),
mediaLayerRef: this.mediaLayerRef,
nodesManagerRef: this.nodesManagerRef,
nodeTransformerRef: this.nodeTransformerRef,
selectionManagerRef: this.selectionManagerRef,
renderEngineRef: this.renderEngineRef,
});
this.undoStackManagerRef.executeCommand(command);
// Set the kNode's manual id to same as kNode's internal _id
// This helps identify the kNode in the Konva layer with find
node.kNode.id(node.kNode._id.toString());
//this.renderEngineRef.render();
}
deleteNodes() {
const nodes = this.selectionManagerRef.getSelectedNodes();
const command = new DeleteCommand({
nodes: nodes,
mediaLayerRef: this.mediaLayerRef,
nodesManagerRef: this.nodesManagerRef,
nodeTransformerRef: this.nodeTransformerRef,
selectionManagerRef: this.selectionManagerRef,
renderEngineRef: this.renderEngineRef,
});
this.undoStackManagerRef.executeCommand(command);
//this.renderEngineRef.render();
}
deleteSpecificNodes(nodes: MediaNode[]) {
const command = new DeleteCommand({
nodes: nodes,
mediaLayerRef: this.mediaLayerRef,
nodesManagerRef: this.nodesManagerRef,
nodeTransformerRef: this.nodeTransformerRef,
selectionManagerRef: this.selectionManagerRef,
renderEngineRef: this.renderEngineRef,
});
this.undoStackManagerRef.executeCommand(command);
//this.renderEngineRef.render();
}
toggleLockNodes() {
const nodes = this.selectionManagerRef.getSelectedNodes();
const node = nodes.values().next().value;
if (!node) {
console.log("Node Not Found for Locking");
return;
}
if (node.isLocked()) {
const command = new UnlockNodesCommand({
nodes: this.selectionManagerRef.getSelectedNodes(),
});
this.undoStackManagerRef.executeCommand(command);
} else {
const command = new LockNodesCommand({
nodes: this.selectionManagerRef.getSelectedNodes(),
});
this.undoStackManagerRef.executeCommand(command);
}
}
moveNodesUp() {
const command = new MoveLayerUp({
nodes: this.selectionManagerRef.getSelectedNodes(),
nodesManagerRef: this.nodesManagerRef,
mediaLayerRef: this.mediaLayerRef,
});
this.undoStackManagerRef.executeCommand(command);
//this.renderEngineRef.render();
}
moveNodesDown() {
const command = new MoveLayerDown({
nodes: this.selectionManagerRef.getSelectedNodes(),
nodesManagerRef: this.nodesManagerRef,
mediaLayerRef: this.mediaLayerRef,
});
this.undoStackManagerRef.executeCommand(command);
//this.renderEngineRef.render();
}
translateNodes(props: {
nodes: Set<MediaNode>;
initialPositions: Map<MediaNode, Position>;
finalPositions: Map<MediaNode, Position>;
}) {
const command = new TranslateCommand({
...props,
layerRef: this.mediaLayerRef,
});
this.undoStackManagerRef.pushCommand(command);
//this.renderEngineRef.render();
}
transformNodes(props: {
nodes: Set<MediaNode>;
initialTransformations: Map<MediaNode, Transformation[]>;
finalTransformations: Map<MediaNode, Transformation[]>;
}) {
const command = new TransformCommand({
...props,
layerRef: this.mediaLayerRef,
});
this.undoStackManagerRef.pushCommand(command);
//this.renderEngineRef.render();
}
}
@@ -0,0 +1,134 @@
import Konva from "konva";
import { Size } from "../types";
import { Colors } from "../constants";
import { uiAccess } from "../../signals/uiAccess";
export class MatteBox {
private matteBoxPoly: Konva.Line;
private lightBoxRect: Konva.Rect;
private uiLayerRef: Konva.Layer;
private boardCanvasSize: Size;
private captureCanvasSize: Size;
constructor({
boardCanvasSize,
captureCanvasSize,
uiLayerRef,
}: {
boardCanvasSize: Size;
captureCanvasSize: Size;
uiLayerRef: Konva.Layer;
}) {
this.uiLayerRef = uiLayerRef;
this.boardCanvasSize = boardCanvasSize;
this.captureCanvasSize = captureCanvasSize;
this.matteBoxPoly = new Konva.Line({
points: getMatteBoxPolygonPoints({
boardCanvasSize: this.boardCanvasSize,
captureCanvasSize: this.captureCanvasSize,
}),
fill: Colors.translucentBlack,
closed: true,
});
this.lightBoxRect = new Konva.Rect({
size: captureCanvasSize,
position: {
x: (boardCanvasSize.width - captureCanvasSize.width) / 2,
y: (boardCanvasSize.height - captureCanvasSize.height) / 2,
},
fill: Colors.transparent,
// fill: "rgba(100,0,0,0.3)",
});
}
public enable(showMagicBox?: boolean) {
this.uiLayerRef.add(this.matteBoxPoly);
this.uiLayerRef.add(this.lightBoxRect);
if (showMagicBox) {
uiAccess.magicBox.show();
}
}
public disable() {
this.matteBoxPoly.remove();
this.lightBoxRect.remove();
if (uiAccess.magicBox.isShowing()) {
uiAccess.magicBox.hide();
}
}
public updateSize({
boardCanvasSize,
captureCanvasSize,
}: {
boardCanvasSize?: Size;
captureCanvasSize?: Size;
}) {
if (boardCanvasSize) {
this.boardCanvasSize = boardCanvasSize;
}
if (captureCanvasSize) {
this.captureCanvasSize = captureCanvasSize;
}
this.matteBoxPoly.points(
getMatteBoxPolygonPoints({
boardCanvasSize: this.boardCanvasSize,
captureCanvasSize: this.captureCanvasSize,
}),
);
this.lightBoxRect.setAttrs({
size: this.captureCanvasSize,
position: {
x: (this.boardCanvasSize.width - this.captureCanvasSize.width) / 2,
y: (this.boardCanvasSize.height - this.captureCanvasSize.height) / 2,
},
});
}
}
function getMatteBoxPolygonPoints({
boardCanvasSize: board,
captureCanvasSize: capture,
}: {
boardCanvasSize: Size;
captureCanvasSize: Size;
}) {
return [
//top left
0,
0,
// top right
board.width,
0,
//bottom right
board.width,
board.height,
//bottom middle
board.width / 2,
board.height,
//canvas bottom middle,
board.width / 2,
(board.height - capture.height) / 2 + capture.height,
//canvas bottom right,
(board.width - capture.width) / 2 + capture.width,
(board.height - capture.height) / 2 + capture.height,
//canvas top right,
(board.width - capture.width) / 2 + capture.width,
(board.height - capture.height) / 2,
//canvas top left,
(board.width - capture.width) / 2,
(board.height - capture.height) / 2,
//canvas bottom left,
(board.width - capture.width) / 2,
(board.height - capture.height) / 2 + capture.height,
//canvas bottom middle,
board.width / 2,
(board.height - capture.height) / 2 + capture.height,
//bottom middle
board.width / 2,
board.height,
//bottom left
0,
board.height,
//back to top left
0,
0,
];
}
@@ -0,0 +1,230 @@
import Konva from "konva";
import { v4 as uuidv4 } from "uuid";
// TODO: USE API STORTELLE
import { MediaFilesApi, MediaUploadApi } from "~/Classes/ApiManager";
import { NodesManager, SelectionManager } from "../NodesManagers";
import {
ImageNodeData,
NodeData,
TextNodeData,
TransformationData,
VideoNodeData,
} from "../types";
import { uiAccess } from "../../signals/uiAccess";
import { NavigateFunction } from "react-router-dom";
import { NodeType } from "../Nodes/constants";
import { ImageNode, TextNode } from "../Nodes";
import { RealTimeDrawEngine as RenderEngine } from "../RenderingPrimitives/RealTimeDrawEngine";
export class SceneManager {
private navigateRef: NavigateFunction;
private mediaLayerRef: Konva.Layer;
private nodesManagerRef: NodesManager;
private selectionManagerRef: SelectionManager;
private renderEngineRef: RenderEngine;
private currentSceneToken: string | undefined;
constructor({
navigateRef,
mediaLayerRef,
nodesManagerRef,
selectionManagerRef,
renderEngineRef,
}: {
navigateRef: NavigateFunction;
mediaLayerRef: Konva.Layer;
nodesManagerRef: NodesManager;
selectionManagerRef: SelectionManager;
renderEngineRef: RenderEngine;
}) {
this.navigateRef = navigateRef;
this.mediaLayerRef = mediaLayerRef;
this.nodesManagerRef = nodesManagerRef;
this.selectionManagerRef = selectionManagerRef;
this.renderEngineRef = renderEngineRef;
}
public async saveScene() {
const mediaUploadApi = new MediaUploadApi();
const timestamp = new Date();
const sceneTitle =
"board_" + timestamp.toISOString().substring(0, 19).replace(/\D/g, "");
const saveJson = this.extractAllNodesData();
console.log("saveJson", saveJson);
const file = new File([saveJson], `${sceneTitle}`, {
type: "application/json",
});
if (!this.currentSceneToken) {
const uploadResponse = await mediaUploadApi.UploadNewScene({
blob: file,
fileName: file.name,
uuid: uuidv4(),
});
console.log(uploadResponse);
if (!uploadResponse.success || !uploadResponse.data) {
uiAccess.dialogError.show({
title: "Save New Scene Error",
message: uploadResponse.errorMessage,
});
return;
}
this.currentSceneToken = uploadResponse.data;
this.navigateRef(`/${this.currentSceneToken}`);
} else {
const uploadResponse = await mediaUploadApi.UploadSavedScene({
blob: file,
fileName: file.name,
uuid: uuidv4(),
mediaToken: this.currentSceneToken,
});
console.log(uploadResponse);
if (!uploadResponse.success || !uploadResponse.data) {
uiAccess.dialogError.show({
title: "Save Scene Error",
message: uploadResponse.errorMessage,
});
return;
}
}
}
public async loadScene(mediaFileToken: string) {
const mediaFileApi = new MediaFilesApi();
const getSceneResponse = await mediaFileApi.GetMediaFileByToken({
mediaFileToken: mediaFileToken,
});
if (!getSceneResponse.success || !getSceneResponse.data) {
uiAccess.dialogError.show({
title: "Load Scene Error",
message: getSceneResponse.errorMessage,
});
return;
}
this.currentSceneToken = mediaFileToken;
// console.log(getSceneResponse.data);
// console.log(getSceneResponse.data.public_bucket_path);
const fileResponse = await fetch(getSceneResponse.data.public_bucket_url);
if (!fileResponse.ok) {
uiAccess.dialogError.show({
title: "Load Scene Error",
message: "Failed to download Scene File",
});
return;
}
const fileBlob = await fileResponse.blob();
const loadJson: NodeData[] = await new Promise((resolve, reject) => {
const reader = new FileReader();
const readerError = () => {
uiAccess.dialogError.show({
title: "Load Scene Error",
message: "Failed to read Scene File",
});
this.navigateRef("/");
console.log("debug file Response", fileResponse);
console.log("debug file Blob", fileBlob);
reject();
};
reader.onloadend = () => {
try {
resolve(JSON.parse(reader.result as string));
} catch {
readerError();
}
};
reader.onerror = () => {
readerError();
};
reader.readAsText(fileBlob);
});
this.rebuildScene(loadJson);
}
private extractAllNodesData() {
const nodesData: NodeData[] = [];
this.nodesManagerRef.getAllNodes().forEach((node) => {
const nodeData = node.getNodeData(
this.renderEngineRef.captureCanvas.position(),
);
if (nodeData !== null) {
nodesData.push(nodeData);
}
});
nodesData.sort((a, b) => a.transform.zIndex - b.transform.zIndex);
return JSON.stringify(nodesData);
}
private rebuildScene(nodesData: NodeData[]) {
// console.log(nodesData);
nodesData.forEach((nodeDatum) => {
if (nodeDatum.type === NodeType.IMAGE && nodeDatum.imageNodeData) {
this.addImage(nodeDatum.imageNodeData, nodeDatum.transform);
} else if (nodeDatum.type === NodeType.VIDEO && nodeDatum.videoNodeData) {
this.addVideo(nodeDatum.videoNodeData, nodeDatum.transform);
} else if (nodeDatum.type === NodeType.TEXT && nodeDatum.textNodeData) {
this.addText({
textNodeData: nodeDatum.textNodeData,
transform: nodeDatum.transform,
});
}
});
}
private addImage(
imageNodeData: ImageNodeData,
transform: TransformationData,
) {
const imageNode = new ImageNode({
mediaFileUrl: imageNodeData.mediaFileUrl,
mediaLayerRef: this.mediaLayerRef,
canvasPosition: this.renderEngineRef.captureCanvas.position(),
canvasSize: this.renderEngineRef.captureCanvas.size(),
transform: transform,
selectionManagerRef: this.selectionManagerRef,
});
this.nodesManagerRef.saveNode(imageNode);
this.renderEngineRef.addNodes(imageNode);
}
private addVideo(
videoNodeData: VideoNodeData,
transform: TransformationData,
) {
const videoNode = new VideoNode({
loadingVideosProviderRef: this.loadingVideosProviderRef,
mediaLayerRef: this.mediaLayerRef,
selectionManagerRef: this.selectionManagerRef,
canvasPosition: this.renderEngineRef.captureCanvas.position(),
canvasSize: this.renderEngineRef.captureCanvas.size(),
videoNodeData: videoNodeData,
transform: transform,
});
this.nodesManagerRef.saveNode(videoNode);
this.renderEngineRef.addNodes(videoNode);
}
private addText({
textNodeData,
transform,
}: {
textNodeData: TextNodeData;
transform: TransformationData;
}) {
const textNode = new TextNode({
textNodeData: textNodeData,
transform: transform,
mediaLayerRef: this.mediaLayerRef,
canvasPosition: this.renderEngineRef.captureCanvas.position(),
canvasSize: this.renderEngineRef.captureCanvas.size(),
selectionManagerRef: this.selectionManagerRef,
});
this.nodesManagerRef.saveNode(textNode);
this.renderEngineRef.addNodes(textNode);
}
}
@@ -0,0 +1,3 @@
export { CommandManager } from "./CommandManager";
export { MatteBox } from "./MatteBox";
export { SceneManager } from "./SceneManager";
@@ -0,0 +1,46 @@
import { invoke } from "@tauri-apps/api/core";
import {
cancelGeneration,
finishGeneration,
onStartGeneration,
} from "~/signals/generation/generationSignals";
export class GenerationEngine {
private isGenerating = false;
constructor() {
console.log("GenerationEngine constructor");
this.attachEventListeners();
}
private attachEventListeners() {
onStartGeneration((prompt) => {
this.invokeGeneration(prompt);
});
}
private async invokeGeneration(prompt: string) {
if (this.isGenerating) {
return;
}
this.isGenerating = true;
console.log("Invoking generation for prompt:", prompt);
try {
const base64BitmapResponse = await invoke("text_to_image", {
prompt: prompt,
});
console.log("Generation finished with response:", base64BitmapResponse);
const base64string = base64BitmapResponse as string;
finishGeneration(base64string);
} catch (error) {
console.error("Error during image processing:", error);
cancelGeneration();
} finally {
this.isGenerating = false;
}
}
}
@@ -0,0 +1,253 @@
import Konva from "konva";
import { SelectionManager } from "../NodesManagers";
import { highlightStrokeWidth, primaryOrange } from "./constants";
import { NodeData, Position } from "../types";
import { RealTimeDrawEngine } from "../RenderingPrimitives/RealTimeDrawEngine";
import { MouseEventHandler } from "react";
import { KonvaEventObject } from "konva/lib/Node";
export type SupportedNode = Konva.Image | Konva.Group | Konva.Shape;
export abstract class BaseNode {
protected selectionManagerRef: SelectionManager;
public kNode: SupportedNode;
protected mediaLayerRef: Konva.Layer;
// Internal State members
// do not modify internal
protected isSelecting: boolean = false;
// This locks interaction when the render engine is rendering
protected isProcessing: boolean = false;
protected _isLocked: boolean = false;
protected _areBaseEventsListening: boolean = false;
protected _isKDragEventListening: boolean = false;
protected _isKTranformEventListening: boolean = false;
abstract getNodeData(captureCanvasPosition: Position): NodeData | null;
constructor({
selectionManagerRef,
mediaLayerRef,
kNode,
}: {
selectionManagerRef: SelectionManager;
mediaLayerRef: Konva.Layer;
kNode: SupportedNode;
}) {
// console.log("Node constructed");
// this.uuid = uuidv4();
this.mediaLayerRef = mediaLayerRef;
this.selectionManagerRef = selectionManagerRef;
this.kNode = kNode;
}
protected delete() {
// Do any other clean up and delete the konva node.
this.kNode.destroy();
}
public async setProcessing(val: boolean) {
this.isProcessing = val;
}
public isKEventRef() {
return this._isKDragEventListening && this._isKTranformEventListening;
}
public setIsKEventRef(flag: boolean) {
this._isKDragEventListening = flag;
this._isKTranformEventListening = flag;
this.removeListenToBaseKNodeTransformations();
this.removeListenToBaseKNodeDrags();
if (flag) {
this.listenToBaseKNodeTransformations();
this.listenToBaseKNodeDrags();
}
}
public highlight() {
// console.log("Highlight", this.kNode._id);
if (this.kNode instanceof Konva.Image || this.kNode instanceof Konva.Shape) {
this.kNode.stroke(primaryOrange);
this.kNode.strokeWidth(highlightStrokeWidth);
return;
}
if (this.kNode instanceof Konva.Group) {
const wrapperRect = this.kNode.findOne(".wrapper") as Konva.Rect;
console.log("Wrapper rect:", wrapperRect);
if (wrapperRect != null) {
wrapperRect.stroke(primaryOrange);
wrapperRect.strokeWidth(highlightStrokeWidth);
}
return;
}
if (import.meta.env.DEV) {
console.warn(
"DEV: BaseNode Highlight Error, kNode of BaseNode should be override by MediaNodes that extends kNode",
);
}
}
public unhighlight() {
// console.log("unHighlight", this.kNode._id);
if (this.kNode instanceof Konva.Image || this.kNode instanceof Konva.Shape) {
this.kNode.strokeWidth(0);
return;
}
if (this.kNode instanceof Konva.Group) {
const wrapperRect = this.kNode.findOne(".wrapper") as Konva.Rect;
console.log("Wrapper rect:", wrapperRect);
if (wrapperRect != null) {
// Issue investigate
wrapperRect.strokeWidth(0);
}
return;
}
if (import.meta.env.DEV) {
console.warn(
"DEV: BaseNode UnHighlight Error, kNode of BaseNode should be override by MediaNodes that extends kNode",
);
}
}
public moveLayerUp() {
this.kNode.moveUp();
}
public moveLayerDown() {
this.kNode.moveDown();
}
public isLocked() {
return this._isLocked;
}
public lock() {
this._isLocked = true;
this.kNode.setDraggable(false);
this.selectionManagerRef.updateNodeTransformer();
if (this.isKEventRef()) {
this.selectionManagerRef.updateContextComponents();
}
}
public unlock() {
this._isLocked = false;
this.kNode.setDraggable(true);
this.selectionManagerRef.updateNodeTransformer();
if (this.isKEventRef()) {
this.selectionManagerRef.updateContextComponents();
}
}
public listenToBaseKNode() {
if (this._areBaseEventsListening) {
return;
}
this._areBaseEventsListening = true;
const handleSelect = (isMultiSelect: boolean) => {
// console.log("handle select");
if (!isMultiSelect) {
// clear selection if not multislect
console.log("No Shift >> no multiselect");
this.selectionManagerRef.clearSelection();
}
if (this.selectionManagerRef.isNodeSelected(this)) {
this.selectionManagerRef.deselectNode(this);
return;
}
if (this.isSelecting) {
this.selectionManagerRef.selectNode(this);
return;
}
};
const handleRightClick = (e: KonvaEventObject<MouseEvent>) => {
this.selectionManagerRef.clearSelection();
this.selectionManagerRef.selectNode(this, undefined, {
show: true,
at: { x: e.evt.x, y: e.evt.y }
});
}
this.kNode.on("contextmenu", (e) => {
console.log("CONTEXT MENU", e);
e.evt.preventDefault();
handleRightClick(e);
});
this.kNode.on("mousedown", (e) => {
// https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/which
// 3: Right mouse button
// If right click, let context event handle it
if (e.evt.button === 2) {
return;
}
// Selection of Node
if (!this.selectionManagerRef.isNodeSelected(this)) {
this.isSelecting = true;
// send shift key to check for multiselect
handleSelect(e.evt.shiftKey);
}
});
this.kNode.on("mouseup", (e) => {
// https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/which
// 3: Right mouse button
// If right click, let context event handle it
if (e.evt.button === 2) {
return;
}
// just coming out of dragging or selecting mode
// no need to handle select
if (this.selectionManagerRef.isDragging() || this.isSelecting) {
this.isSelecting = false;
return;
}
// checking for multiselect, in multiselect deselection is possible
handleSelect(e.evt.shiftKey);
});
}
public removeListenToBaseKNode() {
this._areBaseEventsListening = false;
this.kNode.removeEventListener("mousedown mouseup");
}
private handleDragStart = () => {
console.log("Drag start", this.kNode._id);
if (this.isProcessing) {
return;
}
this.selectionManagerRef.dragStart();
};
private handleDragEnd = () => {
this.selectionManagerRef.dragEnd();
};
public listenToBaseKNodeDrags() {
this.kNode.on("dragstart", this.handleDragStart);
this.kNode.on("dragend", this.handleDragEnd);
}
public removeListenToBaseKNodeDrags() {
this.kNode.off("dragstart", this.handleDragStart);
this.kNode.off("dragend", this.handleDragEnd);
}
public listenToBaseKNodeTransformations() {
this.kNode.on("transformstart", (event) => {
console.log("transformstart", event.target._id);
this.selectionManagerRef.transformStart();
});
this.kNode.on("transform", () => {
this.kNode.setAttrs({
width: this.kNode.width() * this.kNode.scaleX(),
height: this.kNode.height() * this.kNode.scaleY(),
scaleX: 1,
scaleY: 1,
});
});
this.kNode.on("transformend", (event) => {
console.log("transformend", event.target._id);
this.selectionManagerRef.transformEnd();
});
}
public removeListenToBaseKNodeTransformations() {
this.kNode.removeEventListener("transformend");
}
}
@@ -0,0 +1,261 @@
import Konva from "konva";
import { MediaFilesApi, MediaUploadApi } from "~/Classes/ApiManager";
import { SelectionManager } from "../NodesManagers";
import { Position, Size, NodeData, TransformationData } from "../types";
import { NetworkedNode, UploadStatus } from "./NetworkedNode";
import { NodeType, transparent } from "./constants";
import { NodeUtilities } from "./NodeUtilities";
interface ImageNodeContructor {
canvasPosition: Position;
canvasSize: Size;
imageFile?: File;
imageBitmap?: ImageBitmap;
mediaFileToken?: string;
mediaFileUrl?: string;
transform?: TransformationData;
mediaLayerRef: Konva.Layer;
selectionManagerRef: SelectionManager;
loaded: () => Promise<void>;
}
export class ImageNode extends NetworkedNode {
public kNode: Konva.Image;
public imageSize?: Size;
private loaded!: () => Promise<void>;
constructor({
canvasPosition,
canvasSize,
imageFile,
mediaFileToken,
mediaFileUrl,
transform: existingTransform,
mediaLayerRef,
selectionManagerRef,
loaded,
imageBitmap,
}: ImageNodeContructor) {
// kNodes need to be created first to guaruntee
// that it is not undefined in parent's context
const transform = NodeUtilities.getInitialTransform({
existingTransform,
canvasPosition,
canvasSize,
});
const kNode = new Konva.Image({
image: undefined, // to do replace with placeholder
...transform,
draggable: true,
strokeScaleEnabled: false,
});
super({
selectionManagerRef: selectionManagerRef,
mediaLayerRef: mediaLayerRef,
kNode: kNode,
localFile: imageFile,
});
this.kNode = kNode;
this.loaded = loaded;
// this.imageSize = minNodeSize;
this.mediaLayerRef.add(this.kNode);
if (imageFile) {
this.loadImageFromFile({
imageFile: imageFile,
maxSize: canvasSize,
refPosition: canvasPosition,
});
return;
}
if (mediaFileUrl && transform) {
this.mediaFileToken = mediaFileToken;
this.mediaFileUrl = mediaFileUrl;
this.loadImageFromUrl(mediaFileUrl);
return;
}
if (imageBitmap) {
this.loadImageFromImageBitmap({
imageBitmap: imageBitmap,
maxSize: canvasSize,
refPosition: canvasPosition,
});
return;
}
console.log("image node creation is fucked");
}
private loadImageFromFile({
imageFile,
maxSize,
refPosition,
}: {
imageFile: File;
maxSize: Size;
refPosition: Position;
}) {
const imageComponent = new Image();
imageComponent.crossOrigin = "anonymous";
// this.setProgress({ progress: 100, status: UploadStatus.SUCCESS });
imageComponent.onload = () => {
//this.setProgress({ progress: 0, status: UploadStatus.FILE_STAGED });
this.imageSize = {
width: imageComponent.width,
height: imageComponent.height,
};
const adjustedSize = NodeUtilities.adjustNodeSizeToCanvas({
componentSize: this.imageSize,
maxSize: maxSize,
});
const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({
canvasOffset: refPosition,
componentSize: adjustedSize,
maxSize: maxSize,
});
this.kNode.image(imageComponent);
this.kNode.setSize(adjustedSize);
this.kNode.setPosition(centerPosition);
this.kNode.moveToTop();
this.kNode.fill(transparent);
this.listenToBaseKNode();
this.mediaLayerRef.draw();
//this.uploadImage(imageFile);
this.loaded();
};
imageComponent.onerror = () => {
//this.setProgress({ progress: 0, status: UploadStatus.ERROR_ON_FILE });
};
imageComponent.src = URL.createObjectURL(imageFile);
}
private async loadImageFromUrl(mediaFileUrl: string) {
//this.setProgress({ progress: 75, status: UploadStatus.LOADING });
const newImage = new Image();
newImage.crossOrigin = "anonymous";
newImage.onerror = () => {
//this.setProgress({ progress: 90, status: UploadStatus.ERROR_ON_LOAD });
};
newImage.onload = () => {
// console.log("network image", newImage);
this.kNode.image(newImage);
this.kNode.draw();
//this.setProgress({ progress: 100, status: UploadStatus.SUCCESS });
};
newImage.src = mediaFileUrl;
this.listenToBaseKNode();
}
private loadImageFromImageBitmap({
imageBitmap,
maxSize,
refPosition,
}: {
imageBitmap: ImageBitmap;
maxSize: Size;
refPosition: Position;
}) {
this.imageSize = {
width: imageBitmap.width,
height: imageBitmap.height,
};
const adjustedSize = NodeUtilities.adjustNodeSizeToCanvas({
componentSize: this.imageSize,
maxSize: maxSize, // TODO
});
const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({
canvasOffset: refPosition, // TODO
componentSize: adjustedSize, // TODO
maxSize: maxSize, // TODO
});
this.kNode.image(imageBitmap);
this.kNode.setSize(adjustedSize);
this.kNode.setPosition(centerPosition);
this.kNode.moveToTop();
this.kNode.fill(transparent);
this.listenToBaseKNode();
this.mediaLayerRef.draw();
//this.uploadImage(imageFile);
this.loaded();
this.listenToBaseKNode();
}
private async uploadImage(imageFile: File) {
//this.setProgress({ progress: 10, status: UploadStatus.UPLOADING });
const mediaUploadApi = new MediaUploadApi();
const uploadResponse = await mediaUploadApi.UploadImage({
blob: imageFile,
uuid: this.uuidGenerate(),
fileName: imageFile.name,
});
// console.log(uploadResponse);
if (!uploadResponse.success || !uploadResponse.data) {
this.setStatus(UploadStatus.ERROR_ON_UPLOAD, uploadResponse.errorMessage);
return;
}
this.mediaFileToken = uploadResponse.data;
this.retreiveImage(this.mediaFileToken);
}
private async retreiveImage(mediaFileToken: string) {
//this.setProgress({ progress: 50, status: UploadStatus.RETREIVING });
const mediaFileApi = new MediaFilesApi();
const mediaFileResponse = await mediaFileApi.GetMediaFileByToken({
mediaFileToken: mediaFileToken,
});
// console.log(mediaFileResponse);
if (!mediaFileResponse.success || !mediaFileResponse.data) {
this.setStatus(
UploadStatus.ERROR_ON_RETREIVE,
mediaFileResponse.errorMessage,
);
return;
}
this.mediaFileUrl = mediaFileResponse.data.media_links.cdn_url;
this.loadImageFromUrl(this.mediaFileUrl);
}
public async retry() {
if (this.mediaFileUrl) {
this.loadImageFromUrl(this.mediaFileUrl);
return;
}
if (this.mediaFileToken) {
this.retreiveImage(this.mediaFileToken);
return;
}
if (this.localFile) {
this.uploadImage(this.localFile);
return;
}
console.warn("Image Node has no data to recontruct itself!");
}
public getNodeData(canvasPostion: Position) {
if (!this.mediaFileUrl) {
console.error("Image Node can not be saved");
return null;
}
const data: NodeData = {
type: NodeType.IMAGE,
transform: {
position: {
x: this.kNode.position().x - canvasPostion.x,
y: this.kNode.position().y - canvasPostion.y,
},
size: this.kNode.size(),
rotation: this.kNode.rotation(),
scale: {
x: this.kNode.scaleX(),
y: this.kNode.scaleY(),
},
zIndex: this.kNode.getZIndex(),
},
imageNodeData: {
mediaFileUrl: this.mediaFileUrl,
mediaFileToken: this.mediaFileToken,
},
};
return data;
}
}
@@ -0,0 +1,134 @@
import Konva from "konva";
import { v4 as uuidv4 } from "uuid";
import { SelectionManager } from "../NodesManagers";
import { BaseNode, SupportedNode } from "./BaseNode";
import { Size } from "../types";
import { LoadingVideosProvider } from "../EngineUtitlities/LoadingVideosProvider";
import { NodeProgressEventDetail } from "../types/events";
export enum UploadStatus {
INIT = "init",
FILE_STAGED = "file_staged",
ERROR_ON_FILE = "error_on_file",
UPLOADING = "uploading",
ERROR_ON_UPLOAD = "error_on_upload",
RETREIVING = "retreiving",
ERROR_ON_RETREIVE = "error_on_retreive",
LOADING = "loading",
ERROR_ON_LOAD = "error_on_load",
SUCCESS = "success",
}
export abstract class NetworkedNode extends BaseNode {
public kNode: SupportedNode;
// members to deal with loading progress
public didFinishLoading: boolean = false;
protected _progress: number = 0;
protected _progressMessage?: string;
protected progressStatus: string = UploadStatus.INIT;
public progressEvent: EventTarget;
// members to deal with the file carried
protected localFile?: File;
protected mediaFileToken?: string;
protected mediaFileUrl?: string;
public mediaFileSize?: Size;
// loading screen
protected loadingVideosProviderRef?: LoadingVideosProvider;
// error and retry handling
public errorMessage?: string;
abstract retry(): void;
constructor({
kNode,
selectionManagerRef,
mediaLayerRef,
localFile,
loadingVideosProviderRef,
}: {
kNode: SupportedNode;
selectionManagerRef: SelectionManager;
mediaLayerRef: Konva.Layer;
loadingVideosProviderRef?: LoadingVideosProvider;
localFile?: File;
}) {
super({
kNode,
selectionManagerRef,
mediaLayerRef,
});
this.kNode = kNode;
this.loadingVideosProviderRef = loadingVideosProviderRef;
this.didFinishLoading = false;
this.progressEvent = new EventTarget();
this.localFile = localFile;
}
public progress() {
return this._progress;
}
public progressMessage() {
return this._progressMessage;
}
public status() {
return this.progressStatus;
}
public isError() {
const errorStatues: string[] = [
UploadStatus.ERROR_ON_FILE,
UploadStatus.ERROR_ON_UPLOAD,
UploadStatus.ERROR_ON_RETREIVE,
UploadStatus.ERROR_ON_LOAD,
];
return errorStatues.includes(this.progressStatus);
}
protected uuidGenerate() {
return uuidv4();
}
protected setStatus(newStatus: string, message?: string) {
this.progressStatus = newStatus;
this.errorMessage = message;
this.selectionManagerRef.updateContextComponents();
}
protected setProgress<S>({
progress,
name,
status,
message,
}: {
progress: number;
name?: string;
status?: S;
message?: string;
}) {
this._progress = progress;
if (message) {
this._progressMessage = message;
}
if (status && typeof status === "string") {
this.progressStatus = status;
}
if (this._progress === 100) {
this.didFinishLoading = true;
}
// TODO: change to use
this.progressEvent.dispatchEvent(
new CustomEvent<NodeProgressEventDetail<S>>(
name ? name : "GenericNodeProgress",
{
detail: {
node: this,
progress: this._progress,
message: message,
status: status,
},
},
),
);
this.selectionManagerRef.updateContextComponents();
}
}
@@ -0,0 +1,158 @@
import Konva from "konva";
import { Position, Size, TransformationData } from "../types";
import { minNodeSize, transparent } from "./constants";
export const NodeUtilities = {
adjustNodeSizeToCanvas,
downloadOffscreenCanvas,
getInitialTransform,
isAssetUrlAvailable,
positionNodeOnCanvasCenter,
printKNodeAttrs,
urlToBlob,
};
function downloadOffscreenCanvas(canvas: OffscreenCanvas) {
canvas.convertToBlob().then((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "offscreen-canvas-image.png"; // Specify the file name
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url); // Clean up
}
});
}
function getInitialTransform({
existingTransform,
mediaFileSize,
canvasPosition,
canvasSize,
}: {
existingTransform?: TransformationData;
mediaFileSize?: Size;
canvasPosition: Position;
canvasSize: Size;
}) {
return existingTransform
? {
...existingTransform,
position: {
x: existingTransform.position.x + canvasPosition.x,
y: existingTransform.position.y + canvasPosition.y,
},
fill: transparent,
}
: {
position: positionNodeOnCanvasCenter({
canvasOffset: canvasPosition,
componentSize: mediaFileSize ?? minNodeSize,
maxSize: canvasSize,
}),
size: mediaFileSize ?? minNodeSize,
fill: "gray",
};
}
function adjustNodeSizeToCanvas({
componentSize,
maxSize,
}: {
componentSize: Size;
maxSize: Size;
}) {
const adjustedSize = {
width: componentSize.width,
height: componentSize.height,
};
if (adjustedSize.width > maxSize.width) {
const scaleDown = maxSize.width / adjustedSize.width;
adjustedSize.width = adjustedSize.width * scaleDown;
adjustedSize.height = adjustedSize.height * scaleDown;
}
if (adjustedSize.height > maxSize.height) {
const scaleDownAgain = maxSize.height / adjustedSize.height;
adjustedSize.width = adjustedSize.width * scaleDownAgain;
adjustedSize.height = adjustedSize.height * scaleDownAgain;
}
return adjustedSize;
}
function positionNodeOnCanvasCenter({
canvasOffset,
componentSize,
maxSize,
}: {
canvasOffset: Position;
componentSize: Size;
maxSize: Size;
}) {
return {
x: canvasOffset.x + maxSize.width / 2 - componentSize.width / 2,
y: canvasOffset.y + maxSize.height / 2 - componentSize.height / 2,
};
}
function printKNodeAttrs(kNode: Konva.Node) {
console.log({
position: kNode.position(),
size: kNode.size(),
scale: kNode.scale(),
rotation: kNode.rotation(),
});
}
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Function to check if the URL returns a 200 status
async function checkUrl(url: string): Promise<boolean> {
try {
const response = await fetch(url);
return response.status === 200;
} catch (error) {
console.error("Error fetching URL:", error);
return false;
}
}
// wait using a while loop to display the result.
async function isAssetUrlAvailable({
url,
sleepDurationMs = 500,
totalRetries,
}: {
url: string;
sleepDurationMs?: number;
totalRetries?: number;
}): Promise<boolean> {
let retryCount = 0;
let isAvailable = false;
const totalRetriesReached = () => {
if (!totalRetries) {
return false;
}
return retryCount >= totalRetries;
};
while (!isAvailable && !totalRetriesReached()) {
isAvailable = await checkUrl(url);
if (!isAvailable) {
console.log("Asset at Url not available yet, retrying...");
retryCount = retryCount + 1;
await sleep(sleepDurationMs);
} else if (import.meta.env.DEV) {
console.log("Preview Image is available:", url);
}
}
return isAvailable;
}
async function urlToBlob(url: string): Promise<Blob> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch resource: ${response.statusText}`);
}
const blob = await response.blob();
return blob;
}
@@ -0,0 +1,91 @@
import Konva from "konva";
import { SelectionManager } from "../NodesManagers";
import { Position, NodeData } from "../types";
import { NodeType } from "./constants";
import { NetworkedNode } from "./NetworkedNode";
interface PaintNodeConstructor {
canvasElement: HTMLCanvasElement;
lineBounds: {
width: number;
height: number;
x: number;
y: number;
};
mediaLayerRef: Konva.Layer;
selectionManagerRef: SelectionManager;
loaded: () => Promise<void>;
}
export class PaintNode extends NetworkedNode {
retry(): void {
throw new Error("Method not implemented.");
}
constructor({
canvasElement,
lineBounds,
mediaLayerRef,
selectionManagerRef,
loaded,
}: PaintNodeConstructor) {
const cl = canvasElement;
// Convert circle to image
const dataURL = cl.toDataURL();
const imageObj = new Image();
imageObj.src = dataURL;
const paintShape = new Konva.Image({
x: lineBounds.x,
y: lineBounds.y,
width: lineBounds.width,
height: lineBounds.height,
image: imageObj,
listening: false, // Disable event listening to allow captureCanvas to receive events
draggable: true,
// stroke: '#000000',
// strokeWidth: 2,
// dash: [5, 5], // Creates dotted outline THIS IS FOR DEBUG
globalCompositeOperation: "source-over",
fill: "transparent",
});
imageObj.onload = () => {
this.mediaLayerRef.draw();
loaded();
};
super({
selectionManagerRef: selectionManagerRef,
mediaLayerRef: mediaLayerRef,
kNode: paintShape,
});
this.kNode.moveToTop();
this.mediaLayerRef.add(this.kNode);
this.listenToBaseKNode();
this.mediaLayerRef.draw();
}
public getNodeData(canvasPosition: Position) {
const data: NodeData = {
type: NodeType.SHAPE,
transform: {
position: {
x: this.kNode.position().x - canvasPosition.x,
y: this.kNode.position().y - canvasPosition.y,
},
size: this.kNode.size(),
rotation: this.kNode.rotation(),
scale: {
x: this.kNode.scaleX(),
y: this.kNode.scaleY(),
},
zIndex: this.kNode.getZIndex(),
},
};
return data;
}
}
@@ -0,0 +1,58 @@
import Konva from "konva";
import { SelectionManager } from "../NodesManagers";
import { Position, Size, NodeData, TransformationData } from "../types";
import { NodeType } from "./constants";
import { NodeUtilities } from "./NodeUtilities";
import { NetworkedNode } from "./NetworkedNode";
interface PreviewCopyConstructor {
image: Konva.Image;
mediaLayerRef: Konva.Layer;
selectionManagerRef: SelectionManager;
loaded: () => Promise<void>;
}
export class PreviewCopyNode extends NetworkedNode {
retry(): void {
throw new Error("Method not implemented.");
}
constructor({
image,
mediaLayerRef,
selectionManagerRef,
loaded,
}: PreviewCopyConstructor) {
// Create the actual shape inside the group
super({
selectionManagerRef: selectionManagerRef,
mediaLayerRef: mediaLayerRef,
kNode: image,
});
this.kNode.moveToTop();
this.mediaLayerRef.add(this.kNode);
this.listenToBaseKNode();
this.mediaLayerRef.draw();
}
public getNodeData(canvasPosition: Position) {
const data: NodeData = {
type: NodeType.SHAPE,
transform: {
position: {
x: this.kNode.position().x - canvasPosition.x,
y: this.kNode.position().y - canvasPosition.y,
},
size: this.kNode.size(),
rotation: this.kNode.rotation(),
scale: {
x: this.kNode.scaleX(),
y: this.kNode.scaleY(),
},
zIndex: this.kNode.getZIndex(),
},
};
return data;
}
}
@@ -0,0 +1,186 @@
import Konva from "konva";
import { SelectionManager } from "../NodesManagers";
import { BaseNode } from "./BaseNode";
import {
NodeData,
Position,
Size,
TextNodeChildrenTransformData,
TextNodeData,
TransformationData,
} from "../types";
import { NodeUtilities } from "./NodeUtilities";
import { NodeType } from "./constants";
export class PureTextNode extends BaseNode {
public kNode: Konva.Group;
public rectNode: Konva.Rect;
public textNode: Konva.Text;
private originalTextSize: Size;
private textNodeData: TextNodeData;
public didFinishLoading: boolean = true;
constructor({
textNodeData,
selectionManagerRef,
mediaLayerRef,
canvasPosition,
canvasSize,
transform: existingTransform,
textChildrenTransforms,
}: {
textNodeData: TextNodeData;
selectionManagerRef: SelectionManager;
mediaLayerRef: Konva.Layer;
canvasSize: Size;
canvasPosition: Position;
transform?: TransformationData;
textChildrenTransforms?: TextNodeChildrenTransformData;
}) {
const textNode = new Konva.Text({
...textNodeData,
x: 10,
y: 10,
width: 500,
});
const kNode = new Konva.Group({
position: NodeUtilities.positionNodeOnCanvasCenter({
canvasOffset: canvasPosition,
componentSize: {
width: textNode.width() + 20,
height: textNode.height() + 20,
},
maxSize: canvasSize,
}),
width: textNode.width() + 20,
height: textNode.height() + 20,
draggable: true,
});
const rectNode = new Konva.Rect({
x: 0,
y: 0,
width: textNode.width() + 20,
height: textNode.height() + 20,
strokeScaleEnabled: false,
name: "wrapper",
});
super({
selectionManagerRef: selectionManagerRef,
mediaLayerRef: mediaLayerRef,
kNode: kNode,
});
this.textNodeData = textNodeData;
this.kNode = kNode;
this.textNode = textNode;
this.rectNode = rectNode;
this.kNode.add(this.rectNode);
this.kNode.add(this.textNode);
if (existingTransform && textChildrenTransforms) {
this.applyTransform({
existingTransform,
textChildrenTransforms,
canvasPosition,
});
}
this.mediaLayerRef.add(this.kNode);
this.originalTextSize = this.textNode.size();
this.listenToBaseKNode();
}
public listenToBaseKNodeTransformations() {
this.kNode.on("transformstart", () => {
// console.log("transformstart", event.target._id);
this.selectionManagerRef.transformStart();
});
this.kNode.on("transform", () => {
const newBoxSize = {
width: this.kNode.width() * this.kNode.scaleX(),
height: this.kNode.height() * this.kNode.scaleY(),
scaleX: 1,
scaleY: 1,
};
this.rectNode.setAttrs(newBoxSize);
this.kNode.setAttrs(newBoxSize);
const scaleX = (newBoxSize.width - 20) / this.originalTextSize.width;
const scaleY = (newBoxSize.height - 20) / this.originalTextSize.height;
this.textNode.setAttrs({
// make sure these stay the same
x: 10,
y: 10,
size: this.originalTextSize,
//while these changes in size
scaleX: scaleX,
scaleY: scaleY,
});
});
this.kNode.on("transformend", () => {
// console.log("transformend", event.target._id);
NodeUtilities.printKNodeAttrs(this.kNode);
NodeUtilities.printKNodeAttrs(this.textNode);
this.selectionManagerRef.transformEnd(this);
});
}
public getNodeData(canvasPostion: Position) {
const data: NodeData = {
type: NodeType.TEXT,
transform: {
position: {
x: this.kNode.position().x - canvasPostion.x,
y: this.kNode.position().y - canvasPostion.y,
},
size: this.kNode.size(),
rotation: this.kNode.rotation(),
scale: {
x: this.kNode.scaleX(),
y: this.kNode.scaleY(),
},
zIndex: this.kNode.getZIndex(),
},
textNodeData: this.textNodeData,
textChildrenTransforms: {
wrapperRectTransform: {
position: this.rectNode.position(),
size: this.rectNode.size(),
rotation: this.rectNode.rotation(),
scale: {
x: this.rectNode.scaleX(),
y: this.rectNode.scaleY(),
},
zIndex: this.rectNode.getZIndex(),
},
textNodeTransform: {
position: this.textNode.position(),
size: this.textNode.size(),
rotation: this.textNode.rotation(),
scale: {
x: this.textNode.scaleX(),
y: this.textNode.scaleY(),
},
zIndex: this.textNode.getZIndex(),
},
},
};
return data;
}
private applyTransform({
canvasPosition,
existingTransform,
textChildrenTransforms,
}: {
canvasPosition: Position;
existingTransform: TransformationData;
textChildrenTransforms: TextNodeChildrenTransformData;
}) {
this.kNode.setAttrs({
...existingTransform,
position: {
x: existingTransform.position.x + canvasPosition.x,
y: existingTransform.position.y + canvasPosition.y,
},
});
this.rectNode.setAttrs(textChildrenTransforms.wrapperRectTransform);
this.textNode.setAttrs(textChildrenTransforms.textNodeTransform);
}
}
@@ -0,0 +1,152 @@
import Konva from "konva";
import { SelectionManager } from "../NodesManagers";
import { Position, Size, NodeData, TransformationData } from "../types";
import { NodeType } from "./constants";
import { NodeUtilities } from "./NodeUtilities";
import { NetworkedNode } from "./NetworkedNode";
export enum ShapeType {
CIRCLE = "circle",
SQUARE = "square",
TRIANGLE = "triangle",
}
interface ShapeNodeConstructor {
canvasPosition: Position;
canvasSize: Size;
shapeType: ShapeType;
size: Size;
color?: string; // Hex color string
transform?: TransformationData;
mediaLayerRef: Konva.Layer;
selectionManagerRef: SelectionManager;
loaded: () => Promise<void>;
}
export class ShapeNode extends NetworkedNode {
retry(): void {
throw new Error("Method not implemented.");
}
public shapeType: ShapeType;
public size: Size;
public color: string;
constructor({
canvasPosition,
canvasSize,
shapeType,
size,
color = "#2D81FF", // Default red if no color provided
transform: existingTransform,
mediaLayerRef,
selectionManagerRef,
loaded,
}: ShapeNodeConstructor) {
// Create the actual shape inside the group
const transform = NodeUtilities.getInitialTransform({
existingTransform,
mediaFileSize: size,
canvasPosition,
canvasSize,
});
let shape = null;
switch (shapeType) {
case ShapeType.CIRCLE:
shape = new Konva.Ellipse({
radiusX: size.width / 2,
radiusY: size.height / 2,
fill: color,
strokeScaleEnabled: false,
draggable: true,
});
break;
case ShapeType.SQUARE:
shape = new Konva.Rect({
width: size.width,
height: size.height,
...transform,
fill: color,
strokeScaleEnabled: false,
draggable: true,
});
break;
case ShapeType.TRIANGLE:
shape = new Konva.Shape({
width: size.width,
height: size.height,
...transform,
fill: color,
strokeScaleEnabled: false,
draggable: true,
sceneFunc: (context, shape) => {
context.beginPath();
context.moveTo(shape.width() / 2, 0);
context.lineTo(shape.width(), shape.height());
context.lineTo(0, shape.height());
context.closePath();
context.fillStrokeShape(shape);
}
});
break;
default:
throw new Error("Invalid shape type");
}
super({
selectionManagerRef: selectionManagerRef,
mediaLayerRef: mediaLayerRef,
kNode: shape,
});
this.shapeType = shapeType;
this.size = size;
this.color = color;
// Center the shape
const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({
canvasOffset: canvasPosition,
componentSize: this.size,
maxSize: canvasSize,
});
// If the shape is an ellipse, the center is the origin so we need to offset it
if (shapeType === ShapeType.CIRCLE) {
centerPosition.x += size.width / 2;
centerPosition.y += size.height / 2;
}
// Add shape to group
// bring to the front
this.kNode.moveToTop();
this.kNode.setPosition(centerPosition);
this.mediaLayerRef.add(this.kNode);
this.listenToBaseKNode();
this.mediaLayerRef.draw();
}
public getNodeData(canvasPosition: Position) {
const data: NodeData = {
type: NodeType.SHAPE,
transform: {
position: {
x: this.kNode.position().x - canvasPosition.x,
y: this.kNode.position().y - canvasPosition.y,
},
size: this.kNode.size(),
rotation: this.kNode.rotation(),
scale: {
x: this.kNode.scaleX(),
y: this.kNode.scaleY(),
},
zIndex: this.kNode.getZIndex(),
},
};
return data;
}
}
@@ -0,0 +1,168 @@
import Konva from "konva";
import { SelectionManager } from "../NodesManagers";
import { BaseNode } from "./BaseNode";
import {
NodeData,
Position,
Size,
TextAlign,
TextNodeData,
TransformationData,
} from "../types";
import { NodeUtilities } from "./NodeUtilities";
import { NodeType, transparent } from "./constants";
export class TextNode extends BaseNode {
public kNode: Konva.Image;
private textNodeData: TextNodeData;
private originalTextSize?: Size;
public didFinishLoading: boolean = true;
constructor({
textNodeData,
selectionManagerRef,
mediaLayerRef,
canvasPosition,
canvasSize,
transform: existingTransform,
}: {
textNodeData: TextNodeData;
selectionManagerRef: SelectionManager;
mediaLayerRef: Konva.Layer;
canvasSize: Size;
canvasPosition: Position;
transform?: TransformationData;
}) {
const transform = NodeUtilities.getInitialTransform({
existingTransform,
canvasPosition,
canvasSize,
});
// Create a new ImageNode to house the canvas text
const kNode = new Konva.Image({
image: undefined, // will use the canvas as the image source
...transform,
draggable: true,
strokeScaleEnabled: false,
});
super({
selectionManagerRef: selectionManagerRef,
mediaLayerRef: mediaLayerRef,
kNode: kNode,
});
this.textNodeData = textNodeData;
this.kNode = kNode;
this.mediaLayerRef.add(this.kNode);
this.listenToBaseKNode();
this.loadTextImageUsingCanvas({
maxSize: canvasSize,
refPosition: canvasPosition,
existingTransform: existingTransform,
});
}
private loadTextImageUsingCanvas({
maxSize,
refPosition,
existingTransform,
}: {
maxSize: Size;
refPosition: Position;
existingTransform?: TransformationData;
}) {
// Create a temporary canvas to draw the text
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (context === null) {
return;
}
// calvulate and set canvas
const lines: string[] = this.textNodeData.text.split("\n");
const { fontSize, fontFamily, fontStyle, fontWeight, maxWidth, textAlign } =
this.textNodeData;
this.originalTextSize = {
width: maxWidth,
height: fontSize * lines.length * 1.5 + fontSize * 0.5,
};
canvas.width = this.originalTextSize.width;
canvas.height = this.originalTextSize.height;
// Set the font styles to match the text node
context.font = `${fontWeight} ${fontStyle} ${fontSize}px ${fontFamily}`;
context.fillStyle = this.textNodeData.color;
context.textAlign = textAlign;
context.textBaseline = "top";
// Draw the text on the canvas
lines.forEach((line, idx) => {
const yOffset = fontSize * (1.5 * idx + 0.5);
const xOffset =
textAlign === TextAlign.LEFT
? 0
: textAlign === TextAlign.CENTER
? maxWidth / 2
: maxWidth; // textAlign === TextAlign.Right
context.fillText(line, xOffset, yOffset);
});
// Convert canvas to data URL
const dataURL = canvas.toDataURL("image/png");
// Create a new Image object
const imageComponent = new Image();
imageComponent.crossOrigin = "anonymous";
imageComponent.onload = () => {
if (existingTransform) {
this.kNode.setAttrs({
image: imageComponent,
fill: transparent,
});
return;
}
const imageSize = {
width: canvas.width,
height: canvas.height,
};
const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({
canvasOffset: refPosition,
componentSize: imageSize,
maxSize: maxSize,
});
this.kNode.setAttrs({
image: imageComponent,
size: imageSize,
position: centerPosition,
fill: transparent,
});
};
imageComponent.src = dataURL;
}
public getNodeData(canvasPostion: Position) {
const data: NodeData = {
type: NodeType.TEXT,
transform: {
position: {
x: this.kNode.position().x - canvasPostion.x,
y: this.kNode.position().y - canvasPostion.y,
},
size: this.kNode.size(),
rotation: this.kNode.rotation(),
scale: {
x: this.kNode.scaleX(),
y: this.kNode.scaleY(),
},
zIndex: this.kNode.getZIndex(),
},
textNodeData: this.textNodeData,
};
return data;
}
}
@@ -0,0 +1,16 @@
import { Colors } from "../constants";
export enum NodeType {
IMAGE = "image",
TEXT = "text",
VIDEO = "video",
SHAPE = "shape",
}
export const transparent = Colors.transparent;
export const primaryOrange = Colors.primaryOrange;
export const highlightStrokeWidth = 2;
export const minNodeSize = {
width: 200,
height: 200,
};
@@ -0,0 +1,7 @@
export { NodeType } from "./constants";
export { BaseNode } from "./BaseNode";
export { ImageNode } from "./ImageNode";
export { PreviewCopyNode } from "./PreviewCopy";
export { TextNode } from "./TextNode";
export { ShapeNode , ShapeType} from "./ShapeNode";
export { PaintNode } from "./PaintNode";
@@ -0,0 +1,122 @@
import Konva from "konva";
import { MediaNode, TransformationData } from "../types";
import { uiAccess } from "../../signals/uiAccess";
export class NodeIsolator {
private mediaLayerRef: Konva.Layer;
private backgroundRect: Konva.Rect;
private referenceRect: Konva.Rect;
private nodeIsolationLayerRef: Konva.Layer;
private currentNode: MediaNode | undefined;
private originalKNodeTransformation: TransformationData | undefined;
private adjustSizeFnRef: (() => void) | undefined;
constructor({
mediaLayerRef,
nodeIsolationLayerRef,
}: {
mediaLayerRef: Konva.Layer;
nodeIsolationLayerRef: Konva.Layer;
}) {
this.mediaLayerRef = mediaLayerRef;
this.nodeIsolationLayerRef = nodeIsolationLayerRef;
this.backgroundRect = new Konva.Rect({
fill: "rgba(0,0,0,0.5)",
});
this.referenceRect = new Konva.Rect({
fill: "gray",
stroke: "salmon",
strokeWidth: 10,
dash: [20, 10],
strokeScaleEnabled: false,
});
}
private adjustSizes() {
this.backgroundRect.setAttrs({
width: window.innerWidth,
height: window.innerHeight,
});
if (this.currentNode && this.originalKNodeTransformation) {
this.referenceRect.setAttrs(this.originalKNodeTransformation);
const originalSize = {
width:
this.originalKNodeTransformation.size.width *
this.originalKNodeTransformation.scale.x,
height:
this.originalKNodeTransformation.size.height *
this.originalKNodeTransformation.scale.y,
};
const maxWidth = window.innerWidth * 0.8;
const isolationResize = {
width: maxWidth,
height: (maxWidth / originalSize.width) * originalSize.height,
};
const maxHeight = window.innerHeight - 350;
if (isolationResize.height > maxHeight) {
isolationResize.height = maxHeight;
isolationResize.width =
(maxHeight / originalSize.height) * originalSize.width;
}
const isolationReposition = {
x: (window.innerWidth - isolationResize.width) / 2,
y: (maxHeight - isolationResize.height) / 2 + 100,
};
this.currentNode.kNode.setAttrs({
scale: { x: 1, y: 1 },
rotation: 0,
size: isolationResize,
position: isolationReposition,
});
}
}
public enterIsolation(node: MediaNode) {
// console.log("NodeIsolator > enterIsolation");
uiAccess.toolbarMain.disable();
this.currentNode = node;
this.adjustSizeFnRef = () => this.adjustSizes();
this.preserveKNodeTransformation(node.kNode);
this.currentNode.kNode.remove();
this.nodeIsolationLayerRef.add(this.backgroundRect);
this.nodeIsolationLayerRef.add(this.currentNode.kNode);
this.mediaLayerRef.add(this.referenceRect);
this.adjustSizeFnRef();
window.addEventListener("resize", this.adjustSizeFnRef);
}
public exitIsolation() {
// console.log("NodeIsolator > exitIsolation");
if (this.adjustSizeFnRef) {
window.removeEventListener("resize", this.adjustSizeFnRef);
this.adjustSizeFnRef = undefined;
}
if (!this.currentNode) {
console.error("NodeIsolator lost crrent node before isolation!!");
return;
}
this.currentNode.kNode.remove();
this.backgroundRect.remove();
this.referenceRect.remove();
this.mediaLayerRef.add(this.currentNode.kNode);
this.currentNode.kNode.setAttrs(this.originalKNodeTransformation);
this.currentNode = undefined;
this.originalKNodeTransformation = undefined;
uiAccess.toolbarMain.enable();
}
private preserveKNodeTransformation(kNode: Konva.Node) {
this.originalKNodeTransformation = {
position: kNode.position(),
size: kNode.size(),
rotation: kNode.rotation(),
scale: {
x: kNode.scaleX(),
y: kNode.scaleY(),
},
zIndex: kNode.zIndex(),
};
}
}
@@ -0,0 +1,61 @@
import Konva from "konva";
import { MediaNode } from "../types";
export class NodeTransformer {
private kTransformer: Konva.Transformer;
constructor() {
this.kTransformer = new Konva.Transformer({
padding: 0,
anchorStyleFunc: (anchor) => {
// anchor is Konva.Rect instance
// you manually change its styling
anchor.cornerRadius(10);
if (anchor.hasName("top-center") || anchor.hasName("bottom-center")) {
anchor.height(6);
anchor.offsetY(3);
anchor.width(30);
anchor.offsetX(15);
}
if (anchor.hasName("middle-left") || anchor.hasName("middle-right")) {
anchor.height(30);
anchor.offsetY(15);
anchor.width(6);
anchor.offsetX(3);
}
// if (anchor.hasName("rotater")) {
// anchor.offsetY(-25);
// }
// you also can set other properties
// e.g. you can set fillPatternImage to set icon to the anchor
},
});
}
public getKonvaNode() {
return this.kTransformer;
}
public addNodes({ selectedNodes }: { selectedNodes: Set<MediaNode> }) {
const kNodesArray = Array.from(selectedNodes).reduce((acc, node) => {
acc.push(node.kNode);
return acc;
}, [] as Konva.Node[]);
//if there are more things in the ui layer, turn this on
// this.kTransformer.moveToTop();
this.kTransformer.nodes(kNodesArray);
}
public clear() {
this.kTransformer.nodes([]);
}
public enable() {
this.kTransformer.rotateEnabled(true);
this.kTransformer.resizeEnabled(true);
this.kTransformer.draggable(true);
}
public disable() {
this.kTransformer.rotateEnabled(false);
this.kTransformer.resizeEnabled(false);
this.kTransformer.draggable(false);
}
}
@@ -0,0 +1,63 @@
import { MediaNode } from "../types";
export class NodesManager {
private allNodes: Set<MediaNode>;
private allZIndices: Map<MediaNode, number>;
constructor() {
this.allNodes = new Set();
this.allZIndices = new Map();
}
public getAllNodes() {
return this.allNodes;
}
public getAllZIndices() {
return this.allZIndices;
}
public getSortedZIndices() {
return Array.from(this.allZIndices).sort(
(nodeA, nodeB) => nodeA[1] - nodeB[1],
);
}
public updateAllZIndices() {
this.allNodes.forEach((node) => {
this.allZIndices.set(node, node.kNode.getZIndex());
});
}
public saveNode(node: MediaNode): void {
if (this.allNodes.has(node)) {
return;
}
this.allNodes.add(node);
this.updateAllZIndices();
// this.printNodesAndZIndices();
}
public removeNode(node: MediaNode): void {
if (this.allNodes.has(node)) {
this.allNodes.delete(node);
this.allZIndices.delete(node);
this.updateAllZIndices();
}
// this.printNodesAndZIndices();
}
public hasNode(node: MediaNode): boolean {
return this.allNodes.has(node);
}
/*************************
* For Debugging use
*/
public printNodesAndZIndices() {
console.log(this.allNodes);
console.log(this.allZIndices);
const trueZIndices = Array.from(this.allNodes).map((node) => ({
id: node.kNode._id,
z: node.kNode.getZIndex(),
}));
console.log("True ZIndces", trueZIndices);
}
}
@@ -0,0 +1,384 @@
import Konva from "konva";
import { NodeTransformer } from "./NodeTransformer";
import { MediaNode, Position, Transformation } from "../types";
import { uiAccess, uiEvents } from "../../signals";
import { NetworkedNode } from "../Nodes/NetworkedNode";
import { LoadingBarStatus } from "../../components/ui";
import { ImageNode, ShapeNode, TextNode, PreviewCopyNode } from "../Nodes";
import {
calculateContextualsPosition,
getImageNodeButtonStates,
getMultiSelectButtonStates, getPreviewCopyNodeButtonStates,
getShapeNodeButtonStates,
getTextNodeButtonStates,
getVideoNodeButtonStates,
} from "./ToolbarNodeUtilities";
export enum SelectionManagerEvents {
NODES_TRANSLATIONS = "nodestranslation",
NODES_TRANSFORMATION = "nodestransformation",
}
export interface NodesTranslationEventDetails {
nodes: Set<MediaNode>;
initialPositions: Map<MediaNode, Position>;
finalPositions: Map<MediaNode, Position>;
}
export interface NodeTransformationEventDetails {
nodes: Set<MediaNode>;
initialTransformations: Map<MediaNode, Transformation[]>;
finalTransformations: Map<MediaNode, Transformation[]>;
}
export class SelectionManager {
private selectedNodes: Set<MediaNode>;
private mediaLayerRef: Konva.Layer;
private nodeTransformerRef: NodeTransformer;
private initialPositions: Map<MediaNode, Position>;
private initialTransformations: Map<MediaNode, Transformation[]>;
public eventTarget: EventTarget;
private _isDragging: boolean = false;
private disabled: boolean = false;
public firstSelectedNode: MediaNode | undefined;
constructor({
mediaLayerRef,
nodeTransformerRef,
}: {
mediaLayerRef: Konva.Layer;
nodeTransformerRef: NodeTransformer;
}) {
this.firstSelectedNode = undefined;
this.selectedNodes = new Set();
this.initialPositions = new Map<MediaNode, Position>();
this.initialTransformations = new Map<MediaNode, Transformation[]>();
this.eventTarget = new EventTarget();
this.mediaLayerRef = mediaLayerRef;
this.nodeTransformerRef = nodeTransformerRef;
}
// This lets us perfom operations on the selected node.
public getSelectedNodes(): Set<MediaNode> {
return this.selectedNodes;
}
public selectNodes(nodes: MediaNode[]) {
const unlockedNodes = nodes.filter((node) => {
return !node.isLocked();
});
unlockedNodes.forEach((node) => {
this.selectNode(node, true);
});
this.mediaLayerRef.batchDraw();
}
public disable() {
this.clearSelection();
this.disabled = true;
}
public enable() {
this.disabled = false;
}
public isDisabled() {
return this.disable;
}
public selectNode(node: MediaNode, doNotDraw?: boolean, contextMenu: {
show?: boolean,
at?: { x: number, y: number }
} = {
show: false,
}): boolean {
if (
this.disabled || // no-op when disabled
this.selectedNodes.has(node) || // if the node is already selected
(this.selectedNodes.size > 0 && node.isLocked()) ||
// if in multiselect but picked a locked item
(this.selectedNodes.size > 0 &&
this.selectedNodes.values().next().value?.isLocked())
// if the first node picked is a locked item
) {
return false;
}
node.highlight();
if (this.selectedNodes.size === 0) {
node.setIsKEventRef(true);
}
this.selectedNodes.add(node);
this.updateNodeTransformer();
this.updateContextComponents();
console.log("SelectionManager > selectNode > showContextMenu", contextMenu.show);
if (contextMenu.show) {
this.showContextComponents(contextMenu.at);
}
if (!doNotDraw) {
this.mediaLayerRef.batchDraw();
}
return true;
}
public deselectNode(node: MediaNode, doNotDraw?: boolean): void {
if (this.disabled) {
// no-op when disabled
return;
}
node.unhighlight();
if (node.isKEventRef()) {
node.setIsKEventRef(false);
}
this.selectedNodes.delete(node);
this.updateNodeTransformer();
this.updateContextComponents();
this.showContextComponents();
if (!doNotDraw) {
this.mediaLayerRef.batchDraw();
}
}
public clearSelection(): void {
if (this.disabled) {
//no-op when disabled
return;
}
this.selectedNodes.forEach((node) => {
node.unhighlight();
if (node.isKEventRef()) {
node.setIsKEventRef(false);
}
});
this.selectedNodes.clear();
this.nodeTransformerRef.clear();
this.hideContextComponents();
this.mediaLayerRef.batchDraw();
}
public onToggleLock() {
this.updateContextComponents();
}
public isDragging() {
return this._isDragging;
}
public isNodeSelected(node: MediaNode): boolean {
return this.selectedNodes.has(node);
}
public dragStart() {
// track the dragging state for mouseevents in nodes
this._isDragging = true;
this.hideContextComponents();
// track all nodes initial position when dragstart
this.selectedNodes.forEach((selectedNode) => {
this.initialPositions.set(selectedNode, selectedNode.kNode.position());
});
}
public dragEnd() {
// release the dragging state for mouseevents in nodes
this._isDragging = false;
// track and map the final positions
const finalPositions = new Map<MediaNode, Position>();
this.selectedNodes.forEach((currNode) => {
finalPositions.set(currNode, currNode.kNode.position());
});
// dispatch the info for engine to manage un-redo stack
this.eventTarget.dispatchEvent(
new CustomEvent<NodesTranslationEventDetails>(
SelectionManagerEvents.NODES_TRANSLATIONS,
{
detail: {
nodes: this.selectedNodes,
initialPositions: new Map(this.initialPositions),
finalPositions: finalPositions,
},
},
),
);
// done, clear data and show menu
this.initialPositions.clear();
this.updateContextComponents();
}
private getKNodeTransformation(kNode: Konva.Node) {
return {
kNodeId: kNode._id,
position: kNode.position(),
size: kNode.size(),
rotation: kNode.rotation(),
scale: {
x: kNode.scaleX(),
y: kNode.scaleY(),
},
};
}
public transformStart() {
// track all nodes initial transformation when transformStart
this.selectedNodes.forEach((selectedNode) => {
const transformations = [this.getKNodeTransformation(selectedNode.kNode)];
if (selectedNode.kNode instanceof Konva.Group) {
const childKNodes = selectedNode.kNode.getChildren();
childKNodes.forEach((childKNode: Konva.Node) => {
transformations.push(this.getKNodeTransformation(childKNode));
});
}
this.initialTransformations.set(selectedNode, transformations);
});
this.hideContextComponents();
}
public transformEnd() {
// track and map the final positions
const finalTransformations = new Map<MediaNode, Transformation[]>();
this.selectedNodes.forEach((selectedNode) => {
const transformations = [this.getKNodeTransformation(selectedNode.kNode)];
if (selectedNode.kNode instanceof Konva.Group) {
const childKNodes = selectedNode.kNode.getChildren();
childKNodes.forEach((childKNode: Konva.Node) => {
transformations.push(this.getKNodeTransformation(childKNode));
});
}
finalTransformations.set(selectedNode, transformations);
});
// dispatch the info for engine to manage un-redo stack
this.eventTarget.dispatchEvent(
new CustomEvent<NodeTransformationEventDetails>(
SelectionManagerEvents.NODES_TRANSFORMATION,
{
detail: {
nodes: this.selectedNodes,
initialTransformations: new Map(this.initialTransformations),
finalTransformations: finalTransformations,
},
},
),
);
}
public updateNodeTransformer() {
const lockFlag = this.selectedNodes.values().next().value?.isLocked();
if (lockFlag) {
this.nodeTransformerRef.disable();
} else {
this.nodeTransformerRef.enable();
}
this.nodeTransformerRef.addNodes({ selectedNodes: this.selectedNodes });
}
public hideContextComponents() {
uiAccess.toolbarNode.hide();
uiAccess.loadingBar.hide();
}
public showContextComponents(at?: { x: number, y: number }) {
const showOrUpdate = uiAccess.toolbarNode.isShowing()
? uiAccess.toolbarNode.update
: uiAccess.toolbarNode.show;
// If a position is received, set it
// Otherwise use the previous position
const position = at ? at : uiAccess.toolbarNode.signal.value.position;
const node = this.selectedNodes.values().next().value;
if (node === undefined) {
this.hideContextComponents();
return; // no node
}
if (this.selectedNodes.size === 1) {
if (node instanceof ImageNode) {
showOrUpdate({
knodeIds: [node.kNode.id()],
locked: node.isLocked(),
buttonStates: getImageNodeButtonStates({ locked: node.isLocked() }),
position: position
});
}
if (node instanceof PreviewCopyNode) {
showOrUpdate({
knodeIds: [node.kNode.id()],
locked: node.isLocked(),
buttonStates: getPreviewCopyNodeButtonStates({ locked: node.isLocked() }),
position: position
});
}
else if (node instanceof ShapeNode) {
showOrUpdate({
knodeIds: [node.kNode.id()],
locked: node.isLocked(),
buttonStates: getShapeNodeButtonStates({ locked: node.isLocked() }),
color: node.color,
position: position
});
}
else if (node instanceof TextNode) {
showOrUpdate({
knodeIds: [node.kNode.id()],
locked: node.isLocked(),
buttonStates: getTextNodeButtonStates({ locked: node.isLocked() }),
color: node.getNodeData({ x: 0, y: 0 })?.textNodeData?.color ?? "#000000",
position: position
});
}
// show loading bar is the node is noding
if (node instanceof NetworkedNode) {
if (!node.didFinishLoading && !uiAccess.loadingBar.isShowing()) {
// console.log("show loading bar");
uiAccess.loadingBar.show();
}
}
} else {
if (node) {
showOrUpdate({
locked: node.isLocked(),
buttonStates: getMultiSelectButtonStates({ locked: node.isLocked() }),
position: position
});
}
}
}
public updateContextComponents() {
// use first node as reference
const node = this.selectedNodes.values().next().value;
if (node === undefined) {
this.hideContextComponents();
return; // no nodes
}
// console.log("SelectionManager > updateContextComponents for node:", node);
const coord = calculateContextualsPosition(
this.nodeTransformerRef.getKonvaNode(),
);
if (node.isLocked() !== uiAccess.toolbarNode.isLocked()) {
// console.log("setting lock");
uiAccess.toolbarNode.setLocked(node.isLocked());
}
console.debug("Setting context position to:", coord)
uiAccess.toolbarNode.setPosition({
x: coord.x,
y: coord.y,
});
if (node instanceof NetworkedNode) {
uiAccess.loadingBar.update({
position: coord,
progress: node.progress(),
status: node.isError()
? LoadingBarStatus.ERROR
: LoadingBarStatus.LOADING,
message: node.progressMessage(),
});
uiEvents.toolbarNode.retry.onClick((e) => {
if (e) {
console.log("CONTEXTUAL LOADING BAR RETRY ONCLICK", e, node);
node.retry();
}
});
if (node.didFinishLoading) {
// console.log("node finished loading", node);
uiAccess.loadingBar.hide();
}
}
}
}
@@ -0,0 +1,176 @@
import Konva from "konva";
import { NodesManager } from "./NodesManager";
import { SelectionManager } from "./SelectionManager";
import { MediaNode } from "../types";
interface SqaureCoordinates {
x1: number;
y1: number;
x2: number;
y2: number;
}
export class SelectorSquare {
private kSquare: Konva.Rect;
private selecting: boolean = false;
private sqaureCoordinates: SqaureCoordinates = {
x1: 0,
x2: 0,
y1: 0,
y2: 0,
};
// engine class references
private captureCanvasRef: Konva.Rect;
private mediaLayerRef: Konva.Layer;
private nodesManagerRef: NodesManager;
private selectionManagerRef: SelectionManager;
private stageRef: Konva.Stage;
constructor({
captureCanvasRef,
mediaLayerRef,
nodesManagerRef,
selectionManagerRef,
stageRef,
}: {
captureCanvasRef: Konva.Rect;
mediaLayerRef: Konva.Layer;
nodesManagerRef: NodesManager;
selectionManagerRef: SelectionManager;
stageRef: Konva.Stage;
}) {
this.kSquare = new Konva.Rect({
fill: "rgba(45, 129, 255, 0.3)",
stroke: "#00a1ff",
strokeWidth: 1,
visible: false,
// disable events to not interrupt with events
listening: false,
});
this.captureCanvasRef = captureCanvasRef;
this.mediaLayerRef = mediaLayerRef;
this.nodesManagerRef = nodesManagerRef;
this.selectionManagerRef = selectionManagerRef;
this.stageRef = stageRef;
}
public getKonvaNode() {
return this.kSquare;
}
public disable() {
this.stageRef.off("mousedown touchstart");
this.stageRef.off("mousemove touchmove");
this.stageRef.off("mouseup touchend");
this.stageRef.off("mouseleave");
}
public enable() {
// Remove any existing event handlers first to prevent duplicates
this.disable();
this.stageRef.on("mousedown touchstart", (e) => {
const stagePointerPos = this.stageRef.getPointerPosition();
if (
(e.target !== this.stageRef && e.target !== this.captureCanvasRef) || // do nothing if we mousedown on any shape
stagePointerPos === null || // do nothing if pointers not available
e.evt.shiftKey // do nothing so then multiselect is more forgiving in misclicks
) {
return;
}
// start handle mousedown
e.evt.preventDefault();
if (e.target === this.stageRef || e.target === this.captureCanvasRef) {
//moused down on empty space, clear previous selection first
this.selectionManagerRef.clearSelection();
}
// this starts drawing the square
this.sqaureCoordinates = {
x1: stagePointerPos.x,
y1: stagePointerPos.y,
x2: stagePointerPos.x,
y2: stagePointerPos.y,
};
this.kSquare.size({
width: 0,
height: 0,
});
this.selecting = true;
});
this.stageRef.on("mousemove touchmove", (e) => {
// do nothing if we didn't start selection
const stagePointerPos = this.stageRef.getPointerPosition();
if (!this.selecting || stagePointerPos === null) {
return;
}
// handle drawing square according to mouse move
e.evt.preventDefault();
this.sqaureCoordinates = {
...this.sqaureCoordinates,
x2: stagePointerPos.x,
y2: stagePointerPos.y,
};
const { x1, y1, x2, y2 } = this.sqaureCoordinates;
this.kSquare.moveToTop();
this.kSquare.setAttrs({
visible: true,
x: Math.min(x1, x2),
y: Math.min(y1, y2),
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1),
});
});
this.stageRef.on("mouseup touchend", (e) => {
// do nothing if we didn't start selection
if (!this.selecting) {
return;
}
this.selecting = false;
if (!this.kSquare.visible()) {
return;
}
e.evt.preventDefault();
// update visibility
this.kSquare.visible(false);
// Find all the Nodes and feed them into Selectmanager
var shapes = this.mediaLayerRef.getChildren();
var box = this.kSquare.getClientRect();
var foundKNodes = shapes.filter((shape) =>
Konva.Util.haveIntersection(box, shape.getClientRect()),
);
if (foundKNodes.length > 0) {
const kNodeIds = foundKNodes.map((kNode) => kNode._id);
const foundNodes = Array.from(
this.nodesManagerRef.getAllNodes(),
).reduce((accNodes, currNode) => {
if (kNodeIds.includes(currNode.kNode._id) && !currNode.isLocked()) {
accNodes.push(currNode);
}
return accNodes;
}, [] as MediaNode[]);
console.log("selector square found", foundNodes);
this.selectionManagerRef.selectNodes(foundNodes);
}
});
this.stageRef.on("mouseleave", (e) => {
if (!this.selecting) {
return;
}
this.selecting = false;
if (!this.kSquare.visible()) {
return;
}
e.evt.preventDefault();
// update visibility
this.kSquare.visible(false);
// and then do nothing, this is considered a cancel
});
}
}
@@ -0,0 +1,236 @@
import Konva from "konva";
import { ToolbarNodeButtonNames } from "../../components/features/ToolbarNode";
import { ContextualToolbarProps } from "../../signals/uiAccess/toolbarNode";
export function calculateContextualsPosition(kNode: Konva.Transformer) {
const w = kNode.getSize().width * kNode.scaleX();
const h = kNode.getSize().height * kNode.scaleY();
const x0 = kNode.getPosition().x;
const y0 = kNode.getPosition().y;
const d = kNode.getAbsoluteRotation();
const r = d >= 0 ? (d * Math.PI) / 180 : ((360 + d) * Math.PI) / 180;
let px: number, py: number;
if (r < Math.PI / 2) {
//top right quadrants
px = x0 + (h * Math.sin(r) + w * Math.cos(r)) / 2 - h * Math.sin(r);
py = y0 + h * Math.cos(r) + w * Math.cos(Math.PI / 2 - r);
} else if (r < Math.PI) {
//lower right quadrants
px = x0 - (h * Math.sin(r) - w * Math.cos(r)) / 2;
py = y0 + w * Math.cos(Math.PI / 2 - r);
} else if (r < (Math.PI * 3) / 2) {
//lower left quadrants
px = x0 + h * Math.cos(r) - (h * Math.cos(r) + w * Math.sin(r)) / 2;
py = y0;
} else {
//top left quadrants
px = x0 + (-h * Math.sin(r) + w * Math.cos(r)) / 2;
py = y0 + h * Math.cos(r);
}
return { x: px, y: py };
}
export function getImageNodeButtonStates(
props: { locked?: boolean | "unknown" } = {},
) {
const ButtonNames = ToolbarNodeButtonNames;
return Object.values(ButtonNames).reduce(
(buttonStates, buttonName) => {
switch (buttonName) {
case ButtonNames.AI_STYLIZE:
case ButtonNames.SEGMENTATION:
case ButtonNames.DOWNLOAD:
case ButtonNames.CHROMA:
case ButtonNames.COLOR:
case ButtonNames.TRANSFORM:
// hidden buttons
buttonStates[buttonName] = {
disabled: true,
hidden: true,
active: false,
};
return buttonStates;
default:
// all other buttons
buttonStates[buttonName] = {
disabled: false,
hidden: false,
active: false,
};
return buttonStates;
}
},
{} as ContextualToolbarProps["buttonStates"],
);
}
export function getPreviewCopyNodeButtonStates(
props: { locked?: boolean | "unknown" } = {},
) {
const ButtonNames = ToolbarNodeButtonNames;
return Object.values(ButtonNames).reduce(
(buttonStates, buttonName) => {
switch (buttonName) {
case ButtonNames.AI_STYLIZE:
case ButtonNames.SEGMENTATION:
case ButtonNames.DOWNLOAD:
case ButtonNames.CHROMA:
case ButtonNames.COLOR:
case ButtonNames.TRANSFORM:
// hidden buttons
buttonStates[buttonName] = {
disabled: true,
hidden: true,
active: false,
};
return buttonStates;
default:
// all other buttons
buttonStates[buttonName] = {
disabled: false,
hidden: false,
active: false,
};
return buttonStates;
}
},
{} as ContextualToolbarProps["buttonStates"],
);
}
export function getTextNodeButtonStates(
props: { locked?: boolean | "unknown" } = {},
) {
const ButtonNames = ToolbarNodeButtonNames;
return Object.values(ButtonNames).reduce(
(buttonStates, buttonName) => {
switch (buttonName) {
case ButtonNames.AI_STYLIZE:
case ButtonNames.SEGMENTATION:
case ButtonNames.DOWNLOAD:
case ButtonNames.CHROMA:
case ButtonNames.REMOVE_BACKGROUND:
case ButtonNames.TRANSFORM:
// hidden buttons
buttonStates[buttonName] = {
disabled: true,
hidden: true,
active: false,
};
return buttonStates;
default:
// all other buttons
buttonStates[buttonName] = {
disabled: false,
hidden: false,
active: false,
};
return buttonStates;
}
},
{} as ContextualToolbarProps["buttonStates"],
);
}
export function getVideoNodeButtonStates(
props: { locked?: boolean | "unknown" } = {},
) {
const ButtonNames = ToolbarNodeButtonNames;
return Object.values(ButtonNames).reduce(
(buttonStates, buttonName) => {
switch (buttonName) {
case ButtonNames.AI_STYLIZE:
case ButtonNames.COLOR:
case ButtonNames.REMOVE_BACKGROUND:
case ButtonNames.TRANSFORM:
// soon to come feature is disabled
buttonStates[buttonName] = {
disabled: true,
hidden: true,
active: false,
};
return buttonStates;
default:
// all other buttons
buttonStates[buttonName] = {
disabled: false,
hidden: false,
active: false,
};
return buttonStates;
}
},
{} as ContextualToolbarProps["buttonStates"],
);
}
export function getShapeNodeButtonStates(
props: { locked?: boolean | "unknown" } = {},
) {
const ButtonNames = ToolbarNodeButtonNames;
return Object.values(ButtonNames).reduce(
(buttonStates, buttonName) => {
switch (buttonName) {
case ButtonNames.AI_STYLIZE:
case ButtonNames.SEGMENTATION:
case ButtonNames.DOWNLOAD:
case ButtonNames.CHROMA:
case ButtonNames.REMOVE_BACKGROUND:
case ButtonNames.TRANSFORM:
// hidden buttons
buttonStates[buttonName] = {
disabled: true,
hidden: true,
active: false,
};
return buttonStates;
default:
// all other buttons
buttonStates[buttonName] = {
disabled: false,
hidden: false,
active: false,
};
return buttonStates;
}
},
{} as ContextualToolbarProps["buttonStates"],
);
}
export function getMultiSelectButtonStates(
props: { locked?: boolean | "unknown" } = {},
) {
const ButtonNames = ToolbarNodeButtonNames;
return Object.values(ButtonNames).reduce(
(buttonStates, buttonName) => {
switch (buttonName) {
case ButtonNames.AI_STYLIZE:
case ButtonNames.SEGMENTATION:
case ButtonNames.DOWNLOAD:
case ButtonNames.CHROMA:
case ButtonNames.COLOR:
case ButtonNames.REMOVE_BACKGROUND:
case ButtonNames.TRANSFORM:
// hidden buttons
buttonStates[buttonName] = {
disabled: true,
hidden: true,
active: false,
};
return buttonStates;
default:
// all other buttons
buttonStates[buttonName] = {
disabled: false,
hidden: false,
active: false,
};
return buttonStates;
}
},
{} as ContextualToolbarProps["buttonStates"],
);
}
@@ -0,0 +1,10 @@
export { NodesManager } from "./NodesManager";
export { NodeIsolator } from "./NodeIsolator";
export { NodeTransformer } from "./NodeTransformer";
export { SelectionManager, SelectionManagerEvents } from "./SelectionManager";
export { SelectorSquare } from "./SelectorSquare";
export type {
NodesTranslationEventDetails,
NodeTransformationEventDetails,
} from "./SelectionManager";
@@ -0,0 +1,59 @@
//@ts-ignore
// TODO: have to export and modify the code
import { ICanvasConfig } from "konva/lib/Canvas";
import { Canvas } from "konva/lib/Canvas";
import { OffScreenSceneContext } from "./OffScreenSceneContext";
export class OffScreenSceneCanvas extends Canvas {
constructor(
config: ICanvasConfig = { width: 0, height: 0, willReadFrequently: false },
) {
super(config); // creates canvas then you have to replace it
//@ts-ignore
// replace here also kills the css but thats ok
this._canvas = new OffscreenCanvas(config.width, config.height);
this.context = new OffScreenSceneContext(this, {
willReadFrequently: config.willReadFrequently,
});
console.log("Created Offscreen Scene Canvas");
this.setSize(config.width, config.height);
}
setPixelRatio(pixelRatio: number) {
console.log("overriden setPixelRatio");
var previousRatio = this.pixelRatio;
this.pixelRatio = pixelRatio;
this.setSize(
this.getWidth() / previousRatio,
this.getHeight() / previousRatio,
);
}
public override setWidth(width: number) {
// take into account pixel ratio
// console.log("overriden setWidth");
this.width = this._canvas.width = width * this.pixelRatio;
var pixelRatio = this.pixelRatio,
_context = this.getContext()._context;
_context.scale(pixelRatio, pixelRatio);
}
public override setHeight(height: number) {
// take into account pixel ratio
// console.log("overriden setHeight");
this.height = this._canvas.height = height * this.pixelRatio;
var pixelRatio = this.pixelRatio,
_context = this.getContext()._context;
_context.scale(pixelRatio, pixelRatio);
}
getWidth() {
return this.width;
}
getHeight() {
return this.height;
}
setSize(width: number | undefined, height: number | undefined) {
this.setWidth(width || 0);
this.setHeight(height || 0);
}
}
@@ -0,0 +1,11 @@
import { Context } from "konva/lib/Context";
import { Canvas } from "konva/lib/Canvas";
export class OffScreenSceneContext extends Context {
constructor(canvas: Canvas, { willReadFrequently = false } = {}) {
super(canvas);
this._context = canvas._canvas.getContext("2d", {
willReadFrequently,
}) as CanvasRenderingContext2D;
}
}
@@ -0,0 +1,879 @@
import Konva from "konva";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { FileUtilities } from "../../utilities/FileUtilities";
import {
ImageNode,
TextNode,
ShapeNode,
PreviewCopyNode,
PaintNode,
} from "../Nodes";
import { MediaNode } from "../types";
import { RenderTask } from "./RenderTask";
import {
isLoadingVisible,
loadingProgress,
} from "../../signals/uiEvents/loadingIndicator";
import { EncodeImageBitmapToBase64 } from "../../utilities/EncodeImageBitmapToBase64";
import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap";
interface ServerSettings {
model_path: string;
lora_path?: string;
}
export class RealTimeDrawEngine {
private imageNodes: (ImageNode | TextNode | ShapeNode | PaintNode)[];
public offScreenCanvas: OffscreenCanvas;
private outputBitmap: ImageBitmap | undefined;
// private frames: ImageBitmap[];
// capturing composite within window
public mediaLayerRef: Konva.Layer;
private drawingsLayer: Konva.Layer; // New Layer for Drawings
private height: number;
private width: number;
private positionX: number;
private positionY: number;
private positionPreviewX: number;
private positionPreviewY: number;
private port: MessagePort | undefined;
public captureCanvas: Konva.Rect;
public backgroundRasterRect: Konva.Image;
public fps: number = 24;
public currentPrompt: string;
public currentStrength: number;
public lastRenderedBitmap: ImageBitmap | undefined;
// Paint Color
// paint Brush Size
// has to exit out of paint mode when shape or image are used.
public paintColor: string = "#000000";
private brushSize: number = 5; // Default brush size
public onDrawCallback?: (
canvas: HTMLCanvasElement,
lineBounds: {
width: number;
height: number;
x: number;
y: number;
},
) => void;
private offscreenRenderDiv: HTMLDivElement;
private client: WebSocketClient | null = null;
private isConnected: boolean = false;
public backgroundColor: string = "#d2d2d2"; // off white
constructor({
width,
height,
mediaLayerRef,
offScreenCanvas,
onDraw,
onPreviewCopy, // New Parameter
}: {
width: number;
height: number;
mediaLayerRef: Konva.Layer;
offScreenCanvas: OffscreenCanvas;
onDraw?: (
canvas: HTMLCanvasElement,
lineBounds: {
width: number;
height: number;
x: number;
y: number;
},
) => void;
onPreviewCopyCallback?: (previewCopy: Konva.Image) => void; // New Parameter
}) {
this.imageNodes = [];
this.onDrawCallback = onDraw;
// TODO: Make this dynamic and update this on change of canvas.
this.width = width * 1.5;
this.height = height * 1;
this.positionX = window.innerWidth - this.width;
this.positionY = window.innerHeight - this.height;
this.offScreenCanvas = offScreenCanvas;
this.offScreenCanvas.width = this.width;
this.offScreenCanvas.height = this.height;
// this is the whole canvas
this.mediaLayerRef = mediaLayerRef;
// Create a separate layer for drawings
this.drawingsLayer = new Konva.Layer({
clearBeforeDraw: true, // Ensures transparent background
});
this.mediaLayerRef.getStage()?.add(this.drawingsLayer); // to od pass in stage
// Set background layer to red and media layer to green for visibility
this.port = undefined;
this.fps = 24;
this.currentPrompt = "";
this.currentStrength = 1;
// This is captures a subset of the medialayer ref
this.captureCanvas = new Konva.Rect({
name: "CaptureCanvas",
x: this.positionX,
y: this.positionY,
width: this.width,
height: this.height,
stroke: "blue",
strokeWidth: 1,
cornerRadius: 10,
draggable: false,
});
this.offscreenRenderDiv = document.createElement("div");
this.mediaLayerRef.add(this.captureCanvas);
this.captureCanvas.setZIndex(0);
this.listenToServerEvents();
}
public async listenToServerEvents() {
listen("notification", (event) => {
console.log(event);
// Handle model download events
const payload = event.payload as any;
// Model download started
if (payload.model_download_started) {
const modelInfo = payload.model_download_started;
console.log(
`Model download started: ${modelInfo.model_name} (${modelInfo.model_type})`,
);
// Set up loading indicator
isLoadingVisible.value = true;
loadingProgress.value = 0;
// Create fake progress updates
const downloadTimer = setInterval(() => {
loadingProgress.value += 2;
// Cap at 95% until we get the completed event
if (loadingProgress.value >= 95) {
loadingProgress.value = 95;
clearInterval(downloadTimer);
}
}, 500);
}
// Model download completed
if (payload.model_download_complete) {
const modelInfo = payload.model_download_complete;
console.log(
`Model download completed: ${modelInfo.model_name} (${modelInfo.model_type})`,
);
// Complete the loading progress
loadingProgress.value = 100;
// Hide loading indicator after a short delay
setTimeout(() => {
isLoadingVisible.value = false;
}, 1000);
}
});
}
private isEnabled: boolean = false;
private cleanupFunction: (() => void) | null = null;
// this starts the python server
public async startServer() {
try {
this.client = new WebSocketClient("ws://localhost:8765");
// Set up message handlers
this.client.onProgress((progress) => {
console.log(
`Progress: ${progress.message} (${progress.progress * 100}%)`,
);
if (progress.error) {
console.error("Error:", progress.error);
}
});
// Wait for connection to be established
await new Promise<void>((resolve, reject) => {
if (!this.client) return reject("Client not initialized");
this.client.ws.onopen = () => {
this.isConnected = true;
resolve();
};
this.client.ws.onerror = (error) => reject(error);
});
// Load initial model
await this.loadModel({
model_path: "C:/Users/Tensor/Downloads/animagineXL40_v4Opt.safetensors",
lora_path: "C:/Users/Tensor/Downloads/anyu_all.safetensors",
});
} catch (error) {
console.error("Failed to start server:", error);
this.isConnected = false;
}
}
private async loadModel(settings: ServerSettings) {
if (!this.client || !this.isConnected) {
throw new Error("Server not connected");
}
await this.client.loadModel(settings);
}
private updateCursor(stage: Konva.Stage) {
// Create cursor canvas
const cursorCanvas = document.createElement("canvas");
const size = this.brushSize * 5; // Match the brush size used in drawing
cursorCanvas.width = size * 2; // Double size for padding
cursorCanvas.height = size * 2;
const ctx = cursorCanvas.getContext("2d");
if (!ctx) return;
// Draw the outer circle with a light stroke
ctx.beginPath();
ctx.arc(size, size, size / 2, 0, Math.PI * 2);
ctx.strokeStyle = "white";
ctx.lineWidth = 3;
ctx.stroke();
// Draw the inner circle with a dark stroke
ctx.beginPath();
ctx.arc(size, size, size / 2 - 1, 0, Math.PI * 2);
ctx.strokeStyle = "black";
ctx.lineWidth = 1;
ctx.stroke();
// Convert to data URL
const cursorUrl = cursorCanvas.toDataURL();
// Apply custom cursor
stage.container().style.cursor = `url(${cursorUrl}) ${size} ${size}, auto`;
}
public paintMode() {
let isDrawing = false;
let currentLine: Konva.Line | null = null;
const stage = this.mediaLayerRef.getStage();
if (!stage) return;
// Initialize cursor
this.updateCursor(stage);
const startDrawing = (pos: { x: number; y: number }) => {
if (!this.isEnabled) return;
const stage = this.mediaLayerRef.getStage();
if (!stage) return;
// Convert pointer position to relative position within capture canvas
const captureBox = this.captureCanvas.getClientRect();
const relativeX = pos.x - captureBox.x;
const relativeY = pos.y - captureBox.y;
currentLine = new Konva.Line({
points: [relativeX, relativeY],
stroke: this.paintColor,
strokeWidth: this.brushSize * 5,
lineCap: "round",
lineJoin: "round",
x: this.captureCanvas.x(),
y: this.captureCanvas.y(),
draggable: false,
});
this.drawingsLayer.moveToTop();
this.drawingsLayer.add(currentLine); // Add to drawingsLayer
isDrawing = true;
};
const draw = (pos: { x: number; y: number }) => {
if (!this.isEnabled) return;
if (!isDrawing || !currentLine) return;
// Convert pointer position to relative position
const captureBox = this.captureCanvas.getClientRect();
const relativeX = pos.x - captureBox.x;
const relativeY = pos.y - captureBox.y;
const newPoints = currentLine.points().concat([relativeX, relativeY]);
currentLine.points(newPoints);
this.drawingsLayer.batchDraw();
};
const stopDrawing = () => {
if (!this.isEnabled) return;
if (!isDrawing || !currentLine) return;
// Store current line before resetting state
const lineToConvert = currentLine;
// Reset drawing state immediately so we can start a new stroke
isDrawing = false;
currentLine = null;
// Create a temporary layer to render the line
const tempLayer = new Konva.Layer({
clearBeforeDraw: true,
});
this.mediaLayerRef.getStage()?.add(tempLayer);
tempLayer.add(lineToConvert);
// Get the line canvas with transparent background
// Get the bounding box of the line
const lineBounds = lineToConvert.getClientRect();
// Create canvas with just enough size to contain the line
const lineCanvas = tempLayer.toCanvas({
x: lineBounds.x,
y: lineBounds.y,
width: lineBounds.width,
height: lineBounds.height,
pixelRatio: 1,
});
if (this.onDrawCallback) {
this.onDrawCallback(lineCanvas, lineBounds);
}
// Clean up
lineToConvert.destroy();
tempLayer.destroy();
this.drawingsLayer.batchDraw();
};
// Check if point is within capture canvas bounds
const isWithinCaptureCanvas = (pos: { x: number; y: number }) => {
const captureBox = this.captureCanvas.getClientRect();
return (
pos.x >= captureBox.x &&
pos.x <= captureBox.x + captureBox.width &&
pos.y >= captureBox.y &&
pos.y <= captureBox.y + captureBox.height
);
};
// Add event listeners
stage.on("mousedown touchstart", (e) => {
const pos = stage.getPointerPosition();
if (pos && isWithinCaptureCanvas(pos)) {
startDrawing(pos);
}
});
stage.on("mousemove touchmove", (e) => {
const pos = stage.getPointerPosition();
if (pos && isWithinCaptureCanvas(pos)) {
draw(pos);
}
});
stage.on("mouseup touchend", async () => {
stopDrawing();
//await this.render();
});
// Store cleanup function
this.cleanupFunction = () => {
stage.off("mousedown touchstart");
stage.off("mousemove touchmove");
stage.off("mouseup touchend");
};
}
public enablePaintMode() {
this.isEnabled = true;
this.disableDragging();
if (!this.cleanupFunction) {
this.paintMode();
}
// Update cursor when enabling paint mode
const stage = this.mediaLayerRef.getStage();
if (stage) {
this.updateCursor(stage);
}
}
public enableDragging() {
// Enable dragging for all nodes in media layer
this.imageNodes?.forEach((node) => {
console.log("Image Nodes Enable");
console.log(node);
node.kNode.draggable(true);
node.kNode.listening(true);
});
this.mediaLayerRef.batchDraw();
}
public disableDragging() {
// Disable dragging for all nodes in media layer
this.imageNodes?.forEach((node) => {
node.kNode.draggable(false);
node.kNode.listening(false);
});
this.mediaLayerRef.batchDraw();
}
public disablePaintMode() {
this.isEnabled = false;
this.enableDragging();
if (this.cleanupFunction) {
this.cleanupFunction();
this.cleanupFunction = null;
}
// Reset cursor when disabling paint mode
const stage = this.mediaLayerRef.getStage();
if (stage) {
stage.container().style.cursor = "default";
}
}
public findImageNodeById(
id: string,
): ImageNode | TextNode | ShapeNode | PaintNode | undefined {
return this.imageNodes.find((node) => {
if (node.kNode) {
return node.kNode.id() === id;
}
return false;
});
}
async updateCaptureCanvas(
width: number | undefined,
height: number | undefined,
) {
console.log("updateCaptureCanvas");
if (!this.captureCanvas) {
return;
}
const scaleFactor = 0.9;
// Store old values for calculating delta
const oldPositionX = this.positionX;
const oldPositionY = this.positionY;
const oldWidth = this.width;
const oldHeight = this.height;
// Original aspect ratio
const originalAspectRatio = oldWidth / oldHeight;
// Calculate new dimensions based on window size
let newWidth: number;
let newHeight: number;
if (width === undefined) {
newWidth = window.innerWidth * scaleFactor;
} else {
newWidth = width * scaleFactor;
}
if (height === undefined) {
newHeight = window.innerHeight * scaleFactor;
} else {
newHeight = height * scaleFactor;
}
// Preserve aspect ratio
const newAspectRatio = newWidth / newHeight;
if (newAspectRatio > originalAspectRatio) {
// Width is constraining factor
newWidth = newHeight * originalAspectRatio;
} else {
// Height is constraining factor
newHeight = newWidth / originalAspectRatio;
}
this.width = newWidth;
this.height = newHeight;
console.log("New dimensions:", this.width, this.height);
// Calculate new position to center the canvas in the window
this.positionX = (window.innerWidth - this.width) / 2;
const headerPadding = 56;
this.positionY = (window.innerHeight - headerPadding - this.height) / 2;
console.log("New centered position:", this.positionX, this.positionY);
// Update the canvas position and size
this.captureCanvas.setPosition({
x: this.positionX,
y: this.positionY,
});
this.captureCanvas.size({ width: this.width, height: this.height });
// Calculate scale factors to maintain proportions
const scaleX = this.width / oldWidth;
const scaleY = this.height / oldHeight;
// Use a uniform scale factor to preserve aspect ratio
const uniformScale = Math.min(scaleX, scaleY);
// Update all children except the capture canvas
var children = this.mediaLayerRef.getChildren();
for (let i = 0; i < children.length; i++) {
let node = children[i];
// skip the capture canvas and preview canvas update.
if (node.name() === "CaptureCanvas") {
continue;
}
const pos = node.getPosition();
const oldScale = node.scale();
// Apply both position shift and scaling to maintain relative positioning
node.setPosition({
x: (pos.x - oldPositionX) * uniformScale + this.positionX,
y: (pos.y - oldPositionY) * uniformScale + this.positionY,
});
// Update scale proportionally if the node has a scale property
if (
oldScale &&
typeof oldScale.x === "number" &&
typeof oldScale.y === "number"
) {
node.scale({
x: oldScale.x * uniformScale,
y: oldScale.y * uniformScale,
});
}
}
this.mediaLayerRef.batchDraw();
}
debug() {
// DEBUG ONLY
const rectangle = new Konva.Rect({
x: this.positionX,
y: this.positionY,
width: 100,
height: 100,
fill: "green",
stroke: "black",
strokeWidth: 1,
draggable: false,
});
this.mediaLayerRef.add(rectangle);
}
async sendCanvasPayload(renderTask: RenderTask) {
if (!this.port) {
return console.log("Undefined Worker");
}
this.port.postMessage(renderTask);
}
public placeDebugRect(
x: number,
y: number,
width: number,
height: number,
layer: Konva.Layer,
): void {
const rect = new Konva.Rect({
x: x,
y: y,
width: width, // Default width
height: height, // Default height
fill: "green",
draggable: false,
});
layer.add(rect);
layer.draw();
}
// This function uses a portion of the video layer to capture just the capture canvas.
// capture everything after seeking each video node.
renderPortionOfLayer(
layer: Konva.Layer,
x: number,
y: number,
width: number,
height: number,
): HTMLCanvasElement {
const canvas = layer.toCanvas({
x: x,
y: y,
width: width,
height: height,
});
return canvas;
}
public isProcessing = false;
private handleNodeDragEnd = async () => {
// Clean up any existing state
//await this.render();
};
public async addNodes(node: MediaNode) {
if (
node instanceof ImageNode ||
node instanceof TextNode ||
node instanceof ShapeNode ||
node instanceof PaintNode ||
node instanceof PreviewCopyNode
) {
console.debug("Adding node:", node);
this.imageNodes.push(node);
console.log(this.imageNodes);
}
// ensure the layer doesn't move if added while painting.
if (this.isEnabled) {
this.disableDragging();
}
//await this.render();
}
public removeNodes(node: MediaNode) {
if (
node instanceof ImageNode ||
node instanceof TextNode ||
node instanceof ShapeNode ||
node instanceof PaintNode ||
node instanceof PreviewCopyNode
) {
const index = this.imageNodes.indexOf(node);
if (index > -1) {
node.kNode.off("dragend", this.handleNodeDragEnd);
this.imageNodes.splice(index, 1);
}
}
}
private cloneStageForRender(
stage: Konva.Stage,
layerOfInterest: Konva.Layer,
): Konva.Stage {
const stageClone = new Konva.Stage({
width: stage.width(),
height: stage.height(),
container: this.offscreenRenderDiv,
});
const renderLayer = new Konva.Layer();
// Clone all the nodes then reset them to render right
layerOfInterest.getChildren().forEach((node) => {
const dupNode = node.clone();
dupNode.strokeWidth(0);
renderLayer.add(dupNode);
});
stageClone.add(renderLayer);
return stageClone;
}
private async imageBitmapToBase64(imageBitmap: ImageBitmap): Promise<string> {
// Create a temporary canvas
const canvas = document.createElement("canvas");
canvas.width = imageBitmap.width;
canvas.height = imageBitmap.height;
// Draw the ImageBitmap onto the canvas
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Failed to get 2D context");
ctx.drawImage(imageBitmap, 0, 0);
// Convert to base64
const base64String = canvas.toDataURL("image/png");
// Clean up
canvas.remove();
// Remove the data:image/png;base64, prefix if you want just the base64 string
return base64String.split(",")[1];
}
public async saveOutput() {
if (!this.outputBitmap) {
console.error("No output bitmap available to save");
return;
}
const base64Bitmap = await EncodeImageBitmapToBase64(this.outputBitmap);
const saveResponse = await invoke("save_image", {
image: base64Bitmap,
});
}
public async generateImage() {
if (!this.lastRenderedBitmap) {
console.error("No rendered bitmap available to generate from");
return;
}
const base64Bitmap = await this.imageBitmapToBase64(
this.lastRenderedBitmap,
);
const generateResponse = await invoke("image_generation_command", {
image: base64Bitmap,
prompt: this.currentPrompt,
});
}
public async render() {
// only pick nodes that intersect wi th the canvas on screen bounds to freeze.
if (this.isProcessing) {
console.log("isProcessing Returning");
return;
}
console.log("Calling Render");
this.isProcessing = true;
this.mediaLayerRef.draw();
// Output all nodes in mediaLayerRef
const nodes = this.mediaLayerRef.getChildren();
console.log("All nodes in mediaLayer:", nodes);
console.log(
`context: x:${this.positionX} y:${this.positionY} ${this.width} x ${this.height}`,
);
const bitmap = (await this.renderFrame({
layerOfInterest: this.mediaLayerRef,
x: this.captureCanvas.x(),
y: this.captureCanvas.y(),
width: this.width,
height: this.height,
mimeType: "image/jpeg",
pixelRatio: 1,
quality: 1.0,
test: false,
})) as ImageBitmap;
this.lastRenderedBitmap = bitmap;
setCanvasRenderBitmap(bitmap);
try {
} catch (error) {
console.error("Error during image processing:", error);
} finally {
this.isProcessing = false;
}
}
// Add getter/setter for brush size
public set paintBrushSize(size: number) {
this.brushSize = size * 7.5;
const stage = this.mediaLayerRef.getStage();
if (stage && this.isEnabled) {
this.updateCursor(stage);
}
}
public get paintBrushSize(): number {
return this.brushSize;
}
// Add method to check connection status
public isServerConnected(): boolean {
return this.isConnected && this.client !== null;
}
// Add method to reconnect if needed
public async reconnect() {
if (this.client) {
this.client.ws.close();
}
await this.startServer();
}
private async renderFrame(config: {
layerOfInterest: Konva.Layer; // layer where the element that you want to clip lives.
// XY and height of a captureCanvas ( region of interest )
x?: number; // x position of the region of interest
y?: number; // y position of the region of interest
width?: number; // size of the region of interest
height?: number; // size of the region of interest
pixelRatio?: number; // higher means higher quality
mimeType?: string; // image/jpeg or image/png
quality?: number; // 1.0 is the best.
test: boolean; // true == blob else Image Bitmap
}): Promise<ImageBitmap | Blob> {
const box = config.layerOfInterest.getClientRect();
const stage = config.layerOfInterest.getStage();
const x = config.x !== undefined ? config.x : Math.floor(box.x);
const y = config.y !== undefined ? config.y : Math.floor(box.y);
const pixelRatio = config.pixelRatio || 1;
// Clone the required layer from the stage
// Set the right details (like removing highlight stroke)
// Then render the cloned stage to a bitmap
const stageClone = this.cloneStageForRender(stage, config.layerOfInterest);
const stageBlob = (await stageClone.toBlob({
x: x,
y: y,
width: config.width || Math.ceil(box.width),
height: config.height || Math.ceil(box.height),
pixelRatio: pixelRatio,
})) as Blob;
// if config.test is true, the result is downloaded to the local files
// config.test = true;
if (config.test) {
await FileUtilities.blobToFileJpeg(stageBlob, "1");
}
const result = await createImageBitmap(stageBlob);
return result;
}
// Add method to create or update background
public updateBackground(color: string) {
this.captureCanvas.fill(color);
const captureCanvasImage = this.captureCanvas.toDataURL();
const imageSource = new Image();
imageSource.src = captureCanvasImage;
imageSource.onload = () => {
this.backgroundRasterRect.fill(color);
this.backgroundRasterRect.image(imageSource);
this.mediaLayerRef.batchDraw();
//this.render();
};
}
}
@@ -0,0 +1,37 @@
import { bool } from "@techstark/opencv-js";
export enum WorkerEvent {
START = "start",
PROGRESS = "progress",
COMPLETE = "complete",
DATA = "data",
ERROR = "error",
}
export interface RenderTaskPayload {
offscreenCanvas: OffscreenCanvas;
id: number;
}
// RenderSharedWorker.ts
export interface RenderTask {
jobID: number;
isDone: bool;
event: WorkerEvent;
data: RenderTaskPayload | undefined;
}
export interface RenderTaskProgress {
isProcessing: boolean;
progress: number;
jobID: number;
event: WorkerEvent;
}
export interface RenderTaskResult {
event: Worker;
jobID: number;
data: {
mediaUrl: string;
};
}
@@ -0,0 +1,18 @@
const dataURL = this.videoLayer.toDataURL({
x: this.captureCanvas.x(),
y: this.captureCanvas.y(),
width: this.captureCanvas.width(),
height: this.captureCanvas.height(),
});
// Create an image element to display the captured area
const img = new Image();
img.src = dataURL;
document.body.appendChild(img); // Append the image to the body or any other container
// Optionally, you can download the captured image
const link = document.createElement("a");
link.href = dataURL;
link.download = "snapshot.png";
link.click();

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