mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-01 23:35:40 -05:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aebf78f765 | ||
|
|
326fa8c4a4 | ||
|
|
51bdd4eff8 | ||
|
|
a64ca7d4ac | ||
|
|
ec3ac242b0 | ||
|
|
fbe7fa3c77 | ||
|
|
418d824c35 | ||
|
|
2e6823834f |
@@ -668,8 +668,4 @@ pub fn get_random_document_key() -> String {
|
||||
|
||||
pub fn get_random_window_key() -> String {
|
||||
return WINDOW_KEY[rand::rng().random_range(0..WINDOW_KEY.len())].to_string();
|
||||
}
|
||||
|
||||
pub fn get_random_cached_script() -> String {
|
||||
return CACHED_SCRIPTS[rand::rng().random_range(0..CACHED_SCRIPTS.len())].to_string();
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ pub struct GenerateSentinelRefreshRequest {
|
||||
|
||||
fn generate_date() -> String {
|
||||
let now_utc = Utc::now();
|
||||
let today_utc = now_utc.date_naive();
|
||||
let offset = FixedOffset::west_opt(5 * 60 * 60).unwrap();
|
||||
let now_with_offset = Utc::now().with_timezone(&offset);
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ use serde_derive::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use idempotency::uuid::generate_random_uuid;
|
||||
|
||||
use super::request::GenerateSentinelRefreshRequest;
|
||||
|
||||
const SORA_IMAGE_GEN_URL: &str = "https://chatgpt.com/backend-api/sentinel/req";
|
||||
|
||||
/// This user agent is tied to the sentinel generation. If we need to change it, we may need to change sentinel generation too.
|
||||
@@ -39,8 +41,9 @@ pub struct SentinelResponse {
|
||||
}
|
||||
|
||||
|
||||
pub async fn generate_token(problem: String) -> AnyhowResult<String> {
|
||||
let request = SentinelRequest::new(problem);
|
||||
pub async fn generate_token() -> AnyhowResult<String> {
|
||||
let (_request, base64_request) = GenerateSentinelRefreshRequest::new().with_fourth_and_tenth();
|
||||
let request = SentinelRequest::new(base64_request);
|
||||
let client = reqwest::Client::new();
|
||||
let response = client.post(SORA_IMAGE_GEN_URL)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
@@ -57,12 +60,10 @@ pub async fn generate_token(problem: String) -> AnyhowResult<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sentinel_refresh::generate::request::GenerateSentinelRefreshRequest;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_generate_token() {
|
||||
let (_request, base64_request) = GenerateSentinelRefreshRequest::new().with_fourth_and_tenth();
|
||||
let token = generate_token(base64_request.to_string()).await.unwrap();
|
||||
let token = generate_token().await.unwrap();
|
||||
println!("{}", token);
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,14 @@ use crate::helpers::boolean_converters::i8_to_bool;
|
||||
use crate::payloads::generic_inference_args::generic_inference_args::{GenericInferenceArgs, PolymorphicInferenceArgs};
|
||||
use crate::queries::generic_inference::web::job_status::{GenericInferenceJobStatus, RequestDetails, ResultDetails, UserDetails};
|
||||
|
||||
pub enum SessionUser<'a> {
|
||||
User(&'a UserToken),
|
||||
Anonymous(&'a AnonymousVisitorTrackingToken),
|
||||
}
|
||||
|
||||
pub struct ListSessionJobsForUserArgs<'a> {
|
||||
// TODO(bt,2024-04-23): Support AnonymousVisitorToken
|
||||
pub user_token: &'a UserToken,
|
||||
pub user: SessionUser<'a>,
|
||||
pub maybe_include_job_statuses: Option<&'a HashSet<JobStatusPlus>>,
|
||||
pub maybe_exclude_job_statuses: Option<&'a HashSet<JobStatusPlus>>,
|
||||
}
|
||||
@@ -63,14 +68,29 @@ pub async fn list_session_jobs_from_connection(
|
||||
// Furthermore, even though the join query's `created_at` lacks an index, sort
|
||||
// by id will accomplish this quickly.
|
||||
{
|
||||
query_builder.push(r#"
|
||||
INNER JOIN (
|
||||
SELECT id
|
||||
FROM generic_inference_jobs
|
||||
WHERE maybe_creator_user_token =
|
||||
"#);
|
||||
|
||||
query_builder.push_bind(args.user_token.to_string());
|
||||
match args.user {
|
||||
SessionUser::User(user_token) => {
|
||||
query_builder.push(r#"
|
||||
INNER JOIN (
|
||||
SELECT id
|
||||
FROM generic_inference_jobs
|
||||
WHERE maybe_creator_user_token =
|
||||
"#);
|
||||
query_builder.push_bind(user_token.to_string());
|
||||
}
|
||||
SessionUser::Anonymous(avt_token) => {
|
||||
query_builder.push(r#"
|
||||
INNER JOIN (
|
||||
SELECT id
|
||||
FROM generic_inference_jobs
|
||||
WHERE maybe_creator_anonymous_visitor_token =
|
||||
"#);
|
||||
query_builder.push_bind(avt_token.to_string());
|
||||
query_builder.push(r#"
|
||||
AND maybe_creator_user_token IS NULL
|
||||
"#);
|
||||
}
|
||||
}
|
||||
|
||||
query_builder.push(r#"
|
||||
AND created_at > DATE_SUB(NOW(), INTERVAL 36 HOUR)
|
||||
|
||||
+67
-28
@@ -16,9 +16,12 @@ 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::generate::token::generate_token;
|
||||
use openai_sora_client::sentinel_refresh::refresh_sentinel;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use shared_service_components::sora_redis_credentials::set_sora_credential_field_in_redis::set_sora_credential_field_in_redis;
|
||||
use shared_service_components::sora_redis_credentials::keys::RedisSoraCredentialSubkey;
|
||||
use sqlx::MySqlPool;
|
||||
use tempdir::TempDir;
|
||||
use utoipa::ToSchema;
|
||||
@@ -97,6 +100,7 @@ pub enum EnqueueImageGenRequestError {
|
||||
NotAuthorized,
|
||||
ServerError,
|
||||
RateLimited,
|
||||
TooManyConcurrentTasks,
|
||||
}
|
||||
|
||||
impl ResponseError for EnqueueImageGenRequestError {
|
||||
@@ -106,6 +110,7 @@ impl ResponseError for EnqueueImageGenRequestError {
|
||||
EnqueueImageGenRequestError::NotAuthorized => StatusCode::UNAUTHORIZED,
|
||||
EnqueueImageGenRequestError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
EnqueueImageGenRequestError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
|
||||
EnqueueImageGenRequestError::TooManyConcurrentTasks => StatusCode::TOO_MANY_REQUESTS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +120,7 @@ impl ResponseError for EnqueueImageGenRequestError {
|
||||
EnqueueImageGenRequestError::NotAuthorized => "unauthorized".to_string(),
|
||||
EnqueueImageGenRequestError::ServerError => "server error".to_string(),
|
||||
EnqueueImageGenRequestError::RateLimited => "rate limited".to_string(),
|
||||
EnqueueImageGenRequestError::TooManyConcurrentTasks => "too many concurrent tasks".to_string(),
|
||||
};
|
||||
|
||||
to_simple_json_error(&error_reason, self.status_code())
|
||||
@@ -288,12 +294,12 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
|
||||
|
||||
let prompt = create_prompt(&request);
|
||||
|
||||
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
|
||||
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);
|
||||
@@ -301,27 +307,54 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
|
||||
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
|
||||
match generate_token().await {
|
||||
Ok(sentinel_token) => {
|
||||
set_sora_credential_field_in_redis(
|
||||
&mut redis,
|
||||
RedisSoraCredentialSubkey::Sentinel,
|
||||
&sentinel_token
|
||||
).map_err(|e| {
|
||||
EnqueueImageGenRequestError::ServerError
|
||||
})?;
|
||||
|
||||
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
|
||||
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);
|
||||
},
|
||||
// Fallback to the old refresh stragegy relying on the external service
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,27 +363,33 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
|
||||
SoraError::TokenExpired(msg) => {
|
||||
error!("Sora token expired, needs refresh: {}", msg);
|
||||
// TODO: Implement token refresh logic here
|
||||
EnqueueImageGenRequestError::ServerError
|
||||
},
|
||||
SoraError::SentinelBlock(msg) => {
|
||||
error!("Sora sentinel block still occurring after refresh: {}", msg);
|
||||
EnqueueImageGenRequestError::ServerError
|
||||
},
|
||||
SoraError::TooManyConcurrentTasks(msg) => {
|
||||
error!("Sora too many concurrent tasks: {}", msg);
|
||||
EnqueueImageGenRequestError::TooManyConcurrentTasks
|
||||
},
|
||||
_ => {
|
||||
error!("Failed to call Sora image generation: {:?}", err);
|
||||
EnqueueImageGenRequestError::ServerError
|
||||
},
|
||||
}
|
||||
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
|
||||
@@ -370,8 +409,8 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
|
||||
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),
|
||||
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(),
|
||||
|
||||
+17
-7
@@ -31,7 +31,7 @@ use enums::by_table::generic_inference_jobs::inference_category::InferenceCatego
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
|
||||
use mysql_queries::queries::generic_inference::web::list_session_jobs::{list_session_jobs_from_connection, ListSessionJobsForUserArgs};
|
||||
use mysql_queries::queries::generic_inference::web::list_session_jobs::{list_session_jobs_from_connection, ListSessionJobsForUserArgs, SessionUser};
|
||||
use primitives::numerics::i64_to_u64_zero_clamped::i64_to_u64_zero_clamped;
|
||||
use redis_common::redis_keys::RedisKeys;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
@@ -238,11 +238,6 @@ pub async fn list_session_jobs_handler(
|
||||
ListSessionJobsError::ServerError
|
||||
})?;
|
||||
|
||||
let session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => return Err(ListSessionJobsError::NotAuthorized),
|
||||
};
|
||||
|
||||
let include_states = query.include_states
|
||||
.as_deref()
|
||||
.map(|s| s.split(",")
|
||||
@@ -255,8 +250,23 @@ pub async fn list_session_jobs_handler(
|
||||
.filter_map(|status| JobStatusPlus::from_str(status).ok())
|
||||
.collect::<HashSet<_>>());
|
||||
|
||||
let user;
|
||||
|
||||
match (maybe_user_session.as_ref(), maybe_avt_token.as_ref()) {
|
||||
(Some(session), _) => {
|
||||
user = SessionUser::User(&session.user_token_typed);
|
||||
}
|
||||
(None, Some(avt_token)) => {
|
||||
user = SessionUser::Anonymous(avt_token);
|
||||
}
|
||||
(None, None) => {
|
||||
// TODO(bt,2025-04-15): We should install an AVT cookie.
|
||||
return Err(ListSessionJobsError::NotAuthorized);
|
||||
}
|
||||
}
|
||||
|
||||
let args = ListSessionJobsForUserArgs {
|
||||
user_token: &session.user_token_typed,
|
||||
user,
|
||||
maybe_include_job_statuses: include_states.as_ref(),
|
||||
maybe_exclude_job_statuses: exclude_states.as_ref(),
|
||||
};
|
||||
|
||||
@@ -56,11 +56,8 @@ export class ShapeNode extends NetworkedNode {
|
||||
switch (shapeType) {
|
||||
case ShapeType.CIRCLE:
|
||||
shape = new Konva.Ellipse({
|
||||
x: size.width,
|
||||
y: size.height,
|
||||
radiusX: size.width,
|
||||
radiusY: size.height,
|
||||
...transform,
|
||||
radiusX: size.width / 2,
|
||||
radiusY: size.height / 2,
|
||||
fill: color,
|
||||
strokeScaleEnabled: false,
|
||||
draggable: true,
|
||||
@@ -80,8 +77,6 @@ export class ShapeNode extends NetworkedNode {
|
||||
|
||||
case ShapeType.TRIANGLE:
|
||||
shape = new Konva.Shape({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
...transform,
|
||||
@@ -113,12 +108,20 @@ export class ShapeNode extends NetworkedNode {
|
||||
this.size = size;
|
||||
this.color = color;
|
||||
|
||||
// Add shape to group
|
||||
// Center the shape
|
||||
const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({
|
||||
canvasOffset: canvasPosition,
|
||||
componentSize: canvasSize,
|
||||
componentSize: this.size,
|
||||
maxSize: canvasSize,
|
||||
});
|
||||
|
||||
// If the shape is an ellipse, the center is the origin so we need to offset it
|
||||
if (shapeType === ShapeType.CIRCLE) {
|
||||
centerPosition.x += size.width / 2;
|
||||
centerPosition.y += size.height / 2;
|
||||
}
|
||||
|
||||
// Add shape to group
|
||||
// bring to the front
|
||||
this.kNode.moveToTop();
|
||||
this.kNode.setPosition(centerPosition);
|
||||
|
||||
@@ -45,7 +45,7 @@ export const Button = ({
|
||||
);
|
||||
|
||||
const className = twMerge(
|
||||
"text-sm font-medium rounded-lg px-3 py-2 border border-[#363636] shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 transition-all duration-150 flex gap-2 items-center justify-center",
|
||||
"text-sm font-medium rounded-lg px-3 py-2 border border-transparent shadow-sm focus-visible:outline focus-visible:outline-0 focus-visible:outline-offset-0 transition-all duration-150 flex gap-2 items-center justify-center",
|
||||
getVariantClassNames(propsVariant),
|
||||
propsClassName,
|
||||
disabledClass,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { CloseButton, Button } from "~/components";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import dayjs from "dayjs";
|
||||
import { faDownToLine } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
interface LightboxModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
imageUrl?: string | null;
|
||||
imageAlt?: string;
|
||||
onImageError?: () => void;
|
||||
title?: string;
|
||||
createdAt?: string;
|
||||
additionalInfo?: React.ReactNode;
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
export function LightboxModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
imageUrl,
|
||||
imageAlt = "",
|
||||
onImageError,
|
||||
title,
|
||||
createdAt,
|
||||
additionalInfo,
|
||||
downloadUrl,
|
||||
}: LightboxModalProps) {
|
||||
return createPortal(
|
||||
<Transition appear show={isOpen}>
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<Transition.Child
|
||||
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={onClose}
|
||||
/>
|
||||
</Transition.Child>
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
className="relative h-[90vh] w-[80vw] rounded-xl bg-[#2C2C2C]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CloseButton
|
||||
onClick={onClose}
|
||||
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 rounded-l-xl bg-black/40">
|
||||
{!imageUrl ? (
|
||||
<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={imageUrl}
|
||||
alt={imageAlt}
|
||||
className="h-full w-full object-contain"
|
||||
onError={onImageError}
|
||||
crossOrigin="anonymous"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-full flex-col py-5 pe-5">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="text-xl font-medium">
|
||||
{title || "Image Generation"}
|
||||
</div>
|
||||
{createdAt && (
|
||||
<div className="text-sm text-white/60">
|
||||
Created:{" "}
|
||||
{dayjs(createdAt).format("MMM D, YYYY HH:mm:ss")}
|
||||
</div>
|
||||
)}
|
||||
{additionalInfo}
|
||||
</div>
|
||||
{downloadUrl && (
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button
|
||||
icon={faDownToLine}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export const Tooltip = ({
|
||||
transitionTimingFunction: "ease-out",
|
||||
}}
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute z-10 w-max rounded-lg bg-[#5F5F68] px-2.5 py-1.5 text-sm font-medium text-white shadow-xl",
|
||||
"pointer-events-none absolute z-10 w-max rounded-lg bg-[#5F5F68] px-2.5 py-1.5 text-[13px] font-medium text-white shadow-xl",
|
||||
className ? className : "",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -27,16 +27,16 @@ 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-[60] w-full border-b border-white/5 bg-ui-background">
|
||||
<nav
|
||||
className="mx-auto grid h-[56px] w-screen grid-cols-3 items-center justify-between px-3"
|
||||
aria-label="Global"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<a href="/" className="">
|
||||
<a href="/">
|
||||
<span className="sr-only">ArtCraft</span>
|
||||
<img
|
||||
className="h-[30px] w-auto"
|
||||
className="h-[28px] w-auto"
|
||||
src="/resources/images/artcraft-logo-3.png"
|
||||
alt="Logo ArtCraft"
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,11 @@ import { SceneManager, SceneObject } from "./scene_manager_api";
|
||||
import { FreeCam } from "./free_cam";
|
||||
import Stats from "three/examples/jsm/libs/stats.module.js";
|
||||
import { FKHelper } from "./KinHelpers/FKHelper";
|
||||
import { selectedMode } from "../signals/selectedMode";
|
||||
import {
|
||||
selectedMode,
|
||||
poseMode,
|
||||
showPoseControls,
|
||||
} from "../signals/selectedMode";
|
||||
|
||||
export enum KinMode {
|
||||
FK,
|
||||
@@ -122,6 +126,10 @@ export class MouseControls {
|
||||
scene: this.scene,
|
||||
onDragChange: this.onFKControlsDragging.bind(this),
|
||||
});
|
||||
|
||||
// Expose handlePoseModeChange to window
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).__mouseControls = this;
|
||||
}
|
||||
|
||||
onFKControlsDragging(dragging: boolean) {
|
||||
@@ -185,7 +193,11 @@ export class MouseControls {
|
||||
this.setSelected(this.selected);
|
||||
this.publishSelect();
|
||||
|
||||
// this.editor.update_properties()
|
||||
if (currentObject.userData.isCharacter) {
|
||||
showPoseControls.value = true;
|
||||
}
|
||||
|
||||
// Normal selection behavior
|
||||
if (currentObject.userData["locked"] !== true && this.control) {
|
||||
this.scene.add(this.control);
|
||||
this.control.attach(currentObject);
|
||||
@@ -235,6 +247,9 @@ export class MouseControls {
|
||||
this.fkHelper.clear();
|
||||
this.kinMode = KinMode.NONE;
|
||||
this.reattachTransformControls();
|
||||
if (poseMode.value === "pose") {
|
||||
poseMode.value = "select";
|
||||
}
|
||||
console.log("FK mode off");
|
||||
return;
|
||||
}
|
||||
@@ -255,6 +270,9 @@ export class MouseControls {
|
||||
this.hideTransformControls();
|
||||
this.kinMode = KinMode.FK;
|
||||
this.fkHelper.setTarget(this.selected[0]);
|
||||
if (poseMode.value === "select") {
|
||||
poseMode.value = "pose";
|
||||
}
|
||||
console.log("FK mode on");
|
||||
return;
|
||||
}
|
||||
@@ -305,30 +323,20 @@ export class MouseControls {
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !this.isProcessing) {
|
||||
if (event.key === "Z" || event.key === "z") {
|
||||
if (event.shiftKey) {
|
||||
// redo
|
||||
this.isProcessing = true;
|
||||
await this.sceneManager?.redo();
|
||||
this.isProcessing = false;
|
||||
} else {
|
||||
// undo
|
||||
this.isProcessing = true;
|
||||
await this.sceneManager?.undo();
|
||||
this.isProcessing = false;
|
||||
}
|
||||
return;
|
||||
// redo
|
||||
this.isProcessing = true;
|
||||
await this.sceneManager?.redo();
|
||||
this.isProcessing = false;
|
||||
} else if (event.key === "c") {
|
||||
// redo
|
||||
this.isProcessing = true;
|
||||
await this.sceneManager?.copy();
|
||||
this.isProcessing = false;
|
||||
return;
|
||||
} else if (event.key === "v") {
|
||||
// redo
|
||||
this.isProcessing = true;
|
||||
await this.sceneManager?.paste();
|
||||
this.isProcessing = false;
|
||||
return;
|
||||
} else if (event.key === "0") {
|
||||
// Stats Menu
|
||||
this.enable_stats();
|
||||
@@ -454,7 +462,8 @@ export class MouseControls {
|
||||
this.selected = [];
|
||||
this.setSelected(this.selected);
|
||||
this.removeTransformControls();
|
||||
//hideObjectPanel();
|
||||
hideObjectPanel();
|
||||
showPoseControls.value = false;
|
||||
}
|
||||
|
||||
if (this.sceneManager) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ControlPanelSceneObject } from "./comps/ControlPanelSceneObject";
|
||||
import { PreviewEngineCamera } from "./comps/PreviewEngineCamera";
|
||||
import { PreviewFrameImage } from "./comps/PreviewFrameImage";
|
||||
import { pageHeight, pageWidth } from "~/signals";
|
||||
import { PoseModeSelector } from "./comps/PoseModeSelector";
|
||||
|
||||
import {
|
||||
timelineHeight,
|
||||
@@ -24,7 +25,6 @@ import { Outliner } from "./comps/Outliner";
|
||||
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 = () => {
|
||||
@@ -106,6 +106,9 @@ export const PageEditor = () => {
|
||||
{/* Focal Length Display */}
|
||||
<FocalLengthDisplay />
|
||||
|
||||
{/* Pose Mode Selector */}
|
||||
<PoseModeSelector />
|
||||
|
||||
{/* Top controls */}
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
|
||||
@@ -16,6 +16,8 @@ import * as gpu from "detect-gpu";
|
||||
import { UsersApi } from "~/Classes/ApiManager";
|
||||
import { PageEnigmaComponent } from "~/pages/PageEnigma/PageEnigmaComponent";
|
||||
import { TurnOnGpu } from "~/pages/PageEnigma/TurnOnGpu";
|
||||
import { PrecisionSelector } from "./comps/PrecisionSelector/PrecisionSelector";
|
||||
import { precisionSelectedValue, precisionSelectorMenuCoords, precisionSelectorValues, showPrecisionSelector } from "./signals/precisionSelectorMenu";
|
||||
|
||||
export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
useSignals();
|
||||
@@ -71,7 +73,7 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
case "apple gpu (Apple GPU)":
|
||||
// TODO(bt,2025-04-08): We may want to disable this heuristic.
|
||||
// We're getting lack of hardware acceleration using Tauri on Mac and Linux.
|
||||
isValid = true;
|
||||
isValid = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -93,6 +95,12 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
<EngineProvider sceneToken={sceneToken}>
|
||||
<PageEnigmaComponent />
|
||||
<DragComponent />
|
||||
<PrecisionSelector
|
||||
showSignal={showPrecisionSelector}
|
||||
coordSignal={precisionSelectorMenuCoords}
|
||||
valuesSignal={precisionSelectorValues}
|
||||
selectedValueSignal={precisionSelectedValue}
|
||||
/>
|
||||
<GenerateModals />
|
||||
<ErrorDialog />
|
||||
<Wizard />
|
||||
|
||||
+13
-1
@@ -22,6 +22,7 @@ import { sanitize } from "./utils/sanitize";
|
||||
import { objectMismatch } from "~/pages/PageEnigma/comps/ControlPanelSceneObject/utils/objectMismatch";
|
||||
import { XYZ } from "~/pages/PageEnigma/datastructures/common";
|
||||
import { pageHeight } from "~/signals";
|
||||
import { DraggablePrecisionMutator } from "./DraggablePrecisionMutator";
|
||||
|
||||
// TODO this will be useful later to fix the bug on leading zeros
|
||||
// const formatNumber = (input: string): number => {
|
||||
@@ -147,6 +148,15 @@ export const ControlPanelSceneObject = () => {
|
||||
setLocalRotation(xyz);
|
||||
};
|
||||
|
||||
const handleUniformScaleChange = (scale: number) => {
|
||||
const updatedScaleValues: Record<string, string> = {};
|
||||
updatedScaleValues.x = (parseFloat(localScale.x) + scale).toString();
|
||||
updatedScaleValues.y = (parseFloat(localScale.y) + scale).toString();
|
||||
updatedScaleValues.z = (parseFloat(localScale.z) + scale).toString();
|
||||
|
||||
handleScaleChange(updatedScaleValues);
|
||||
}
|
||||
|
||||
const handleScaleChange = (xyz: Record<string, string>) => {
|
||||
if (isInvalid(xyz)) {
|
||||
setLocalScale(xyz);
|
||||
@@ -255,7 +265,9 @@ export const ControlPanelSceneObject = () => {
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex flex-col gap-1">
|
||||
<H5>Scale</H5>
|
||||
<DraggablePrecisionMutator onChange={handleUniformScaleChange}>
|
||||
<h5>Scale</h5>
|
||||
</DraggablePrecisionMutator>
|
||||
<InputVector
|
||||
x={localScale.x.toString()}
|
||||
y={localScale.y.toString()}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { precisionSelectedValue, precisionSelectorMenuCoords, precisionSelectorValues, showPrecisionSelector } from "../../signals/precisionSelectorMenu";
|
||||
|
||||
const DEFAULT_PRECISIONS = [
|
||||
10,
|
||||
1,
|
||||
0.1,
|
||||
0.01,
|
||||
0.001
|
||||
]
|
||||
|
||||
export const DraggablePrecisionMutator = ({
|
||||
precisions = DEFAULT_PRECISIONS,
|
||||
className = "",
|
||||
initialValue = 0,
|
||||
onChange = () => { },
|
||||
onCommit = () => { },
|
||||
children
|
||||
}: {
|
||||
precisions?: number[],
|
||||
className?: string,
|
||||
initialValue?: number,
|
||||
onChange?: (value: number) => void,
|
||||
onCommit?: (value: number) => void,
|
||||
children?: React.ReactNode
|
||||
}) => {
|
||||
const classes = twMerge(["cursor-ew-resize", className]);
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragStartCoordsRef = useRef<{ x: number, y: number } | null>(null);
|
||||
const localAdjustedValueRef = useRef(initialValue);
|
||||
|
||||
const handleMouseMove = useCallback((event: MouseEvent) => {
|
||||
// Ignore mouse movement if we're not even dragging
|
||||
// or if the menu is still open (we're selecting the precision)
|
||||
if (!isDraggingRef.current || showPrecisionSelector.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
// Record the initial drag coordinates if they haven't been set yet
|
||||
if (!dragStartCoordsRef.current) {
|
||||
dragStartCoordsRef.current = {
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Check how far the mouse has moved, and calculate the updated change
|
||||
const distX = event.x - dragStartCoordsRef.current.x;
|
||||
const scaledDistX = distX / 10;
|
||||
const scaledPrecision = Math.round(scaledDistX) * precisionSelectedValue.value;
|
||||
localAdjustedValueRef.current = initialValue + scaledPrecision;
|
||||
|
||||
// Update the listeners
|
||||
onChange(localAdjustedValueRef.current);
|
||||
}, [initialValue, onChange]);
|
||||
|
||||
const handleMouseUp = useCallback((event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
showPrecisionSelector.value = false;
|
||||
isDraggingRef.current = false;
|
||||
dragStartCoordsRef.current = null;
|
||||
|
||||
// clean up the mouse listeners
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
|
||||
// Call back the commit listener
|
||||
onCommit(localAdjustedValueRef.current);
|
||||
}, [handleMouseMove, onCommit]);
|
||||
|
||||
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
precisionSelectorMenuCoords.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
precisionSelectorValues.value = precisions;
|
||||
showPrecisionSelector.value = true;
|
||||
isDraggingRef.current = true;
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}, [handleMouseMove, handleMouseUp, precisions]);
|
||||
|
||||
return (
|
||||
<div className={classes} ref={divRef} onMouseDown={handleMouseDown}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ export const FocalLengthDisplay = () => {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute left-1/2 top-16 -translate-x-1/2 transform">
|
||||
<div className="absolute left-1/2 top-16 z-10 -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>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { activeImageGenerationJobs, toasts, userMovies } from "~/signals";
|
||||
import { useEffect, useState } from "react";
|
||||
import { JobsApi } from "~/Classes/ApiManager/JobsApi";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ActiveJob } from "~/pages/PageEnigma/models";
|
||||
// TODO ensure we de-dupe all this extra code.
|
||||
interface CompletedItem {
|
||||
token: string;
|
||||
@@ -18,17 +18,6 @@ interface CompletedItem {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ActiveJob {
|
||||
job_token: string;
|
||||
request: {
|
||||
maybe_model_title: string;
|
||||
};
|
||||
status: {
|
||||
status: string;
|
||||
progress_percentage: number;
|
||||
};
|
||||
}
|
||||
|
||||
// {
|
||||
// "job_token": "jinf_421nnr6rj8wy2sj0s11v9fns32s",
|
||||
// "request": {
|
||||
@@ -80,65 +69,78 @@ export function Activity() {
|
||||
try {
|
||||
const jobsApi = new JobsApi();
|
||||
const jobsResponse = await jobsApi.ListRecentJobs();
|
||||
|
||||
|
||||
if (jobsResponse.success && jobsResponse.data) {
|
||||
// Process active jobs
|
||||
const activeJobs = jobsResponse.data
|
||||
.filter(job => {
|
||||
.filter((job) => {
|
||||
const status = job.status?.status?.toLowerCase();
|
||||
return status && status !== "complete_success" && status !== "failed";
|
||||
return (
|
||||
status && status !== "complete_success" && status !== "failed"
|
||||
);
|
||||
})
|
||||
.map(job => ({
|
||||
.map((job) => ({
|
||||
job_token: job.job_token,
|
||||
request: {
|
||||
maybe_model_title: job.request?.maybe_model_title || "Image Generation"
|
||||
maybe_model_title:
|
||||
job.request?.maybe_model_title || "Image Generation",
|
||||
},
|
||||
status: {
|
||||
status: job.status?.status?.toUpperCase() || "",
|
||||
progress_percentage: job.status?.progress_percentage || 0
|
||||
}
|
||||
progress_percentage: job.status?.progress_percentage || 0,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
// Process completed items
|
||||
// Get completed items and filter out any that are already in our list
|
||||
const successfulJobs = jobsResponse.data
|
||||
.filter(job => job.status?.status?.toLowerCase() === "complete_success");
|
||||
|
||||
const successfulJobs = jobsResponse.data.filter(
|
||||
(job) => job.status?.status?.toLowerCase() === "complete_success",
|
||||
);
|
||||
|
||||
// Create a set of existing tokens for faster lookup
|
||||
const existingTokens = new Set(completedItems.map(item => item.token));
|
||||
|
||||
const existingTokens = new Set(
|
||||
completedItems.map((item) => item.token),
|
||||
);
|
||||
|
||||
// Filter out jobs we already have in our list
|
||||
const newCompletedItems = successfulJobs
|
||||
.filter(job => !existingTokens.has(job.job_token))
|
||||
.map(job => ({
|
||||
.filter((job) => !existingTokens.has(job.job_token))
|
||||
.map((job) => ({
|
||||
token: job.job_token,
|
||||
maybe_title: job.request?.maybe_model_title || "Image Generation",
|
||||
public_bucket_path: job.maybe_result?.maybe_public_bucket_media_path || "",
|
||||
updated_at: job.updated_at || new Date().toISOString()
|
||||
maybe_title:
|
||||
job.request?.maybe_model_title || "Image Generation",
|
||||
public_bucket_path:
|
||||
job.maybe_result?.maybe_public_bucket_media_path || "",
|
||||
updated_at: job.updated_at || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
|
||||
// Show toast notification for newly completed items
|
||||
if (newCompletedItems.length > 0) {
|
||||
const count = newCompletedItems.length;
|
||||
const message = count === 1
|
||||
? `${newCompletedItems[0].maybe_title} completed successfully`
|
||||
: `${count} images completed successfully`;
|
||||
const message =
|
||||
count === 1
|
||||
? `${newCompletedItems[0].maybe_title} completed successfully`
|
||||
: `${count} images completed successfully`;
|
||||
toast.success(message);
|
||||
}
|
||||
|
||||
// Update completed items with a maximum limit of 50 items
|
||||
setCompletedItems(prev => {
|
||||
setCompletedItems((prev) => {
|
||||
const combined = [...newCompletedItems, ...prev];
|
||||
// Remove duplicates based on token
|
||||
const uniqueItems = Array.from(
|
||||
new Map(combined.map(item => [item.token, item])).values()
|
||||
new Map(combined.map((item) => [item.token, item])).values(),
|
||||
);
|
||||
// Sort by updated_at in descending order and limit to 50 items
|
||||
return uniqueItems
|
||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updated_at).getTime() -
|
||||
new Date(a.updated_at).getTime(),
|
||||
)
|
||||
.slice(0, 50);
|
||||
});
|
||||
setJobs(activeJobs);
|
||||
setJobs(activeJobs as ActiveJob[]);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -158,6 +160,7 @@ export function Activity() {
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -170,23 +173,26 @@ export function Activity() {
|
||||
align="end"
|
||||
triggerIcon={
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faBell} />
|
||||
{activeImageGenerationJobs.value &&
|
||||
activeImageGenerationJobs.value.length > 0 && (
|
||||
<div className="bg-blue-500 absolute -right-1 -top-1 h-3 w-3 animate-pulse rounded-full" />
|
||||
)}
|
||||
{jobs && jobs.length > 0 ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faBell} />
|
||||
)}
|
||||
{jobs && jobs.length > 0 && (
|
||||
<div className="bg-blue-500 absolute -right-1 -top-1 h-3 w-3 animate-pulse rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="max-h-[480px] overflow-y-auto">
|
||||
{/* In progress */}
|
||||
{jobs && jobs.length > 0 && (
|
||||
<div>
|
||||
{jobs.map((job) => (
|
||||
<InProgressCard key={job.job_token} movie={job} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{jobs.map((job) => (
|
||||
<InProgressCard key={job.job_token} job={job} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!userMovies.value && !activeImageGenerationJobs.value ? (
|
||||
<div className="flex h-48 w-full flex-col justify-center gap-4 p-4 text-center align-middle">
|
||||
@@ -212,16 +218,10 @@ export function Activity() {
|
||||
{/* Completed */}
|
||||
<div className="flex flex-col">
|
||||
{completedItems.map((item) => (
|
||||
<CompletedCard
|
||||
key={item.token}
|
||||
movie={item}
|
||||
setMovieId={(item_id) => {
|
||||
console.log("item_id", item_id);
|
||||
}}
|
||||
/>
|
||||
<CompletedCard key={item.token} job={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverMenu>
|
||||
|
||||
+65
-79
@@ -1,98 +1,84 @@
|
||||
import { MediaInfo } from "~/pages/PageEnigma/models/movies";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faArrowDownToLine } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { useRef, useState } from "react";
|
||||
import { BucketConfig } from "~/api/BucketConfig";
|
||||
import { useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import environmentVariables from "~/Classes/EnvironmentVariables";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { downloadFileImage } from "~/pages/PageEnigma/comps/GenerateModals/utils/downloadFile";
|
||||
import { styleList } from "~/pages/PageEnigma/styleList";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faDownToLine } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { LightboxModal } from "~/components/reusable/LightboxModal";
|
||||
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
|
||||
|
||||
export function getStyleName(typeInput: string) {
|
||||
const foundStyle = styleList.find((style) => {
|
||||
return style.type === typeInput;
|
||||
});
|
||||
return foundStyle ? foundStyle.label : "Unknown Style";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
movie: MediaInfo;
|
||||
setMovieId?: (id: string) => void;
|
||||
job: {
|
||||
token: string;
|
||||
maybe_title: string;
|
||||
updated_at: string;
|
||||
public_bucket_path: string;
|
||||
maybe_style_name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function CompletedCard({ movie, setMovieId }: Props) {
|
||||
useSignals();
|
||||
const bucketConfig = useRef<BucketConfig>(new BucketConfig());
|
||||
export function CompletedCard({ job }: Props) {
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const cdnOrigin = GetCdnOrigin();
|
||||
const mediaPath = job?.public_bucket_path || "";
|
||||
const downloadLink = `${cdnOrigin}${mediaPath}`;
|
||||
const imageUrl = downloadLink;
|
||||
const fullImageUrl = downloadLink;
|
||||
|
||||
|
||||
const downloadLink = `${cdnOrigin}${movie.public_bucket_path}`;
|
||||
|
||||
// console.log(downloadLink);
|
||||
// console.log("This is the completed item", movie);
|
||||
// console.log("item completed card", movie);
|
||||
|
||||
// to do use the thumbnail image
|
||||
const imageUrl = downloadLink; // bucketConfig.current.getCdnUrl(
|
||||
// movie.public_bucket_path + "-thumb.gif",
|
||||
// 360,
|
||||
// 20,
|
||||
// );
|
||||
const styleName =
|
||||
movie.maybe_style_name && getStyleName(movie.maybe_style_name);
|
||||
|
||||
const handleClick = () => {
|
||||
setMovieId?.(movie.token);
|
||||
};
|
||||
if (!job) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-center justify-between rounded-lg p-2 text-start transition-all duration-150 hover:cursor-pointer hover:bg-white/10"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex aspect-square h-14 w-14 justify-center overflow-hidden rounded-lg border border-[#A9A9A9]/50 bg-black/60">
|
||||
<img
|
||||
src={
|
||||
loadError ? "/resources/images/movie-placeholder.png" : imageUrl
|
||||
}
|
||||
className="h-full w-full object-cover"
|
||||
alt={movie.maybe_title ?? "unknown"}
|
||||
crossOrigin="anonymous"
|
||||
onError={() => setLoadError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-1">
|
||||
<div className="font-medium">{movie.maybe_title || "Untitled"}</div>
|
||||
<div>
|
||||
{/* {styleName && (
|
||||
<div className="text-sm text-white/60">{styleName}</div>
|
||||
)} */}
|
||||
<>
|
||||
<div
|
||||
className="flex w-full items-center justify-between rounded-lg p-2 text-start transition-all duration-150 hover:cursor-pointer hover:bg-white/10"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex aspect-square h-14 w-14 justify-center overflow-hidden rounded-lg border border-[#A9A9A9]/50 bg-black/60">
|
||||
<img
|
||||
src={
|
||||
loadError ? "/resources/images/movie-placeholder.png" : imageUrl
|
||||
}
|
||||
className="h-full w-full object-cover"
|
||||
alt={job.maybe_title ?? "unknown"}
|
||||
crossOrigin="anonymous"
|
||||
onError={() => setLoadError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-1">
|
||||
<div className="font-medium">{job.maybe_title || "Untitled"}</div>
|
||||
<div className="text-sm text-white/60">
|
||||
{dayjs(movie.updated_at).format("MMM D, YYYY HH:mm:ss")}
|
||||
{dayjs(job.updated_at).format("MMM D, YYYY HH:mm:ss")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pr-5">
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const title =
|
||||
movie.maybe_title !== null ? movie.maybe_title : "Untitled";
|
||||
downloadFileImage({ url: downloadLink, title });
|
||||
}}
|
||||
className="text-[15px] font-medium text-white/50 transition-all duration-150 hover:text-white/100"
|
||||
<a
|
||||
href={downloadLink}
|
||||
download
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex h-9 w-9 items-center justify-center text-lg text-white/60 transition-all hover:text-white"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowDownToLine} className="mr-2" />
|
||||
Download
|
||||
</button>
|
||||
<FontAwesomeIcon icon={faDownToLine} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LightboxModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
imageUrl={fullImageUrl}
|
||||
imageAlt={job.maybe_title ?? "unknown"}
|
||||
onImageError={() => setLoadError(true)}
|
||||
title={job.maybe_title}
|
||||
createdAt={job.updated_at}
|
||||
downloadUrl={downloadLink}
|
||||
additionalInfo={
|
||||
job.maybe_style_name && (
|
||||
<div className="text-sm text-white/60">
|
||||
Style: {job.maybe_style_name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+19
-25
@@ -1,22 +1,15 @@
|
||||
import { useCallback } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faClose,
|
||||
faCircleExclamation,
|
||||
faSpinnerThird,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
import { faClose, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { ToastTypes } from "~/enums";
|
||||
import { addToast } from "~/signals";
|
||||
|
||||
import { JobState } from "~/pages/PageEnigma/enums";
|
||||
import { ActiveJob } from "~/pages/PageEnigma/models";
|
||||
|
||||
import { Tooltip } from "~/components";
|
||||
import { JobsApi } from "~/Classes/ApiManager";
|
||||
import { PollRecentJobs } from "~/hooks/useActiveJobs/utilities";
|
||||
interface Props {
|
||||
movie: ActiveJob;
|
||||
job: ActiveJob;
|
||||
}
|
||||
|
||||
function getPercent(status: JobState, percentage: number) {
|
||||
@@ -33,16 +26,16 @@ function getPercent(status: JobState, percentage: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export function InProgressCard({ movie }: Props) {
|
||||
export function InProgressCard({ job }: Props) {
|
||||
const completePercent = getPercent(
|
||||
movie.status.status as JobState,
|
||||
movie.status.progress_percentage,
|
||||
job.status.status as JobState,
|
||||
job.status.progress_percentage,
|
||||
);
|
||||
|
||||
const deleteJob = useCallback(async (movieJob: ActiveJob) => {
|
||||
const deleteJob = useCallback(async (job: ActiveJob) => {
|
||||
const jobsApi = new JobsApi();
|
||||
|
||||
const response = await jobsApi.DeleteJobByToken(movieJob.job_token);
|
||||
const response = await jobsApi.DeleteJobByToken(job.job_token);
|
||||
if (response.success) {
|
||||
PollRecentJobs();
|
||||
addToast(ToastTypes.SUCCESS, "File successfully deleted.");
|
||||
@@ -58,32 +51,33 @@ export function InProgressCard({ movie }: Props) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between rounded-lg p-2 text-start transition-all duration-150 hover:bg-white/10">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex aspect-square h-14 w-14 items-center justify-center overflow-hidden rounded-lg border border-[#A9A9A9]/50 bg-black/60">
|
||||
{(movie.status.status === JobState.STARTED ||
|
||||
movie.status.status === JobState.PENDING) && (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} spin size={"lg"} />
|
||||
<div className="flex aspect-square h-14 w-14 items-center justify-center rounded-lg border border-[#A9A9A9]/50 bg-black/60 text-white">
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
{/* {(job.status.status === JobState.STARTED ||
|
||||
job.status.status === JobState.PENDING) && (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
)}
|
||||
{(movie.status.status === JobState.COMPLETE_FAILURE ||
|
||||
movie.status.status === JobState.ATTEMPT_FAILED) && (
|
||||
{(job.status.status === JobState.COMPLETE_FAILURE ||
|
||||
job.status.status === JobState.ATTEMPT_FAILED) && (
|
||||
<FontAwesomeIcon icon={faCircleExclamation} size={"lg"} />
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-1">
|
||||
<div className="font-medium">
|
||||
{movie.request.maybe_model_title || "Untitled"}
|
||||
{job.request.maybe_model_title || "Image Generation"}
|
||||
</div>
|
||||
|
||||
<div className="text-sm capitalize text-white/60">
|
||||
{movie.status.status.replaceAll("_", " ")}... {completePercent}%
|
||||
{job.status.status.replaceAll("_", " ")}... {completePercent}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{movie.status.status !== JobState.STARTED && (
|
||||
{job.status.status !== JobState.STARTED && (
|
||||
<div className="pr-5">
|
||||
<Tooltip content="Cancel" position="left">
|
||||
<button
|
||||
onClick={() => deleteJob(movie)}
|
||||
onClick={() => deleteJob(job)}
|
||||
className="text-[15px] font-medium text-white/50 transition-all duration-150 hover:text-white/100"
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} />
|
||||
|
||||
@@ -3,19 +3,12 @@ import { faCheck } 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 React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { LibraryModalApi } from "./LibraryModalApi";
|
||||
import { FilterMediaClasses } from "../../../../enums";
|
||||
import { BucketConfig } from "../../../../api/BucketConfig";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { LightboxModal } from "~/components/reusable/LightboxModal";
|
||||
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
|
||||
|
||||
export interface LibraryItem {
|
||||
id: string;
|
||||
@@ -64,7 +57,8 @@ export const LibraryModal = React.memo(
|
||||
);
|
||||
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
|
||||
const [failedImageUrls] = useState<Set<string>>(new Set());
|
||||
const bucketConfig = useMemo(() => new BucketConfig(), []);
|
||||
const cdnOrigin = GetCdnOrigin();
|
||||
const imageUrl = lightboxImage?.fullImage || "";
|
||||
const api = useMemo(() => new LibraryModalApi(), []);
|
||||
|
||||
const formatDate = useCallback((date: string) => {
|
||||
@@ -109,10 +103,11 @@ export const LibraryModal = React.memo(
|
||||
const getImageUrl = useCallback(
|
||||
(path: string | undefined | null) => {
|
||||
if (!path) return null;
|
||||
const url = bucketConfig.getGcsUrl(path);
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const url = `${cdnOrigin}${normalizedPath}`;
|
||||
return failedImageUrls.has(url) ? null : url;
|
||||
},
|
||||
[bucketConfig, failedImageUrls],
|
||||
[cdnOrigin, failedImageUrls],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -125,11 +120,8 @@ export const LibraryModal = React.memo(
|
||||
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,
|
||||
),
|
||||
label: item.maybe_title || "Image Generation",
|
||||
thumbnail: getImageUrl(item.public_bucket_path),
|
||||
fullImage: getImageUrl(item.public_bucket_path),
|
||||
createdAt: item.created_at,
|
||||
}));
|
||||
@@ -318,74 +310,16 @@ export const LibraryModal = React.memo(
|
||||
</div>
|
||||
</TransitionDialogue>
|
||||
|
||||
{/* Lightbox Modal */}
|
||||
{lightboxImage &&
|
||||
createPortal(
|
||||
<Transition appear show={isLightboxVisible} as={Fragment}>
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<Transition.Child
|
||||
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
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>,
|
||||
document.body,
|
||||
)}
|
||||
<LightboxModal
|
||||
isOpen={isLightboxVisible}
|
||||
onClose={handleCloseLightbox}
|
||||
imageUrl={imageUrl}
|
||||
imageAlt={lightboxImage?.label || ""}
|
||||
onImageError={() => imageUrl && handleImageError(imageUrl)}
|
||||
title={lightboxImage?.label}
|
||||
createdAt={lightboxImage?.createdAt}
|
||||
downloadUrl={imageUrl}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { poseMode, showPoseControls } from "../signals/selectedMode";
|
||||
import { Button, Tooltip } from "~/components";
|
||||
import {
|
||||
faCheck,
|
||||
faCheckDouble,
|
||||
faPersonRunning,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__mouseControls?: {
|
||||
toggleFKMode: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface PoseModeSelectorProps {}
|
||||
|
||||
export const PoseModeSelector: React.FC<PoseModeSelectorProps> = () => {
|
||||
useSignals();
|
||||
|
||||
const handleModeChange = () => {
|
||||
if (poseMode.value === "select") {
|
||||
poseMode.value = "pose";
|
||||
} else {
|
||||
poseMode.value = "select";
|
||||
}
|
||||
window.__mouseControls?.toggleFKMode();
|
||||
};
|
||||
|
||||
if (showPoseControls.value === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed left-1/2 top-32 flex -translate-x-1/2 transform items-center justify-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip
|
||||
content={"Toggle pose mode (K)"}
|
||||
position={"bottom"}
|
||||
delay={300}
|
||||
closeOnClick={true}
|
||||
>
|
||||
<>
|
||||
{poseMode.value === "select" ? (
|
||||
<Button
|
||||
icon={faPersonRunning}
|
||||
onClick={handleModeChange}
|
||||
className="rounded-xl shadow-xl outline-none focus-visible:outline-none"
|
||||
>
|
||||
Enter Pose Mode
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon={faCheck}
|
||||
onClick={handleModeChange}
|
||||
className="rounded-xl outline-none focus-visible:outline-none"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { Signal } from "@preact/signals-react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
|
||||
export const PrecisionSelector = ({
|
||||
showSignal,
|
||||
coordSignal,
|
||||
valuesSignal,
|
||||
selectedValueSignal
|
||||
}: {
|
||||
showSignal: Signal<boolean>,
|
||||
coordSignal: Signal<{ x: number; y: number }>,
|
||||
valuesSignal: Signal<number[]>
|
||||
selectedValueSignal: Signal<number>
|
||||
}) => {
|
||||
useSignals();
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
showSignal.value = false;
|
||||
}
|
||||
|
||||
const handleMouseEnterItem = (scale: number) => {
|
||||
selectedValueSignal.value = scale;
|
||||
console.log("selectedValueSignal", selectedValueSignal.value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 bg-red-600 -translate-x-1/2 -translate-y-1/2 flex-col gap-[1px] shadow-md bg-ui-divider border-ui-divider border-2 rounded-md overflow-clip"
|
||||
style={{
|
||||
top: coordSignal.value.y,
|
||||
left: coordSignal.value.x,
|
||||
display: showSignal.value ? "flex" : "none"
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{valuesSignal.value.map((scale, index) => (
|
||||
<span onMouseEnter={() => handleMouseEnterItem(scale)} key={index}
|
||||
className="flex bg-ui-panel text-sm justify-center align-middle justify-items-center px-2 hover:bg-ui-controls"
|
||||
>{scale}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Signal, signal } from "@preact/signals-react";
|
||||
|
||||
export const showPrecisionSelector = signal(false);
|
||||
export const precisionSelectorMenuCoords = signal({ x: 0, y: 0 });
|
||||
export const precisionSelectorValues: Signal<number[]> = signal([]);
|
||||
export const precisionSelectedValue = signal(0);
|
||||
@@ -1,3 +1,5 @@
|
||||
import { signal } from "@preact/signals-react";
|
||||
|
||||
export const selectedMode = signal("move");
|
||||
export const showPoseControls = signal(false);
|
||||
export const poseMode = signal("select");
|
||||
|
||||
Reference in New Issue
Block a user