mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 13:54:53 -05:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc67bc9526 | ||
|
|
ffbf1c7505 | ||
|
|
0180869b63 | ||
|
|
ce3d7085b3 |
Generated
-1
@@ -7859,7 +7859,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"subprocess_common",
|
||||
"testing",
|
||||
"thiserror 2.0.11",
|
||||
"tokio",
|
||||
"toml 0.7.8",
|
||||
"workspace-build-acceleration",
|
||||
|
||||
+3
-4
@@ -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)
|
||||
}
|
||||
+132
-98
@@ -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
@@ -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&utm_medium=readme&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 »](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 »](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry »](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 »](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&utm_medium=readme&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)
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-41
@@ -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"
|
||||
/>
|
||||
|
||||
+1
-1
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
-25
@@ -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();
|
||||
|
||||
-7
@@ -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 },
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ img {
|
||||
}
|
||||
|
||||
.topbar-spacer {
|
||||
height: 56px;
|
||||
height: 64px;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 |
@@ -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/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user