mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-30 05:22:51 -04:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
442bf66d55 | ||
|
|
9ead129bfc | ||
|
|
ecc4f800ab | ||
|
|
b42f457022 | ||
|
|
cb3f7906a2 | ||
|
|
004a5c157a | ||
|
|
63d3d63709 | ||
|
|
d6e23838ce | ||
|
|
6785db1d59 |
@@ -20,10 +20,13 @@ then the story, then the fine print.
|
||||
### Ordering within a file
|
||||
|
||||
1. **Constants** at the top, right after imports.
|
||||
2. **Structs, enums, and type definitions** next. If a struct contains another struct, place
|
||||
the outer/larger struct above the inner/smaller one.
|
||||
3. **Type ordering for API types**: Request, then Response (success), then Error.
|
||||
4. **`impl` blocks** after their type definitions.
|
||||
2. **The primary public type** (the main struct/enum the file is about) comes next.
|
||||
A reader opening this file should immediately see what it defines.
|
||||
3. **Supporting/subordinate types** (sub-structs, helper enums, serialization DTOs) go
|
||||
below the primary type, not above it. Ask: "Would a reader need to know this type
|
||||
exists before seeing the main type?" — if no, push it down.
|
||||
4. **Type ordering for API types**: Request, then Response (success), then Error.
|
||||
5. **`impl` blocks** after their type definitions.
|
||||
|
||||
### Ordering within an `impl` block
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::notification::generate_deduplication_key::generate_deduplication_key;
|
||||
use crate::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use crate::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
/// Details for a pager notification.
|
||||
@@ -20,7 +19,7 @@ pub struct NotificationDetails {
|
||||
pub(crate) event_time: DateTime<Utc>,
|
||||
|
||||
/// The error that triggered this notification, if any.
|
||||
pub(crate) maybe_error: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
|
||||
pub(crate) maybe_error: Option<std::sync::Arc<dyn std::error::Error + Send + Sync + 'static>>,
|
||||
|
||||
/// Whether this notification originated from an error.
|
||||
pub(crate) is_from_error: bool,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
use crate::notification::notification_details::NotificationDetails;
|
||||
use crate::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
pub struct NotificationDetailsBuilder {
|
||||
title: String,
|
||||
pub(crate) title: Option<String>,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) urgency: Option<NotificationUrgency>,
|
||||
pub(crate) event_time: DateTime<Utc>,
|
||||
|
||||
pub(crate) maybe_error: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
|
||||
pub(crate) maybe_error: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
|
||||
pub(crate) is_from_error: bool,
|
||||
|
||||
pub(crate) http_method: Option<String>,
|
||||
@@ -24,10 +25,12 @@ pub struct NotificationDetailsBuilder {
|
||||
}
|
||||
|
||||
impl NotificationDetailsBuilder {
|
||||
/// Create a builder from a summary string.
|
||||
// --- Constructors ---
|
||||
|
||||
/// Create a builder with an explicit title.
|
||||
pub fn from_title(title: String) -> Self {
|
||||
Self {
|
||||
title,
|
||||
title: Some(title),
|
||||
description: None,
|
||||
urgency: None,
|
||||
event_time: Utc::now(),
|
||||
@@ -43,13 +46,16 @@ impl NotificationDetailsBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a builder from a boxed error.
|
||||
///
|
||||
/// Sets the title to a placeholder and attaches the error object.
|
||||
/// Callers should chain `.set_title()` to provide a meaningful title.
|
||||
pub fn from_error(error: Box<dyn std::error::Error + Send + Sync + 'static>) -> Self {
|
||||
/// Create a builder from a boxed error. Converts to `Arc` internally.
|
||||
pub fn from_boxed_error(error: Box<dyn std::error::Error + Send + Sync + 'static>) -> Self {
|
||||
Self::from_error(Arc::from(error))
|
||||
}
|
||||
|
||||
/// Create a builder from an `Arc`'d error.
|
||||
/// Title will be auto-generated from the error if not explicitly set.
|
||||
pub fn from_error(error: Arc<dyn std::error::Error + Send + Sync + 'static>) -> Self {
|
||||
Self {
|
||||
title: "Notification from Error".to_string(),
|
||||
title: None,
|
||||
description: None,
|
||||
urgency: None,
|
||||
event_time: Utc::now(),
|
||||
@@ -65,8 +71,10 @@ impl NotificationDetailsBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Setters ---
|
||||
|
||||
pub fn set_title(mut self, title: String) -> Self {
|
||||
self.title = title;
|
||||
self.title = Some(title);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -80,7 +88,7 @@ impl NotificationDetailsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_error(mut self, error: Option<Box<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
|
||||
pub fn set_error(mut self, error: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
|
||||
self.maybe_error = error;
|
||||
self
|
||||
}
|
||||
@@ -120,9 +128,16 @@ impl NotificationDetailsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
// --- Build ---
|
||||
|
||||
pub fn build(self) -> NotificationDetails {
|
||||
let title = match self.title {
|
||||
Some(t) => t,
|
||||
None => Self::generate_title(&self.maybe_error, &self.http_method, &self.http_path),
|
||||
};
|
||||
|
||||
NotificationDetails {
|
||||
title: self.title,
|
||||
title,
|
||||
description: self.description,
|
||||
urgency: self.urgency,
|
||||
event_time: self.event_time,
|
||||
@@ -137,4 +152,41 @@ impl NotificationDetailsBuilder {
|
||||
third_party_id: self.third_party_id,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
/// Generate a reasonable title from available context.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. Error + HTTP info: "POST /v1/foo - SomeError: details"
|
||||
/// 2. Error alone: "SomeError: details"
|
||||
/// 3. HTTP info alone: "POST /v1/foo - Unknown Error"
|
||||
/// 4. Nothing: "Unknown Error"
|
||||
fn generate_title(
|
||||
maybe_error: &Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
|
||||
http_method: &Option<String>,
|
||||
http_path: &Option<String>,
|
||||
) -> String {
|
||||
let http_prefix = match (http_method, http_path) {
|
||||
(Some(method), Some(path)) => Some(format!("{} {}", method, path)),
|
||||
(None, Some(path)) => Some(path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let error_summary = maybe_error.as_ref().map(|err| {
|
||||
let msg = format!("{}", err);
|
||||
if msg.len() > 150 {
|
||||
format!("{}...", &msg[..147])
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
});
|
||||
|
||||
match (http_prefix, error_summary) {
|
||||
(Some(prefix), Some(summary)) => format!("{} - {}", prefix, summary),
|
||||
(None, Some(summary)) => summary,
|
||||
(Some(prefix), None) => format!("{} - Unknown Error", prefix),
|
||||
(None, None) => "Unknown Error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,18 @@ pub enum DatabaseQueryError {
|
||||
AnyhowError(anyhow::Error),
|
||||
}
|
||||
|
||||
impl std::error::Error for DatabaseQueryError {}
|
||||
|
||||
impl std::fmt::Display for DatabaseQueryError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DatabaseQueryError::IdempotencyDuplicateKeyError => write!(f, "Idempotency duplicate key error"),
|
||||
DatabaseQueryError::SqlxError(err) => write!(f, "SQLX error: {}", err),
|
||||
DatabaseQueryError::AnyhowError(err) => write!(f, "Anyhow error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for DatabaseQueryError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
DatabaseQueryError::AnyhowError(err)
|
||||
|
||||
@@ -58,7 +58,12 @@ pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server> {
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
})
|
||||
.bind(bind_address)?
|
||||
.bind(&bind_address)
|
||||
.unwrap_or_else(|err| {
|
||||
eprintln!("FATAL: Failed to bind to address '{}': {}", bind_address, err);
|
||||
eprintln!("The address is likely already in use by another process.");
|
||||
std::process::exit(1);
|
||||
})
|
||||
.workers(num_workers)
|
||||
.run();
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn alert_pager_and_return_err<T>(
|
||||
) -> anyhow::Result<T> {
|
||||
let err_message = format!("{:#}", err);
|
||||
|
||||
let mut builder = NotificationDetailsBuilder::from_error(err.into())
|
||||
let mut builder = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
.set_title(title.to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium));
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ pub async fn process_failed_job(
|
||||
err,
|
||||
);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_error(err.into())
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
.set_title("Seedance2Pro refund failed".to_string())
|
||||
.set_inference_job_token(Some(job.job_token.to_string()))
|
||||
.set_third_party_id(Some(job.order_id.to_string()))
|
||||
@@ -140,7 +140,7 @@ pub async fn process_failed_job(
|
||||
err
|
||||
);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_error(err.into())
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
.set_title("Seedance2Pro mark job failed error".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
|
||||
@@ -58,7 +58,12 @@ pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server> {
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
})
|
||||
.bind(bind_address)?
|
||||
.bind(&bind_address)
|
||||
.unwrap_or_else(|err| {
|
||||
eprintln!("FATAL: Failed to bind to address '{}': {}", bind_address, err);
|
||||
eprintln!("The address is likely already in use by another process.");
|
||||
std::process::exit(1);
|
||||
})
|
||||
.workers(num_workers)
|
||||
.run();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ pub fn alert_pager_and_return_err<T>(
|
||||
) -> anyhow::Result<T> {
|
||||
let err_message = format!("{:#}", err);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_error(err.into())
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
.set_title(title.to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
|
||||
@@ -48,6 +48,11 @@ async fn main() -> Result<(), impl Error> {
|
||||
.url("/api-docs/openapi.json", ApiDoc::openapi()),
|
||||
)
|
||||
})
|
||||
.bind(("localhost", 8989)).unwrap()
|
||||
.bind(("localhost", 8989))
|
||||
.unwrap_or_else(|err| {
|
||||
eprintln!("FATAL: Failed to bind docs server to localhost:8989: {}", err);
|
||||
eprintln!("The address is likely already in use by another process.");
|
||||
std::process::exit(1);
|
||||
})
|
||||
.run().await
|
||||
}
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError};
|
||||
use anyhow::anyhow;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
|
||||
/// An error type for actix-web handlers that wraps causal errors for debugging
|
||||
/// and paging while presenting safe, generic HTTP responses to users.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub async fn my_handler(...) -> Result<Json<MyResponse>, AdvancedCommonWebError> {
|
||||
/// // sqlx errors, anyhow errors, session errors — all convert via ?
|
||||
/// let user = require_user_session_using_connection(&req, ...)?;
|
||||
/// let data = some_db_query(&pool).await?;
|
||||
///
|
||||
/// if data.is_none() {
|
||||
/// return Err(AdvancedCommonWebError::NotFound);
|
||||
/// }
|
||||
///
|
||||
/// Ok(Json(MyResponse { data }))
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Errors converted via `From<T>` become `UncaughtServerError` (always 500).
|
||||
/// The wrapped cause is never shown to users but is available to the error
|
||||
/// alerting middleware for paging and logging.
|
||||
#[derive(Clone)]
|
||||
pub enum AdvancedCommonWebError {
|
||||
/// 400 Bad Request with a user-facing message.
|
||||
BadInputWithSimpleMessage(String),
|
||||
/// 401 Unauthorized.
|
||||
NotAuthorized,
|
||||
/// 404 Not Found.
|
||||
NotFound,
|
||||
/// 402 Payment Required.
|
||||
PaymentRequired,
|
||||
|
||||
/// Uncaught errors are always 500 Internal Server Error.
|
||||
/// The user will never see the error cause or message, but our
|
||||
/// middleware will handle alerting, logging, etc.
|
||||
///
|
||||
/// Stored in `Arc` so the error alerting middleware can clone it for paging
|
||||
/// without consuming the original.
|
||||
UncaughtServerError(Arc<dyn std::error::Error + Send + Sync + 'static>),
|
||||
}
|
||||
|
||||
// =============== Public accessors ===============
|
||||
|
||||
impl AdvancedCommonWebError {
|
||||
/// Wrap any error as an `UncaughtServerError`.
|
||||
pub fn from_error(error: impl std::error::Error + Send + Sync + 'static) -> Self {
|
||||
Self::UncaughtServerError(Arc::new(error))
|
||||
}
|
||||
|
||||
/// Wrap an `anyhow::Error` as an `UncaughtServerError`.
|
||||
/// (`anyhow::Error` doesn't implement `std::error::Error`, so `from_error` can't accept it.)
|
||||
pub fn from_anyhow_error(error: anyhow::Error) -> Self {
|
||||
let boxed: Box<dyn std::error::Error + Send + Sync> = error.into();
|
||||
Self::UncaughtServerError(Arc::from(boxed))
|
||||
}
|
||||
|
||||
pub fn server_error_with_message(msg: &str) -> Self {
|
||||
Self::from_anyhow_error(anyhow!("ServerErrorWithMessage: {:?}", msg))
|
||||
}
|
||||
|
||||
/// Extract the wrapped causal error (only present for `UncaughtServerError`).
|
||||
pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
|
||||
match self {
|
||||
Self::UncaughtServerError(err) => Some(err.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone the wrapped causal error as an `Arc` (only present for `UncaughtServerError`).
|
||||
/// Useful for passing the error to the pager system without consuming the original.
|
||||
pub fn clone_cause_arc(&self) -> Option<Arc<dyn std::error::Error + Send + Sync + 'static>> {
|
||||
match self {
|
||||
Self::UncaughtServerError(err) => Some(Arc::clone(err)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a server error (500).
|
||||
pub fn is_server_error(&self) -> bool {
|
||||
matches!(self, Self::UncaughtServerError(_))
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Display / Debug / Error ===============
|
||||
|
||||
impl Display for AdvancedCommonWebError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BadInputWithSimpleMessage(msg) => write!(f, "Bad input: {}", msg),
|
||||
Self::NotAuthorized => write!(f, "Not authorized"),
|
||||
Self::NotFound => write!(f, "Not found"),
|
||||
Self::PaymentRequired => write!(f, "Payment required"),
|
||||
Self::UncaughtServerError(err) => write!(f, "Server error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdvancedCommonWebError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BadInputWithSimpleMessage(msg) => write!(f, "BadInputWithSimpleMessage({:?})", msg),
|
||||
Self::NotAuthorized => write!(f, "NotAuthorized"),
|
||||
Self::NotFound => write!(f, "NotFound"),
|
||||
Self::PaymentRequired => write!(f, "PaymentRequired"),
|
||||
Self::UncaughtServerError(err) => write!(f, "UncaughtServerError({:?})", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AdvancedCommonWebError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::UncaughtServerError(err) => Some(err.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Actix ResponseError ===============
|
||||
|
||||
impl ResponseError for AdvancedCommonWebError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::BadInputWithSimpleMessage(_) => StatusCode::BAD_REQUEST,
|
||||
Self::NotAuthorized => StatusCode::UNAUTHORIZED,
|
||||
Self::NotFound => StatusCode::NOT_FOUND,
|
||||
Self::PaymentRequired => StatusCode::PAYMENT_REQUIRED,
|
||||
Self::UncaughtServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let status = self.status_code();
|
||||
|
||||
match self {
|
||||
Self::BadInputWithSimpleMessage(msg) => {
|
||||
HttpResponse::BadRequest()
|
||||
.json(JsonErrorWithMessage {
|
||||
success: false,
|
||||
error_code: status.as_u16(),
|
||||
error_code_str: status.canonical_reason(),
|
||||
message: msg,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
HttpResponseBuilder::new(status)
|
||||
.json(JsonErrorWithoutMessage {
|
||||
success: false,
|
||||
error_code: status.as_u16(),
|
||||
error_code_str: status.canonical_reason(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============== From impls (automatic ? conversion) ===============
|
||||
|
||||
impl From<sqlx::Error> for AdvancedCommonWebError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
Self::from_error(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AdvancedCommonWebError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
// anyhow::Error doesn't impl std::error::Error, so we go through Box -> Arc.
|
||||
let boxed: Box<dyn std::error::Error + Send + Sync> = err.into();
|
||||
Self::UncaughtServerError(Arc::from(boxed))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AdvancedCommonWebError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::from_error(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RequireUserSessionError> for AdvancedCommonWebError {
|
||||
fn from(value: RequireUserSessionError) -> Self {
|
||||
match value {
|
||||
RequireUserSessionError::NotAuthorized => Self::NotAuthorized,
|
||||
RequireUserSessionError::ServerError => Self::from_error(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::http_server::common_responses::common_web_error::CommonWebError> for AdvancedCommonWebError {
|
||||
fn from(value: crate::http_server::common_responses::common_web_error::CommonWebError) -> Self {
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
match value {
|
||||
CommonWebError::BadInputWithSimpleMessage(msg) => Self::BadInputWithSimpleMessage(msg),
|
||||
CommonWebError::NotAuthorized => Self::NotAuthorized,
|
||||
CommonWebError::NotFound => Self::NotFound,
|
||||
CommonWebError::PaymentRequired => Self::PaymentRequired,
|
||||
CommonWebError::ServerError => Self::from_error(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Serialization helpers (private) ===============
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonErrorWithoutMessage {
|
||||
success: bool,
|
||||
error_code: u16,
|
||||
error_code_str: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonErrorWithMessage<'a> {
|
||||
success: bool,
|
||||
error_code: u16,
|
||||
error_code_str: Option<&'static str>,
|
||||
message: &'a str,
|
||||
}
|
||||
|
||||
// =============== OpenAPI schema ===============
|
||||
|
||||
impl utoipa::PartialSchema for AdvancedCommonWebError {
|
||||
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::Schema> {
|
||||
utoipa::openapi::ObjectBuilder::new()
|
||||
.property(
|
||||
"success",
|
||||
utoipa::openapi::ObjectBuilder::new()
|
||||
.schema_type(utoipa::openapi::schema::Type::Boolean),
|
||||
)
|
||||
.required("success")
|
||||
.property(
|
||||
"error_code",
|
||||
utoipa::openapi::ObjectBuilder::new()
|
||||
.schema_type(utoipa::openapi::schema::Type::Integer)
|
||||
.format(Some(utoipa::openapi::SchemaFormat::KnownFormat(
|
||||
utoipa::openapi::KnownFormat::Int32,
|
||||
))),
|
||||
)
|
||||
.required("error_code")
|
||||
.property(
|
||||
"error_code_str",
|
||||
utoipa::openapi::ObjectBuilder::new()
|
||||
.schema_type(utoipa::openapi::schema::Type::String),
|
||||
)
|
||||
.property(
|
||||
"message",
|
||||
utoipa::openapi::ObjectBuilder::new()
|
||||
.schema_type(utoipa::openapi::schema::Type::String)
|
||||
.description(Some("User-facing error message (present only for bad input errors)")),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl utoipa::ToSchema for AdvancedCommonWebError {
|
||||
fn name() -> std::borrow::Cow<'static, str> {
|
||||
std::borrow::Cow::Borrowed("AdvancedCommonWebError")
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Tests ===============
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use actix_http::body::MessageBody;
|
||||
|
||||
#[test]
|
||||
fn bad_input_returns_400_with_message() {
|
||||
let error = AdvancedCommonWebError::BadInputWithSimpleMessage("name is required".to_string());
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
assert!(!error.is_server_error());
|
||||
assert!(error.cause().is_none());
|
||||
|
||||
let response = error.error_response();
|
||||
let bytes = response.into_body().try_into_bytes().unwrap();
|
||||
let body = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
assert!(body.contains("\"message\":\"name is required\""));
|
||||
assert!(body.contains("\"error_code\":400"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_returns_404() {
|
||||
let error = AdvancedCommonWebError::NotFound;
|
||||
assert_eq!(error.status_code(), StatusCode::NOT_FOUND);
|
||||
assert!(!error.is_server_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_authorized_returns_401() {
|
||||
let error = AdvancedCommonWebError::NotAuthorized;
|
||||
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payment_required_returns_402() {
|
||||
let error = AdvancedCommonWebError::PaymentRequired;
|
||||
assert_eq!(error.status_code(), StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncaught_io_error_returns_500_and_hides_cause() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk exploded");
|
||||
let error = AdvancedCommonWebError::from_error(io_err);
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(error.is_server_error());
|
||||
|
||||
// Cause is accessible for middleware
|
||||
let cause = error.cause().unwrap();
|
||||
assert_eq!(format!("{}", cause), "disk exploded");
|
||||
|
||||
// But never shown to the user in the HTTP response
|
||||
let response = error.error_response();
|
||||
let bytes = response.into_body().try_into_bytes().unwrap();
|
||||
let body = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
assert!(!body.contains("disk exploded"));
|
||||
assert!(body.contains("\"error_code\":500"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anyhow_error_converts_to_500_via_from() {
|
||||
let error: AdvancedCommonWebError = anyhow::anyhow!("something broke in the pipeline").into();
|
||||
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(error.is_server_error());
|
||||
|
||||
let cause = error.cause().unwrap();
|
||||
assert!(format!("{}", cause).contains("something broke in the pipeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_anyhow_error_preserves_context_chain() {
|
||||
let inner = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused");
|
||||
let with_context = anyhow::Error::new(inner).context("failed to connect to database");
|
||||
let error: AdvancedCommonWebError = with_context.into();
|
||||
|
||||
assert!(error.is_server_error());
|
||||
let cause = error.cause().unwrap();
|
||||
let display = format!("{}", cause);
|
||||
assert!(display.contains("failed to connect to database"));
|
||||
|
||||
// The original io error should be in the source chain
|
||||
let source = cause.source();
|
||||
assert!(source.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_user_session_not_authorized_maps_to_401() {
|
||||
let error: AdvancedCommonWebError = RequireUserSessionError::NotAuthorized.into();
|
||||
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
|
||||
assert!(!error.is_server_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_user_session_server_error_wraps_cause() {
|
||||
let error: AdvancedCommonWebError = RequireUserSessionError::ServerError.into();
|
||||
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(error.is_server_error());
|
||||
assert!(error.cause().is_some());
|
||||
assert!(format!("{}", error.cause().unwrap()).contains("ServerError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_json_error_converts_to_500() {
|
||||
let bad_json = "not json at all{{{";
|
||||
let serde_err: serde_json::Error = serde_json::from_str::<serde_json::Value>(bad_json).unwrap_err();
|
||||
let error: AdvancedCommonWebError = serde_err.into();
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(error.is_server_error());
|
||||
let cause = error.cause().unwrap();
|
||||
assert!(format!("{}", cause).contains("expected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_source_returns_wrapped_error() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "root cause");
|
||||
let error = AdvancedCommonWebError::from_error(io_err);
|
||||
|
||||
let source = std::error::Error::source(&error);
|
||||
assert!(source.is_some());
|
||||
assert_eq!(format!("{}", source.unwrap()), "root cause");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_server_errors_have_no_source() {
|
||||
assert!(std::error::Error::source(&AdvancedCommonWebError::NotFound).is_none());
|
||||
assert!(std::error::Error::source(&AdvancedCommonWebError::NotAuthorized).is_none());
|
||||
assert!(std::error::Error::source(&AdvancedCommonWebError::PaymentRequired).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod advanced_common_web_error;
|
||||
pub mod common_web_error;
|
||||
pub mod media;
|
||||
pub mod media_file_origin_details;
|
||||
|
||||
+38
-40
@@ -2,9 +2,10 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure;
|
||||
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
|
||||
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
@@ -17,11 +18,11 @@ use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_funct
|
||||
};
|
||||
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::visibility::Visibility;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use log::{error, info, warn};
|
||||
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
|
||||
@@ -33,6 +34,8 @@ use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_it
|
||||
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
|
||||
};
|
||||
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{
|
||||
generate_video, BatchCount, GenerateVideoArgs, GenerateVideoResponse, ModelType, Resolution,
|
||||
@@ -46,9 +49,6 @@ use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url, ExtractExtensions};
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
|
||||
// ======================== Result of a successful generation ========================
|
||||
|
||||
@@ -76,7 +76,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<Seedance2p0MultiFunctionVideoGenRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<Seedance2p0MultiFunctionVideoGenResponse>, CommonWebError> {
|
||||
) -> Result<Json<Seedance2p0MultiFunctionVideoGenResponse>, AdvancedCommonWebError> {
|
||||
|
||||
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
|
||||
|
||||
@@ -94,20 +94,20 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::from_anyhow_error(e)
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
return Err(AdvancedCommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
let user_token = &user_session.user_token;
|
||||
|
||||
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
|
||||
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(reason));
|
||||
}
|
||||
|
||||
// --- Collect all media tokens to look up ---
|
||||
@@ -148,7 +148,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error inserting idempotency token: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
|
||||
AdvancedCommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
|
||||
})?;
|
||||
|
||||
// --- Calculate cost and charge wallet upfront (before uploads) ---
|
||||
@@ -221,16 +221,15 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Regular session fallback also failed for user {:?}: {:?}", user_token, err);
|
||||
if let Err(page_err) = server_state.pager.enqueue_page(
|
||||
NotificationDetailsBuilder::from_error(err.into())
|
||||
.set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build()
|
||||
) {
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into())
|
||||
.set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build();
|
||||
if let Err(page_err) = server_state.pager.enqueue_page(notification) {
|
||||
warn!("Failed to enqueue pager alert: {:?}", page_err);
|
||||
}
|
||||
refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?;
|
||||
return Err(CommonWebError::ServerError);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,16 +254,15 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
warn!("Error calling seedance2pro generate_video: {:?}", err);
|
||||
if let Err(page_err) = server_state.pager.enqueue_page(
|
||||
NotificationDetailsBuilder::from_error(err.into())
|
||||
.set_title("Seedance 2.0 generation failed".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build()
|
||||
) {
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into())
|
||||
.set_title("Seedance 2.0 generation failed".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build();
|
||||
if let Err(page_err) = server_state.pager.enqueue_page(notification) {
|
||||
warn!("Failed to enqueue pager alert: {:?}", page_err);
|
||||
}
|
||||
refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?;
|
||||
return Err(CommonWebError::ServerError);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -286,7 +284,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// NB: Don't fail the job if the prompt insert fails.
|
||||
@@ -419,7 +417,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
Err(err) => {
|
||||
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
|
||||
if i == 0 {
|
||||
return Err(CommonWebError::ServerError);
|
||||
return Err(AdvancedCommonWebError::from_error(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,7 +425,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
|
||||
let first_job_token = all_job_tokens.first().cloned().ok_or_else(|| {
|
||||
error!("No inference job token was created");
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::server_error_with_message("No inference job token was created")
|
||||
})?;
|
||||
|
||||
transaction
|
||||
@@ -435,7 +433,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error committing MySQL transaction: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(Json(Seedance2p0MultiFunctionVideoGenResponse {
|
||||
@@ -497,14 +495,14 @@ async fn upload_and_generate(
|
||||
resolution: Resolution,
|
||||
batch_count: BatchCount,
|
||||
duration_seconds: u8,
|
||||
) -> Result<SeedanceGenerationResult, CommonWebError> {
|
||||
) -> Result<SeedanceGenerationResult, AdvancedCommonWebError> {
|
||||
|
||||
// --- Upload files to seedance2pro CDN ---
|
||||
|
||||
let start_frame_url = match request.start_frame_media_token.as_ref() {
|
||||
None => None,
|
||||
Some(token) => match file_urls_by_token.get(token) {
|
||||
None => return Err(CommonWebError::BadInputWithSimpleMessage("Start frame media not found.".to_string())),
|
||||
None => return Err(AdvancedCommonWebError::BadInputWithSimpleMessage("Start frame media not found.".to_string())),
|
||||
Some(url) => Some(upload_to_seedance2pro(session, url).await?),
|
||||
}
|
||||
};
|
||||
@@ -512,7 +510,7 @@ async fn upload_and_generate(
|
||||
let end_frame_url = match request.end_frame_media_token.as_ref() {
|
||||
None => None,
|
||||
Some(token) => match file_urls_by_token.get(token) {
|
||||
None => return Err(CommonWebError::BadInputWithSimpleMessage("End frame media not found.".to_string())),
|
||||
None => return Err(AdvancedCommonWebError::BadInputWithSimpleMessage("End frame media not found.".to_string())),
|
||||
Some(url) => Some(upload_to_seedance2pro(session, url).await?),
|
||||
}
|
||||
};
|
||||
@@ -578,7 +576,7 @@ async fn upload_and_generate(
|
||||
let gen_response = generate_video(video_gen_args).await
|
||||
.map_err(|err| {
|
||||
warn!("Error calling seedance2pro generate_video: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(SeedanceGenerationResult {
|
||||
@@ -594,7 +592,7 @@ async fn upload_reference_tokens_to_seedance2pro(
|
||||
file_urls_by_token: &HashMap<MediaFileToken, Url>,
|
||||
maybe_tokens: Option<&[MediaFileToken]>,
|
||||
label: &str,
|
||||
) -> Result<Option<Vec<String>>, CommonWebError> {
|
||||
) -> Result<Option<Vec<String>>, AdvancedCommonWebError> {
|
||||
let tokens = match maybe_tokens {
|
||||
None => return Ok(None),
|
||||
Some(tokens) if tokens.is_empty() => return Ok(None),
|
||||
@@ -605,7 +603,7 @@ async fn upload_reference_tokens_to_seedance2pro(
|
||||
|
||||
for token in tokens {
|
||||
match file_urls_by_token.get(token) {
|
||||
None => return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
None => return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
format!("{} media not found: {:?}", label, token),
|
||||
)),
|
||||
Some(url) => {
|
||||
@@ -620,7 +618,7 @@ async fn upload_reference_tokens_to_seedance2pro(
|
||||
async fn upload_to_seedance2pro(
|
||||
session: &Seedance2ProSession,
|
||||
our_cdn_url: &Url,
|
||||
) -> Result<String, CommonWebError> {
|
||||
) -> Result<String, AdvancedCommonWebError> {
|
||||
let extension = extract_extension_from_url(our_cdn_url, &ExtractExtensions::All)
|
||||
.map(|ext| ext.without_period().to_string())
|
||||
.unwrap_or_else(|| "png".to_string());
|
||||
@@ -631,7 +629,7 @@ async fn upload_to_seedance2pro(
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Error downloading media file from CDN: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::from_anyhow_error(err)
|
||||
})?
|
||||
.to_vec();
|
||||
|
||||
@@ -643,7 +641,7 @@ async fn upload_to_seedance2pro(
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Error preparing seedance2pro file upload: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
@@ -654,7 +652,7 @@ async fn upload_to_seedance2pro(
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Error uploading file to seedance2pro: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(upload_result.public_url)
|
||||
|
||||
+31
-65
@@ -1,65 +1,19 @@
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::webhooks::process_failure::handle_failed_fal_webhook::handle_failed_fal_webhook;
|
||||
use crate::http_server::endpoints::webhooks::process_success::handle_successful_fal_webhook::handle_successful_fal_webhook;
|
||||
use crate::state::server_state::ServerState;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Bytes;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use anyhow::Error;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use fal_client::webhook_api::parse_webhook_inner_payload::parse_webhook_inner_payload;
|
||||
use fal_client::webhook_api::payload::webhook_inner_payload::WebhookInnerPayload;
|
||||
use fal_client::webhook_api::parse_webhook_payload::parse_webhook_payload;
|
||||
use fal_client::webhook_api::payload::webhook_inner_payload::WebhookInnerPayload;
|
||||
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
|
||||
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
|
||||
use log::{error, info, warn};
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// =============== Error Response ===============
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub enum FalWebhookError {
|
||||
BadInput(String),
|
||||
NotFound,
|
||||
NotAuthorized,
|
||||
ServerError,
|
||||
}
|
||||
|
||||
impl ResponseError for FalWebhookError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match *self {
|
||||
FalWebhookError::BadInput(_) => StatusCode::BAD_REQUEST,
|
||||
FalWebhookError::NotFound => StatusCode::NOT_FOUND,
|
||||
FalWebhookError::NotAuthorized => StatusCode::UNAUTHORIZED,
|
||||
FalWebhookError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
serialize_as_json_error(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FalWebhookError {}
|
||||
|
||||
// NB: Not using derive_more::Display since Clion doesn't understand it.
|
||||
impl fmt::Display for FalWebhookError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for FalWebhookError {
|
||||
fn from(value: Error) -> Self {
|
||||
info!("Converting anyhow::Error to FalWebhookError: {:?}", value);
|
||||
FalWebhookError::ServerError
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Handler ===============
|
||||
|
||||
@@ -68,14 +22,16 @@ pub async fn fal_webhook_handler(
|
||||
http_request: HttpRequest,
|
||||
request_body_bytes: Bytes,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<SimpleGenericJsonSuccess>, FalWebhookError> {
|
||||
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
|
||||
|
||||
// Step 1: Parse bytes into a UTF-8 string and log it.
|
||||
let raw_body = String::from_utf8(request_body_bytes.to_vec())
|
||||
.map_err(|err| {
|
||||
error!("FAL webhook: could not decode request body to UTF-8: {:?}", err);
|
||||
enqueue_parse_error_alert(&server_state, &http_request, "UTF-8 decode failed", &err, None);
|
||||
FalWebhookError::BadInput("Could not decode request body to UTF-8".to_string())
|
||||
// Ordinarily this is a user input error, but I want to know when this happens and see the error trace:
|
||||
// AdvancedCommonWebError::BadInputWithSimpleMessage("Could not decode request body to UTF-8".to_string())
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
info!("Received FAL webhook body: {}", raw_body);
|
||||
@@ -85,7 +41,9 @@ pub async fn fal_webhook_handler(
|
||||
.map_err(|err| {
|
||||
error!("FAL webhook: could not parse webhook payload: {:?}", err);
|
||||
enqueue_parse_error_alert(&server_state, &http_request, "JSON parse failed", &err, Some(&raw_body));
|
||||
FalWebhookError::BadInput("Could not parse webhook payload".to_string())
|
||||
// Ordinarily this is a user input error, but I want to know when this happens and see the error trace:
|
||||
// AdvancedCommonWebError::BadInputWithSimpleMessage("Could not parse webhook payload".to_string())
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let request_id = webhook_payload.request_id.as_str();
|
||||
@@ -113,15 +71,14 @@ pub async fn fal_webhook_handler(
|
||||
"FAL webhook payload_error for request_id {}: {}",
|
||||
request_id, payload_error_data.payload_error,
|
||||
);
|
||||
// Treat payload encoding errors as server errors — the request was OK but
|
||||
// FAL couldn't encode the payload, so we can't process it.
|
||||
Err(FalWebhookError::ServerError)
|
||||
Err(AdvancedCommonWebError::from_anyhow_error(
|
||||
anyhow::anyhow!("FAL payload_error: {}", payload_error_data.payload_error)
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(success) => Ok(success),
|
||||
Err(err) => {
|
||||
if let Err(ref err) = result {
|
||||
if err.is_server_error() {
|
||||
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
|
||||
|
||||
let description = format!(
|
||||
@@ -129,24 +86,33 @@ pub async fn fal_webhook_handler(
|
||||
request_id, err, raw_body,
|
||||
);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_error(err.into())
|
||||
.set_title("FAL webhook processing failed".to_string())
|
||||
let mut builder = if let Some(cause_arc) = err.clone_cause_arc() {
|
||||
NotificationDetailsBuilder::from_error(cause_arc)
|
||||
.set_title(format!("FAL webhook processing failed (request_id: {})", request_id))
|
||||
} else {
|
||||
NotificationDetailsBuilder::from_title(
|
||||
format!("FAL webhook processing failed (request_id: {})", request_id)
|
||||
)
|
||||
};
|
||||
|
||||
builder = builder
|
||||
.set_description(Some(description))
|
||||
.set_third_party_id(Some(request_id.to_string()))
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.set_http_method(Some(http_request.method().to_string()))
|
||||
.set_http_path(Some(http_request.path().to_string()))
|
||||
.build();
|
||||
.set_http_path(Some(http_request.path().to_string()));
|
||||
|
||||
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
|
||||
if let Err(pager_err) = server_state.pager.enqueue_page(builder.build()) {
|
||||
error!("Failed to enqueue FAL webhook pager alert: {:?}", pager_err);
|
||||
}
|
||||
|
||||
Err(FalWebhookError::ServerError)
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// =============== Private helpers ===============
|
||||
|
||||
/// Send a pager alert for early parse failures (before we have a request_id).
|
||||
fn enqueue_parse_error_alert<E: std::fmt::Debug>(
|
||||
server_state: &ServerState,
|
||||
|
||||
+29
-29
@@ -1,32 +1,13 @@
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::state::server_state::ServerState;
|
||||
use actix_web::web::Json;
|
||||
use log::{error, info, warn};
|
||||
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id;
|
||||
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
|
||||
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
|
||||
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
|
||||
use fal_client::webhook_api::payload::webhook_error_type::WebhookErrorType;
|
||||
use fal_client::webhook_api::payload::webhook_inner_payload::ErrorData;
|
||||
use crate::http_server::endpoints::webhooks::fal_webhook_handler::FalWebhookError;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Map a FAL WebhookErrorType to a frontend failure category.
|
||||
///
|
||||
/// Returns `GenerationFailed` as the default if the error type is None or unrecognized.
|
||||
fn guess_failure_category(error_type: Option<&WebhookErrorType>) -> FrontendFailureCategory {
|
||||
match error_type {
|
||||
Some(WebhookErrorType::ContentPolicyViolation) => FrontendFailureCategory::RuleBansUserContent,
|
||||
Some(WebhookErrorType::FaceDetectionError) => FrontendFailureCategory::FaceNotDetected,
|
||||
// These all map to GenerationFailed for now, but are listed explicitly,
|
||||
// so we can refine them later.
|
||||
Some(WebhookErrorType::NoMediaGenerated)
|
||||
| Some(WebhookErrorType::ImageTooSmall)
|
||||
| Some(WebhookErrorType::ImageTooLarge)
|
||||
| Some(WebhookErrorType::ImageLoadError)
|
||||
| Some(WebhookErrorType::FileDownloadError)
|
||||
| Some(WebhookErrorType::FileTooLarge) => FrontendFailureCategory::GenerationFailed,
|
||||
_ => FrontendFailureCategory::GenerationFailed,
|
||||
}
|
||||
}
|
||||
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
|
||||
use log::{error, info, warn};
|
||||
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id;
|
||||
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
|
||||
|
||||
/// Handle a FAL webhook with status ERROR.
|
||||
///
|
||||
@@ -36,7 +17,7 @@ pub async fn handle_failed_fal_webhook(
|
||||
request_id: &str,
|
||||
error_data: &ErrorData,
|
||||
maybe_top_level_error: Option<&str>,
|
||||
) -> Result<Json<SimpleGenericJsonSuccess>, FalWebhookError> {
|
||||
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
|
||||
|
||||
info!(
|
||||
"FAL webhook ERROR for request_id {}: top_level_error={:?}, error_type={:?}, message={:?}",
|
||||
@@ -51,11 +32,11 @@ pub async fn handle_failed_fal_webhook(
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => {
|
||||
warn!("Could not find job record by fal request_id: {}", request_id);
|
||||
return Err(FalWebhookError::NotFound);
|
||||
return Err(AdvancedCommonWebError::NotFound);
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error querying job record for request_id {}: {:?}", request_id, err);
|
||||
return Err(FalWebhookError::ServerError);
|
||||
return Err(AdvancedCommonWebError::from_anyhow_error(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +80,7 @@ pub async fn handle_failed_fal_webhook(
|
||||
request_id,
|
||||
err,
|
||||
);
|
||||
return Err(FalWebhookError::ServerError);
|
||||
return Err(AdvancedCommonWebError::from_anyhow_error(err));
|
||||
}
|
||||
|
||||
info!(
|
||||
@@ -110,3 +91,22 @@ pub async fn handle_failed_fal_webhook(
|
||||
|
||||
Ok(SimpleGenericJsonSuccess::wrapped(true))
|
||||
}
|
||||
|
||||
// =============== Private helpers ===============
|
||||
|
||||
/// Map a FAL WebhookErrorType to a frontend failure category.
|
||||
///
|
||||
/// Returns `GenerationFailed` as the default if the error type is None or unrecognized.
|
||||
fn guess_failure_category(error_type: Option<&WebhookErrorType>) -> FrontendFailureCategory {
|
||||
match error_type {
|
||||
Some(WebhookErrorType::ContentPolicyViolation) => FrontendFailureCategory::RuleBansUserContent,
|
||||
Some(WebhookErrorType::FaceDetectionError) => FrontendFailureCategory::FaceNotDetected,
|
||||
Some(WebhookErrorType::NoMediaGenerated)
|
||||
| Some(WebhookErrorType::ImageTooSmall)
|
||||
| Some(WebhookErrorType::ImageTooLarge)
|
||||
| Some(WebhookErrorType::ImageLoadError)
|
||||
| Some(WebhookErrorType::FileDownloadError)
|
||||
| Some(WebhookErrorType::FileTooLarge) => FrontendFailureCategory::GenerationFailed,
|
||||
_ => FrontendFailureCategory::GenerationFailed,
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,11 +1,11 @@
|
||||
use actix_web::web::Json;
|
||||
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
|
||||
use log::{info, warn};
|
||||
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id;
|
||||
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::{mark_fal_generic_inference_job_successfully_done, MarkJobArgs};
|
||||
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::http_server::endpoints::webhooks::fal_webhook_handler::FalWebhookError;
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
use super::process_image_payload::process_image_payload;
|
||||
@@ -18,7 +18,7 @@ pub async fn handle_successful_fal_webhook(
|
||||
server_state: &ServerState,
|
||||
request_id: &str,
|
||||
payload: &Value,
|
||||
) -> Result<Json<SimpleGenericJsonSuccess>, FalWebhookError> {
|
||||
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
|
||||
|
||||
let db_result = get_inference_job_by_fal_id(
|
||||
request_id,
|
||||
@@ -29,11 +29,11 @@ pub async fn handle_successful_fal_webhook(
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => {
|
||||
warn!("Could not find job record by fal request_id: {}", request_id);
|
||||
return Err(FalWebhookError::NotFound)
|
||||
return Err(AdvancedCommonWebError::NotFound)
|
||||
},
|
||||
Err(err) => {
|
||||
warn!("Error querying job record for request_id {}: {:?}", request_id, err);
|
||||
return Err(FalWebhookError::ServerError);
|
||||
return Err(AdvancedCommonWebError::from_anyhow_error(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ pub async fn handle_successful_fal_webhook(
|
||||
phantom: Default::default(),
|
||||
}).await.map_err(|err| {
|
||||
warn!("Error marking job as successfully done for request_id {}: {:?}", request_id, err);
|
||||
FalWebhookError::ServerError
|
||||
AdvancedCommonWebError::from_anyhow_error(err)
|
||||
})?;
|
||||
} else {
|
||||
warn!("No media token found in payload for request_id {} / job {:?}", request_id, job.job_token);
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
use log::{debug, warn};
|
||||
|
||||
use pager::client::pager::Pager;
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
/// Check `AdvancedCommonWebError` and alert on uncaught server errors.
|
||||
/// Returns `true` if the error was handled (alerted or intentionally skipped).
|
||||
pub(super) fn check_advanced_common_web_error(
|
||||
pager: &Pager,
|
||||
method: &str,
|
||||
path: &str,
|
||||
error: &AdvancedCommonWebError,
|
||||
) -> bool {
|
||||
if !error.is_server_error() {
|
||||
// Non-500 errors (400, 401, 404, 402) are intentional — don't alert.
|
||||
return true;
|
||||
}
|
||||
|
||||
let title = format!("UncaughtServerError on {} {}", method, path);
|
||||
|
||||
// Clone the Arc'd causal error so the notification owns a reference to it.
|
||||
let cause_arc = error.clone_cause_arc();
|
||||
|
||||
let mut builder = if let Some(arc_err) = cause_arc {
|
||||
NotificationDetailsBuilder::from_error(arc_err)
|
||||
.set_title(title)
|
||||
} else {
|
||||
NotificationDetailsBuilder::from_title(title)
|
||||
};
|
||||
|
||||
builder = builder
|
||||
.set_http_method(Some(method.to_string()))
|
||||
.set_http_path(Some(path.to_string()))
|
||||
.set_http_status_code(Some(500))
|
||||
.set_urgency(Some(NotificationUrgency::Medium));
|
||||
|
||||
let notification = builder.build();
|
||||
|
||||
if let Err(err) = pager.enqueue_page(notification) {
|
||||
warn!("Error alerting middleware: failed to enqueue page: {:?}", err);
|
||||
} else {
|
||||
debug!("Error alerting middleware: enqueued alert for AdvancedCommonWebError::UncaughtServerError");
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
+14
@@ -6,8 +6,10 @@ use actix_web::Error;
|
||||
|
||||
use pager::client::pager::Pager;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::state::flags::paging_flags::PagingFlags;
|
||||
use super::check_advanced_common_web_error::check_advanced_common_web_error;
|
||||
use super::check_common_web_error::check_common_web_error;
|
||||
use super::check_status_code_fallback::check_status_code_fallback;
|
||||
|
||||
@@ -121,6 +123,12 @@ fn check_ok_response_for_alerts<B>(
|
||||
if let Some(err) = response.response().error() {
|
||||
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
|
||||
|
||||
if let Some(advanced_err) = err.as_error::<AdvancedCommonWebError>() {
|
||||
if check_advanced_common_web_error(pager, method, path, advanced_err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(common_err) = err.as_error::<CommonWebError>() {
|
||||
if check_common_web_error(pager, method, path, common_err) {
|
||||
return;
|
||||
@@ -141,6 +149,12 @@ fn check_err_for_alerts(
|
||||
) {
|
||||
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
|
||||
|
||||
if let Some(advanced_err) = err.as_error::<AdvancedCommonWebError>() {
|
||||
if check_advanced_common_web_error(pager, method, path, advanced_err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(common_err) = err.as_error::<CommonWebError>() {
|
||||
if check_common_web_error(pager, method, path, common_err) {
|
||||
return;
|
||||
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
mod check_advanced_common_web_error;
|
||||
mod check_common_web_error;
|
||||
mod check_status_code_fallback;
|
||||
|
||||
|
||||
@@ -713,7 +713,12 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()>
|
||||
|
||||
add_routes(app, old_server_environment)
|
||||
})
|
||||
.bind(bind_address)?
|
||||
.bind(&bind_address)
|
||||
.unwrap_or_else(|err| {
|
||||
eprintln!("FATAL: Failed to bind to address '{}': {}", bind_address, err);
|
||||
eprintln!("The address is likely already in use by another process.");
|
||||
std::process::exit(1);
|
||||
})
|
||||
.workers(num_workers)
|
||||
.run()
|
||||
.await?;
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ pub async fn db_health_checker_thread(
|
||||
Ok(_) => {},
|
||||
}
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_error(database_error.into())
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(database_error.into())
|
||||
.set_title("DB health check thread failed check".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build();
|
||||
|
||||
+67
-23
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { faCube, faImages, faUpRightAndDownLeftFromCenter } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { UploadModal3D } from "../reusable/UploadModal3D/UploadModal3D";
|
||||
@@ -26,11 +27,11 @@ export function GlobalFileDropHandler() {
|
||||
const dragCounter = useRef(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [modalType, setModalType] = useState<ModalType>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
|
||||
const closeModal = () => {
|
||||
setModalType(null);
|
||||
setPendingFile(null);
|
||||
setPendingFiles([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -54,22 +55,44 @@ export function GlobalFileDropHandler() {
|
||||
setIsDragging(false);
|
||||
if (isAnyModalOpen()) return;
|
||||
|
||||
const path = payload.paths[0];
|
||||
if (!path) return;
|
||||
if (payload.paths.length === 0) return;
|
||||
|
||||
const fileName = path.split(/[/\\]/).pop() ?? "file";
|
||||
const type = getModalTypeForFileName(fileName);
|
||||
if (type === null) return;
|
||||
// Find first path with a recognised extension
|
||||
let modalKind: ModalType = null;
|
||||
for (const p of payload.paths) {
|
||||
modalKind = getModalTypeForFileName(p.split(/[/\\]/).pop() ?? "");
|
||||
if (modalKind) break;
|
||||
}
|
||||
if (!modalKind) return;
|
||||
|
||||
// Keep only paths matching the detected modal type
|
||||
const matchingPaths = payload.paths.filter((p) => {
|
||||
const name = p.split(/[/\\]/).pop() ?? "";
|
||||
return getModalTypeForFileName(name) === modalKind;
|
||||
});
|
||||
|
||||
const skippedCount = payload.paths.length - matchingPaths.length;
|
||||
if (skippedCount > 0) {
|
||||
toast(
|
||||
`${skippedCount} file${skippedCount > 1 ? "s" : ""} skipped — unsupported or mixed types`,
|
||||
{ icon: "⚠️" }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const assetUrl = convertFileSrc(path);
|
||||
const response = await fetch(assetUrl);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], fileName);
|
||||
const files = await Promise.all(
|
||||
matchingPaths.map(async (path) => {
|
||||
const fileName = path.split(/[/\\]/).pop() ?? "file";
|
||||
const assetUrl = convertFileSrc(path);
|
||||
const response = await fetch(assetUrl);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
return new File([blob], fileName);
|
||||
})
|
||||
);
|
||||
await appWindow.setFocus();
|
||||
setModalType(type);
|
||||
setPendingFile(file);
|
||||
setModalType(modalKind);
|
||||
setPendingFiles(files);
|
||||
} catch (err) {
|
||||
console.error("[DragDrop] file read failed:", err);
|
||||
}
|
||||
@@ -111,12 +134,33 @@ export function GlobalFileDropHandler() {
|
||||
setIsDragging(false);
|
||||
dragCounter.current = 0;
|
||||
if (isAnyModalOpen()) return;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (!file) return;
|
||||
const type = getModalTypeForFileName(file.name);
|
||||
if (type === null) return;
|
||||
setModalType(type);
|
||||
setPendingFile(file);
|
||||
|
||||
const allFiles = Array.from(e.dataTransfer.files);
|
||||
if (allFiles.length === 0) return;
|
||||
|
||||
// Find first file with a recognised extension
|
||||
let modalKind: ModalType = null;
|
||||
for (const f of allFiles) {
|
||||
modalKind = getModalTypeForFileName(f.name);
|
||||
if (modalKind) break;
|
||||
}
|
||||
if (!modalKind) return;
|
||||
|
||||
// Keep only files matching the detected modal type
|
||||
const matchingFiles = allFiles.filter(
|
||||
(f) => getModalTypeForFileName(f.name) === modalKind
|
||||
);
|
||||
|
||||
const skippedCount = allFiles.length - matchingFiles.length;
|
||||
if (skippedCount > 0) {
|
||||
toast(
|
||||
`${skippedCount} file${skippedCount > 1 ? "s" : ""} skipped — unsupported or mixed types`,
|
||||
{ icon: "⚠️" }
|
||||
);
|
||||
}
|
||||
|
||||
setModalType(modalKind);
|
||||
setPendingFiles(matchingFiles);
|
||||
};
|
||||
window.addEventListener("dragenter", handleDragEnter);
|
||||
window.addEventListener("dragleave", handleDragLeave);
|
||||
@@ -144,7 +188,7 @@ export function GlobalFileDropHandler() {
|
||||
)}
|
||||
<UploadModal3D
|
||||
isOpen={modalType === "3d"}
|
||||
initialFile={pendingFile ?? undefined}
|
||||
initialFiles={pendingFiles.length > 0 ? pendingFiles : undefined}
|
||||
onClose={closeModal}
|
||||
onSuccess={(_category: FilterEngineCategories) => closeModal()}
|
||||
title="Upload a 3D Model"
|
||||
@@ -152,7 +196,7 @@ export function GlobalFileDropHandler() {
|
||||
/>
|
||||
<UploadModalImage
|
||||
isOpen={modalType === "image"}
|
||||
initialFile={pendingFile ?? undefined}
|
||||
initialFiles={pendingFiles.length > 0 ? pendingFiles : undefined}
|
||||
onClose={closeModal}
|
||||
onSuccess={() => closeModal()}
|
||||
title="Upload an Image"
|
||||
@@ -160,7 +204,7 @@ export function GlobalFileDropHandler() {
|
||||
/>
|
||||
<UploadModalSplat
|
||||
isOpen={modalType === "splat"}
|
||||
initialFile={pendingFile ?? undefined}
|
||||
initialFiles={pendingFiles.length > 0 ? pendingFiles : undefined}
|
||||
onClose={closeModal}
|
||||
onSuccess={() => {}}
|
||||
title="Upload a Splat"
|
||||
|
||||
+448
-98
@@ -1,19 +1,42 @@
|
||||
import { useCallback, useEffect, useState, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels";
|
||||
import { ListDropdown } from "@storyteller/ui-list-dropdown";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { FileUploader } from "@storyteller/ui-file-uploader";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCube,
|
||||
faXmark,
|
||||
faCheck,
|
||||
faCircleExclamation,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faRotateRight,
|
||||
faSpinner,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import * as THREE from "three";
|
||||
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "./utilities";
|
||||
import { upload3DObjects } from "./utilities/upload3DObjects";
|
||||
import { UploaderState } from "../../../models";
|
||||
import { FilterEngineCategories, MediaFileAnimationType } from "../../../enums";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCube } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { upload3DObjectsBatch } from "./utilities/upload3DObjectsBatch";
|
||||
import { FileEntryStatus } from "../UploadModalImage/utilities/uploadImagesBatch";
|
||||
import type { UploaderState } from "../../../models";
|
||||
import {
|
||||
FilterEngineCategories,
|
||||
MediaFileAnimationType,
|
||||
UploaderStates,
|
||||
} from "../../../enums";
|
||||
|
||||
interface FileEntry {
|
||||
file: File;
|
||||
status: FileEntryStatus;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
fileTypes: string[];
|
||||
engineCategory: FilterEngineCategories;
|
||||
initialFile?: File;
|
||||
initialFiles?: File[];
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
@@ -26,21 +49,39 @@ interface Props {
|
||||
export const UploadFiles3D = ({
|
||||
fileTypes,
|
||||
engineCategory,
|
||||
initialFile,
|
||||
initialFiles,
|
||||
options,
|
||||
onClose,
|
||||
onUploadProgress,
|
||||
}: Props) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | undefined>(undefined);
|
||||
const canvasCallbackRef = useCallback((node: HTMLCanvasElement) => {
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const canvasCallbackRef = useCallback((node: HTMLCanvasElement | null) => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
if (node !== null) {
|
||||
canvasRef.current = node;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const renderer = rendererRef.current;
|
||||
const camera = cameraRef.current;
|
||||
if (!renderer || !camera) return;
|
||||
const w = node.clientWidth;
|
||||
const h = node.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
});
|
||||
observer.observe(node);
|
||||
resizeObserverRef.current = observer;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fileSubtypes = options?.fileSubtypes;
|
||||
// const hasLength = options?.hasLength;
|
||||
// const hasThumbnailUpload = options?.hasThumbnailUpload;
|
||||
|
||||
const [subtype, setSubtype] = useState<MediaFileAnimationType | undefined>(
|
||||
fileSubtypes
|
||||
@@ -48,136 +89,445 @@ export const UploadFiles3D = ({
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const [assetFile, setAssetFile] = useState<{
|
||||
value: File | null;
|
||||
error?: string;
|
||||
}>({ value: initialFile ?? null });
|
||||
const seedFiles = initialFiles ?? [];
|
||||
|
||||
const [fileEntries, setFileEntries] = useState<FileEntry[]>(
|
||||
seedFiles.map((f) => ({ file: f, status: "idle" })),
|
||||
);
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
// Incremented on every handleFilesChange so useEffect re-runs even when count stays the same
|
||||
const [filesVersion, setFilesVersion] = useState(0);
|
||||
const [previewStatus, setPreviewStatus] = useState<{
|
||||
type: string;
|
||||
message?: string;
|
||||
}>({ type: "init" });
|
||||
const [thumbnails, setThumbnails] = useState<Map<File, Blob>>(new Map());
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [overallProgress, setOverallProgress] = useState<{
|
||||
current: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [selectionError, setSelectionError] = useState<string | undefined>();
|
||||
|
||||
const [thumbnailFile, setThumbnailFile] = useState<Blob | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!assetFile.value) {
|
||||
setAssetFile((curr) => ({
|
||||
...curr,
|
||||
error: "Please select a file to upload.",
|
||||
}));
|
||||
return;
|
||||
const disposeRenderer = () => {
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.setAnimationLoop(null);
|
||||
rendererRef.current.dispose();
|
||||
rendererRef.current = null;
|
||||
}
|
||||
|
||||
// Use the filename as the title
|
||||
const title = assetFile.value.name.split(".")[0];
|
||||
|
||||
upload3DObjects({
|
||||
title: title,
|
||||
assetFile: assetFile.value,
|
||||
thumbnailSnapshot: thumbnailFile,
|
||||
engineCategory: engineCategory,
|
||||
animationType: subtype,
|
||||
progressCallback: onUploadProgress,
|
||||
});
|
||||
};
|
||||
|
||||
// Reload canvas preview whenever the current file or index changes.
|
||||
// filesVersion ensures this re-runs when the same-count file set is replaced.
|
||||
useEffect(() => {
|
||||
// Reset subtype when fileSubtypes set changes (e.g., toggling character)
|
||||
if (!fileSubtypes || fileSubtypes.length === 0) {
|
||||
setSubtype(undefined);
|
||||
return;
|
||||
}
|
||||
const nextDefault = Object.values(fileSubtypes[0])[0] as
|
||||
| MediaFileAnimationType
|
||||
| undefined;
|
||||
setSubtype(nextDefault);
|
||||
}, [fileSubtypes]);
|
||||
const currentFile = fileEntries[previewIndex]?.file;
|
||||
if (!canvasRef.current || !currentFile) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current && assetFile.value) {
|
||||
loadPreviewOnCanvas({
|
||||
file: assetFile.value,
|
||||
canvas: canvasRef.current,
|
||||
statusCallback: (statusObject: { type: string; message?: string }) => {
|
||||
setPreviewStatus(statusObject);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [assetFile.value]);
|
||||
disposeRenderer();
|
||||
setPreviewStatus({ type: "init" });
|
||||
|
||||
const { renderer, camera } = loadPreviewOnCanvas({
|
||||
file: currentFile,
|
||||
canvas: canvasRef.current,
|
||||
statusCallback: setPreviewStatus,
|
||||
});
|
||||
rendererRef.current = renderer;
|
||||
cameraRef.current = camera;
|
||||
|
||||
return disposeRenderer;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [previewIndex, filesVersion]);
|
||||
|
||||
// Snapshot thumbnail when preview finishes loading
|
||||
useEffect(() => {
|
||||
if (previewStatus.type === "OK" && canvasRef.current) {
|
||||
snapshotCanvasAsThumbnail({
|
||||
targetNode: canvasRef.current!,
|
||||
resultCallback: (snapshotBlob) => {
|
||||
if (snapshotBlob) {
|
||||
setThumbnailFile(snapshotBlob);
|
||||
targetNode: canvasRef.current,
|
||||
resultCallback: (blob) => {
|
||||
const currentFile = fileEntries[previewIndex]?.file;
|
||||
if (blob && currentFile) {
|
||||
setThumbnails((prev) => new Map(prev).set(currentFile, blob));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [previewStatus]);
|
||||
}, [previewStatus, previewIndex]);
|
||||
|
||||
// Reset subtype when fileSubtypes set changes (e.g. toggling character)
|
||||
useEffect(() => {
|
||||
if (!fileSubtypes || fileSubtypes.length === 0) {
|
||||
setSubtype(undefined);
|
||||
return;
|
||||
}
|
||||
setSubtype(
|
||||
Object.values(fileSubtypes[0])[0] as MediaFileAnimationType | undefined,
|
||||
);
|
||||
}, [fileSubtypes]);
|
||||
|
||||
const updateFileStatus = (
|
||||
index: number,
|
||||
status: FileEntryStatus,
|
||||
errorMessage?: string,
|
||||
) => {
|
||||
setFileEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, status, errorMessage } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFileEntries((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index);
|
||||
setPreviewIndex((prevIdx) =>
|
||||
Math.min(prevIdx, Math.max(0, next.length - 1)),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
setFilesVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
const retrySingleFile = async (index: number) => {
|
||||
const entry = fileEntries[index];
|
||||
if (!entry || entry.status === "uploading") return;
|
||||
updateFileStatus(index, "uploading");
|
||||
await upload3DObjects({
|
||||
title: entry.file.name.slice(0, entry.file.name.lastIndexOf(".")),
|
||||
assetFile: entry.file,
|
||||
engineCategory,
|
||||
animationType: subtype,
|
||||
thumbnailSnapshot: thumbnails.get(entry.file),
|
||||
progressCallback: (state) => {
|
||||
if (state.status === UploaderStates.success) {
|
||||
updateFileStatus(index, "success");
|
||||
} else if (
|
||||
state.status === UploaderStates.assetError ||
|
||||
state.status === UploaderStates.coverCreateError ||
|
||||
state.status === UploaderStates.coverSetError
|
||||
) {
|
||||
updateFileStatus(index, "error", state.errorMessage);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilesChange = (files: File[]) => {
|
||||
setFileEntries(files.map((f) => ({ file: f, status: "idle" })));
|
||||
setPreviewIndex(0);
|
||||
setFilesVersion((v) => v + 1);
|
||||
setThumbnails(new Map());
|
||||
setSelectionError(undefined);
|
||||
setOverallProgress(null);
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (fileEntries.length === 0) {
|
||||
setSelectionError("Please select a file to upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only upload files that haven't already succeeded
|
||||
const pendingEntries = fileEntries
|
||||
.map((e, i) => ({ entry: e, originalIndex: i }))
|
||||
.filter(
|
||||
({ entry }) => entry.status !== "success" && entry.file !== undefined,
|
||||
);
|
||||
const files = pendingEntries.map(({ entry }) => entry.file!);
|
||||
const originalIndices = pendingEntries.map(
|
||||
({ originalIndex }) => originalIndex,
|
||||
);
|
||||
const pendingThumbnails = new Map<File, Blob>(
|
||||
files
|
||||
.map((file) => [file, thumbnails.get(file) ?? undefined])
|
||||
.filter((pair): pair is [File, Blob] => pair[1] !== undefined),
|
||||
);
|
||||
|
||||
if (files.length === 1 && fileEntries.length === 1) {
|
||||
// Single file: use parent modal state machine (shows loading spinner)
|
||||
upload3DObjects({
|
||||
title: files[0].name.slice(0, files[0].name.lastIndexOf(".")),
|
||||
assetFile: files[0],
|
||||
engineCategory,
|
||||
animationType: subtype,
|
||||
thumbnailSnapshot: thumbnails.get(files[0]),
|
||||
progressCallback: onUploadProgress,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-file: stay on form, show inline progress
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: files.length });
|
||||
|
||||
upload3DObjectsBatch({
|
||||
files,
|
||||
thumbnails: pendingThumbnails,
|
||||
engineCategory,
|
||||
animationType: subtype,
|
||||
onFileStatusChange: (batchIndex, status, errorMessage) =>
|
||||
updateFileStatus(originalIndices[batchIndex], status, errorMessage),
|
||||
onOverallProgress: (completed, total) =>
|
||||
setOverallProgress({ current: completed, total }),
|
||||
onComplete: (allSucceeded, anySucceeded) => {
|
||||
setIsUploading(false);
|
||||
if (allSucceeded) {
|
||||
onUploadProgress({ status: UploaderStates.success });
|
||||
} else if (!anySucceeded) {
|
||||
onUploadProgress({
|
||||
status: UploaderStates.assetError,
|
||||
errorMessage: "All uploads failed.",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const retryAllFailed = () => {
|
||||
const failedIndices = fileEntries
|
||||
.map((e, i) => (e.status === "error" ? i : -1))
|
||||
.filter((i) => i !== -1);
|
||||
if (failedIndices.length === 0) return;
|
||||
|
||||
const failedFiles = failedIndices
|
||||
.map((i) => fileEntries[i].file!)
|
||||
.filter((file) => file !== undefined);
|
||||
const failedThumbnails = new Map<File, Blob>(
|
||||
failedFiles
|
||||
.map((file): [File, Blob | undefined] => [
|
||||
file,
|
||||
thumbnails.get(file) ?? undefined,
|
||||
])
|
||||
.filter((pair): pair is [File, Blob] => pair[1] !== undefined),
|
||||
);
|
||||
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: failedFiles.length });
|
||||
|
||||
upload3DObjectsBatch({
|
||||
files: failedFiles,
|
||||
thumbnails: failedThumbnails,
|
||||
engineCategory,
|
||||
animationType: subtype,
|
||||
onFileStatusChange: (batchIndex, status, errorMessage) =>
|
||||
updateFileStatus(failedIndices[batchIndex], status, errorMessage),
|
||||
onOverallProgress: (completed, total) =>
|
||||
setOverallProgress({ current: completed, total }),
|
||||
onComplete: (allSucceeded, anySucceeded) => {
|
||||
setIsUploading(false);
|
||||
if (allSucceeded) {
|
||||
onUploadProgress({ status: UploaderStates.success });
|
||||
} else if (!anySucceeded) {
|
||||
onUploadProgress({
|
||||
status: UploaderStates.assetError,
|
||||
errorMessage: "All uploads failed.",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isMulti = fileEntries.length > 1;
|
||||
const anyFailed = fileEntries.some((e) => e.status === "error");
|
||||
const anyUploading = fileEntries.some((e) => e.status === "uploading");
|
||||
const hasUploadStarted = fileEntries.some((e) => e.status !== "idle");
|
||||
const allDone =
|
||||
fileEntries.length > 0 &&
|
||||
fileEntries.every((e) => e.status === "success" || e.status === "error");
|
||||
const currentFile = fileEntries[previewIndex]?.file;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
file={assetFile.value ?? undefined}
|
||||
handleChange={(file: File) => {
|
||||
setAssetFile({
|
||||
value: file,
|
||||
});
|
||||
}}
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileSubtypes && fileSubtypes.length > 1 && (
|
||||
<ListDropdown
|
||||
list={fileSubtypes}
|
||||
onSelect={(value) => setSubtype(value as MediaFileAnimationType)}
|
||||
/>
|
||||
{fileSubtypes && fileSubtypes.length > 1 && (
|
||||
<ListDropdown
|
||||
list={fileSubtypes}
|
||||
onSelect={(value) => setSubtype(value as MediaFileAnimationType)}
|
||||
/>
|
||||
)}
|
||||
{assetFile.error && (
|
||||
<h6 className="z-10 text-red">{assetFile.error}</h6>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
files={fileEntries.map((e) => e.file)}
|
||||
handleChange={handleFilesChange}
|
||||
multiple={true}
|
||||
fileIcon={faCube}
|
||||
/>
|
||||
|
||||
{selectionError && <h6 className="z-10 text-red">{selectionError}</h6>}
|
||||
|
||||
{isMulti ? (
|
||||
<PanelGroup orientation="horizontal">
|
||||
{/* File list sidebar */}
|
||||
<Panel defaultSize="33%" minSize="20%">
|
||||
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-lg bg-brand-secondary p-2">
|
||||
{fileEntries.map((entry, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`group flex cursor-pointer items-center justify-between gap-1.5 rounded px-2 py-1 text-sm transition-colors ${
|
||||
i === previewIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setPreviewIndex(i)}
|
||||
>
|
||||
<span className="flex-1 truncate" title={entry.file.name}>
|
||||
{entry.file.name.slice(0, entry.file.name.lastIndexOf("."))}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{entry.status === "idle" && (
|
||||
<button
|
||||
className="opacity-40 transition-opacity hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(i);
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
{entry.status === "uploading" && (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinner}
|
||||
className="animate-spin opacity-60"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "success" && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="text-green-400"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleExclamation}
|
||||
className="text-red-400"
|
||||
/>
|
||||
<button
|
||||
className="hidden items-center text-xs text-white/60 transition-colors hover:text-white group-hover:inline-flex"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retrySingleFile(i);
|
||||
}}
|
||||
title="Retry"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotateRight} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="flex w-4 items-center justify-center" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="h-8 w-1 rounded-full bg-white/20 transition-colors hover:bg-white/40" />
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Canvas preview + carousel */}
|
||||
<Panel defaultSize="67%" minSize="25%">
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div className="relative w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<canvas
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<FontAwesomeIcon icon={faCube} />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPreviewIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={previewIndex === 0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
</Button>
|
||||
<span className="text-sm opacity-60">
|
||||
{previewIndex + 1} / {fileEntries.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setPreviewIndex((p) =>
|
||||
Math.min(fileEntries.length - 1, p + 1),
|
||||
)
|
||||
}
|
||||
disabled={previewIndex === fileEntries.length - 1}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
) : (
|
||||
/* Single-file layout: canvas takes full width (original behaviour) */
|
||||
<div className="relative m-auto w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<canvas
|
||||
className="pointer-events-none h-full !w-full"
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
{!assetFile.value && (
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<FontAwesomeIcon icon={faCube} />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<>
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
</>
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
{(isUploading || anyUploading) && overallProgress && isMulti && (
|
||||
<p className="text-center text-sm opacity-60">
|
||||
Uploading {overallProgress.current} / {overallProgress.total}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<p className="text-center text-sm text-red-400">
|
||||
{fileEntries.filter((e) => e.status === "error").length} file(s)
|
||||
failed to upload.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<Button variant="secondary" onClick={retryAllFailed}>
|
||||
Retry Failed
|
||||
</Button>
|
||||
)}
|
||||
{/* Hide Upload once any upload has started; only Retry Failed or Close remains */}
|
||||
{!hasUploadStarted && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!assetFile.value}
|
||||
disabled={fileEntries.length === 0}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ interface Props {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
titleIcon: IconDefinition;
|
||||
initialFile?: File;
|
||||
initialFiles?: File[];
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
@@ -33,7 +33,7 @@ interface Props {
|
||||
const objectFileTypes = Object.values(OBJECT_FILE_TYPE);
|
||||
|
||||
export function UploadModal3D(props: Props) {
|
||||
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile, options } = props;
|
||||
const { isOpen, onClose, onSuccess, title, titleIcon, initialFiles, options } = props;
|
||||
const [uploaderState, setUploaderState] =
|
||||
useState<UploaderState>(initialUploaderState);
|
||||
const [isCharacter, setIsCharacter] = useState(false);
|
||||
@@ -102,7 +102,7 @@ export function UploadModal3D(props: Props) {
|
||||
title={title}
|
||||
engineCategory={selectedCategory}
|
||||
fileTypes={objectFileTypes}
|
||||
initialFile={initialFile}
|
||||
initialFiles={initialFiles}
|
||||
options={{
|
||||
...(options ?? {}),
|
||||
fileSubtypes: characterAnimationOptions,
|
||||
@@ -114,13 +114,19 @@ export function UploadModal3D(props: Props) {
|
||||
);
|
||||
case UploaderStates.uploadingAsset:
|
||||
case UploaderStates.uploadingCover:
|
||||
case UploaderStates.settingCover:
|
||||
case UploaderStates.settingCover: {
|
||||
const p = uploaderState.uploadProgress;
|
||||
return (
|
||||
<>
|
||||
<LoadingDots className="mb-1 bg-transparent" />
|
||||
<div className="w-100 text-center opacity-50">Uploading...</div>
|
||||
<div className="w-100 text-center opacity-50">
|
||||
{p && p.total > 1
|
||||
? `Uploading ${p.current} / ${p.total}...`
|
||||
: "Uploading..."}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case UploaderStates.success: {
|
||||
return (
|
||||
<UploadSuccess
|
||||
|
||||
+2
-1
@@ -15,6 +15,7 @@ interface LoaderInterface {
|
||||
|
||||
interface PreviewReturn {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
}
|
||||
|
||||
export const loadPreviewOnCanvas = ({
|
||||
@@ -115,7 +116,7 @@ export const loadPreviewOnCanvas = ({
|
||||
};
|
||||
renderer.setAnimationLoop(animate);
|
||||
|
||||
return { renderer };
|
||||
return { renderer, camera };
|
||||
};
|
||||
|
||||
const glbLoader = ({
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
FilterEngineCategories,
|
||||
MediaFileAnimationType,
|
||||
UploaderStates,
|
||||
} from "~/enums";
|
||||
import { upload3DObjects } from "./upload3DObjects";
|
||||
import { FileEntryStatus } from "../../UploadModalImage/utilities/uploadImagesBatch";
|
||||
|
||||
export const upload3DObjectsBatch = async ({
|
||||
files,
|
||||
thumbnails,
|
||||
engineCategory,
|
||||
animationType,
|
||||
onFileStatusChange,
|
||||
onOverallProgress,
|
||||
onComplete,
|
||||
}: {
|
||||
files: File[];
|
||||
thumbnails: Map<File, Blob>;
|
||||
engineCategory: FilterEngineCategories;
|
||||
animationType?: MediaFileAnimationType;
|
||||
onFileStatusChange: (
|
||||
index: number,
|
||||
status: FileEntryStatus,
|
||||
errorMessage?: string,
|
||||
) => void;
|
||||
onOverallProgress: (completed: number, total: number) => void;
|
||||
onComplete: (allSucceeded: boolean, anySucceeded: boolean) => void;
|
||||
}) => {
|
||||
// JS is single-threaded; these counters are safe across concurrent async callbacks.
|
||||
let completedCount = 0;
|
||||
let successCount = 0;
|
||||
const completedIndices = new Set<number>();
|
||||
|
||||
files.forEach((_, i) => onFileStatusChange(i, "uploading"));
|
||||
onOverallProgress(0, files.length);
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
files.map((file, i) =>
|
||||
upload3DObjects({
|
||||
title: file.name.slice(0, file.name.lastIndexOf(".")),
|
||||
assetFile: file,
|
||||
engineCategory,
|
||||
animationType,
|
||||
thumbnailSnapshot: thumbnails.get(file),
|
||||
progressCallback: (state) => {
|
||||
if (state.status === UploaderStates.success) {
|
||||
successCount++;
|
||||
onFileStatusChange(i, "success");
|
||||
} else if (
|
||||
state.status === UploaderStates.assetError ||
|
||||
state.status === UploaderStates.coverCreateError ||
|
||||
state.status === UploaderStates.coverSetError
|
||||
) {
|
||||
onFileStatusChange(i, "error", state.errorMessage);
|
||||
}
|
||||
|
||||
if (
|
||||
state.status === UploaderStates.success ||
|
||||
state.status === UploaderStates.assetError ||
|
||||
state.status === UploaderStates.coverCreateError ||
|
||||
state.status === UploaderStates.coverSetError
|
||||
) {
|
||||
if (!completedIndices.has(i)) {
|
||||
completedIndices.add(i);
|
||||
completedCount++;
|
||||
onOverallProgress(completedCount, files.length);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
// Only mark files that haven't already reached a terminal state
|
||||
files.forEach((_, i) => {
|
||||
if (!completedIndices.has(i)) {
|
||||
onFileStatusChange(
|
||||
i,
|
||||
"error",
|
||||
err instanceof Error ? err.message : "Unexpected error",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onComplete(successCount === files.length, successCount > 0);
|
||||
};
|
||||
+342
-49
@@ -1,83 +1,376 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { FileUploader } from "@storyteller/ui-file-uploader";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faXmark,
|
||||
faCheck,
|
||||
faCircleExclamation,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faRotateRight,
|
||||
faSpinner,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { uploadImage } from "./utilities/uploadImage";
|
||||
import { UploaderState } from "../../../models";
|
||||
import {
|
||||
uploadImagesBatch,
|
||||
FileEntryStatus,
|
||||
} from "./utilities/uploadImagesBatch";
|
||||
import type { UploaderState } from "../../../models";
|
||||
import { UploaderStates } from "../../../enums";
|
||||
|
||||
interface FileEntry {
|
||||
file: File;
|
||||
status: FileEntryStatus;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
fileTypes: string[];
|
||||
initialFile?: File;
|
||||
initialFiles?: File[];
|
||||
onClose: () => void;
|
||||
onUploadProgress: (newState: UploaderState) => void;
|
||||
}
|
||||
|
||||
export const UploadFilesImage = ({
|
||||
fileTypes,
|
||||
initialFile,
|
||||
initialFiles,
|
||||
onClose,
|
||||
onUploadProgress,
|
||||
}: Props) => {
|
||||
const [assetFile, setAssetFile] = useState<{
|
||||
value: File | null;
|
||||
error?: string;
|
||||
}>({ value: initialFile ?? null });
|
||||
const seedFiles = initialFiles ?? [];
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!assetFile.value) {
|
||||
setAssetFile((curr) => ({
|
||||
...curr,
|
||||
error: "Please select an image to upload.",
|
||||
}));
|
||||
const [fileEntries, setFileEntries] = useState<FileEntry[]>(
|
||||
seedFiles.map((f) => ({ file: f, status: "idle" }))
|
||||
);
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
// Incremented on every handleFilesChange so useEffect re-runs even when count stays the same
|
||||
const [filesVersion, setFilesVersion] = useState(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [overallProgress, setOverallProgress] = useState<{ current: number; total: number } | null>(null);
|
||||
const [selectionError, setSelectionError] = useState<string | undefined>();
|
||||
|
||||
// Create and clean up the object URL for the current preview.
|
||||
// filesVersion ensures this re-runs when the same-count file set is replaced.
|
||||
useEffect(() => {
|
||||
const currentFile = fileEntries[previewIndex]?.file;
|
||||
if (!currentFile) {
|
||||
setPreviewUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(currentFile);
|
||||
setPreviewUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [previewIndex, filesVersion]);
|
||||
|
||||
// Use the filename as the title
|
||||
const title = assetFile.value.name.split(".")[0];
|
||||
const updateFileStatus = (
|
||||
index: number,
|
||||
status: FileEntryStatus,
|
||||
errorMessage?: string
|
||||
) => {
|
||||
setFileEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, status, errorMessage } : entry
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
uploadImage({
|
||||
title: title,
|
||||
assetFile: assetFile.value,
|
||||
progressCallback: onUploadProgress,
|
||||
const removeFile = (index: number) => {
|
||||
setFileEntries((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index);
|
||||
// Compute new index inside the updater so it's always based on the post-remove length
|
||||
setPreviewIndex((prevIdx) => Math.min(prevIdx, Math.max(0, next.length - 1)));
|
||||
return next;
|
||||
});
|
||||
setFilesVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
const retrySingleFile = async (index: number) => {
|
||||
const entry = fileEntries[index];
|
||||
if (!entry || entry.status === "uploading") return;
|
||||
updateFileStatus(index, "uploading");
|
||||
await uploadImage({
|
||||
title: entry.file.name.slice(0, entry.file.name.lastIndexOf(".")),
|
||||
assetFile: entry.file,
|
||||
progressCallback: (state) => {
|
||||
if (state.status === UploaderStates.success) {
|
||||
updateFileStatus(index, "success");
|
||||
} else if (state.status === UploaderStates.assetError) {
|
||||
updateFileStatus(index, "error", state.errorMessage);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilesChange = (files: File[]) => {
|
||||
setFileEntries(files.map((f) => ({ file: f, status: "idle" })));
|
||||
setPreviewIndex(0);
|
||||
setFilesVersion((v) => v + 1);
|
||||
setSelectionError(undefined);
|
||||
setOverallProgress(null);
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (fileEntries.length === 0) {
|
||||
setSelectionError("Please select an image to upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only upload files that haven't already succeeded
|
||||
const files = fileEntries
|
||||
.filter((e) => e.status !== "success")
|
||||
.map((e) => e.file);
|
||||
const originalIndices = fileEntries
|
||||
.map((e, i) => (e.status !== "success" ? i : -1))
|
||||
.filter((i) => i !== -1);
|
||||
|
||||
if (files.length === 1 && fileEntries.length === 1) {
|
||||
// Single file: use parent modal state machine (shows loading spinner)
|
||||
uploadImage({
|
||||
title: files[0].name.slice(0, files[0].name.lastIndexOf(".")),
|
||||
assetFile: files[0],
|
||||
progressCallback: onUploadProgress,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-file: stay on form, show inline progress
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: files.length });
|
||||
|
||||
uploadImagesBatch({
|
||||
files,
|
||||
onFileStatusChange: (batchIndex, status, errorMessage) =>
|
||||
updateFileStatus(originalIndices[batchIndex], status, errorMessage),
|
||||
onOverallProgress: (completed, total) =>
|
||||
setOverallProgress({ current: completed, total }),
|
||||
onComplete: (allSucceeded, anySucceeded) => {
|
||||
setIsUploading(false);
|
||||
if (allSucceeded) {
|
||||
onUploadProgress({ status: UploaderStates.success });
|
||||
} else if (!anySucceeded) {
|
||||
onUploadProgress({
|
||||
status: UploaderStates.assetError,
|
||||
errorMessage: "All uploads failed.",
|
||||
});
|
||||
}
|
||||
// Partial failure: stay on form, sidebar shows which files are red
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const retryAllFailed = () => {
|
||||
const failedIndices = fileEntries
|
||||
.map((e, i) => (e.status === "error" ? i : -1))
|
||||
.filter((i) => i !== -1);
|
||||
if (failedIndices.length === 0) return;
|
||||
|
||||
const failedFiles = failedIndices.map((i) => fileEntries[i].file);
|
||||
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: failedFiles.length });
|
||||
|
||||
uploadImagesBatch({
|
||||
files: failedFiles,
|
||||
onFileStatusChange: (batchIndex, status, errorMessage) =>
|
||||
updateFileStatus(failedIndices[batchIndex], status, errorMessage),
|
||||
onOverallProgress: (completed, total) =>
|
||||
setOverallProgress({ current: completed, total }),
|
||||
onComplete: (allSucceeded, anySucceeded) => {
|
||||
setIsUploading(false);
|
||||
if (allSucceeded) {
|
||||
onUploadProgress({ status: UploaderStates.success });
|
||||
} else if (!anySucceeded) {
|
||||
onUploadProgress({
|
||||
status: UploaderStates.assetError,
|
||||
errorMessage: "All uploads failed.",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isMulti = fileEntries.length > 1;
|
||||
const anyFailed = fileEntries.some((e) => e.status === "error");
|
||||
const anyUploading = fileEntries.some((e) => e.status === "uploading");
|
||||
// Upload has started if any file has left the idle state
|
||||
const hasUploadStarted = fileEntries.some((e) => e.status !== "idle");
|
||||
const allDone =
|
||||
fileEntries.length > 0 &&
|
||||
fileEntries.every((e) => e.status === "success" || e.status === "error");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
file={assetFile.value ?? undefined}
|
||||
handleChange={(file: File) => {
|
||||
setAssetFile({
|
||||
value: file,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
files={fileEntries.map((e) => e.file)}
|
||||
handleChange={handleFilesChange}
|
||||
multiple={true}
|
||||
/>
|
||||
|
||||
{assetFile.error && (
|
||||
<h6 className="z-10 text-red">{assetFile.error}</h6>
|
||||
)}
|
||||
{selectionError && (
|
||||
<h6 className="z-10 text-red">{selectionError}</h6>
|
||||
)}
|
||||
|
||||
{assetFile.value && (
|
||||
<div className="relative m-auto flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<img
|
||||
alt="Preview"
|
||||
className="m-auto max-h-full max-w-full object-contain"
|
||||
src={URL.createObjectURL(assetFile.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fileEntries.length > 0 && (
|
||||
isMulti ? (
|
||||
<PanelGroup orientation="horizontal">
|
||||
{/* File list sidebar */}
|
||||
<Panel defaultSize="33%" minSize="20%">
|
||||
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-lg bg-brand-secondary p-2">
|
||||
{fileEntries.map((entry, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`group flex items-center justify-between gap-1.5 rounded px-2 py-1 cursor-pointer text-sm transition-colors ${
|
||||
i === previewIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setPreviewIndex(i)}
|
||||
>
|
||||
<span className="truncate flex-1" title={entry.file.name}>
|
||||
{entry.file.name.slice(0, entry.file.name.lastIndexOf("."))}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{entry.status === "idle" && (
|
||||
<button
|
||||
className="opacity-40 hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(i);
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
{entry.status === "uploading" && (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinner}
|
||||
className="animate-spin opacity-60"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "success" && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="text-green-400"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleExclamation}
|
||||
className="text-red-400"
|
||||
/>
|
||||
<button
|
||||
className="hidden group-hover:inline-flex items-center text-xs text-white/60 hover:text-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retrySingleFile(i);
|
||||
}}
|
||||
title="Retry"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotateRight} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
<PanelResizeHandle className="flex w-4 items-center justify-center" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="h-8 w-1 rounded-full bg-white/20 transition-colors hover:bg-white/40" />
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Preview + carousel navigation */}
|
||||
<Panel defaultSize="67%" minSize="25%">
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-brand-secondary flex items-center justify-center">
|
||||
{previewUrl && (
|
||||
<img
|
||||
key={previewIndex}
|
||||
alt="Preview"
|
||||
className="m-auto max-h-full max-w-full object-contain"
|
||||
src={previewUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPreviewIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={previewIndex === 0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
</Button>
|
||||
<span className="text-sm opacity-60">
|
||||
{previewIndex + 1} / {fileEntries.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setPreviewIndex((p) =>
|
||||
Math.min(fileEntries.length - 1, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={previewIndex === fileEntries.length - 1}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
) : (
|
||||
previewUrl && (
|
||||
<div className="relative m-auto flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<img
|
||||
alt="Preview"
|
||||
className="m-auto max-h-full max-w-full object-contain"
|
||||
src={previewUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
)}
|
||||
|
||||
{(isUploading || anyUploading) && overallProgress && isMulti && (
|
||||
<p className="text-center text-sm opacity-60">
|
||||
Uploading {overallProgress.current} / {overallProgress.total}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<p className="text-center text-sm text-red-400">
|
||||
{fileEntries.filter((e) => e.status === "error").length} file(s) failed to upload.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<Button variant="secondary" onClick={retryAllFailed}>
|
||||
Retry Failed
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSubmit}>
|
||||
)}
|
||||
{/* Hide Upload once any upload has started; only Retry Failed or Close remains */}
|
||||
{!hasUploadStarted && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={fileEntries.length === 0}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+11
-5
@@ -21,13 +21,13 @@ interface Props {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
titleIcon: IconDefinition;
|
||||
initialFile?: File;
|
||||
initialFiles?: File[];
|
||||
}
|
||||
|
||||
const imageFileTypes = Object.values(IMAGEPLANE_FILE_TYPE);
|
||||
|
||||
export function UploadModalImage(props: Props) {
|
||||
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile } = props;
|
||||
const { isOpen, onClose, onSuccess, title, titleIcon, initialFiles } = props;
|
||||
const [uploaderState, setUploaderState] =
|
||||
useState<UploaderState>(initialUploaderState);
|
||||
|
||||
@@ -68,7 +68,7 @@ export function UploadModalImage(props: Props) {
|
||||
<UploadFilesImage
|
||||
title={title}
|
||||
fileTypes={imageFileTypes}
|
||||
initialFile={initialFile}
|
||||
initialFiles={initialFiles}
|
||||
onClose={onClose}
|
||||
onUploadProgress={updateUploaderState}
|
||||
/>
|
||||
@@ -76,13 +76,19 @@ export function UploadModalImage(props: Props) {
|
||||
);
|
||||
case UploaderStates.uploadingAsset:
|
||||
case UploaderStates.uploadingCover:
|
||||
case UploaderStates.settingCover:
|
||||
case UploaderStates.settingCover: {
|
||||
const p = uploaderState.uploadProgress;
|
||||
return (
|
||||
<>
|
||||
<LoadingDots className="mb-1 bg-transparent" />
|
||||
<div className="w-100 text-center opacity-50">Uploading...</div>
|
||||
<div className="w-100 text-center opacity-50">
|
||||
{p && p.total > 1
|
||||
? `Uploading ${p.current} / ${p.total}...`
|
||||
: "Uploading..."}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case UploaderStates.success: {
|
||||
return (
|
||||
<UploadSuccess
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { UploaderStates } from "~/enums";
|
||||
import { uploadImage } from "./uploadImage";
|
||||
|
||||
export type FileEntryStatus = "idle" | "uploading" | "success" | "error";
|
||||
|
||||
export const uploadImagesBatch = async ({
|
||||
files,
|
||||
onFileStatusChange,
|
||||
onOverallProgress,
|
||||
onComplete,
|
||||
}: {
|
||||
files: File[];
|
||||
onFileStatusChange: (index: number, status: FileEntryStatus, errorMessage?: string) => void;
|
||||
onOverallProgress: (completed: number, total: number) => void;
|
||||
onComplete: (allSucceeded: boolean, anySucceeded: boolean) => void;
|
||||
}) => {
|
||||
// JS is single-threaded; these counters are safe across concurrent async callbacks.
|
||||
let completedCount = 0;
|
||||
let successCount = 0;
|
||||
const completedIndices = new Set<number>();
|
||||
|
||||
files.forEach((_, i) => onFileStatusChange(i, "uploading"));
|
||||
onOverallProgress(0, files.length);
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
files.map((file, i) =>
|
||||
uploadImage({
|
||||
title: file.name.slice(0, file.name.lastIndexOf(".")),
|
||||
assetFile: file,
|
||||
progressCallback: (state) => {
|
||||
if (state.status === UploaderStates.success) {
|
||||
successCount++;
|
||||
onFileStatusChange(i, "success");
|
||||
} else if (state.status === UploaderStates.assetError) {
|
||||
onFileStatusChange(i, "error", state.errorMessage);
|
||||
}
|
||||
|
||||
if (
|
||||
state.status === UploaderStates.success ||
|
||||
state.status === UploaderStates.assetError
|
||||
) {
|
||||
if (!completedIndices.has(i)) {
|
||||
completedIndices.add(i);
|
||||
completedCount++;
|
||||
onOverallProgress(completedCount, files.length);
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
// Only mark files that haven't already reached a terminal state
|
||||
files.forEach((_, i) => {
|
||||
if (!completedIndices.has(i)) {
|
||||
onFileStatusChange(i, "error", err instanceof Error ? err.message : "Unexpected error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onComplete(successCount === files.length, successCount > 0);
|
||||
};
|
||||
+427
-117
@@ -1,20 +1,38 @@
|
||||
import { useCallback, useEffect, useState, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels";
|
||||
import { ListDropdown } from "@storyteller/ui-list-dropdown";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { FileUploader } from "@storyteller/ui-file-uploader";
|
||||
import { UploaderState } from "../../../models";
|
||||
import { FilterEngineCategories, MediaFileAnimationType, UploaderStates } from "../../../enums";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCube } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faCube,
|
||||
faXmark,
|
||||
faCheck,
|
||||
faCircleExclamation,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faRotateRight,
|
||||
faSpinner,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { WebGLRenderer, PerspectiveCamera } from "three";
|
||||
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "../UploadModal3D/utilities";
|
||||
import { WebGLRenderer } from "three";
|
||||
import { upload3DObjects } from "../UploadModal3D/utilities/upload3DObjects";
|
||||
import { upload3DObjectsBatch } from "../UploadModal3D/utilities/upload3DObjectsBatch";
|
||||
import { FileEntryStatus } from "../UploadModalImage/utilities/uploadImagesBatch";
|
||||
import type { UploaderState } from "../../../models";
|
||||
import { FilterEngineCategories, MediaFileAnimationType, UploaderStates } from "../../../enums";
|
||||
|
||||
interface FileEntry {
|
||||
file: File;
|
||||
status: FileEntryStatus;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
fileTypes: string[];
|
||||
engineCategory: FilterEngineCategories;
|
||||
initialFile?: File;
|
||||
initialFiles?: File[];
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
@@ -22,185 +40,477 @@ interface Props {
|
||||
};
|
||||
onClose: () => void;
|
||||
onUploadProgress: (newState: UploaderState) => void;
|
||||
onLocalBytes: (bytes: ArrayBuffer, shouldFlip) => void;
|
||||
onLocalBytes: (bytes: ArrayBuffer, shouldFlip: boolean) => void;
|
||||
}
|
||||
|
||||
export const UploadFilesSplat = ({
|
||||
fileTypes,
|
||||
engineCategory,
|
||||
initialFile,
|
||||
initialFiles,
|
||||
options,
|
||||
onClose,
|
||||
onUploadProgress,
|
||||
onLocalBytes,
|
||||
}: Props) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | undefined>(undefined);
|
||||
const canvasCallbackRef = useCallback((node: HTMLCanvasElement) => {
|
||||
const rendererRef = useRef<WebGLRenderer | null>(null);
|
||||
const cameraRef = useRef<PerspectiveCamera | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const canvasCallbackRef = useCallback((node: HTMLCanvasElement | null) => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
if (node !== null) {
|
||||
canvasRef.current = node;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const renderer = rendererRef.current;
|
||||
const camera = cameraRef.current;
|
||||
if (!renderer || !camera) return;
|
||||
const w = node.clientWidth;
|
||||
const h = node.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
});
|
||||
observer.observe(node);
|
||||
resizeObserverRef.current = observer;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fileSubtypes = options?.fileSubtypes;
|
||||
// const hasLength = options?.hasLength;
|
||||
// const hasThumbnailUpload = options?.hasThumbnailUpload;
|
||||
|
||||
const [subtype, setSubtype] = useState<MediaFileAnimationType | undefined>(
|
||||
fileSubtypes
|
||||
? (Object.values(fileSubtypes[0])[0] as MediaFileAnimationType)
|
||||
: undefined,
|
||||
: undefined
|
||||
);
|
||||
|
||||
const [assetFile, setAssetFile] = useState<{
|
||||
value: File | null;
|
||||
error?: string;
|
||||
}>({ value: initialFile ?? null });
|
||||
const seedFiles = initialFiles ?? [];
|
||||
|
||||
const [previewStatus, setPreviewStatus] = useState<{
|
||||
type: string;
|
||||
message?: string;
|
||||
}>({ type: "init" });
|
||||
|
||||
const [thumbnailFile, setThumbnailFile] = useState<Blob | undefined>(
|
||||
undefined,
|
||||
const [fileEntries, setFileEntries] = useState<FileEntry[]>(
|
||||
seedFiles.map((f) => ({ file: f, status: "idle" }))
|
||||
);
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
// Incremented on every handleFilesChange so useEffect re-runs even when count stays the same
|
||||
const [filesVersion, setFilesVersion] = useState(0);
|
||||
const [previewStatus, setPreviewStatus] = useState<{ type: string; message?: string }>({ type: "init" });
|
||||
const [thumbnails, setThumbnails] = useState<Map<File, Blob>>(new Map());
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [overallProgress, setOverallProgress] = useState<{ current: number; total: number } | null>(null);
|
||||
const [selectionError, setSelectionError] = useState<string | undefined>();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!assetFile.value) {
|
||||
setAssetFile((curr) => ({
|
||||
...curr,
|
||||
error: "Please select a file to upload.",
|
||||
}));
|
||||
return;
|
||||
const disposeRenderer = () => {
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.setAnimationLoop(null);
|
||||
rendererRef.current.dispose();
|
||||
rendererRef.current = null;
|
||||
}
|
||||
|
||||
// Use the filename as the title
|
||||
const title = assetFile.value.name.split(".")[0];
|
||||
|
||||
// const shouldFlip = title.search("ceramic") != -1;
|
||||
//
|
||||
// assetFile.value.arrayBuffer().then((arrayBuffer) => {
|
||||
// onLocalBytes(arrayBuffer, shouldFlip);
|
||||
// }).catch((error) => {
|
||||
// console.error("Error reading file as ArrayBuffer:", error);
|
||||
// });
|
||||
|
||||
upload3DObjects({
|
||||
title: title,
|
||||
assetFile: assetFile.value,
|
||||
thumbnailSnapshot: thumbnailFile,
|
||||
engineCategory: engineCategory,
|
||||
animationType: subtype,
|
||||
progressCallback: onUploadProgress,
|
||||
});
|
||||
};
|
||||
|
||||
// Reload canvas preview whenever the current file or index changes.
|
||||
// filesVersion ensures this re-runs when the same-count file set is replaced.
|
||||
useEffect(() => {
|
||||
// Reset subtype when fileSubtypes set changes (e.g., toggling character)
|
||||
if (!fileSubtypes || fileSubtypes.length === 0) {
|
||||
setSubtype(undefined);
|
||||
return;
|
||||
}
|
||||
const nextDefault = Object.values(fileSubtypes[0])[0] as
|
||||
| MediaFileAnimationType
|
||||
| undefined;
|
||||
setSubtype(nextDefault);
|
||||
}, [fileSubtypes]);
|
||||
const currentFile = fileEntries[previewIndex]?.file;
|
||||
if (!canvasRef.current || !currentFile) return;
|
||||
|
||||
useEffect(() => {
|
||||
let renderer: WebGLRenderer | null = null;
|
||||
disposeRenderer();
|
||||
setPreviewStatus({ type: "init" });
|
||||
|
||||
if (canvasRef.current && assetFile.value) {
|
||||
renderer = loadPreviewOnCanvas({
|
||||
file: assetFile.value,
|
||||
canvas: canvasRef.current,
|
||||
statusCallback: (statusObject: { type: string; message?: string }) => {
|
||||
setPreviewStatus(statusObject);
|
||||
},
|
||||
}).renderer;
|
||||
}
|
||||
const { renderer, camera } = loadPreviewOnCanvas({
|
||||
file: currentFile,
|
||||
canvas: canvasRef.current,
|
||||
statusCallback: setPreviewStatus,
|
||||
});
|
||||
rendererRef.current = renderer;
|
||||
cameraRef.current = camera;
|
||||
|
||||
return () => {
|
||||
renderer?.setAnimationLoop(null);
|
||||
renderer?.dispose();
|
||||
}
|
||||
}, [assetFile.value]);
|
||||
return disposeRenderer;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [previewIndex, filesVersion]);
|
||||
|
||||
// Snapshot thumbnail when preview finishes loading
|
||||
// Defer one animation frame so the splat renderer has produced a visible frame
|
||||
useEffect(() => {
|
||||
if (previewStatus.type === "OK" && canvasRef.current) {
|
||||
const canvas = canvasRef.current;
|
||||
// Defer one animation frame so the splat renderer has produced a visible frame
|
||||
const currentFile = fileEntries[previewIndex]?.file;
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
snapshotCanvasAsThumbnail({
|
||||
targetNode: canvas,
|
||||
resultCallback: (snapshotBlob) => {
|
||||
if (snapshotBlob) {
|
||||
setThumbnailFile(snapshotBlob);
|
||||
resultCallback: (blob) => {
|
||||
if (blob && currentFile) {
|
||||
setThumbnails((prev) => new Map(prev).set(currentFile, blob));
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}
|
||||
}, [previewStatus]);
|
||||
}, [previewStatus, previewIndex]);
|
||||
|
||||
// Reset subtype when fileSubtypes set changes
|
||||
useEffect(() => {
|
||||
if (!fileSubtypes || fileSubtypes.length === 0) {
|
||||
setSubtype(undefined);
|
||||
return;
|
||||
}
|
||||
setSubtype(Object.values(fileSubtypes[0])[0] as MediaFileAnimationType | undefined);
|
||||
}, [fileSubtypes]);
|
||||
|
||||
const updateFileStatus = (
|
||||
index: number,
|
||||
status: FileEntryStatus,
|
||||
errorMessage?: string
|
||||
) => {
|
||||
setFileEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, status, errorMessage } : entry
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFileEntries((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index);
|
||||
setPreviewIndex((prevIdx) => Math.min(prevIdx, Math.max(0, next.length - 1)));
|
||||
return next;
|
||||
});
|
||||
setFilesVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
const retrySingleFile = async (index: number) => {
|
||||
const entry = fileEntries[index];
|
||||
if (!entry || entry.status === "uploading") return;
|
||||
updateFileStatus(index, "uploading");
|
||||
await upload3DObjects({
|
||||
title: entry.file.name.slice(0, entry.file.name.lastIndexOf(".")),
|
||||
assetFile: entry.file,
|
||||
engineCategory,
|
||||
animationType: subtype,
|
||||
thumbnailSnapshot: thumbnails.get(entry.file),
|
||||
progressCallback: (state) => {
|
||||
if (state.status === UploaderStates.success) {
|
||||
updateFileStatus(index, "success");
|
||||
} else if (
|
||||
state.status === UploaderStates.assetError ||
|
||||
state.status === UploaderStates.coverCreateError ||
|
||||
state.status === UploaderStates.coverSetError
|
||||
) {
|
||||
updateFileStatus(index, "error", state.errorMessage);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilesChange = (files: File[]) => {
|
||||
setFileEntries(files.map((f) => ({ file: f, status: "idle" })));
|
||||
setPreviewIndex(0);
|
||||
setFilesVersion((v) => v + 1);
|
||||
setThumbnails(new Map());
|
||||
setSelectionError(undefined);
|
||||
setOverallProgress(null);
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (fileEntries.length === 0) {
|
||||
setSelectionError("Please select a file to upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only upload files that haven't already succeeded
|
||||
const pendingEntries = fileEntries
|
||||
.map((e, i) => ({ entry: e, originalIndex: i }))
|
||||
.filter(({ entry }) => entry.status !== "success");
|
||||
const files = pendingEntries.map(({ entry }) => entry.file);
|
||||
const originalIndices = pendingEntries.map(({ originalIndex }) => originalIndex);
|
||||
const pendingThumbnails = new Map<File, Blob>(
|
||||
files
|
||||
.map((file): [File, Blob | undefined] => [file, thumbnails.get(file)])
|
||||
.filter((pair): pair is [File, Blob] => pair[1] !== undefined)
|
||||
);
|
||||
|
||||
if (files.length === 1 && fileEntries.length === 1) {
|
||||
// Single file: use parent modal state machine (shows loading spinner)
|
||||
upload3DObjects({
|
||||
title: files[0].name.slice(0, files[0].name.lastIndexOf(".")),
|
||||
assetFile: files[0],
|
||||
engineCategory,
|
||||
animationType: subtype,
|
||||
thumbnailSnapshot: thumbnails.get(files[0]),
|
||||
progressCallback: onUploadProgress,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-file: stay on form, show inline progress
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: files.length });
|
||||
|
||||
upload3DObjectsBatch({
|
||||
files,
|
||||
thumbnails: pendingThumbnails,
|
||||
engineCategory,
|
||||
animationType: subtype,
|
||||
onFileStatusChange: (batchIndex, status, errorMessage) =>
|
||||
updateFileStatus(originalIndices[batchIndex], status, errorMessage),
|
||||
onOverallProgress: (completed, total) =>
|
||||
setOverallProgress({ current: completed, total }),
|
||||
onComplete: (allSucceeded, anySucceeded) => {
|
||||
setIsUploading(false);
|
||||
if (allSucceeded) {
|
||||
onUploadProgress({ status: UploaderStates.success });
|
||||
} else if (!anySucceeded) {
|
||||
onUploadProgress({
|
||||
status: UploaderStates.assetError,
|
||||
errorMessage: "All uploads failed.",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const retryAllFailed = () => {
|
||||
const failedIndices = fileEntries
|
||||
.map((e, i) => (e.status === "error" ? i : -1))
|
||||
.filter((i) => i !== -1);
|
||||
if (failedIndices.length === 0) return;
|
||||
|
||||
const failedFiles = failedIndices
|
||||
.map((i) => fileEntries[i].file)
|
||||
.filter((file) => file !== undefined);
|
||||
const failedThumbnails = new Map<File, Blob>(
|
||||
failedFiles
|
||||
.map((file): [File, Blob | undefined] => [file, thumbnails.get(file) ?? undefined])
|
||||
.filter((pair): pair is [File, Blob] => pair[1] !== undefined)
|
||||
);
|
||||
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: failedFiles.length });
|
||||
|
||||
upload3DObjectsBatch({
|
||||
files: failedFiles,
|
||||
thumbnails: failedThumbnails,
|
||||
engineCategory,
|
||||
animationType: subtype,
|
||||
onFileStatusChange: (batchIndex, status, errorMessage) =>
|
||||
updateFileStatus(failedIndices[batchIndex], status, errorMessage),
|
||||
onOverallProgress: (completed, total) =>
|
||||
setOverallProgress({ current: completed, total }),
|
||||
onComplete: (allSucceeded, anySucceeded) => {
|
||||
setIsUploading(false);
|
||||
if (allSucceeded) {
|
||||
onUploadProgress({ status: UploaderStates.success });
|
||||
} else if (!anySucceeded) {
|
||||
onUploadProgress({
|
||||
status: UploaderStates.assetError,
|
||||
errorMessage: "All uploads failed.",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isMulti = fileEntries.length > 1;
|
||||
const anyFailed = fileEntries.some((e) => e.status === "error");
|
||||
const anyUploading = fileEntries.some((e) => e.status === "uploading");
|
||||
const hasUploadStarted = fileEntries.some((e) => e.status !== "idle");
|
||||
const allDone =
|
||||
fileEntries.length > 0 &&
|
||||
fileEntries.every((e) => e.status === "success" || e.status === "error");
|
||||
const currentFile = fileEntries[previewIndex]?.file;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
file={assetFile.value ?? undefined}
|
||||
handleChange={(file: File) => {
|
||||
setAssetFile({
|
||||
value: file,
|
||||
});
|
||||
}}
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileSubtypes && fileSubtypes.length > 1 && (
|
||||
<ListDropdown
|
||||
list={fileSubtypes}
|
||||
onSelect={(value) => setSubtype(value as MediaFileAnimationType)}
|
||||
/>
|
||||
{fileSubtypes && fileSubtypes.length > 1 && (
|
||||
<ListDropdown
|
||||
list={fileSubtypes}
|
||||
onSelect={(value) => setSubtype(value as MediaFileAnimationType)}
|
||||
/>
|
||||
)}
|
||||
{assetFile.error && (
|
||||
<h6 className="z-10 text-red">{assetFile.error}</h6>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
files={fileEntries.map((e) => e.file)}
|
||||
handleChange={handleFilesChange}
|
||||
multiple={true}
|
||||
fileIcon={faCube}
|
||||
/>
|
||||
|
||||
{selectionError && (
|
||||
<h6 className="z-10 text-red">{selectionError}</h6>
|
||||
)}
|
||||
|
||||
{isMulti ? (
|
||||
<PanelGroup orientation="horizontal">
|
||||
{/* File list sidebar */}
|
||||
<Panel defaultSize="33%" minSize="20%">
|
||||
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-lg bg-brand-secondary p-2">
|
||||
{fileEntries.map((entry, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`group flex items-center justify-between gap-1.5 rounded px-2 py-1 cursor-pointer text-sm transition-colors ${
|
||||
i === previewIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setPreviewIndex(i)}
|
||||
>
|
||||
<span className="truncate flex-1" title={entry.file.name}>
|
||||
{entry.file.name.slice(0, entry.file.name.lastIndexOf("."))}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{entry.status === "idle" && (
|
||||
<button
|
||||
className="opacity-40 hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(i);
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
{entry.status === "uploading" && (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinner}
|
||||
className="animate-spin opacity-60"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "success" && (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-green-400" />
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleExclamation}
|
||||
className="text-red-400"
|
||||
/>
|
||||
<button
|
||||
className="hidden group-hover:inline-flex items-center text-xs text-white/60 hover:text-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retrySingleFile(i);
|
||||
}}
|
||||
title="Retry"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotateRight} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="flex w-4 items-center justify-center" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="h-8 w-1 rounded-full bg-white/20 transition-colors hover:bg-white/40" />
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Canvas preview + carousel */}
|
||||
<Panel defaultSize="67%" minSize="25%">
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div className="relative w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<canvas
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<FontAwesomeIcon icon={faCube} />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPreviewIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={previewIndex === 0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
</Button>
|
||||
<span className="text-sm opacity-60">
|
||||
{previewIndex + 1} / {fileEntries.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setPreviewIndex((p) =>
|
||||
Math.min(fileEntries.length - 1, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={previewIndex === fileEntries.length - 1}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
) : (
|
||||
/* Single-file layout: canvas takes full width (original behaviour) */
|
||||
<div className="relative m-auto w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<canvas
|
||||
className="pointer-events-none h-full !w-full"
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
{!assetFile.value && (
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<FontAwesomeIcon icon={faCube} />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<>
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
</>
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
{(isUploading || anyUploading) && overallProgress && isMulti && (
|
||||
<p className="text-center text-sm opacity-60">
|
||||
Uploading {overallProgress.current} / {overallProgress.total}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<p className="text-center text-sm text-red-400">
|
||||
{fileEntries.filter((e) => e.status === "error").length} file(s) failed to upload.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<Button variant="secondary" onClick={retryAllFailed}>
|
||||
Retry Failed
|
||||
</Button>
|
||||
)}
|
||||
{/* Hide Upload once any upload has started; only Retry Failed or Close remains */}
|
||||
{!hasUploadStarted && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!assetFile.value}
|
||||
disabled={fileEntries.length === 0}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+11
-5
@@ -22,7 +22,7 @@ interface Props {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
titleIcon: IconDefinition;
|
||||
initialFile?: File;
|
||||
initialFiles?: File[];
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
@@ -34,7 +34,7 @@ const splatFileTypes = Object.values(SPLAT_FILE_TYPE);
|
||||
|
||||
export function UploadModalSplat(props: Props) {
|
||||
const selectedCategory = FilterEngineCategories.SPLAT;
|
||||
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile, options } = props;
|
||||
const { isOpen, onClose, onSuccess, title, titleIcon, initialFiles, options } = props;
|
||||
const [uploaderState, setUploaderState] =
|
||||
useState<UploaderState>(initialUploaderState);
|
||||
|
||||
@@ -73,7 +73,7 @@ export function UploadModalSplat(props: Props) {
|
||||
title={title}
|
||||
engineCategory={selectedCategory}
|
||||
fileTypes={splatFileTypes}
|
||||
initialFile={initialFile}
|
||||
initialFiles={initialFiles}
|
||||
options={{
|
||||
...(options ?? {}),
|
||||
}}
|
||||
@@ -85,13 +85,19 @@ export function UploadModalSplat(props: Props) {
|
||||
);
|
||||
case UploaderStates.uploadingAsset:
|
||||
case UploaderStates.uploadingCover:
|
||||
case UploaderStates.settingCover:
|
||||
case UploaderStates.settingCover: {
|
||||
const p = uploaderState.uploadProgress;
|
||||
return (
|
||||
<>
|
||||
<LoadingDots className="mb-1 bg-transparent" />
|
||||
<div className="w-100 text-center opacity-50">Uploading...</div>
|
||||
<div className="w-100 text-center opacity-50">
|
||||
{p && p.total > 1
|
||||
? `Uploading ${p.current} / ${p.total}...`
|
||||
: "Uploading..."}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case UploaderStates.success: {
|
||||
return (
|
||||
<UploadSuccess
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { coverImageCache } from "~/pages/PageImageTo3DObject/ImageTo3DStore";
|
||||
import { useCreditsState } from "@storyteller/credits";
|
||||
import { getMetaForTask, cleanupOldEntries } from "./taskEnqueueMeta";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type InProgressTask = {
|
||||
id: string;
|
||||
@@ -103,6 +104,90 @@ const formatTimeLeft = (ms: number): string => {
|
||||
return `~ ${seconds}s left`;
|
||||
};
|
||||
|
||||
const PromptLine = ({
|
||||
prompt,
|
||||
className,
|
||||
}: {
|
||||
prompt: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
const [marqueePlaying, setMarqueePlaying] = useState(false);
|
||||
const [promptOverflows, setPromptOverflows] = useState(false);
|
||||
const promptRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = promptRef.current;
|
||||
if (el) setPromptOverflows(el.scrollWidth > el.clientWidth);
|
||||
}, [prompt]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={prompt.length > 300 ? prompt.slice(0, 300) + "\u2026" : prompt}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[280px] text-wrap text-xs"
|
||||
zIndex={50}
|
||||
delay={400}
|
||||
>
|
||||
<div
|
||||
className={twMerge("mt-1 overflow-hidden", className)}
|
||||
onMouseEnter={
|
||||
promptOverflows ? () => setMarqueePlaying(true) : undefined
|
||||
}
|
||||
onMouseLeave={
|
||||
promptOverflows ? () => setMarqueePlaying(false) : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={promptRef}
|
||||
key={marqueePlaying ? "playing" : "idle"}
|
||||
className="whitespace-nowrap text-[11px] italic text-base-fg/40"
|
||||
style={
|
||||
marqueePlaying
|
||||
? {
|
||||
animation: "marquee 6.5s linear infinite",
|
||||
animationDelay: "0.5s",
|
||||
animationFillMode: "both",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{prompt}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const CopyPromptButton = ({ prompt }: { prompt: string }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<Tooltip
|
||||
content={copied ? "Copied!" : "Copy prompt"}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="text-xs"
|
||||
zIndex={50}
|
||||
delay={300}
|
||||
>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Copy prompt"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(prompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={copied ? faCheck : faCopy}
|
||||
className={copied ? "text-green-400" : ""}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const InProgressCard = ({
|
||||
task,
|
||||
onDismiss,
|
||||
@@ -118,61 +203,40 @@ const InProgressCard = ({
|
||||
? formatTimeLeft(task.estimatedTimeLeftMs)
|
||||
: null;
|
||||
const isSeedance2 = task.modelType === "seedance_2p0";
|
||||
const hasRefImages =
|
||||
task.refImageUrls && task.refImageUrls.length > 0;
|
||||
const hasRefImages = task.refImageUrls && task.refImageUrls.length > 0;
|
||||
|
||||
const thumbnailContent = hasRefImages ? (
|
||||
<Tooltip
|
||||
content={task.prompt || ""}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[320px] text-wrap text-xs"
|
||||
zIndex={50}
|
||||
delay={200}
|
||||
disabled={!task.prompt}
|
||||
>
|
||||
<div className="relative h-[72px] w-[72px] shrink-0 overflow-hidden rounded">
|
||||
<img
|
||||
src={task.refImageUrls![0]}
|
||||
alt="Reference"
|
||||
className="h-full w-full object-cover opacity-50"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/30" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-white/80"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
{task.refImageUrls!.length > 1 && (
|
||||
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80">
|
||||
+{task.refImageUrls!.length - 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
content={task.prompt || ""}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[320px] text-wrap text-xs"
|
||||
zIndex={50}
|
||||
delay={200}
|
||||
disabled={!task.prompt}
|
||||
>
|
||||
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-ui-controls">
|
||||
<div className="relative h-[86px] w-[86px] shrink-0 overflow-hidden rounded">
|
||||
<img
|
||||
src={task.refImageUrls![0]}
|
||||
alt="Reference"
|
||||
className="h-full w-full object-cover opacity-50"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/30" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-base-fg/60"
|
||||
className="animate-spin text-white/80"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{task.refImageUrls!.length > 1 && (
|
||||
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80">
|
||||
+{task.refImageUrls!.length - 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[86px] w-[86px] shrink-0 items-center justify-center overflow-hidden rounded bg-ui-controls">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-base-fg/60"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -199,7 +263,7 @@ const InProgressCard = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2 shrink-0 text-xs tabular-nums text-base-fg/60">
|
||||
<div className="ml-2 shrink-0 text-[11px] tabular-nums text-base-fg/60">
|
||||
{progressPercent}%
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,21 +283,25 @@ const InProgressCard = ({
|
||||
</div>
|
||||
</div>
|
||||
{timeLabel && (
|
||||
<div className="mt-1 text-xs text-base-fg/50">{timeLabel}</div>
|
||||
<div className="mt-1 text-[11px] text-base-fg/50">{timeLabel}</div>
|
||||
)}
|
||||
{task.prompt && <PromptLine prompt={task.prompt} className="mt-0" />}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -255,39 +323,29 @@ const CompletedCard = ({
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : -1}
|
||||
>
|
||||
<Tooltip
|
||||
content={task.prompt || ""}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[320px] text-wrap text-xs"
|
||||
zIndex={50}
|
||||
delay={200}
|
||||
disabled={!task.prompt}
|
||||
>
|
||||
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded bg-ui-controls">
|
||||
{task.thumbnailUrl ? (
|
||||
<img
|
||||
src={task.thumbnailUrl}
|
||||
alt={task.title}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = getPlaceholderForMediaClass(
|
||||
task.mediaFileClass,
|
||||
);
|
||||
e.currentTarget.style.opacity = "0.3";
|
||||
// Set the `data-brokenurl` property for debugging the broken images:
|
||||
(e.currentTarget as HTMLImageElement).dataset.brokenurl =
|
||||
task.thumbnailUrl;
|
||||
}}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-base-fg/40">
|
||||
Done
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="min-w-0">
|
||||
<div className="h-[86px] w-[86px] shrink-0 overflow-hidden rounded bg-ui-controls">
|
||||
{task.thumbnailUrl ? (
|
||||
<img
|
||||
src={task.thumbnailUrl}
|
||||
alt={task.title}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = getPlaceholderForMediaClass(
|
||||
task.mediaFileClass,
|
||||
);
|
||||
e.currentTarget.style.opacity = "0.3";
|
||||
// Set the `data-brokenurl` property for debugging the broken images:
|
||||
(e.currentTarget as HTMLImageElement).dataset.brokenurl =
|
||||
task.thumbnailUrl;
|
||||
}}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-base-fg/40">
|
||||
Done
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-base-fg/90">
|
||||
{task.title}
|
||||
</div>
|
||||
@@ -301,19 +359,23 @@ const CompletedCard = ({
|
||||
{task.completedAt.toISOString()}
|
||||
</div>
|
||||
)}
|
||||
{task.prompt && <PromptLine prompt={task.prompt} />}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -346,70 +408,41 @@ const FailedCard = ({
|
||||
task: FailedTask;
|
||||
onDismiss?: () => void;
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [marqueePlaying, setMarqueePlaying] = useState(false);
|
||||
const [promptOverflows, setPromptOverflows] = useState(false);
|
||||
const promptRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = promptRef.current;
|
||||
if (el) setPromptOverflows(el.scrollWidth > el.clientWidth);
|
||||
}, [task.prompt]);
|
||||
const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed";
|
||||
const hasRefImages =
|
||||
task.refImageUrls && task.refImageUrls.length > 0;
|
||||
const hasRefImages = task.refImageUrls && task.refImageUrls.length > 0;
|
||||
|
||||
const thumbnailContent = hasRefImages ? (
|
||||
<Tooltip
|
||||
content={task.prompt || ""}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[320px] text-wrap text-xs"
|
||||
zIndex={50}
|
||||
delay={200}
|
||||
disabled={!task.prompt}
|
||||
>
|
||||
<div className="relative h-[72px] w-[72px] shrink-0 overflow-hidden rounded">
|
||||
<img
|
||||
src={task.refImageUrls![0]}
|
||||
alt="Reference"
|
||||
className="h-full w-full object-cover opacity-50"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-red-900/40" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleExclamation}
|
||||
className="text-red-400"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
{task.refImageUrls!.length > 1 && (
|
||||
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80">
|
||||
+{task.refImageUrls!.length - 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
content={task.prompt || ""}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[320px] text-wrap text-xs"
|
||||
zIndex={50}
|
||||
delay={200}
|
||||
disabled={!task.prompt}
|
||||
>
|
||||
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
|
||||
<div className="relative h-[86px] w-[86px] shrink-0 overflow-hidden rounded">
|
||||
<img
|
||||
src={task.refImageUrls![0]}
|
||||
alt="Reference"
|
||||
className="h-full w-full object-cover opacity-50"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-red-900/40" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleExclamation}
|
||||
className="text-red-400"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{task.refImageUrls!.length > 1 && (
|
||||
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80">
|
||||
+{task.refImageUrls!.length - 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[86px] w-[86px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleExclamation}
|
||||
className="text-red-400"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -434,90 +467,37 @@ const FailedCard = ({
|
||||
{task.failureReason && (
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<Tooltip
|
||||
content={task.failureReason}
|
||||
content={
|
||||
task.failureMessage ? (
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
{task.failureReason}
|
||||
</div>
|
||||
<div className="mt-0.5 font-normal opacity-80">
|
||||
{task.failureMessage}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
task.failureReason
|
||||
)
|
||||
}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[280px] text-wrap bg-danger text-xs"
|
||||
zIndex={50}
|
||||
delay={300}
|
||||
>
|
||||
<div className="truncate text-[11px] text-red-400/60">
|
||||
<div className="cursor-default truncate text-[11px] text-red-400/80 underline decoration-red-400/30 decoration-dashed underline-offset-2">
|
||||
{task.failureReason}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{task.failureMessage && (
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip
|
||||
content={task.failureMessage}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[280px] text-wrap text-xs"
|
||||
zIndex={50}
|
||||
delay={200}
|
||||
>
|
||||
<div className="mt-0.5 cursor-default truncate text-[11px] text-base-fg/40">
|
||||
{task.failureMessage}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{task.prompt && (
|
||||
<Tooltip
|
||||
content={task.prompt.length > 300 ? task.prompt.slice(0, 300) + "…" : task.prompt}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[280px] text-wrap text-xs"
|
||||
zIndex={50}
|
||||
delay={400}
|
||||
>
|
||||
<div
|
||||
className="mt-1 overflow-hidden"
|
||||
onMouseEnter={promptOverflows ? () => setMarqueePlaying(true) : undefined}
|
||||
onMouseLeave={promptOverflows ? () => setMarqueePlaying(false) : undefined}
|
||||
>
|
||||
<div
|
||||
ref={promptRef}
|
||||
key={marqueePlaying ? "playing" : "idle"}
|
||||
className="whitespace-nowrap text-[11px] italic text-base-fg/40"
|
||||
style={marqueePlaying ? {
|
||||
animation: "marquee 8s linear infinite",
|
||||
animationDelay: "0.5s",
|
||||
animationFillMode: "both",
|
||||
} : undefined}
|
||||
>
|
||||
{task.prompt}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task.prompt && <PromptLine prompt={task.prompt} />}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{task.prompt && (
|
||||
<Tooltip
|
||||
content={copied ? "Copied!" : "Copy prompt"}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="text-xs"
|
||||
zIndex={50}
|
||||
delay={300}
|
||||
>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Copy prompt"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(task.prompt!);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className={copied ? "text-green-400" : ""} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
@@ -566,7 +546,7 @@ export const TaskQueue = () => {
|
||||
primaryActionText: "",
|
||||
primaryActionIcon: faTrashAlt,
|
||||
primaryActionBtnClassName: "",
|
||||
onConfirm: async () => { },
|
||||
onConfirm: async () => {},
|
||||
});
|
||||
|
||||
const handleClearCompleted = (onSuccess?: () => void) => {
|
||||
@@ -724,8 +704,8 @@ export const TaskQueue = () => {
|
||||
if (!duration) {
|
||||
const actualModel = t.model_type
|
||||
? ALL_MODELS_LIST.find(
|
||||
(m) => m.tauriId === t.model_type || m.id === t.model_type,
|
||||
)
|
||||
(m) => m.tauriId === t.model_type || m.id === t.model_type,
|
||||
)
|
||||
: undefined;
|
||||
duration =
|
||||
actualModel?.progressBarTime ??
|
||||
@@ -1024,7 +1004,7 @@ export const TaskQueue = () => {
|
||||
<PopoverMenu
|
||||
mode="default"
|
||||
buttonClassName="h-[38px] w-[38px] !p-0 relative"
|
||||
panelClassName="w-[360px] p-2 bg-ui-panel mt-2.5"
|
||||
panelClassName="w-[400px] p-2 bg-ui-panel mt-2.5"
|
||||
position="bottom"
|
||||
align="end"
|
||||
triggerIcon={
|
||||
|
||||
@@ -1,98 +1,33 @@
|
||||
import { useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { faUpload } from "@fortawesome/pro-solid-svg-icons";
|
||||
import toast from "react-hot-toast";
|
||||
import { MediaUploadApi } from "@storyteller/api";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { EIntermediateFile } from "~/enums/EIntermediateFile";
|
||||
import { faUpload, faImages } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { UploadModalImage } from "../../reusable/UploadModalImage/UploadModalImage";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UploadImagesButton = ({ className }: Props) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = () => fileInputRef.current?.click();
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const filesArr = Array.from(files);
|
||||
const allowedMimes = new Set(["image/jpeg", "image/png"]);
|
||||
const allowedExts = new Set([".jpg", ".jpeg", ".png"]);
|
||||
|
||||
const isAllowed = (file: File) => {
|
||||
if (allowedMimes.has(file.type)) return true;
|
||||
const dot = file.name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : "";
|
||||
return allowedExts.has(ext);
|
||||
};
|
||||
|
||||
const allowedFiles = filesArr.filter(isAllowed);
|
||||
const rejected = filesArr.length - allowedFiles.length;
|
||||
|
||||
if (allowedFiles.length === 0) {
|
||||
toast.error("Only JPG and PNG images are supported");
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (rejected > 0) {
|
||||
toast.error(
|
||||
`Skipped ${rejected} unsupported ${rejected === 1 ? "image" : "images"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const count = allowedFiles.length;
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
|
||||
const uploads = allowedFiles.map((file) =>
|
||||
mediaUploadApi
|
||||
.UploadImage({
|
||||
blob: file,
|
||||
fileName: file.name,
|
||||
uuid: uuidv4(),
|
||||
maybe_title: file.name,
|
||||
is_intermediate_system_file: EIntermediateFile.false,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.errorMessage || "Upload failed");
|
||||
}
|
||||
return res.data;
|
||||
}),
|
||||
);
|
||||
|
||||
toast.promise(Promise.all(uploads), {
|
||||
loading: `Uploading ${count} ${count === 1 ? "image" : "images"}...`,
|
||||
success: `Uploaded ${count} ${count === 1 ? "image" : "images"}`,
|
||||
error: `Failed to upload ${count} ${count === 1 ? "image" : "images"}`,
|
||||
});
|
||||
|
||||
e.target.value = "";
|
||||
};
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,image/jpeg,image/png"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Tooltip content="Upload images" position="bottom" delay={300}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={faUpload}
|
||||
className={className || "h-[38px] w-[38px]"}
|
||||
onClick={handleClick}
|
||||
onClick={() => setIsOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<UploadModalImage
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onSuccess={() => setIsOpen(false)}
|
||||
title="Upload an Image"
|
||||
titleIcon={faImages}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface UploaderState {
|
||||
status: UploaderStates;
|
||||
errorMessage?: string;
|
||||
data?: string;
|
||||
uploadProgress?: { current: number; total: number };
|
||||
}
|
||||
|
||||
export const initialUploaderState = {
|
||||
|
||||
@@ -150,7 +150,7 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
<ErrorDialog />
|
||||
|
||||
<EditorLoadingBar />
|
||||
<Toaster offsetTop={70} offsetRight={12} />
|
||||
<Toaster offsetTop={70} offsetRight={12} zIndex={9999} />
|
||||
|
||||
{currentReminderModalProps && (
|
||||
<ActionReminderModal
|
||||
|
||||
@@ -540,3 +540,12 @@ img {
|
||||
stroke-dashoffset: -200;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-drag-drop-files": "^2.3.10",
|
||||
"react-resizable-panels": "*",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-image-crop": "^11.0.5",
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { faFileArrowUp, faFileAudio } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faFileArrowUp, faFileImage } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
export const DragAndDropZone = ({
|
||||
file,
|
||||
files,
|
||||
fileTypes,
|
||||
fileIcon = faFileImage,
|
||||
}: {
|
||||
file?: File;
|
||||
files: File[];
|
||||
fileTypes: string[];
|
||||
fileIcon?: IconDefinition;
|
||||
}) => {
|
||||
if (!file) {
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={faFileArrowUp} className="text-4xl" />
|
||||
@@ -25,24 +28,39 @@ export const DragAndDropZone = ({
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = file.name.split(".")[0].toUpperCase();
|
||||
const fileSize =
|
||||
file.size >= 1024 * 1024
|
||||
? (file.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
: `${Math.floor(file.size / 1024)} KB`;
|
||||
if (files.length === 1) {
|
||||
const file = files[0];
|
||||
const fileName = file.name.split(".")[0].toUpperCase();
|
||||
const fileSize =
|
||||
file.size >= 1024 * 1024
|
||||
? (file.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
: `${Math.floor(file.size / 1024)} KB`;
|
||||
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={fileIcon} className="text-4xl" />
|
||||
<div className="flex grow flex-col gap-0">
|
||||
<p className="font-medium">
|
||||
{file.name.slice(0, file.name.lastIndexOf("."))}
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-sm font-normal">
|
||||
<span className="opacity-50">
|
||||
{`${fileName} file size: ${fileSize} `}
|
||||
</span>
|
||||
<u className="transition-all hover:text-white/80">Change File</u>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={faFileAudio} className="text-4xl" />
|
||||
<FontAwesomeIcon icon={fileIcon} className="text-4xl" />
|
||||
<div className="flex grow flex-col gap-0">
|
||||
<p className="font-medium">
|
||||
{file.name.slice(0, file.name.lastIndexOf("."))}
|
||||
</p>
|
||||
<p className="font-medium">{files.length} files selected</p>
|
||||
<p className="flex items-center gap-2 text-sm font-normal">
|
||||
<span className="opacity-50">
|
||||
{`${fileName} file size: ${fileSize} `}
|
||||
</span>
|
||||
<u className="transition-all hover:text-white/80">Change File</u>
|
||||
<u className="transition-all hover:text-white/80">Change Files</u>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import { FileUploader as DragDropFileUploader } from "react-drag-drop-files";
|
||||
// Usage refer to https://github.com/KarimMokhtar/react-drag-drop-files
|
||||
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { DragAndDropZone } from "./drag-and-drop-zone";
|
||||
|
||||
export const FileUploader = ({
|
||||
file,
|
||||
files,
|
||||
fileTypes,
|
||||
handleChange,
|
||||
multiple = false,
|
||||
fileIcon,
|
||||
}: {
|
||||
file: File | undefined;
|
||||
handleChange: (file: File) => void;
|
||||
files: File[];
|
||||
handleChange: (files: File[]) => void;
|
||||
fileTypes: string[];
|
||||
multiple?: boolean;
|
||||
fileIcon?: IconDefinition;
|
||||
}) => (
|
||||
<DragDropFileUploader
|
||||
handleChange={handleChange}
|
||||
handleChange={(result: File | File[]) => {
|
||||
if (Array.isArray(result)) {
|
||||
handleChange(result);
|
||||
} else if (result instanceof File) {
|
||||
handleChange([result]);
|
||||
} else {
|
||||
// react-drag-drop-files may return a FileList from the file picker when multiple=true;
|
||||
// Array.isArray returns false for FileList, so normalize via Array.from.
|
||||
handleChange(Array.from(result as unknown as Iterable<File>));
|
||||
}
|
||||
}}
|
||||
name="file"
|
||||
maxSize={50}
|
||||
types={fileTypes}
|
||||
multiple={multiple}
|
||||
classes="!outline-none"
|
||||
>
|
||||
<DragAndDropZone file={file} fileTypes={fileTypes} />
|
||||
<DragAndDropZone files={files} fileTypes={fileTypes} fileIcon={fileIcon} />
|
||||
</DragDropFileUploader>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,9 @@ import { useState, useRef, useEffect } from "react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { Button, ToggleButton, GenerateButton } from "@storyteller/ui-button";
|
||||
import { Button, GenerateButton } from "@storyteller/ui-button";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import {
|
||||
faMessageXmark,
|
||||
faMessageCheck,
|
||||
faFrame,
|
||||
faExpand,
|
||||
faChevronDown,
|
||||
@@ -73,22 +71,24 @@ export const PromptBox2D = ({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// CSS viewport units handle resize reactivity automatically
|
||||
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
|
||||
|
||||
const toggleExpand = () => {
|
||||
setIsExpanded((prev) => {
|
||||
const next = !prev;
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = next ? "300px" : "auto";
|
||||
textareaRef.current.style.height = next ? EXPANDED_HEIGHT : "auto";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const [content, setContent] = useState<React.ReactNode>("");
|
||||
|
||||
//const { lastRenderedBitmap } = useCanvasSignal();
|
||||
const prompt = usePrompt2DStore((s) => s.prompt);
|
||||
const setPrompt = usePrompt2DStore((s) => s.setPrompt);
|
||||
const useSystemPrompt = usePrompt2DStore((s) => s.useSystemPrompt);
|
||||
const setUseSystemPrompt = usePrompt2DStore((s) => s.setUseSystemPrompt);
|
||||
const aspectRatio = usePrompt2DStore((s) => s.aspectRatio);
|
||||
const setAspectRatio = usePrompt2DStore((s) => s.setAspectRatio);
|
||||
const resolution = usePrompt2DStore((s) => s.resolution);
|
||||
@@ -158,6 +158,7 @@ export const PromptBox2D = ({
|
||||
false;
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) return;
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
@@ -267,7 +268,9 @@ export const PromptBox2D = ({
|
||||
const busy = Boolean(isEnqueueing ?? internalEnqueueing);
|
||||
if (busy || isDisabled || !prompt.trim()) return;
|
||||
if (prompt.length > maxLen) {
|
||||
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
|
||||
toast.error(
|
||||
`Prompt exceeds the ${maxLen} character limit for this model`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setInternalEnqueueing(true);
|
||||
@@ -372,8 +375,8 @@ export const PromptBox2D = ({
|
||||
className={twMerge(
|
||||
"glass relative w-[860px] rounded-xl p-4",
|
||||
selectedImageModel?.canUseImagePrompt &&
|
||||
isImageRowVisible &&
|
||||
"rounded-t-none",
|
||||
isImageRowVisible &&
|
||||
"rounded-t-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-center gap-2">
|
||||
@@ -415,15 +418,17 @@ export const PromptBox2D = ({
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Describe your image..."
|
||||
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none ${isExpanded ? "max-h-[300px]" : "max-h-[5.5em]"}`}
|
||||
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none ${isExpanded ? "max-h-[500px]" : "max-h-[5.5em]"}`}
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => { }}
|
||||
onBlur={() => { }}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}>
|
||||
<span
|
||||
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
|
||||
>
|
||||
{prompt.length} / {maxLen}
|
||||
</span>
|
||||
</div>
|
||||
@@ -509,13 +514,20 @@ export const PromptBox2D = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
|
||||
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
|
||||
<Tooltip
|
||||
content={isExpanded ? "Collapse" : "Expand"}
|
||||
position="top"
|
||||
className="-mb-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpand}
|
||||
className="text-base-fg/30 hover:text-base-fg/90 transition-colors px-3 py-0.5"
|
||||
>
|
||||
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
|
||||
<FontAwesomeIcon
|
||||
icon={isExpanded ? faChevronUp : faChevronDown}
|
||||
className="text-xs"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -87,15 +87,19 @@ export const PromptBoxEdit = ({
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// CSS viewport units handle resize reactivity automatically
|
||||
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
|
||||
|
||||
const toggleExpand = () => {
|
||||
setIsExpanded((prev) => {
|
||||
const next = !prev;
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = next ? "300px" : "auto";
|
||||
textareaRef.current.style.height = next ? EXPANDED_HEIGHT : "auto";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const [internalEnqueueing, setInternalEnqueueing] = useState(false);
|
||||
const referenceImages = usePromptEditStore((s) => s.referenceImages);
|
||||
const setReferenceImages = usePromptEditStore((s) => s.setReferenceImages);
|
||||
@@ -195,6 +199,7 @@ export const PromptBoxEdit = ({
|
||||
false;
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) return;
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
@@ -258,7 +263,9 @@ export const PromptBoxEdit = ({
|
||||
const busy = Boolean(isEnqueueing ?? internalEnqueueing);
|
||||
if (busy || isDisabled || !prompt.trim()) return;
|
||||
if (prompt.length > maxLen) {
|
||||
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
|
||||
toast.error(
|
||||
`Prompt exceeds the ${maxLen} character limit for this model`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setInternalEnqueueing(true);
|
||||
@@ -282,23 +289,10 @@ export const PromptBoxEdit = ({
|
||||
|
||||
const modes = isNanoBananaModel
|
||||
? [
|
||||
{
|
||||
value: "marker",
|
||||
icon: faPen,
|
||||
text: "Marker",
|
||||
},
|
||||
{
|
||||
value: "eraser",
|
||||
icon: faEraser,
|
||||
text: "Eraser",
|
||||
},
|
||||
]
|
||||
: supportsMaskedInpainting
|
||||
? [
|
||||
{
|
||||
value: "edit",
|
||||
icon: faEdit,
|
||||
text: "Edit Region",
|
||||
value: "marker",
|
||||
icon: faPen,
|
||||
text: "Marker",
|
||||
},
|
||||
{
|
||||
value: "eraser",
|
||||
@@ -306,18 +300,31 @@ export const PromptBoxEdit = ({
|
||||
text: "Eraser",
|
||||
},
|
||||
]
|
||||
: supportsMaskedInpainting
|
||||
? [
|
||||
{
|
||||
value: "edit",
|
||||
icon: faEdit,
|
||||
text: "Edit Region",
|
||||
},
|
||||
{
|
||||
value: "eraser",
|
||||
icon: faEraser,
|
||||
text: "Eraser",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
value: "edit",
|
||||
icon: faEdit,
|
||||
text: "Edit Region",
|
||||
},
|
||||
{
|
||||
value: "select",
|
||||
icon: faMousePointer,
|
||||
text: "Select",
|
||||
},
|
||||
];
|
||||
{
|
||||
value: "edit",
|
||||
icon: faEdit,
|
||||
text: "Edit Region",
|
||||
},
|
||||
{
|
||||
value: "select",
|
||||
icon: faMousePointer,
|
||||
text: "Select",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -327,8 +334,8 @@ export const PromptBoxEdit = ({
|
||||
className={twMerge(
|
||||
"glass w-fit mx-auto rounded-xl px-2 py-2 flex items-center gap-3",
|
||||
selectedImageModel?.canUseImagePrompt &&
|
||||
isImageRowVisible &&
|
||||
"mb-[72px]",
|
||||
isImageRowVisible &&
|
||||
"mb-[72px]",
|
||||
)}
|
||||
>
|
||||
<ButtonIconSelect
|
||||
@@ -372,7 +379,7 @@ export const PromptBoxEdit = ({
|
||||
referenceImages={referenceImages}
|
||||
setReferenceImages={setReferenceImages}
|
||||
uploadImage={uploadImage}
|
||||
onImageClick={() => { }}
|
||||
onImageClick={() => {}}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
@@ -381,8 +388,8 @@ export const PromptBoxEdit = ({
|
||||
"glass relative w-[860px] rounded-xl p-4",
|
||||
isFocused && "ring-1 ring-primary border-primary",
|
||||
selectedImageModel?.canUseImagePrompt &&
|
||||
isImageRowVisible &&
|
||||
"rounded-t-none",
|
||||
isImageRowVisible &&
|
||||
"rounded-t-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-center gap-2">
|
||||
@@ -423,7 +430,7 @@ export const PromptBoxEdit = ({
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Write what you want to change in your image and click generate..."
|
||||
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none ${isExpanded ? "max-h-[300px]" : "max-h-[5.5em]"}`}
|
||||
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none ${isExpanded ? "max-h-[500px]" : "max-h-[5.5em]"}`}
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
@@ -431,7 +438,9 @@ export const PromptBoxEdit = ({
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-white/40"}`}>
|
||||
<span
|
||||
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-white/40"}`}
|
||||
>
|
||||
{prompt.length} / {maxLen}
|
||||
</span>
|
||||
</div>
|
||||
@@ -527,13 +536,20 @@ export const PromptBoxEdit = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
|
||||
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
|
||||
<Tooltip
|
||||
content={isExpanded ? "Collapse" : "Expand"}
|
||||
position="top"
|
||||
className="-mb-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpand}
|
||||
className="text-white/30 hover:text-white/90 transition-colors px-3 py-0.5"
|
||||
>
|
||||
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
|
||||
<FontAwesomeIcon
|
||||
icon={isExpanded ? faChevronUp : faChevronDown}
|
||||
className="text-xs"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -100,19 +100,19 @@ export const PromptBoxImage = ({
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// CSS viewport units handle resize reactivity automatically
|
||||
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
|
||||
|
||||
const toggleExpand = () => {
|
||||
setIsExpanded((prev) => {
|
||||
const next = !prev;
|
||||
if (textareaRef.current) {
|
||||
if (next) {
|
||||
textareaRef.current.style.height = `${window.innerHeight - 300}px`;
|
||||
} else {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
textareaRef.current.style.height = next ? EXPANDED_HEIGHT : "auto";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const referenceImages = usePromptImageStore((s) => s.referenceImages);
|
||||
const setReferenceImages = usePromptImageStore((s) => s.setReferenceImages);
|
||||
const [uploadingImages, _setUploadingImages] = useState<
|
||||
@@ -126,7 +126,9 @@ export const PromptBoxImage = ({
|
||||
|
||||
// New aspect ratio and resolution — stored globally so cost estimates can observe them
|
||||
const commonAspectRatio = usePromptImageStore((s) => s.commonAspectRatio);
|
||||
const setCommonAspectRatio = usePromptImageStore((s) => s.setCommonAspectRatio);
|
||||
const setCommonAspectRatio = usePromptImageStore(
|
||||
(s) => s.setCommonAspectRatio,
|
||||
);
|
||||
const commonResolution = usePromptImageStore((s) => s.commonResolution);
|
||||
const setCommonResolution = usePromptImageStore((s) => s.setCommonResolution);
|
||||
|
||||
@@ -173,11 +175,14 @@ export const PromptBoxImage = ({
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
// Hard pixel limit so the resize handle can never exceed viewport
|
||||
textareaRef.current.style.maxHeight = `${window.innerHeight - 700}px`;
|
||||
const maxH = isExpanded ? 500 : Math.min(window.innerHeight - 700, 500);
|
||||
textareaRef.current.style.maxHeight = `${Math.max(maxH, 88)}px`;
|
||||
textareaRef.current.style.minHeight = "0";
|
||||
// Cap auto-grow at ~5.5em so it doesn't fight with manual resize
|
||||
const capped = Math.min(textareaRef.current.scrollHeight, 88);
|
||||
textareaRef.current.style.minHeight = `${capped}px`;
|
||||
if (!isExpanded) {
|
||||
// Cap auto-grow at ~5.5em so it doesn't fight with manual resize
|
||||
const capped = Math.min(textareaRef.current.scrollHeight, 88);
|
||||
textareaRef.current.style.minHeight = `${capped}px`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -259,7 +264,9 @@ export const PromptBoxImage = ({
|
||||
return;
|
||||
}
|
||||
if (prompt.length > maxLen) {
|
||||
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
|
||||
toast.error(
|
||||
`Prompt exceeds the ${maxLen} character limit for this model`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -322,9 +329,7 @@ export const PromptBoxImage = ({
|
||||
|
||||
window.__storeTaskEnqueueMeta?.({
|
||||
prompt,
|
||||
refImageUrls: referenceImages
|
||||
?.map((img) => img.url)
|
||||
.filter(Boolean),
|
||||
refImageUrls: referenceImages?.map((img) => img.url).filter(Boolean),
|
||||
modelType: (selectedModel as any)?.tauriId || String(selectedModel),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -433,8 +438,8 @@ export const PromptBoxImage = ({
|
||||
className={twMerge(
|
||||
"glass relative w-[860px] rounded-xl p-4",
|
||||
isImageRowVisible &&
|
||||
selectedModel?.canUseImagePrompt &&
|
||||
"rounded-t-none",
|
||||
selectedModel?.canUseImagePrompt &&
|
||||
"rounded-t-none",
|
||||
isFocused
|
||||
? "ring-1 ring-primary border-primary"
|
||||
: "ring-1 ring-transparent",
|
||||
@@ -487,7 +492,9 @@ export const PromptBoxImage = ({
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}>
|
||||
<span
|
||||
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
|
||||
>
|
||||
{prompt.length} / {maxLen}
|
||||
</span>
|
||||
</div>
|
||||
@@ -568,13 +575,20 @@ export const PromptBoxImage = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
|
||||
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
|
||||
<Tooltip
|
||||
content={isExpanded ? "Collapse" : "Expand"}
|
||||
position="top"
|
||||
className="-mb-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpand}
|
||||
className="text-base-fg/30 hover:text-base-fg/90 transition-colors px-3 py-0.5"
|
||||
>
|
||||
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
|
||||
<FontAwesomeIcon
|
||||
icon={isExpanded ? faChevronUp : faChevronDown}
|
||||
className="text-xs"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -133,19 +133,19 @@ export const PromptBoxVideo = ({
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// CSS viewport units handle resize reactivity automatically
|
||||
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
|
||||
|
||||
const toggleExpand = () => {
|
||||
setIsExpanded((prev) => {
|
||||
const next = !prev;
|
||||
if (textareaRef.current) {
|
||||
if (next) {
|
||||
textareaRef.current.style.height = `${window.innerHeight - 300}px`;
|
||||
} else {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
textareaRef.current.style.height = next ? EXPANDED_HEIGHT : "auto";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
@@ -197,11 +197,14 @@ export const PromptBoxVideo = ({
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
// Hard pixel limit so the resize handle can never exceed viewport
|
||||
textareaRef.current.style.maxHeight = `${window.innerHeight - 700}px`;
|
||||
const maxH = isExpanded ? 500 : Math.min(window.innerHeight - 700, 500);
|
||||
textareaRef.current.style.maxHeight = `${Math.max(maxH, 88)}px`;
|
||||
textareaRef.current.style.minHeight = "0";
|
||||
// Cap auto-grow at ~5.5em so it doesn't fight with manual resize
|
||||
const capped = Math.min(textareaRef.current.scrollHeight, 88);
|
||||
textareaRef.current.style.minHeight = `${capped}px`;
|
||||
if (!isExpanded) {
|
||||
// Cap auto-grow at ~5.5em so it doesn't fight with manual resize
|
||||
const capped = Math.min(textareaRef.current.scrollHeight, 88);
|
||||
textareaRef.current.style.minHeight = `${capped}px`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user