Compare commits

..
Author SHA1 Message Date
Brandon Thomas bc67bc9526 fix 2d build 2025-04-11 04:27:26 -04:00
Brandon Thomas ffbf1c7505 move frontend_old/editor2d -> apps/editor2d 2025-04-11 04:19:57 -04:00
Brandon Thomas 0180869b63 packages for editor2d 2025-04-11 04:18:58 -04:00
Brandon Thomas ce3d7085b3 remove placeholder 'editor2d' from nx 2025-04-11 04:04:26 -04:00
47 changed files with 895 additions and 1225 deletions
Generated
-1
View File
@@ -7859,7 +7859,6 @@ dependencies = [
"serde_json",
"subprocess_common",
"testing",
"thiserror 2.0.11",
"tokio",
"toml 0.7.8",
"workspace-build-acceleration",
+3 -4
View File
@@ -117,7 +117,7 @@ members = [
"crates/vendor/candle/tensor-tools",
"crates/vendor/limitation",
# NB(bt,2023-12-12): Newrelic keeps breaking with upgrades. Kill for now.
#"crates/vendor/newrelic-telemetry-sdk-rust",
#"crates/vendor/newrelic-telemetry-sdk-rust",
# cargo-hakari workspace hack (compilation speedup hack)
# see usage: https://crates.io/crates/cargo-hakari
@@ -131,12 +131,12 @@ publish = false
[workspace.dependencies]
# Hakari workspace hack (faster compile times)
#
# If an unrelated leaf package starts causing general build problems,
# If an unrelated leaf package starts causing general build problems,
# for packages including hakari, look up what depends on the bad package:
#
# `cargo tree --target all -p {BAD_PACKAGE_NAME} -i`
#
# Packages to remove from hakari to ensure we can build in Netlify,
# Packages to remove from hakari to ensure we can build in Netlify,
# Github Actions (without package installs), etc.:
#
# - cudarc
@@ -162,7 +162,6 @@ serde = { version = "1.0.219", features = ["derive"] }
serde_derive = "1.0.219"
serde_json = "1.0.140"
serde_yaml = "0.9.1"
thiserror = "2"
toml = "0.7.4"
url = "2.5.4"
@@ -30,7 +30,6 @@ serde_derive = { workspace = true }
serde_json = { workspace = true }
tokio = { version = "1.38.0", features = ["macros"] }
toml = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
testing = { path = "../../testing" }
@@ -1,27 +1,13 @@
use anyhow::anyhow;
use serde_derive::{Deserialize, Serialize};
use errors::AnyhowResult;
use crate::credentials::SoraCredentials;
use thiserror::Error;
const SORA_IMAGE_GEN_URL: &str = "https://sora.com/backend/video_gen";
/// This user agent is tied to the sentinel generation. If we need to change it, we may need to change sentinel generation too.
const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36";
#[derive(Error, Debug)]
pub enum SoraError {
#[error("Sora token expired: {0}")]
TokenExpired(String),
#[error("Sora sentinel block: {0}")]
SentinelBlock(String),
#[error("Sora API error: {0}")]
GenericError(String),
#[error("Network error: {0}")]
NetworkError(String),
}
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
pub (crate) enum VideoGenType {
@@ -99,51 +85,22 @@ pub (crate) struct RawSoraResponse {
// type "invalid_request_error"
// param null
// code null
#[derive(Deserialize, Debug, Clone)]
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
pub struct RawSoraErrorResponse {
error: RawSoraErrorInner,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
pub enum RawSoraError {
Error(RawSoraErrorInner),
Unknown(String),
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
pub enum SoraErrorCode {
SentinelBlock,
TokenExpired,
Unknown(String),
}
#[derive(Deserialize, Debug, Clone, Error)]
#[serde(rename_all = "snake_case")]
pub struct RawSoraErrorInner {
pub (crate) struct RawSoraErrorResponse {
error: String,
message: String,
r#type: String,
param: Option<String>,
code: Option<SoraErrorCode>,
}
impl std::fmt::Display for RawSoraErrorInner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Sora API error: {}", self.message)
}
code: Option<String>,
}
/// Don't expose the internal request implementation as there are only a few "correct" ways to call the API.
pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest, credentials: &SoraCredentials) -> Result<RawSoraResponse, SoraError> {
pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest, credentials: &SoraCredentials) -> AnyhowResult<RawSoraResponse> {
let client = reqwest::Client::new();
let sentinel = match credentials.sentinel.as_deref() {
None => return Err(SoraError::GenericError("Calls to image gen require a sentinel in the credentials payload.".to_string())),
None => return Err(anyhow!("Calls to image gen require a sentinel in the credentials payload.")),
Some(sentinel) => sentinel,
};
@@ -154,42 +111,21 @@ pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest
.header("Content-Type", "application/json")
.header("OpenAI-Sentinel-Token", sentinel);
let http_request = http_request.json(&sora_request).build()
.map_err(|e| SoraError::NetworkError(e.to_string()))?;
let http_request = http_request.json(&sora_request).build()?;
let response = client.execute(http_request)
.await
.map_err(|e| SoraError::NetworkError(e.to_string()))?;
.await?;
let status = response.status();
let response_body = &response.text().await
.map_err(|e| SoraError::NetworkError(e.to_string()))?;
let response_body = &response.text().await?;
if status != reqwest::StatusCode::OK {
let error_response: RawSoraErrorResponse = serde_json::from_str(response_body)
.map_err(|e| SoraError::GenericError(format!("Failed to parse error response: {}", e)))?;
// Check for specific error codes
if let Some(code) = &error_response.error.code {
match code {
SoraErrorCode::TokenExpired => {
return Err(SoraError::TokenExpired(error_response.error.message));
}
SoraErrorCode::SentinelBlock => {
return Err(SoraError::SentinelBlock(error_response.error.message));
}
SoraErrorCode::Unknown(_) => {
return Err(SoraError::GenericError(error_response.error.message));
}
}
}
return Err(SoraError::GenericError(error_response.error.message));
//TODO: Kasisnu: We should parse the error response and return a custom error. status 400 + code "sentinel_block" means we should retry with new sentinel token.
return Err(anyhow::anyhow!("the request failed; status = {:?}, message = {:?}", status, response_body));
}
let response = serde_json::from_str(response_body)
.map_err(|e| SoraError::GenericError(format!("Failed to parse success response: {}", e)))?;
let response = serde_json::from_str(response_body)?;
Ok(response)
}
@@ -1,5 +1,4 @@
pub (crate) mod image_gen_http_request;
pub use image_gen_http_request::SoraError;
pub mod common;
pub mod sora_image_gen_remix;
pub mod sora_image_gen_simple;
@@ -1,7 +1,7 @@
use errors::AnyhowResult;
use crate::credentials::SoraCredentials;
use crate::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
use crate::image_gen::image_gen_http_request::{image_gen_http_request, InpaintItem, InpaintItemType, OperationType, RawSoraImageGenRequest, SoraError, VideoGenType};
use crate::image_gen::image_gen_http_request::{image_gen_http_request, InpaintItem, InpaintItemType, OperationType, RawSoraImageGenRequest, VideoGenType};
pub struct SoraImageGenRemixRequest<'a> {
pub prompt: String,
@@ -13,7 +13,7 @@ pub struct SoraImageGenRemixRequest<'a> {
/// The "remix" commands let you supply additional images as context.
/// Sora "media tokens" of previously uploaded images must be supplied.
pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Result<SoraImageGenResponse, SoraError> {
pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> AnyhowResult<SoraImageGenResponse> {
let args = RawSoraImageGenRequest {
r#type: VideoGenType::ImageGen,
operation: OperationType::Remix,
@@ -36,6 +36,7 @@ pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Resu
}).collect(),
};
// TODO: Error handling.
let result = image_gen_http_request(args, &request.credentials).await?;
Ok(SoraImageGenResponse {
@@ -1,5 +1,4 @@
pub mod credentials;
pub mod image_gen;
pub mod job;
pub mod upload;
pub mod sentinel_refresh;
pub mod upload;
@@ -1,2 +0,0 @@
pub use refresh::refresh_sentinel;
pub mod refresh;
@@ -1,25 +0,0 @@
// Send a POST request to env variable SORA_SENTINEL_REFRESH_URL
use reqwest::Client;
use std::env;
use errors::AnyhowResult;
const SORA_SENTINEL_REFRESH_URL: &str = "http://oai-token-server/refresh_token";
// Refresh the Sora sentinel
// The response body does _not_ contain the new sentinel
// We should read it again from redis or generate a new one here
// For now we refresh in the server handler
pub async fn refresh_sentinel() -> AnyhowResult<String> {
let client = Client::new();
let url = env::var("SORA_SENTINEL_REFRESH_URL").unwrap_or(SORA_SENTINEL_REFRESH_URL.to_string());
let response = client.post(url).send().await?;
let body = response.text().await?;
Ok(body)
}
@@ -15,8 +15,6 @@ use log::{debug, error, info};
use log::warn;
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::image_gen::SoraError;
use openai_sora_client::sentinel_refresh::refresh_sentinel;
use serde::Deserialize;
use serde::Serialize;
use sqlx::MySqlPool;
@@ -30,9 +28,16 @@ use enums::by_table::generic_inference_jobs::inference_model_type::InferenceMode
use enums::common::visibility::Visibility;
use http_server_common::request::get_request_header_optional::get_request_header_optional;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::payloads::generic_inference_args::generic_inference_args::{GenericInferenceArgs, InferenceCategoryAbbreviated, PolymorphicInferenceArgs};
use mysql_queries::payloads::generic_inference_args::generic_inference_args::{
GenericInferenceArgs,
InferenceCategoryAbbreviated,
PolymorphicInferenceArgs,
};
use mysql_queries::payloads::generic_inference_args::inner_payloads::sora_image_gen_args::SoraImageGenArgs;
use mysql_queries::queries::generic_inference::web::insert_generic_inference_job::{insert_generic_inference_job, InsertGenericInferenceArgs};
use mysql_queries::queries::generic_inference::web::insert_generic_inference_job::{
insert_generic_inference_job,
InsertGenericInferenceArgs,
};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use openai_sora_client::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
@@ -53,10 +58,10 @@ use crate::util::allowed_studio_access::allowed_studio_access;
/// This is the number of images (batch size) to generate for each request.
/// We should allow all users to have multiple images generated at once as this
/// is what other providers do.
const MINIMUM_IMAGE_COUNT: u32 = 1;
const MINIMUM_IMAGE_COUNT : u32 = 1;
/// The maximum number of images (batch size) to generate for each request.
const MAXIMUM_IMAGE_COUNT: u32 = 2;
const MAXIMUM_IMAGE_COUNT : u32 = 2;
/// Debug requests can get routed to special "debug-only" workers, which can
/// be used to trial new code, run debugging, etc.
@@ -142,7 +147,12 @@ impl Display for EnqueueImageGenRequestError {
),
params(("request" = EnqueueStudioImageGenRequest, description = "Payload for Image Generation Request"))
)]
pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest, request: Json<EnqueueStudioImageGenRequest>, server_state: Data<Arc<ServerState>>) -> Result<HttpResponse, EnqueueImageGenRequestError> {
pub async fn enqueue_studio_image_generation_handler(
http_request: HttpRequest,
request: Json<EnqueueStudioImageGenRequest>,
server_state: Data<Arc<ServerState>>
) -> Result<HttpResponse, EnqueueImageGenRequestError> {
validate_request(&request)?;
let mut mysql_connection = server_state.mysql_pool.acquire().await.map_err(|err| {
@@ -152,10 +162,12 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
// ==================== USER SESSION ==================== //
let maybe_user_session = server_state.session_checker.maybe_get_user_session_extended_from_connection(&http_request, &mut mysql_connection).await.map_err(|e| {
warn!("Session checker error: {:?}", e);
EnqueueImageGenRequestError::ServerError
})?;
let maybe_user_session = server_state.session_checker
.maybe_get_user_session_extended_from_connection(&http_request, &mut mysql_connection).await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
EnqueueImageGenRequestError::ServerError
})?;
let mut maybe_user_token: Option<UserToken> = None;
@@ -166,7 +178,10 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
// ==================== PLANS ==================== //
// Plan should handle "first anonymous use" and "investor" cases.
let plan = get_correct_plan_for_session(server_state.server_environment_old, maybe_user_session.as_ref());
let plan = get_correct_plan_for_session(
server_state.server_environment_old,
maybe_user_session.as_ref()
);
// Separate priority for animation.
let priority_level = plan.web_vc_base_priority_level();
@@ -175,7 +190,9 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
let is_debug_request = get_request_header_optional(&http_request, DEBUG_HEADER_NAME).is_some();
let maybe_routing_tag = get_request_header_optional(&http_request, ROUTING_TAG_HEADER_NAME).map(|routing_tag| routing_tag.trim().to_string());
let maybe_routing_tag = get_request_header_optional(&http_request, ROUTING_TAG_HEADER_NAME).map(
|routing_tag| routing_tag.trim().to_string()
);
// ==================== BANNED USERS ==================== //
@@ -207,10 +224,12 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
return Err(EnqueueImageGenRequestError::BadInput(reason));
}
insert_idempotency_token(&request.uuid_idempotency_token, &mut *mysql_connection).await.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
EnqueueImageGenRequestError::BadInput("invalid idempotency token".to_string())
})?;
insert_idempotency_token(&request.uuid_idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
EnqueueImageGenRequestError::BadInput("invalid idempotency token".to_string())
})?;
// ==================== DOWNLOAD FILES AND UPLOAD TO SORA ==================== //
@@ -222,19 +241,30 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
let scene_media_token = request.snapshot_media_token.clone();
let scene_media_path = query_and_download_media_file(&scene_media_token, &work_temp_dir, &public_bucket_client, &server_state.mysql_pool).await.map_err(|err| {
let scene_media_path = query_and_download_media_file(
&scene_media_token,
&work_temp_dir,
&public_bucket_client,
&server_state.mysql_pool
).await.map_err(|err| {
error!("Failed to download scene media file: {:?}", err);
EnqueueImageGenRequestError::ServerError
})?;
let mut files_to_upload = Vec::with_capacity(1 + request.maybe_additional_images.as_ref().map(|v| v.len()).unwrap_or(0));
let mut files_to_upload = Vec::with_capacity(1 + request.maybe_additional_images
.as_ref().map(|v| v.len()).unwrap_or(0));
files_to_upload.push(scene_media_path.clone());
let additional_images = request.maybe_additional_images.clone().unwrap_or_else(|| vec![]);
for media_file_token in &additional_images {
let media_file_path = query_and_download_media_file(&media_file_token, &work_temp_dir, &public_bucket_client, &server_state.mysql_pool).await.map_err(|err| {
let media_file_path = query_and_download_media_file(
&media_file_token,
&work_temp_dir,
&public_bucket_client,
&server_state.mysql_pool
).await.map_err(|err| {
error!("Failed to download additional media file: {:?}", err);
EnqueueImageGenRequestError::ServerError
})?;
@@ -244,25 +274,30 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
// ==================== HANDLE SORA CREDENTIALS ==================== //
let mut redis = server_state.redis_pool.get().map_err(|e| {
error!("redis error: {:?}", e);
EnqueueImageGenRequestError::ServerError
})?;
let mut redis = server_state.redis_pool
.get()
.map_err(|e| {
error!("redis error: {:?}", e);
EnqueueImageGenRequestError::ServerError
})?;
let sora_credentials = get_sora_credentials_from_request(&http_request, &mut redis).map_err(|e| {
error!("sora credential error: {:?}", e);
EnqueueImageGenRequestError::ServerError
})?;
let sora_credentials = get_sora_credentials_from_request(&http_request, &mut redis)
.map_err(|e| {
error!("sora credential error: {:?}", e);
EnqueueImageGenRequestError::ServerError
})?;
// ==================== HANDLE SORA UPLOAD ==================== //
let mut sora_media_tokens = Vec::with_capacity(files_to_upload.len());
for file_path in files_to_upload {
let sora_upload_response = sora_media_upload_from_file(file_path, &sora_credentials).await.map_err(|err| {
error!("Failed to upload scene media to Sora: {:?}", err);
EnqueueImageGenRequestError::ServerError
})?;
let sora_upload_response = sora_media_upload_from_file(file_path, &sora_credentials)
.await
.map_err(|err| {
error!("Failed to upload scene media to Sora: {:?}", err);
EnqueueImageGenRequestError::ServerError
})?;
debug!("Uploaded media to Sora : {:?}", sora_upload_response);
sora_media_tokens.push(sora_upload_response.id);
@@ -271,65 +306,41 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
// ==================== HANDLE SORA PROMPT ==================== //
let number_of_samples = match request.maybe_number_of_samples {
Some(val) => {
if val > MAXIMUM_IMAGE_COUNT {
MAXIMUM_IMAGE_COUNT
} else if val < MINIMUM_IMAGE_COUNT {
MINIMUM_IMAGE_COUNT
} else {
val
}
Some(val) => if val > MAXIMUM_IMAGE_COUNT {
MAXIMUM_IMAGE_COUNT
} else if val < MINIMUM_IMAGE_COUNT {
MINIMUM_IMAGE_COUNT
} else {
val
},
None => 1,
};
let prompt = request.prompt.clone();
let mut response = sora_image_gen_remix(SoraImageGenRemixRequest { prompt: prompt.clone(), num_images: NumImages::One, image_size: ImageSize::Square, sora_media_tokens: sora_media_tokens.clone(), credentials: &sora_credentials }).await;
debug!("Sora image gen response: {:?}", response);
if let Err(SoraError::SentinelBlock(msg)) = &response {
error!("Sora sentinel block, attempting refresh: {}", msg);
// Try to refresh sentinel
match refresh_sentinel().await {
Ok(new_sentinel) => {
// Update the credentials with new sentinel
let updated_sora_credentials = get_sora_credentials_from_request(&http_request, &mut redis).map_err(|e| {
error!("sora credential error: {:?}", e);
EnqueueImageGenRequestError::ServerError
})?;
// Retry the request with new sentinel
response = sora_image_gen_remix(SoraImageGenRemixRequest { prompt: prompt.clone(), num_images: NumImages::One, image_size: ImageSize::Square, sora_media_tokens: sora_media_tokens.clone(), credentials: &updated_sora_credentials }).await;
},
Err(e) => {
error!("Failed to refresh Sora sentinel: {:?}", e);
},
}
}
let response = response.map_err(|err| {
match err {
SoraError::TokenExpired(msg) => {
error!("Sora token expired, needs refresh: {}", msg);
// TODO: Implement token refresh logic here
},
SoraError::SentinelBlock(msg) => {
error!("Sora sentinel block still occurring after refresh: {}", msg);
},
_ => {
error!("Failed to call Sora image generation: {:?}", err);
},
}
EnqueueImageGenRequestError::ServerError
})?;
let response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: prompt.clone(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_credentials,
}).await
.map_err(|err| {
error!("Failed to call Sora image generation: {:?}", err);
EnqueueImageGenRequestError::ServerError
})?;
debug!("Sora image gen response: {:?}", response);
// Store the actual inference args that will go to the database
let inference_args = SoraImageGenArgs { prompt: Some(prompt), scene_snapshot_media_token: Some(request.snapshot_media_token.clone()), maybe_additional_media_file_tokens: Some(additional_images), maybe_number_of_samples: Some(number_of_samples), maybe_sora_media_upload_tokens: Some(sora_media_tokens), maybe_sora_task_id: Some(response.task_id) };
let inference_args = SoraImageGenArgs {
prompt: Some(prompt),
scene_snapshot_media_token: Some(request.snapshot_media_token.clone()),
maybe_additional_media_file_tokens: Some(additional_images),
maybe_number_of_samples: Some(number_of_samples),
maybe_sora_media_upload_tokens: Some(sora_media_tokens),
maybe_sora_task_id: Some(response.task_id),
};
// create the inference args here
let maybe_avt_token = server_state.avt_cookie_manager.get_avt_token_from_request(&http_request);
@@ -341,14 +352,17 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
maybe_product_category: None, // This is not a product anymore
inference_category: InferenceCategory::ImageGeneration,
maybe_model_type: Some(InferenceModelType::ImageGenApi), // NB: Model is static during inference
maybe_model_token: None, // NB: Model is static during inference
maybe_model_token: None, // NB: Model is static during inference
maybe_input_source_token: None,
maybe_input_source_token_type: None,
maybe_download_url: None,
maybe_cover_image_media_file_token: None,
maybe_raw_inference_text: None,
maybe_max_duration_seconds: None,
maybe_inference_args: Some(GenericInferenceArgs { inference_category: Some(InferenceCategoryAbbreviated::ImageGeneration), args: Some(PolymorphicInferenceArgs::Sg(inference_args)) }),
maybe_inference_args: Some(GenericInferenceArgs {
inference_category: Some(InferenceCategoryAbbreviated::ImageGeneration),
args: Some(PolymorphicInferenceArgs::Sg(inference_args)),
}),
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
@@ -358,8 +372,7 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
is_debug_request,
maybe_routing_tag: maybe_routing_tag.as_deref(),
mysql_pool: &server_state.mysql_pool,
})
.await;
}).await;
let job_token = match query_result {
Ok((job_token, _id)) => job_token,
@@ -369,18 +382,24 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
return Err(EnqueueImageGenRequestError::BadInput("Duplicate idempotency token".to_string()));
}
return Err(EnqueueImageGenRequestError::ServerError);
},
}
};
let response: EnqueueImageGenRequestSuccessResponse = EnqueueImageGenRequestSuccessResponse { success: true, inference_job_token: job_token };
let response: EnqueueImageGenRequestSuccessResponse = EnqueueImageGenRequestSuccessResponse {
success: true,
inference_job_token: job_token,
};
let body = serde_json::to_string(&response).map_err(|_e| EnqueueImageGenRequestError::ServerError)?;
let body = serde_json::to_string(&response)
.map_err(|_e| EnqueueImageGenRequestError::ServerError)?;
// Error handling 101 rust result type returned like so.
Ok(HttpResponse::Ok().content_type("application/json").body(body))
}
fn validate_request(request: &Json<EnqueueStudioImageGenRequest>) -> Result<(), EnqueueImageGenRequestError> {
fn validate_request(
request: &Json<EnqueueStudioImageGenRequest>,
) -> Result<(), EnqueueImageGenRequestError> {
Ok(())
}
@@ -397,22 +416,34 @@ impl From<anyhow::Error> for DownloadMediaFileError {
}
}
async fn query_and_download_media_file(media_file_token: &MediaFileToken, download_dir: &TempDir, bucket_client: &BucketClient, mysql_pool: &MySqlPool) -> Result<PathBuf, DownloadMediaFileError> {
let media_file_result = get_media_file(&media_file_token, false, mysql_pool).await;
async fn query_and_download_media_file(
media_file_token: &MediaFileToken,
download_dir: &TempDir,
bucket_client: &BucketClient,
mysql_pool: &MySqlPool,
) -> Result<PathBuf, DownloadMediaFileError> {
let media_file_result = get_media_file(
&media_file_token,
false,
mysql_pool
).await;
let media_file = match media_file_result {
let media_file= match media_file_result {
Ok(Some(result)) => result,
Ok(None) => {
warn!("could not find media file in database: {:?}", media_file_token);
return Err(DownloadMediaFileError::MediaFileNotFound);
},
return Err(DownloadMediaFileError::MediaFileNotFound)
}
Err(e) => {
error!("could not query media file: {:?}", e);
return Err(DownloadMediaFileError::Other(e));
},
return Err(DownloadMediaFileError::Other(e))
}
};
let media_file_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_file_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 extension = media_file.maybe_public_bucket_extension.clone().unwrap_or_else(|| ".jpg".to_string());
let download_filename = format!("download_file_{}.{}", media_file.token.as_str(), extension);
@@ -420,7 +451,10 @@ async fn query_and_download_media_file(media_file_token: &MediaFileToken, downlo
info!("Downloading from bucket...");
bucket_client.download_file_to_disk(&media_file_bucket_path.to_full_object_pathbuf(), &download_file_path).await?;
bucket_client.download_file_to_disk(
&media_file_bucket_path.to_full_object_pathbuf(),
&download_file_path,
).await?;
Ok(download_file_path)
}
+86 -15
View File
@@ -1,30 +1,101 @@
# Frontend
This is an `nx` monorepo that can contain multiple apps and shared libraries.
<a alt="Nx logo" href="https://nx.dev" target="_blank" rel="noreferrer"><img src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-logo.png" width="45"></a>
All commands to run these projects are performed from _this_ directory.
✨ Your new, shiny [Nx workspace](https://nx.dev) is ready ✨.
[Learn more about this workspace setup and its capabilities](https://nx.dev/getting-started/tutorials/react-monorepo-tutorial?utm_source=nx_project&amp;utm_medium=readme&amp;utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed!
## Install dependencies
## Run tasks
```
npm install
To run the dev server for your app, use:
```sh
npx nx serve editor3d
```
## Editor2d
To create a production bundle:
Run dev server:
```bash
nx dev editor2d
```sh
npx nx build editor3d
```
## Editor3d
To see all available targets to run for a project, run:
Run dev server:
```bash
nx dev editor3d
```sh
npx nx show project editor3d
```
These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files.
[More about running tasks in the docs &raquo;](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Add new projects
While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature.
Use the plugin's generator to create new projects.
To generate a new application, use:
```sh
npx nx g @nx/react:app demo
```
To generate a new library, use:
```sh
npx nx g @nx/react:lib mylib
```
You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list <plugin-name>` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE.
[Learn more about Nx plugins &raquo;](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry &raquo;](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Set up CI!
### Step 1
To connect to Nx Cloud, run the following command:
```sh
npx nx connect
```
Connecting to Nx Cloud ensures a [fast and scalable CI](https://nx.dev/ci/intro/why-nx-cloud?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) pipeline. It includes features such as:
- [Remote caching](https://nx.dev/ci/features/remote-cache?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Task distribution across multiple machines](https://nx.dev/ci/features/distribute-task-execution?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Automated e2e test splitting](https://nx.dev/ci/features/split-e2e-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Task flakiness detection and rerunning](https://nx.dev/ci/features/flaky-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
### Step 2
Use the following command to configure a CI workflow for your workspace:
```sh
npx nx g ci-workflow
```
[Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Install Nx Console
Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ.
[Install Nx Console &raquo;](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Useful links
Learn more:
- [Learn more about this workspace setup](https://nx.dev/getting-started/tutorials/react-monorepo-tutorial?utm_source=nx_project&amp;utm_medium=readme&amp;utm_campaign=nx_projects)
- [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
And join the Nx community:
- [Discord](https://go.nx.dev/community)
- [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl)
- [Our Youtube channel](https://www.youtube.com/@nxdevtools)
- [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
+3 -2
View File
@@ -1,6 +1,6 @@
[build]
command = "./apps/editor2d/script/netlify_build.sh"
publish = "apps/editor2d/dist/"
command = "./script/netlify_build.sh"
publish = "dist/"
#[build.environment]
#REACT_APP_PUBLIC_POSTHOG_KEY = "phc_jBFgac0mVALAFk3negnSfYCcHgvkT00yBLQDmCDYNBb"
@@ -16,6 +16,7 @@ publish = "apps/editor2d/dist/"
for = "/build/*"
[headers.values]
"Cache-Control" = "public, max-age=31560000, immutable"
# [[redirects]]
@@ -3,10 +3,10 @@
set -euxo pipefail
echo "Run build script (TODO: Make strict)"
nx build editor2d
yarn run build2
echo "Change to project dir"
pushd apps/editor2d/
echo "Copy test files into dist"
cp -r test/* dist/
echo "Copy netlify configs into dist"
cp _headers dist/
@@ -15,6 +15,5 @@ cp _redirects dist/
echo "Copy netlify 404.html page into dist"
cp "404.html" dist/
echo "List final files in build"
echo "List files after build"
find dist/
+19 -36
View File
@@ -148,6 +148,7 @@ export class ApiManager {
formData.append("file", blob);
}
// console.log(`HELLO${formData}`);
return this.fetchMultipartFormData<T>(endpoint, {
method: "POST",
headers: {
@@ -326,10 +327,7 @@ export class Api extends ApiManager {
return result;
}
public async pollJobSession(
jobToken: string,
thumbnailWidth: number = 256,
): Promise<
public async pollJobSession(jobToken: string): Promise<
ApiResponse<{
job_token: string;
request: {
@@ -340,9 +338,6 @@ export class Api extends ApiManager {
progress_percentage: number;
};
result: {
image_url: string;
thumbnail_url: string;
public_bucket_path: string;
generated_images?: string[];
error?: string;
};
@@ -350,53 +345,41 @@ export class Api extends ApiManager {
> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/jobs/job/${jobToken}`;
const response = await this.get<any>({
const response = await this.get<{
success?: boolean;
status?: string;
result?: {
generated_images?: string[];
error?: string;
};
BadInput?: string;
}>({
endpoint,
});
console.log("Job Polling Response:");
console.log(response);
let image_url = response.state.maybe_result?.media_links?.cdn_url;
let public_bucket_path =
response.state.maybe_result?.maybe_public_bucket_media_path;
let thumbnail_url =
response.state.maybe_result?.media_links?.maybe_thumbnail_template?.replace(
"{WIDTH}",
thumbnailWidth.toString(),
);
let progress_percentage = response.state.status.progress_percentage;
let status_string = response.state.status.status;
console.log("Image URL:", image_url);
console.log("Thumbnail URL:", thumbnail_url);
console.log("Progress Percentage:", progress_percentage);
console.log("Status", status_string);
console.log("response FROM JOBS");
console.log(response);
const success = response.success ?? false;
const status = response.status ?? "";
const result = response.result ?? {
generated_images: [],
error: undefined,
};
const errorMessage = response.BadInput;
return {
success,
data: {
result: {
image_url: image_url || "",
thumbnail_url: thumbnail_url || "",
public_bucket_path: public_bucket_path || "",
error: undefined,
},
result,
job_token: jobToken,
request: {
maybe_model_title: "Image Generation",
},
status: {
status: status_string.toLowerCase(),
progress_percentage: progress_percentage,
status: status.toLowerCase(),
progress_percentage: 0,
},
},
errorMessage,
@@ -1,10 +1,8 @@
import React, { createContext, useContext, useState } from "react";
interface JobContextType {
jobTokens: string[];
addJobToken: (token: string) => void;
removeJobToken: (token: string) => void;
clearJobTokens: () => void;
jobToken: string | null;
setJobToken: (token: string | null) => void;
}
const JobContext = createContext<JobContextType | undefined>(undefined);
@@ -12,24 +10,10 @@ const JobContext = createContext<JobContextType | undefined>(undefined);
export const JobProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [jobTokens, setJobTokens] = useState<string[]>([]);
const addJobToken = (token: string) => {
setJobTokens((prevTokens) => [...prevTokens, token]);
};
const removeJobToken = (token: string) => {
setJobTokens((prevTokens) => prevTokens.filter((t) => t !== token));
};
const clearJobTokens = () => {
setJobTokens([]);
};
const [jobToken, setJobToken] = useState<string | null>(null);
return (
<JobContext.Provider
value={{ jobTokens, addJobToken, removeJobToken, clearJobTokens }}
>
<JobContext.Provider value={{ jobToken, setJobToken }}>
{children}
</JobContext.Provider>
);
@@ -47,8 +47,7 @@ export const PromptBox = () => {
const isDesktopApp = window.navigator.userAgent.includes("Tauri");
console.log("Is this a desktop app?", isDesktopApp);
const { jobTokens, addJobToken, removeJobToken, clearJobTokens } =
useJobContext();
const { jobToken, setJobToken } = useJobContext();
//const { lastRenderedBitmap } = useCanvasSignal();
const [prompt, setPrompt] = useState("");
@@ -120,7 +119,7 @@ export const PromptBox = () => {
setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId),
);
toast.success("Reference image added.");
console.log("Reference image added:", referenceImage);
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
@@ -128,8 +127,7 @@ export const PromptBox = () => {
setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId),
);
toast.error("Upload failed. Please try again.");
console.error("Upload failed");
}
},
});
@@ -208,7 +206,7 @@ export const PromptBox = () => {
const api = new Api();
let image = getCanvasRenderBitmap();
if (image === undefined) {
toast.error(
console.error(
"Error: Unable to generate image. Please check the input and try again.",
);
return;
@@ -231,9 +229,7 @@ export const PromptBox = () => {
});
if (snapshotMediaToken.data === undefined) {
toast.error(
"Error: Unable to upload scene snapshot Please try again.",
);
console.error("Error: Unable to upload scene snapshot.");
return;
}
@@ -246,13 +242,17 @@ export const PromptBox = () => {
});
if (response.success === true) {
toast.success("Please wait while we process your image.");
console.error("Successfully enqueued image generation.");
if (response.data) {
addJobToken(response.data);
console.log("Response:", response.data);
setJobToken(response.data);
}
return;
} else {
toast.error("Failed to enqueue image generation. Please try again.");
console.log("Image generation Not Successful.");
toast.error("Failed to generate image. Please try again.");
}
}
} catch (error) {
@@ -63,11 +63,12 @@ export const ToolbarMain = ({
buttonProps={buttonProps.SELECT}
tooltip="Select"
/>
<ToolbarButton
{/* <ToolbarButton
icon={faText}
buttonProps={buttonProps.ADD_TEXT}
tooltip="Add Text"
/>
/> */}
<Popover className="relative">
{({ close }) => (
<>
@@ -1,5 +1,4 @@
import { twMerge } from "tailwind-merge";
import { Login } from "~/app/pages/login";
import { Activity } from "~/components/ui/Activity/Activity";
// import { paperWrapperStyles } from "~/components/styles";
// import { faPlus, faQuestion } from "@fortawesome/pro-solid-svg-icons";
@@ -6,8 +6,6 @@ import { CompletedCard } from "./CompletedCard";
import { InProgressCard } from "./InProgressCard";
import { useJobContext } from "~/components/JobContext";
import { Api } from "~/KonvaApp/Api";
import { toast } from "sonner";
// Dummy data types
interface CompletedItem {
token: string;
@@ -67,72 +65,90 @@ const dummyCompletedItems: CompletedItem[] = [
// ];
export function Activity() {
const { jobTokens, addJobToken, removeJobToken, clearJobTokens } =
useJobContext();
const { jobToken, setJobToken } = useJobContext();
const [completedItems, setCompletedItems] = useState<CompletedItem[]>([]);
const [jobs, setJobs] = useState<ActiveJob[]>([]);
const [loading, setLoading] = useState(true);
// Dummy polling function
useEffect(() => {
const pollData = async () => {
if (jobTokens.length > 0) {
console.log("Current Job Tokens:", jobTokens);
if (jobToken) {
console.log("Current Job Token:", jobToken);
const api = new Api();
const job = await api.pollJobSession(jobToken);
console.log("Job response:", job);
// Create a map to store the latest job data
const updatedJobs = new Map<string, ActiveJob>();
if (job.success && job.data) {
const { status, progress_percentage } = job.data.status;
const { job_token } = job.data;
for (const jobToken of jobTokens) {
const job = await api.pollJobSession(jobToken);
console.log("Job status:", status);
console.log("Progress percentage:", progress_percentage);
console.log("Job token:", job_token);
if (job.success && job.data) {
const { status, progress_percentage } = job.data.status;
const { job_token } = job.data;
// Update jobs with real job data
if (
status.toLowerCase() === "completed" ||
status.toLowerCase() === "failed"
) {
// If job is completed or failed, remove it from active jobs
setJobs((prevJobs) =>
prevJobs.filter((j) => j.job_token !== job_token),
);
// Add to completed items if it was successful
if (
status.toLowerCase() === "complete_success" ||
status.toLowerCase() === "failed"
status.toLowerCase() === "completed" &&
job.data.result.generated_images
) {
// If job is complete_success or failed, we'll remove it later
removeJobToken(job_token);
// Add to complete_success items if it was successful
if (status.toLowerCase() === "complete_success") {
const newCompletedItem: CompletedItem = {
token: job_token,
maybe_title:
job.data.request.maybe_model_title || "Image Generation",
public_bucket_path: job.data.result.image_url || "",
updated_at: new Date().toISOString(),
};
setCompletedItems((prev) => [newCompletedItem, ...prev]);
toast.success("Done Processing Image");
}
} else {
// Store the job in our map
const updatedJob: ActiveJob = {
job_token,
request: {
maybe_model_title:
job.data.request.maybe_model_title || "Image Generation",
},
status: {
status: status.toUpperCase(),
progress_percentage: progress_percentage || 0,
},
const newCompletedItem: CompletedItem = {
id: job_token,
title: job.data.request.maybe_model_title || "Image Generation",
timestamp: new Date().toISOString(),
type: "image",
imageUrl: job.data.result.generated_images[0] || "",
};
updatedJobs.set(job_token, updatedJob);
setCompletedItems((prev) => [newCompletedItem, ...prev]);
}
} else {
console.error("Failed to fetch job status:", job.errorMessage);
}
}
// Update or add the job to the active jobs list
const updatedJob: ActiveJob = {
job_token,
request: {
maybe_model_title:
job.data.request.maybe_model_title || "Image Generation",
},
status: {
status: status.toUpperCase(),
progress_percentage: progress_percentage || 0,
},
};
// Update jobs state with the deduplicated jobs
setJobs(Array.from(updatedJobs.values()));
setJobs((prevJobs) => {
const existingJobIndex = prevJobs.findIndex(
(j) => j.job_token === job_token,
);
if (existingJobIndex >= 0) {
// Update existing job
const newJobs = [...prevJobs];
newJobs[existingJobIndex] = updatedJob;
return newJobs;
} else {
// Add new job
return [updatedJob, ...prevJobs];
}
});
}
} else {
console.error("Failed to fetch job status:", job.errorMessage);
}
} else {
console.log("No Job Token available.");
}
if (jobTokens.length === 0) {
setJobs([]);
if (jobs.length === 0) {
setLoading(false);
}
};
@@ -147,7 +163,7 @@ export function Activity() {
}, 5000);
return () => clearInterval(interval);
}, [jobTokens, removeJobToken]);
}, [jobToken]);
return (
<PopoverMenu
@@ -17,10 +17,8 @@ interface Props {
export function CompletedCard({ job }: Props) {
const [loadError, setLoadError] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const imageUrl = job.public_bucket_path;
const imageUrl = job.public_bucket_path + "-thumb.gif";
const fullImageUrl = job.public_bucket_path;
console.log("I am hjere");
console.log(job);
return (
<>
@@ -17,7 +17,6 @@ interface PopoverMenuProps {
onAdd?: () => void;
triggerIcon?: ReactNode;
showAddButton?: boolean;
disableAddButton?: boolean;
showIconsInList?: boolean;
mode?: "default" | "toggle" | "button";
triggerLabel?: string;
@@ -37,7 +36,6 @@ export const PopoverMenu = ({
onAdd,
triggerIcon,
showAddButton = false,
disableAddButton = false,
showIconsInList = false,
mode = "default",
triggerLabel,
@@ -86,15 +84,9 @@ export const PopoverMenu = ({
{({ close }) => (
<>
<Popover.Button className={className}>
<>
{triggerIcon}
{mode === "toggle" && selectedItem ? (
<span className="truncate">{selectedItem.label}</span>
) : null}
{mode === "default" && triggerLabel ? (
<span className="truncate">{triggerLabel}</span>
) : null}
</>
{triggerIcon}
{mode === "toggle" && selectedItem ? selectedItem.label : null}
{mode === "default" && triggerLabel ? triggerLabel : null}
</Popover.Button>
<div
@@ -172,9 +164,9 @@ export const PopoverMenu = ({
variant="secondary"
disabled={item.disabled}
>
<div className="flex items-center gap-2 truncate">
<div className="flex items-center gap-2">
{showIconsInList && item.icon}
<span className="truncate">{item.label}</span>
{item.label}
</div>
{mode === "toggle" && (
<input
@@ -188,14 +180,8 @@ export const PopoverMenu = ({
{showAddButton && onAdd && (
<Button
variant="secondary"
className={twMerge(
"mb-0.5 mt-2 border-none py-1",
disableAddButton
? "cursor-not-allowed bg-[#7B7B84]/50 opacity-50"
: "bg-[#7B7B84] hover:bg-[#8c8c96]",
)}
className="mb-0.5 mt-2 border-none bg-[#7B7B84] py-1 hover:bg-[#8c8c96]"
onClick={onAdd}
disabled={disableAddButton}
>
+ Add
</Button>
@@ -27,18 +27,18 @@ export const TopBar = ({ pageName }: Props) => {
return (
<>
<header className="fixed left-0 top-0 z-30 w-full border-b border-white/5 bg-ui-background">
<header className="fixed left-0 top-0 z-30 w-full border-b border-ui-panel-border bg-ui-background">
<nav
className="mx-auto grid h-[56px] w-screen grid-cols-3 items-center justify-between px-3"
className="mx-auto grid h-[64px] w-screen grid-cols-3 items-center justify-between p-3"
aria-label="Global"
>
<div className="flex gap-4">
<a href="/" className="">
<span className="sr-only">ArtCraft</span>
<span className="sr-only">Storyteller.ai</span>
<img
className="h-[30px] w-auto"
src="/resources/images/artcraft-logo-3.png"
alt="Logo ArtCraft"
className="h-[39px] w-auto pb-[3px]"
src="/resources/images/Storyteller-Logo-1.png"
alt="Logo StoryTeller.ai"
/>
</a>
{!isEditorPath(currentLocation) && (
@@ -24,11 +24,7 @@ import Queue from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import { fromEngineActions } from "~/pages/PageEnigma/Queue/fromEngineActions";
import { MediaItem } from "~/pages/PageEnigma/models";
import {
editorState,
cameraAspectRatio,
gridVisibility,
} from "../signals/engine";
import { editorState } from "../signals/engine";
import { SceneUtils } from "./helper";
import { VideoGeneration } from "./video_generation";
import { MouseControls } from "./keybinds_controls";
@@ -197,6 +193,8 @@ class Editor {
processingHasFailed: boolean;
stats: Stats;
cameras: Map<string, THREE.PerspectiveCamera> = new Map();
constructor() {
this.processingHasFailed = false;
console.log(
@@ -327,6 +325,34 @@ class Editor {
if (this.engineFrameBuffers.frameWorkerManager) {
this.engineFrameBuffers.frameWorkerManager.type = imageFormat;
}
// Initialize cameras from signals
cameras.value.forEach((camData) => {
const camera = new THREE.PerspectiveCamera(
camData.fov,
this.getRenderDimensions().aspectRatio,
0.01,
200,
);
camera.position.set(
camData.position.x,
camData.position.y,
camData.position.z,
);
camera.rotation.set(
camData.rotation.x,
camData.rotation.y,
camData.rotation.z,
);
this.cameras.set(camData.id, camera);
});
// Set initial camera
this.camera = this.cameras.get("main") || null;
this.render_camera = this.camera;
// Subscribe to camera changes
this.setupCameraSignalHandlers();
}
// Add helper method to convert focal length to FOV
@@ -335,6 +361,99 @@ class Editor {
return 2 * Math.atan(sensorHeight / (2 * focalLength)) * (180 / Math.PI);
}
setupCameraSignalHandlers() {
// Handle camera selection changes
selectedCameraId.subscribe((id) => {
const newCamera = this.cameras.get(id);
if (newCamera) {
this.camera = newCamera;
this.render_camera = newCamera;
// Update camera properties in signals
const camData = cameras.value.find((c) => c.id === id);
if (camData) {
// Convert focal length to FOV
const fov = this.focalLengthToFov(camData.focalLength);
newCamera.fov = fov;
newCamera.updateProjectionMatrix();
this.renderScene(); // Force a render update
}
}
});
// Handle camera updates
cameras.subscribe((newCameras) => {
newCameras.forEach((camData) => {
let camera = this.cameras.get(camData.id);
// Create new camera if it doesn't exist
if (!camera) {
const fov = this.focalLengthToFov(camData.focalLength);
camera = new THREE.PerspectiveCamera(
fov,
this.getRenderDimensions().aspectRatio,
0.01,
200,
);
this.cameras.set(camData.id, camera);
}
// Update camera properties
camera.position.set(
camData.position.x,
camData.position.y,
camData.position.z,
);
camera.rotation.set(
camData.rotation.x,
camData.rotation.y,
camData.rotation.z,
);
// Update FOV based on focal length
const fov = this.focalLengthToFov(camData.focalLength);
camera.fov = fov;
camera.updateProjectionMatrix();
// Force a render update if this is the active camera
if (camData.id === selectedCameraId.value) {
this.renderScene();
}
});
// Clean up deleted cameras
const currentIds = new Set(newCameras.map((c) => c.id));
for (const [id] of this.cameras.entries()) {
if (!currentIds.has(id) && id !== "main") {
this.cameras.delete(id);
}
}
});
}
// Update camera position in signals when it changes
updateCameraPosition() {
if (this.camera && selectedCameraId.value) {
const pos = this.camera.position;
const rot = this.camera.rotation;
const camData = cameras.value.find(
(c) => c.id === selectedCameraId.value,
);
if (camData) {
cameras.value = cameras.value.map((c) =>
c.id === selectedCameraId.value
? {
...c,
position: { x: pos.x, y: pos.y, z: pos.z },
rotation: { x: rot.x, y: rot.y, z: rot.z },
}
: c,
);
}
}
}
getRenderDimensions() {
switch (this.render_camera_aspect_ratio) {
case CameraAspectRatio.HORIZONTAL_16_9: {
@@ -467,52 +586,24 @@ class Editor {
const width = this.container.offsetWidth;
const height = this.container.offsetHeight;
// Sets up camera and base position using camera configurations from camera.ts
const mainCameraConfig = cameras.value.find((cam) => cam.id === "main");
if (mainCameraConfig) {
this.camera = new THREE.PerspectiveCamera(
this.focalLengthToFov(mainCameraConfig.focalLength),
width / height,
0.01,
200,
);
this.camera.position.set(
mainCameraConfig.position.x,
mainCameraConfig.position.y,
mainCameraConfig.position.z,
);
this.camera.lookAt(
mainCameraConfig.lookAt.x,
mainCameraConfig.lookAt.y,
mainCameraConfig.lookAt.z,
);
}
// Sets up camera and base position.
this.camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 200);
this.camera.position.z = 2.5;
this.camera.position.y = 2.5;
this.camera.position.x = -2.5;
this.camera.lookAt(0, 0, 0);
this.camera.layers.enable(0);
this.camera.layers.enable(1);
this.camera.layers.enable(1); // This camera does not see this layer
this.timeline.camera = this.camera;
const otherCameras = cameras.value.filter((cam) => cam.id !== "main");
if (otherCameras.length > 0) {
const renderCameraConfig = otherCameras[0];
this.render_camera = new THREE.PerspectiveCamera(
this.focalLengthToFov(renderCameraConfig.focalLength),
width / height,
0.01,
200,
);
this.render_camera.position.set(
renderCameraConfig.position.x,
renderCameraConfig.position.y,
renderCameraConfig.position.z,
);
this.render_camera.lookAt(
renderCameraConfig.lookAt.x,
renderCameraConfig.lookAt.y,
renderCameraConfig.lookAt.z,
);
}
this.render_camera = new THREE.PerspectiveCamera(
70,
width / height,
0.01,
200,
);
this.render_camera.layers.disable(1); // This camera does not see this layer );
@@ -693,134 +784,15 @@ class Editor {
document.body.appendChild(this.stats.dom);
}
// Captures the scene without the grid
public snapShotOfCurrentFrame(shouldDownload: boolean = true) {
if (!this.renderer?.domElement || !this.camera) {
console.error("Error: Renderer or camera not available.");
const snapshot = this.renderer?.domElement.toDataURL("image/png");
if (!snapshot) {
console.error("Error: Snapshot could not be created.");
return null;
}
const currentAspectRatio = cameraAspectRatio.value;
// Store grid visibility state and hide grid
const wasGridVisible = gridVisibility.value;
gridVisibility.value = false;
// Store and hide transform controls
const wasControlVisible = this.control?.visible ?? false;
if (this.control) {
this.control.visible = false;
}
// Store and disable outline pass
const wasOutlineEnabled = this.outlinePass?.enabled ?? false;
if (this.outlinePass) {
this.outlinePass.enabled = false;
}
// High quality dimensions for each aspect ratio
let targetWidth: number;
let targetHeight: number;
let aspectRatio: number;
switch (currentAspectRatio) {
case CameraAspectRatio.HORIZONTAL_16_9:
targetWidth = 1280;
targetHeight = 720;
aspectRatio = 16 / 9;
break;
case CameraAspectRatio.VERTICAL_9_16:
targetWidth = 720;
targetHeight = 1280;
aspectRatio = 9 / 16;
break;
case CameraAspectRatio.HORIZONTAL_3_2:
targetWidth = 1536;
targetHeight = 1024;
aspectRatio = 3 / 2;
break;
case CameraAspectRatio.VERTICAL_2_3:
targetWidth = 1024;
targetHeight = 1536;
aspectRatio = 2 / 3;
break;
case CameraAspectRatio.SQUARE_1_1:
default:
targetWidth = 1024;
targetHeight = 1024;
aspectRatio = 1;
break;
}
// Store original renderer and camera state
const originalWidth = this.renderer.domElement.width;
const originalHeight = this.renderer.domElement.height;
const originalPixelRatio = this.renderer.getPixelRatio();
const originalCameraAspect = this.camera.aspect;
const originalRenderCameraAspect =
this.render_camera?.aspect || originalCameraAspect;
// Temporarily set renderer to high resolution
this.renderer.setSize(targetWidth, targetHeight, false);
this.renderer.setPixelRatio(1);
// Update camera for the new aspect ratio
this.camera.aspect = aspectRatio;
this.camera.updateProjectionMatrix();
// If using render camera, update it too
if (this.render_camera) {
this.render_camera.aspect = aspectRatio;
this.render_camera.updateProjectionMatrix();
}
// Re-render the scene at high resolution
if (this.composer) {
this.composer.setSize(targetWidth, targetHeight);
this.composer.render();
} else {
this.renderer.render(this.activeScene.scene, this.camera);
}
// Get the high resolution snapshot
const snapshot = this.renderer.domElement.toDataURL("image/png", 1.0);
const base64Snapshot = snapshot.split(",")[1];
// Restore original camera aspect
this.camera.aspect = originalCameraAspect;
this.camera.updateProjectionMatrix();
// Restore render camera if it exists
if (this.render_camera) {
this.render_camera.aspect = originalRenderCameraAspect;
this.render_camera.updateProjectionMatrix();
}
// Restore original renderer size and pixel ratio
this.renderer.setSize(originalWidth, originalHeight, false);
this.renderer.setPixelRatio(originalPixelRatio);
// Re-render at original resolution
if (this.composer) {
this.composer.setSize(originalWidth, originalHeight);
this.composer.render();
} else {
this.renderer.render(this.activeScene.scene, this.camera);
}
// Restore grid visibility
gridVisibility.value = wasGridVisible;
// Restore transform controls visibility
if (this.control) {
this.control.visible = wasControlVisible;
}
// Restore outline pass
if (this.outlinePass) {
this.outlinePass.enabled = wasOutlineEnabled;
}
if (shouldDownload) {
const link = document.createElement("a");
link.download = "scene-snapshot.png";
@@ -835,7 +807,7 @@ class Editor {
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const uuid = crypto.randomUUID();
const uuid = crypto.randomUUID(); // Generate a new UUID
const file = new File([ab], `${uuid}.png`, { type: mimeString });
return { base64Snapshot, file };
@@ -70,7 +70,7 @@ class FreeCam extends EventDispatcher {
}
keydown(event: KeyboardEvent) {
if (!this.enabled) {
if (event.altKey || !this.enabled) {
return;
}
@@ -235,18 +235,12 @@ class FreeCam extends EventDispatcher {
const pos = this.object.position;
const rot = this.object.rotation;
// Calculate lookAt point based on camera's forward direction
const lookAtPoint = new Vector3(0, 0, -1);
lookAtPoint.applyQuaternion(this.object.quaternion);
lookAtPoint.add(pos);
cameras.value = cameras.value.map((cam) =>
cam.id === selectedCameraId.value
? {
...cam,
position: { x: pos.x, y: pos.y, z: pos.z },
rotation: { x: rot.x, y: rot.y, z: rot.z },
lookAt: { x: lookAtPoint.x, y: lookAtPoint.y, z: lookAtPoint.z },
}
: cam,
);
@@ -5,7 +5,6 @@ import {
outlinerState,
showObjectPanel,
assetModalVisible,
assetModalVisibleDuringDrag,
} from "../signals";
import {
OrbitControls,
@@ -294,12 +293,10 @@ export class MouseControls {
return;
} else if (event.key === "k") {
this.toggleFKMode();
return;
} else if (event.key === "b") {
// Open asset modal
event.preventDefault();
assetModalVisible.value = true;
assetModalVisibleDuringDrag.value = true;
return;
}
@@ -335,19 +332,8 @@ export class MouseControls {
}
}
// Prevent browser shortcuts for Alt combinations
if (
event.altKey &&
(event.key === "Alt" || event.key.toLowerCase() === "d")
) {
event.preventDefault();
event.stopPropagation();
}
if (event.shiftKey) {
this.cameraViewControls.movementSpeed = 4;
} else if (event.altKey) {
this.cameraViewControls.movementSpeed = 0.1;
} else {
this.cameraViewControls.movementSpeed = 1.15;
}
@@ -11,8 +11,6 @@ import {
} from "../signals";
import Editor from "./editor";
import { getArtStyle } from "~/enums";
import { cameras, selectedCameraId, Camera } from "../signals/camera";
export type EditorInitializeConfig = {
sceneToken: string;
};
@@ -100,16 +98,6 @@ export class SaveManager {
);
const timeline_json = await proxyTimeline.saveToJson();
// Save all cameras data
const camerasData = cameras.value.map((cam: Camera) => ({
id: cam.id,
label: cam.label,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}));
const save_data = {
version: this.editor.version,
scene: scene_json,
@@ -120,9 +108,6 @@ export class SaveManager {
position: this.editor.camera?.position,
rotation: this.editor.camera?.rotation,
},
// Add cameras array and selected camera
cameras: camerasData,
selectedCameraId: selectedCameraId.value,
};
// take json scene and figure out checksum
const jsonString = JSON.stringify(save_data);
@@ -161,16 +146,6 @@ export class SaveManager {
);
const timeline_json = await proxyTimeline.saveToJson();
// Save all cameras data
const camerasData = cameras.value.map((cam: Camera) => ({
id: cam.id,
label: cam.label,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}));
const save_data = {
version: this.editor.version,
scene: scene_json,
@@ -181,9 +156,6 @@ export class SaveManager {
position: this.editor.camera?.position,
rotation: this.editor.camera?.rotation,
},
// Add cameras array and selected camera
cameras: camerasData,
selectedCameraId: selectedCameraId.value,
};
// TODO turn scene information into and object ...
@@ -246,23 +218,6 @@ export class SaveManager {
this.editor.camera.rotation.copy(camera_rotation);
}
// Restore cameras data
if (scene_json.cameras) {
cameras.value = scene_json.cameras.map((cam: Camera) => ({
id: cam.id,
label: cam.label,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}));
}
// Restore selected camera
if (scene_json.selectedCameraId) {
selectedCameraId.value = scene_json.selectedCameraId;
}
// For Remixing Scenes.
// this calls the signal function to propagate the data to the UI
restoreSceneGenerationMetadata(scene_json);
@@ -17,7 +17,6 @@ import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import { GetFrontendEnvironment } from "~/Classes/GetFrontendEnvironment";
import { gridVisibility } from "../signals/engine";
import { InfiniteGridHelper } from "./InfiniteGridHelper";
import { cameras, selectedCameraId } from "../signals/camera";
class Scene {
name: string;
@@ -87,7 +86,7 @@ class Scene {
this._createGrid();
this._create_base_lighting();
this._create_skybox();
// this._create_camera_obj();
this._create_camera_obj();
this.helper = new MMDAnimationHelper({ afterglow: 0.0 });
this.scene.userData["helper"] = this.helper;
@@ -111,7 +110,7 @@ class Scene {
this._createGrid();
this._create_base_lighting();
this._create_skybox();
// this._create_camera_obj();
this._create_camera_obj();
}
async instantiate(
@@ -371,45 +370,31 @@ class Scene {
}
async _create_camera_obj() {
cameras.value.forEach((cameraConfig) => {
const camera_position = new THREE.Vector3(
cameraConfig.position.x,
cameraConfig.position.y,
cameraConfig.position.z,
);
const camera_position = new THREE.Vector3(0, 0.6, 1.5);
// TODO(bt,2025-02-10): Make the local dev assets more configurable or seedable.
const camera_id = GetFrontendEnvironment().getIsLocalDev()
? "m_cxh4asqhapdz10j880755dg4yevshb" // Local development
: "m_cxh4asqhapdz10j880755dg4yevshb"; // Production
// TODO(bt,2025-02-10): Make the local dev assets more configurable or seedable.
const camera_id = GetFrontendEnvironment().getIsLocalDev()
? "m_4djqzq89mbn6sdbbqm25ks6drhr55k" // Local development
: "m_cxh4asqhapdz10j880755dg4yevshb"; // Production
this.loadGlbWithPlaceholder(
camera_id,
"Camera",
false,
camera_position,
).then((camera_obj) => {
camera_obj.userData["name"] = cameraConfig.label;
camera_obj.name = cameraConfig.label;
camera_obj.position.set(
camera_position.x,
camera_position.y,
camera_position.z,
);
camera_obj.layers.set(1);
camera_obj.children.forEach((child) => {
child.layers.set(1);
});
this.scene.add(camera_obj);
if (cameraConfig.id === selectedCameraId.value) {
camera_obj.visible = false;
camera_obj.layers.disableAll(); // Make the active camera not touchable
} else {
camera_obj.visible = true;
}
});
const camera_obj = await this.loadGlbWithPlaceholder(
camera_id,
"Camera",
false,
camera_position,
);
camera_obj.userData["name"] = this.camera_name;
camera_obj.name = this.camera_name;
camera_obj.position.set(
camera_position.x,
camera_position.y,
camera_position.z,
);
camera_obj.layers.set(1);
camera_obj.children.forEach((child) => {
child.layers.set(1);
});
this.scene.add(camera_obj);
}
renderMode(enabled: boolean = true) {
@@ -25,7 +25,6 @@ import { CameraAspectRatio } from "./enums";
import { PromptBox } from "./comps/PromptBox";
import { OnboardingHelper } from "./comps/OnboardingHelper";
import { focalLengthDragging } from "./signals/camera";
import { FocalLengthDisplay } from "./comps/FocalLengthDisplay/FocalLengthDisplay";
export const PageEditor = () => {
useSignals();
@@ -52,11 +51,11 @@ export const PageEditor = () => {
: pageWidth.value - 84;
const height =
dndTimelineHeight.value > -1
? pageHeight.value - dndTimelineHeight.value - 56
: pageHeight.value - timelineHeight.value - 56;
? pageHeight.value - dndTimelineHeight.value - 64
: pageHeight.value - timelineHeight.value - 64;
const getScale = () => {
const height = pageHeight.value - timelineHeight.value - 56;
const height = pageHeight.value - timelineHeight.value - 64;
const scaleHeight = height < 610 ? height / 610 : 1;
if (
@@ -104,7 +103,13 @@ export const PageEditor = () => {
</SceneContainer>
{/* Focal Length Display */}
<FocalLengthDisplay />
{focalLengthDragging.value.isDragging && (
<div className="absolute left-1/2 top-16 -translate-x-1/2 transform">
<div className="glass rounded-xl px-6 py-3 text-center text-3xl font-bold text-white">
{focalLengthDragging.value.focalLength}mm
</div>
</div>
)}
{/* Top controls */}
<div
@@ -33,8 +33,6 @@ export enum toEngineActions {
CHANGE_CAMERA_ASPECT_RATIO = "change_camera_aspect_ratio",
// data=CamperaAspectRatio
CAMERA_CHANGED = "camera_changed",
UNMUTE = "unmute",
// data={version, type, group, object_uuid} if lipsync
// data={version, type, group} if global_audio
@@ -4,16 +4,16 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverItem } from "~/components/reusable/Popover";
import { Button, Input, Label, Tooltip } from "~/components";
import { SliderV2 } from "~/components/reusable/SliderV2/SliderV2";
import { focalLengthDragging } from "~/pages/PageEnigma/signals/camera";
import {
updateCamera,
focalLengthDragging,
} from "~/pages/PageEnigma/signals/camera";
import { useState, useEffect } from "react";
import { twMerge } from "tailwind-merge";
interface ExtendedPopoverItem extends PopoverItem {
id: string;
focalLength: number;
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
lookAt: { x: number; y: number; z: number };
}
interface CameraSettingsModalProps {
@@ -24,7 +24,7 @@ interface CameraSettingsModalProps {
onCameraFocalLengthChange: (id: string, value: number) => void;
onAddCamera: () => void;
selectedCameraId: string;
handleCameraSelect: (selectedItem: PopoverItem) => void;
onSelectCamera: (id: string) => void;
onDeleteCamera: (id: string) => void;
}
@@ -36,20 +36,11 @@ export const CameraSettingsModal = ({
onCameraFocalLengthChange,
onAddCamera,
selectedCameraId,
handleCameraSelect,
onSelectCamera,
onDeleteCamera,
}: CameraSettingsModalProps) => {
const selectedCamera = cameras.find((cam) => cam.id === selectedCameraId);
const [isDragging, setIsDragging] = useState(false);
const [tempCameraName, setTempCameraName] = useState<string>("");
const [editingCameraId, setEditingCameraId] = useState<string | null>(null);
useEffect(() => {
if (selectedCamera) {
setTempCameraName(selectedCamera.label);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCamera?.id]);
useEffect(() => {
const handlePointerUp = () => {
@@ -76,29 +67,16 @@ export const CameraSettingsModal = ({
onCameraFocalLengthChange(id, value);
};
const handleNameBlur = () => {
if (selectedCamera && tempCameraName.trim() !== "") {
onCameraNameChange(selectedCamera.id, tempCameraName);
} else if (selectedCamera) {
setTempCameraName(selectedCamera.label);
}
setEditingCameraId(null);
};
const handleNameFocus = (id: string) => {
setEditingCameraId(id);
};
return (
<TransitionDialogue
isOpen={isOpen}
onClose={onClose}
className={twMerge(
"h-[500px] max-w-3xl duration-200",
isDragging ? "opacity-10" : "opacity-100",
"h-[500px] max-w-3xl transition-opacity duration-200",
isDragging ? "opacity-30 hover:opacity-20" : "opacity-100",
)}
backdropClassName={twMerge(
"duration-200",
"transition-opacity duration-200",
isDragging ? "opacity-0" : "opacity-100",
)}
childPadding={false}
@@ -121,16 +99,14 @@ export const CameraSettingsModal = ({
{cameras.map((camera) => (
<button
key={camera.id}
className={`h-9 w-full rounded-lg p-2 text-left transition-colors duration-100 hover:bg-[#63636B]/40 ${
className={`w-full rounded-lg p-2 text-left transition-colors duration-100 hover:bg-[#63636B]/40 ${
camera.id === selectedCameraId ? "bg-[#63636B]/40" : ""
}`}
onClick={() => handleCameraSelect(camera)}
onClick={() => onSelectCamera(camera.id)}
>
<div className="flex items-center gap-2.5 text-sm">
{camera.icon}
{editingCameraId === camera.id
? tempCameraName
: camera.label}
{camera.label}
</div>
</button>
))}
@@ -178,11 +154,10 @@ export const CameraSettingsModal = ({
<Input
id="camera-name"
type="text"
value={tempCameraName}
onChange={(e) => setTempCameraName(e.target.value)}
onBlur={handleNameBlur}
onFocus={() =>
selectedCamera && handleNameFocus(selectedCamera.id)
value={selectedCamera?.label || ""}
onChange={(e) =>
selectedCamera &&
onCameraNameChange(selectedCamera.id, e.target.value)
}
className="text-sm"
/>
@@ -164,7 +164,7 @@ export const ControlPanelSceneObject = () => {
};
const getScale = () => {
const height = pageHeight.value - timelineHeight.value - 56;
const height = pageHeight.value - timelineHeight.value - 64;
return height < 620 ? height / 620 : 1;
};
@@ -1,25 +0,0 @@
import { Transition } from "@headlessui/react";
import { useSignals } from "@preact/signals-react/runtime";
import { focalLengthDragging } from "~/pages/PageEnigma/signals/camera";
export const FocalLengthDisplay = () => {
useSignals();
return (
<Transition
show={focalLengthDragging.value.isDragging}
enter="transition-opacity duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute left-1/2 top-16 -translate-x-1/2 transform">
<div className="glass rounded-xl px-5 py-2.5 text-center text-2xl font-bold text-white">
{focalLengthDragging.value.focalLength}mm
</div>
</div>
</Transition>
);
};
@@ -1,15 +1,9 @@
import { TransitionDialogue } from "~/components/reusable/TransitionDialogue";
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
import { faCheck, faXmark } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, LoadingSpinner, CloseButton } from "~/components";
import { TabSelector } from "~/components/reusable/TabSelector";
import React, {
useState,
useEffect,
Fragment,
useCallback,
useMemo,
} from "react";
import { useState, useEffect, Fragment } from "react";
import { LibraryModalApi } from "./LibraryModalApi";
import { FilterMediaClasses } from "../../../../enums";
import { BucketConfig } from "../../../../api/BucketConfig";
@@ -44,351 +38,323 @@ interface LibraryModalProps {
onTabChange: (tabId: string) => void;
}
export const LibraryModal = React.memo(
({
isOpen,
onClose,
mode = "view",
selectedItemIds = [],
onSelectItem,
maxSelections = 4,
onUseSelected,
tabs,
activeTab,
onTabChange,
}: LibraryModalProps) => {
const [groupedItems, setGroupedItems] = useState<GroupedItems>({});
const [loading, setLoading] = useState(false);
const [lightboxImage, setLightboxImage] = useState<LibraryItem | null>(
null,
);
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
const [failedImageUrls] = useState<Set<string>>(new Set());
const bucketConfig = useMemo(() => new BucketConfig(), []);
const api = useMemo(() => new LibraryModalApi(), []);
export const LibraryModal = ({
isOpen,
onClose,
mode = "view",
selectedItemIds = [],
onSelectItem,
maxSelections = 4,
onUseSelected,
tabs,
activeTab,
onTabChange,
}: LibraryModalProps) => {
const [groupedItems, setGroupedItems] = useState<GroupedItems>({});
const [loading, setLoading] = useState(false);
const [lightboxImage, setLightboxImage] = useState<LibraryItem | null>(null);
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
const [failedImageUrls] = useState<Set<string>>(new Set());
const bucketConfig = new BucketConfig();
const api = new LibraryModalApi();
const formatDate = useCallback((date: string) => {
const d = new Date(date);
return d.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
}, []);
const formatDate = (date: string) => {
const d = new Date(date);
return d.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
};
const groupItemsByDate = useCallback(
(items: LibraryItem[]) => {
const grouped = items.reduce((acc: GroupedItems, item) => {
const dateKey = formatDate(item.createdAt);
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(item);
return acc;
}, {});
// Sort dates in descending order
return Object.fromEntries(
Object.entries(grouped).sort(
(a, b) =>
new Date(b[1][0].createdAt).getTime() -
new Date(a[1][0].createdAt).getTime(),
),
);
},
[formatDate],
);
const handleImageError = useCallback(
(url: string) => {
failedImageUrls.add(url);
},
[failedImageUrls],
);
const getImageUrl = useCallback(
(path: string | undefined | null) => {
if (!path) return null;
const url = bucketConfig.getGcsUrl(path);
return failedImageUrls.has(url) ? null : url;
},
[bucketConfig, failedImageUrls],
);
useEffect(() => {
const loadItems = async () => {
setLoading(true);
try {
const response = await api.listUserMediaFiles({
filter_media_classes: [FilterMediaClasses.IMAGE],
});
if (response.success && response.data) {
const newItems = response.data.map((item) => ({
id: item.token,
label: item.maybe_title || "Untitled",
thumbnail: getImageUrl(
item.cover_image.maybe_cover_image_public_bucket_path ||
item.public_bucket_path,
),
fullImage: getImageUrl(item.public_bucket_path),
createdAt: item.created_at,
}));
setGroupedItems(groupItemsByDate(newItems));
}
} catch (error) {
console.error("Failed to fetch library items:", error);
}
setLoading(false);
};
if (isOpen) {
loadItems();
const groupItemsByDate = (items: LibraryItem[]) => {
const grouped = items.reduce((acc: GroupedItems, item) => {
const dateKey = formatDate(item.createdAt);
if (!acc[dateKey]) {
acc[dateKey] = [];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, isOpen]);
acc[dateKey].push(item);
return acc;
}, {});
// useEffect(() => {
// console.log("LibraryModal render caused by:", {
// isOpen,
// activeTab,
// selectedItemIds,
// });
// }, [isOpen, activeTab, selectedItemIds]);
const handleItemClick = useCallback(
(item: LibraryItem) => {
if (mode === "select" && onSelectItem) {
onSelectItem(item.id);
} else {
setLightboxImage(item);
setIsLightboxVisible(true);
}
},
[mode, onSelectItem],
// Sort dates in descending order
return Object.fromEntries(
Object.entries(grouped).sort(
(a, b) =>
new Date(b[1][0].createdAt).getTime() -
new Date(a[1][0].createdAt).getTime(),
),
);
};
const handleCloseLightbox = useCallback(() => {
setIsLightboxVisible(false);
// Wait for animation to complete before removing the image
setTimeout(() => setLightboxImage(null), 300);
}, []);
const handleImageError = (url: string) => {
failedImageUrls.add(url);
};
const handleDeselectAll = useCallback(() => {
selectedItemIds.forEach((id) => onSelectItem?.(id));
}, [selectedItemIds, onSelectItem]);
const getImageUrl = (path: string | undefined | null) => {
if (!path) return null;
const url = bucketConfig.getGcsUrl(path);
return failedImageUrls.has(url) ? null : url;
};
const handleUseSelected = useCallback(() => {
const selectedItems = Object.values(groupedItems)
.flat()
.filter((item) => selectedItemIds.includes(item.id));
onUseSelected?.(selectedItems);
}, [groupedItems, selectedItemIds, onUseSelected]);
useEffect(() => {
const loadItems = async () => {
setLoading(true);
try {
const response = await api.listUserMediaFiles({
filter_media_classes: [FilterMediaClasses.IMAGE],
});
if (response.success && response.data) {
const newItems = response.data.map((item) => ({
id: item.token,
label: item.maybe_title || "Untitled",
thumbnail: getImageUrl(
item.cover_image.maybe_cover_image_public_bucket_path ||
item.public_bucket_path,
),
fullImage: getImageUrl(item.public_bucket_path),
createdAt: item.created_at,
}));
setGroupedItems(groupItemsByDate(newItems));
}
} catch (error) {
console.error("Failed to fetch library items:", error);
}
setLoading(false);
};
return (
<>
<TransitionDialogue
isOpen={isOpen}
onClose={onClose}
className={twMerge(
"h-[620px] max-w-4xl",
mode === "view" && "h-[80vh] max-h-[870px] max-w-7xl",
)}
childPadding={false}
>
<div className="flex h-full flex-col">
<div className="border-b border-white/10 p-4 py-3">
<div className="grid grid-cols-3 items-center">
<h2 className="text-xl font-semibold">
{mode === "select" ? "Select Images" : "My Gallery"}
</h2>
<div className="flex items-center justify-center">
<TabSelector
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
className="w-auto"
/>
</div>
<div className="flex justify-end">
<CloseButton onClick={onClose} />
</div>
if (isOpen) {
loadItems();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, isOpen]);
const handleItemClick = (item: LibraryItem) => {
if (mode === "select" && onSelectItem) {
onSelectItem(item.id);
} else {
setLightboxImage(item);
setIsLightboxVisible(true);
}
};
const handleCloseLightbox = () => {
setIsLightboxVisible(false);
// Wait for animation to complete before removing the image
setTimeout(() => setLightboxImage(null), 300);
};
return (
<>
<TransitionDialogue
isOpen={isOpen}
onClose={onClose}
className={twMerge(
"h-[620px] max-w-4xl",
mode === "view" && "h-[80vh] max-h-[870px] max-w-7xl",
)}
childPadding={false}
>
<div className="flex h-full flex-col">
<div className="border-b border-white/10 p-4 py-3">
<div className="grid grid-cols-3 items-center">
<h2 className="text-xl font-semibold">
{mode === "select" ? "Select Images" : "My Gallery"}
</h2>
<div className="flex items-center justify-center">
<TabSelector
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
className="w-auto"
/>
</div>
<div className="flex justify-end">
<CloseButton onClick={onClose} />
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-white/60">
<LoadingSpinner className="h-12 w-12" />
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-white/60">
<LoadingSpinner className="h-12 w-12" />
</div>
) : (
<div className="space-y-6">
{Object.entries(groupedItems).map(([date, dateItems]) => (
<div key={date}>
<h3 className="text-md mb-2 font-medium text-white/60">
{date}
</h3>
<div
className={twMerge(
"grid grid-cols-5 gap-1",
mode === "view" && "grid-cols-5",
)}
>
{dateItems.map((item) => (
<button
key={item.id}
className={twMerge(
"group relative aspect-square overflow-hidden rounded-md border-[3px] transition-all",
mode === "select" &&
selectedItemIds.includes(item.id)
? "border-brand-primary"
: "border-transparent hover:border-white",
</div>
) : (
<div className="space-y-6">
{Object.entries(groupedItems).map(([date, dateItems]) => (
<div key={date}>
<h3 className="text-md mb-2 font-medium text-white/60">
{date}
</h3>
<div
className={twMerge(
"grid grid-cols-5 gap-1",
mode === "view" && "grid-cols-5",
)}
>
{dateItems.map((item) => (
<button
key={item.id}
className={twMerge(
"group relative aspect-square overflow-hidden rounded-md border-[3px] transition-all",
mode === "select" &&
selectedItemIds.includes(item.id)
? "border-brand-primary"
: "border-transparent hover:border-white",
)}
onClick={() => handleItemClick(item)}
>
<div className="relative h-full w-full">
{!item.thumbnail ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">
Image not available
</span>
</div>
) : (
<img
src={item.thumbnail}
alt={item.label}
className="h-full w-full object-cover"
onError={() =>
handleImageError(item.thumbnail!)
}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
onClick={() => handleItemClick(item)}
>
<div className="relative h-full w-full">
{!item.thumbnail ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">
Image not available
</span>
</div>
) : (
<img
src={item.thumbnail}
alt={item.label}
className="h-full w-full object-cover"
onError={() =>
handleImageError(item.thumbnail!)
}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
{mode === "select" &&
selectedItemIds.includes(item.id) && (
<div className="absolute inset-0 bg-white/30" />
)}
</div>
{mode === "select" &&
selectedItemIds.includes(item.id) && (
<div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-brand-primary">
<FontAwesomeIcon
icon={faCheck}
className="text-sm"
/>
</div>
<div className="absolute inset-0 bg-white/30" />
)}
</button>
))}
</div>
</div>
{mode === "select" &&
selectedItemIds.includes(item.id) && (
<div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-brand-primary">
<FontAwesomeIcon
icon={faCheck}
className="text-sm"
/>
</div>
)}
</button>
))}
</div>
))}
</div>
)}
</div>
{mode === "select" && (
<div className="flex items-center justify-between border-t border-white/10 bg-black/25 p-4">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-white/80">
{selectedItemIds.length}/{maxSelections} selected
</div>
{selectedItemIds.length > 0 && (
<span className="text-white/10">|</span>
)}
{selectedItemIds.length > 0 && (
<button
onClick={handleDeselectAll}
className="text-sm text-white/60 hover:text-white"
>
Deselect All
</button>
)}
</div>
<Button
onClick={handleUseSelected}
disabled={selectedItemIds.length === 0}
>
Use selected
</Button>
))}
</div>
)}
</div>
</TransitionDialogue>
{/* Lightbox Modal */}
{lightboxImage &&
createPortal(
<Transition appear show={isLightboxVisible} as={Fragment}>
<div className="fixed inset-0 z-[100]">
{mode === "select" && (
<div className="flex items-center justify-between border-t border-white/10 bg-black/25 p-4">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-white/80">
{selectedItemIds.length}/{maxSelections} selected
</div>
{selectedItemIds.length > 0 && (
<span className="text-white/10">|</span>
)}
{selectedItemIds.length > 0 && (
<button
onClick={() => {
selectedItemIds.forEach((id) => onSelectItem?.(id));
}}
className="text-sm text-white/60 hover:text-white"
>
Deselect All
</button>
)}
</div>
<Button
onClick={() => {
const selectedItems = Object.values(groupedItems)
.flat()
.filter((item) => selectedItemIds.includes(item.id));
onUseSelected?.(selectedItems);
}}
disabled={selectedItemIds.length === 0}
>
Use selected
</Button>
</div>
)}
</div>
</TransitionDialogue>
{/* Lightbox Modal */}
{lightboxImage &&
createPortal(
<Transition appear show={isLightboxVisible} as={Fragment}>
<div className="fixed inset-0 z-[100]">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
className="fixed inset-0 cursor-pointer bg-black/60"
onClick={handleCloseLightbox}
/>
</Transition.Child>
<div
className="fixed inset-0 flex items-center justify-center p-4"
onClick={handleCloseLightbox}
>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="fixed inset-0 cursor-pointer bg-black/60"
onClick={handleCloseLightbox}
/>
</Transition.Child>
<div
className="fixed inset-0 flex items-center justify-center p-4"
onClick={handleCloseLightbox}
>
<Transition.Child
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
className="relative h-[90vh] w-[80vw] rounded-xl bg-[#2C2C2C]"
onClick={(e) => e.stopPropagation()}
>
<div
className="relative h-[90vh] w-[80vw] rounded-xl bg-[#2C2C2C]"
onClick={(e) => e.stopPropagation()}
>
<CloseButton
onClick={handleCloseLightbox}
className="absolute right-4 top-4 z-10"
/>
<div className="grid h-full grid-cols-3 gap-6">
<div className="col-span-2 flex h-full items-center justify-center overflow-hidden bg-black/40">
{!lightboxImage.fullImage ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">
Image not available
</span>
</div>
) : (
<img
src={lightboxImage.fullImage}
alt={lightboxImage.label}
className="h-full w-full object-contain"
onError={() =>
handleImageError(lightboxImage.fullImage!)
}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
</div>
<div className="py-5">Prompt</div>
<CloseButton
onClick={handleCloseLightbox}
className="absolute right-4 top-4 z-10"
/>
<div className="grid h-full grid-cols-3 gap-6">
<div className="col-span-2 flex h-full items-center justify-center overflow-hidden bg-black/40">
{!lightboxImage.fullImage ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">
Image not available
</span>
</div>
) : (
<img
src={lightboxImage.fullImage}
alt={lightboxImage.label}
className="h-full w-full object-contain"
onError={() =>
handleImageError(lightboxImage.fullImage!)
}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
</div>
<div className="py-5">Prompt</div>
</div>
</Transition.Child>
</div>
</div>
</Transition.Child>
</div>
</Transition>,
document.body,
)}
</>
);
},
);
LibraryModal.displayName = "LibraryModal";
</div>
</Transition>,
document.body,
)}
</>
);
};
@@ -38,7 +38,7 @@ export const OnboardingHelper = () => {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="glass pointer-events-none absolute left-1/2 top-48 z-10 flex -translate-x-1/2 transform items-center justify-center gap-2 rounded-2xl border-2 border-brand-primary !bg-brand-primary/10 px-5 py-4">
<div className="glass pointer-events-none absolute left-1/2 top-52 z-10 flex -translate-x-1/2 transform items-center justify-center gap-2 rounded-2xl border-2 border-brand-primary !bg-brand-primary/10 px-5 py-4">
<div className="flex items-center">
<p className="pr-3 text-lg font-semibold">Hold</p>
<Mouse button="left" />
@@ -140,7 +140,7 @@ export const Outliner = () => {
useEffect(() => {
const updateEditorHeight = () => {
setEditorHeight(pageHeight.value - timelineHeight.value - 56);
setEditorHeight(pageHeight.value - timelineHeight.value - 64);
};
updateEditorHeight();
@@ -24,19 +24,16 @@ import { CameraAspectRatio, EditorStates } from "~/pages/PageEnigma/enums";
import { CameraViewCanvas } from "~/pages/PageEnigma/comps/EngineCanvases";
import { Button, ButtonIcon, Tooltip } from "~/components";
import { useEffect } from "react";
export const PreviewEngineCamera = () => {
useSignals();
const handleButtonCameraView = () => {
console.log("Camera view button clicked");
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.TOGGLE_CAMERA_STATE,
data: null,
});
console.log("Action published to queue");
};
const getLargeScreenHeightClass = () => {
@@ -70,10 +67,6 @@ export const PreviewEngineCamera = () => {
}
};
useEffect(() => {
console.log("Editor state changed:", editorState.value);
}, [editorState.value]);
return (
<div
id="preview-engine-camera"
@@ -27,7 +27,7 @@ export const PreviewFrameImage = () => {
pageWidth.value -
(sidePanelVisible.value ? sidePanelWidth.value : 0) -
84,
height: pageHeight.value - timelineHeight.value - 56,
height: pageHeight.value - timelineHeight.value - 64,
}}
>
<div className="relative flex h-full w-full flex-col items-center justify-center gap-5">
@@ -48,7 +48,7 @@ export const PreviewFrameImage = () => {
pageWidth.value -
(sidePanelVisible.value ? sidePanelWidth.value : 0) -
84,
height: pageHeight.value - timelineHeight.value - 56,
height: pageHeight.value - timelineHeight.value - 64,
}}
/>
);
@@ -46,7 +46,6 @@ import {
addCamera,
deleteCamera,
updateCamera,
focalLengthDragging,
} from "~/pages/PageEnigma/signals/camera";
interface ReferenceImage {
@@ -94,6 +93,7 @@ export const PromptBox = () => {
[],
);
const [activeLibraryTab, setActiveLibraryTab] = useState("my-media");
const [isGridVisible, setIsGridVisible] = useState(true);
useEffect(() => {
if (textareaRef.current) {
@@ -121,138 +121,36 @@ export const PromptBox = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraAspectRatio.value]);
useEffect(() => {
setGridVisibility(isGridVisible);
}, [isGridVisible]);
const handleCameraSelect = (selectedItem: PopoverItem) => {
const selectedCamera = cameras.value.find(
(cam) => cam.label === selectedItem.label,
);
if (selectedCamera && editorEngine) {
if (selectedCamera) {
selectedCameraId.value = selectedCamera.id;
// Show focal length display temporarily
// TODO: Rename dragging to visible - BFlat
focalLengthDragging.value = {
isDragging: true,
focalLength: selectedCamera.focalLength,
};
setTimeout(() => {
focalLengthDragging.value = {
isDragging: false,
focalLength: selectedCamera.focalLength,
};
}, 1500);
// Update the main camera to match the selected camera's properties
if (editorEngine.camera) {
// First update position and lookAt
editorEngine.camera.position.set(
selectedCamera.position.x,
selectedCamera.position.y,
selectedCamera.position.z,
);
editorEngine.camera.lookAt(
selectedCamera.lookAt.x,
selectedCamera.lookAt.y,
selectedCamera.lookAt.z,
);
// Update FOV
editorEngine.camera.fov = editorEngine.focalLengthToFov(
selectedCamera.focalLength,
);
editorEngine.camera.updateProjectionMatrix();
// Reset and update camera controls
if (editorEngine.cameraViewControls) {
editorEngine.cameraViewControls.reset();
editorEngine.cameraViewControls.update(0);
}
// Force a render to update the view
editorEngine.renderScene();
// Queue an update to ensure the engine processes the camera change
// Queue.publish({
// queueName: QueueNames.TO_ENGINE,
// action: toEngineActions.CAMERA_CHANGED,
// data: selectedCamera,
// });
}
// Force update camera properties in the state
// Force update camera properties
updateCamera(selectedCamera.id, {
focalLength: selectedCamera.focalLength,
position: selectedCamera.position,
rotation: selectedCamera.rotation,
lookAt: selectedCamera.lookAt,
});
}
};
const handleAddCamera = () => {
// Check if we've reached the maximum number of cameras
if (cameras.value.length >= 6) {
console.warn("Maximum number of cameras (6) reached");
return;
}
const newIndex = cameras.value.length + 1;
const newId = `cam${newIndex}`;
// This is for generating random orbital position for the new camera using spherical coordinates
const radius = Math.random() * 5 + 7; // Distance from center: 7 to 12 units
const theta = Math.random() * Math.PI * 2; // Azimuthal angle: 0 to 2π
const phi = Math.PI / 3 + (Math.random() * Math.PI) / 6; // Polar angle: π/3 to π/2 (60° to 90° from horizontal)
// Convert spherical coordinates to Cartesian coordinates
const randomX = radius * Math.sin(phi) * Math.cos(theta);
const randomY = Math.abs(radius * Math.cos(phi)) + 2; // Ensure Y is positive and at least 2 units up
const randomZ = radius * Math.sin(phi) * Math.sin(theta);
addCamera({
id: newId,
label: `Camera ${newIndex}`,
focalLength: 24,
position: {
x: randomX,
y: randomY,
z: randomZ,
},
focalLength: 35,
fov: 70,
position: { x: 0, y: 2.5, z: -5 },
rotation: { x: 0, y: 0, z: 0 },
lookAt: { x: 0, y: 0, z: 0 },
});
// Switch to the newly created camera
selectedCameraId.value = newId;
// Update the engine camera to match the new camera's properties
if (editorEngine && editorEngine.camera) {
editorEngine.camera.position.set(randomX, randomY, randomZ);
editorEngine.camera.lookAt(0, 0, 0);
editorEngine.camera.fov = editorEngine.focalLengthToFov(24);
editorEngine.camera.updateProjectionMatrix();
// Reset and update camera controls
if (editorEngine.cameraViewControls) {
editorEngine.cameraViewControls.reset();
editorEngine.cameraViewControls.update(0);
}
// Force a render to update the view
editorEngine.renderScene();
// Queue.publish({
// queueName: QueueNames.TO_ENGINE,
// action: toEngineActions.CAMERA_CHANGED,
// data: {
// id: newId,
// label: `Camera ${newIndex}`,
// focalLength: 24,
// position: { x: randomX, y: randomY, z: randomZ },
// rotation: { x: 0, y: 0, z: 0 },
// lookAt: { x: 0, y: 0, z: 0 },
// },
// });
}
};
const handleCameraNameChange = (id: string, newName: string) => {
@@ -636,9 +534,6 @@ export const PromptBox = () => {
<FontAwesomeIcon icon={faCamera} className="h-4 w-4" />
),
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}))}
onSelect={handleCameraSelect}
onAdd={handleAddCamera}
@@ -646,13 +541,11 @@ export const PromptBox = () => {
<FontAwesomeIcon icon={faCamera} className="h-4 w-4" />
}
showAddButton
disableAddButton={cameras.value.length >= 6}
showIconsInList
mode="toggle"
panelTitle="Camera"
panelActionLabel="Settings"
onPanelAction={() => setIsCameraSettingsOpen(true)}
buttonClassName="max-w-[130px] h-9"
/>
</Tooltip>
<Tooltip
@@ -737,15 +630,12 @@ export const PromptBox = () => {
selected: cam.id === selectedCameraId.value,
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}))}
onCameraNameChange={handleCameraNameChange}
onCameraFocalLengthChange={handleCameraFocalLengthChange}
onAddCamera={handleAddCamera}
selectedCameraId={selectedCameraId.value}
handleCameraSelect={handleCameraSelect}
onSelectCamera={(id: string) => (selectedCameraId.value = id)}
onDeleteCamera={deleteCamera}
/>
</div>
@@ -11,7 +11,7 @@ export const SceneContainer = ({ children }: { children: React.ReactNode }) => {
const containerWidth = pageWidth.value;
const containerHeight = pageHeight.value - timelineHeight.value - 56;
const containerHeight = pageHeight.value - timelineHeight.value - 64;
const callbackRef = useCallback(
(node: HTMLDivElement) => {
@@ -35,7 +35,7 @@ export const SidePanelMenu = ({
sidePanelVisible.value ? "border-transparent" : "border-[#363636]",
)}
style={{
height: pageHeight.value - 56,
height: pageHeight.value - 64,
}}
>
<div className="flex w-full flex-col gap-2">
@@ -4,9 +4,9 @@ export interface Camera {
id: string;
label: string;
focalLength: number;
fov: number;
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
lookAt: { x: number; y: number; z: number };
}
export const cameras = signal<Camera[]>([
@@ -14,17 +14,17 @@ export const cameras = signal<Camera[]>([
id: "main",
label: "Main View",
focalLength: 17,
position: { x: -2.5, y: 2.5, z: 2.5 },
fov: 70,
position: { x: 2.5, y: 2.5, z: -2.5 },
rotation: { x: 0, y: 0, z: 0 },
lookAt: { x: 0, y: 0, z: 0 },
},
{
id: "cam2",
label: "Camera 2",
focalLength: 10,
position: { x: 0, y: 0.6, z: 1.5 },
focalLength: 35,
fov: 70,
position: { x: -2.5, y: 2.5, z: 2.5 },
rotation: { x: 0, y: 0, z: 0 },
lookAt: { x: 0, y: 0, z: 0 },
},
]);
+1 -1
View File
@@ -52,7 +52,7 @@ img {
}
.topbar-spacer {
height: 56px;
height: 64px;
width: 100vw;
}
+6 -5
View File
@@ -1,14 +1,14 @@
[build]
command = "./apps/editor3d/script/netlify_build.sh"
publish = "apps/editor3d/dist/"
command = "./script/netlify_build.sh"
publish = "build/client"
[build.environment]
REACT_APP_PUBLIC_POSTHOG_KEY = "phc_jBFgac0mVALAFk3negnSfYCcHgvkT00yBLQDmCDYNBb"
REACT_APP_PUBLIC_POSTHOG_UI = "https://us.i.posthog.com"
#[dev]
#command = "npm run dev"
#framework = "vite"
[dev]
command = "npm run dev"
framework = "vite"
# Set immutable caching for static files, because they have fingerprinted filenames
@@ -16,6 +16,7 @@ REACT_APP_PUBLIC_POSTHOG_UI = "https://us.i.posthog.com"
for = "/build/*"
[headers.values]
"Cache-Control" = "public, max-age=31560000, immutable"
[[redirects]]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

+18 -20
View File
@@ -2,26 +2,24 @@
set -euxo pipefail
echo "Build Editor 3D"
nx build editor3d
echo "Run build script"
npm install
npm run build
echo "Change to project dir"
pushd apps/editor3d/
echo "List build directory files"
ls -lA build/
echo "Make output directory"
mkdir dist/
echo "Copy files to putput directory"
cp -r build/server/* dist/
cp -r build/client/* dist/
cp _headers dist/
#cp _redirects dist/
#cp "404.html" dist/
echo "List final files in build"
find dist/
#echo "Emit built files."
#pwd
#find .
# echo "Copy test files into dist"
# cp -r test/* dist/
#
# echo "Copy netlify configs into dist"
# cp _headers dist/
# cp _redirects dist/
#
# echo "Copy netlify 404.html page into dist"
# cp "404.html" dist/
#
# echo "List files after build"
# find dist/