Compare commits

...
Author SHA1 Message Date
Brandon Thomas bc32d7c2ee cleanup 2026-04-02 00:31:49 -04:00
Brandon Thomas bf0703f0c1 refactor web errors with cause + notification details 2026-04-02 00:20:23 -04:00
Brandon Thomas c7943bce59 AdvancedCommonWebError with wrapped errors 2026-04-01 23:41:51 -04:00
Brandon Thomas c5f62daa2b categorize seedance credits error (#1285) 2026-04-01 22:51:25 -04:00
bflatastic 7122e4507f fixed issue where duration isn't passed through when clicking on recreate 2026-04-02 09:35:44 +07:00
Tanish Manku 238cb10499 Add copy prompt button and prompt preview to failed task notifications (#1284)
- Copy button on failed task cards copies the prompt to clipboard with a
3s check/green feedback
- Prompt preview row with marquee scroll on hover (only when text
overflows), tooltip showing full prompt up to 300 chars
- Fix dismiss and action button icon centering across all card types
(InProgress, Completed, Failed)
- Add marquee keyframe animation to tailwind.config.ts
2026-04-01 19:08:43 -07:00
Brandon Thomas 1ebb124f05 cleaned up pager (#1283) 2026-04-01 22:00:38 -04:00
Brandon Thomas e7bb263755 mysql backup tool 2026-04-01 13:15:27 -04:00
Tanish Manku bbfea2ae96 Code cleanup: Delete PageEdit dead code and migrate shared types to ui-pagedraw (#1281)
PageEdit was never rendered in the app. Removes all PageEdit files
(~4000 lines) and migrates the shared types (BaseSelectorImage,
ImageBundle) and HistoryStack component to @storyteller/ui-pagedraw
which already owns the canonical definitions.
2026-04-01 09:29:17 -07:00
BFlat 3bc55c761c footnote update pricing apge 2026-04-01 22:45:42 +07:00
BFlat 8348a91693 show seedance in pricing table 2026-04-01 21:55:11 +07:00
BFlat d1adc0b179 website footer reddit link 2026-04-01 21:51:53 +07:00
BFlat c0ac3cbc9c pricing plan description updated 2026-04-01 20:03:59 +07:00
Brandon Thomas 0a13d36070 no more "free user downgrade" since we're not giving away nano banana… (#1279)
… pro anymore
2026-04-01 03:00:00 -04:00
Brandon Thomas 8662e79824 use tested typesafe fal bindings in webhook (#1278)
wip - still needs tested extractors for image, video, etc.
2026-04-01 02:46:21 -04:00
Brandon Thomas dc30cf9669 fal webhooks - tested cases for error extraction (#1277) 2026-04-01 02:21:44 -04:00
Tanish Manku b09a0fd854 Drag-N-Drop assets into Artcraft to upload them (#1231) 2026-03-31 21:05:38 -04:00
Brandon Thomas 400bccc155 more claude config files (#1276) 2026-03-31 19:22:45 -04:00
Tanish Manku 11011dacaf 2D: Add blank canvas option with preset sizes (#1275) 2026-03-31 15:28:22 -07:00
Tanish Manku 2f73e861d1 2D: Make PageDraw a self-contained lib (#1244)
Moves PageDraw from artcraft app to a component lib, leaving an adapter
implementation and import behind instead
2026-03-31 14:43:21 -07:00
Brandon Thomas 0151a2e28d fix password null bytes paging (#1273) 2026-03-31 14:55:10 -04:00
144 changed files with 4434 additions and 6419 deletions
+47
View File
@@ -0,0 +1,47 @@
# API Conventions
## HTTP Handlers (Actix-Web)
Handlers in `storyteller_web` follow this structure:
1. **Request/Response types** with `#[derive(Deserialize/Serialize, ToSchema)]`
2. **Error enum** implementing `ResponseError`, `Display`, `Serialize`, `ToSchema`
3. **Handler function** annotated with `#[utoipa::path(...)]` for OpenAPI docs
4. Handler returns `Result<Json<Response>, ErrorType>`. Only use `Result<HttpResponse, ErrorType>` when you need to set headers/cookies.
### Error Types
- Use `CommonWebError` for simple handlers that only need standard HTTP error codes
- Use a custom error enum when you need endpoint-specific error variants (e.g. `LoginErrorType::InvalidCredentials`)
- Custom error enums MUST implement `ResponseError` (actix), `Display`, and `Serialize`
- When adding `From<RequireUserSessionError>` or similar conversions, implement them on `CommonWebError` in its own module (orphan rule)
- Error response bodies include: `success: false`, `error_code`, `error_code_str`
### Session/Auth
- `require_user_session_using_connection()` reuses an open DB connection (preferred)
- `require_user_session()` acquires its own connection (deprecated)
- `require_moderator()` for admin-only endpoints
### API Type Definitions
- Shared request/response types go in `artcraft_api_defs` crate (under `crates/api_clients/artcraft/artcraft_api_defs/`)
- Types that need `ResponseError` impl must stay in `storyteller_web` (orphan rule)
- New types must be added to `api_doc.rs` schemas (in storyteller-web) for Swagger generation
## Enums (Database-backed)
- Located in `crates/schema/public/enums/src/by_table/{table_name}/`
- Require manual `to_str()` and `from_str()` implementations
- Use `impl_enum_display_and_debug_using_to_str!` and `impl_mysql_enum_coders!` macros
- Add comprehensive tests: serialization, round-trip, variant count
- YOU CAN ADD NEW VALUES but DO NOT CHANGE EXISTING VALUES without a migration strategy
- Note that adding new values can break older versions of the Tauri desktop app that read
responses over Rust - make sure it consumes variants with an "Unknown(String)" variant
catch-all to future proof the APIs.
## Tokens (Primary Keys)
- Located in `crates/schema/public/tokens/src/tokens/`
- Opaque `String` wrappers with Stripe-like prefixes (e.g. `user_`, `mf_`)
- Use `impl_string_token!`, `impl_mysql_token_from_row!`, `impl_crockford_generator!` macros
+59
View File
@@ -0,0 +1,59 @@
# Code Style
## Rust
- Two spaces for indentation (not four)
- No minimum supported Rust version; use latest stable features freely
- `#[macro_use] extern crate serde_derive;` is used in binary crates; library crates use `use serde_derive::{Serialize, Deserialize};`
- Prefer `log` crate macros (`info!`, `warn!`, `error!`) over `println!`
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code
- Enum Display/Debug: use `impl_enum_display_and_debug_using_to_str!` macro, not `derive_more::Display` (CLion doesn't understand it)
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token`
- When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)`
## File Layout (Top-to-Bottom Reading Order)
Organize code so a human reading top-to-bottom encounters the most important things first and
can drill down into details only when curious. Think of it like a newspaper: headline first,
then the story, then the fine print.
### Ordering within a file
1. **Constants** at the top, right after imports.
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. **Constructors** (`new`, `from_*`, `builder`) at the top.
2. **Public methods** next — these are the main entry points a reader cares about.
3. **Private helper methods** below public methods.
- Among helpers: place "meaty" helpers (substantial logic, direct continuations of the
public entry point) above "leaf" helpers (small formatters, simple lookups).
- A method that calls another method in the same file should appear *above* the method
it calls. This lets readers follow the call chain downward.
## Naming
- Handler functions: `{verb}_{noun}_handler` (e.g. `get_health_check_handler`, `login_handler`)
- Error enums: `{Feature}Error` (e.g. `FalWebhookError`, `HealthCheckError`)
- Request/response structs: `{Action}Request`, `{Action}Response`, `{Action}SuccessResponse`
- Path params: `{Action}PathInfo`
- Builder structs: `{Thing}Builder` with `set_` prefixed setters and a `build()` method
## Imports
- Group imports: std, external crates, internal workspace crates, `crate::` imports
- Use fully qualified paths for one-off references; `use` for repeated references
- Prefer specific imports over wildcards, except in `api_doc.rs` and test modules
## TypeScript / Frontend
- TypeScript with React, Vite, Zustand, Three.js
- Nx monorepo under `crates/frontend/`
- Two spaces for indentation
+27
View File
@@ -0,0 +1,27 @@
# Testing
## Running Tests
- Most crates: `cargo test -p {crate_name}`
- Crates using SQLx offline mode: `SQLX_OFFLINE=true cargo test -p {crate_name}`
- The `storyteller-web` and `mysql_queries` crates require `SQLX_OFFLINE=true` for compilation
- Use `cargo check -p {crate_name}` for fast compilation checks without running tests
## Test Patterns
- Tests live in `#[cfg(test)] mod tests { ... }` at the bottom of each file
- Group related tests in sub-modules: `mod explicit_checks`, `mod mechanical_checks`
- Enum tests should cover: serialization, deserialization, `to_str`/`from_str` round-trip, variant count
- Password/crypto tests: be mindful of computation cost, don't add too many bcrypt test cases
## What to Test
- New error types and their mappings
- Enum variant serialization stability (these are stored in the database)
- Hash/deduplication functions with known inputs
- Edge cases in parsers (empty input, missing fields, malformed data)
## What Not to Test
- Don't add integration tests that require a running database unless explicitly asked
- Don't add tests for simple struct construction or trivial getters
+11
View File
@@ -62,3 +62,14 @@ artcraft/
- A mix of wreq and reqwest for Rust HTTP clients
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
### File Layout
Organize for top-to-bottom reading. Important things first, details later.
- **Constants** at the top (after imports)
- **Structs/enums** next; outer structs above inner sub-structs
- **API types** in order: Request, Response, Error
- **In impl blocks**: constructors first, then public methods, then private helpers
- Private helpers go *below* the methods that call them
- Among helpers: meatier logic above leaf-level formatters
Generated
+7
View File
@@ -5163,6 +5163,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "http-types"
version = "2.12.0"
@@ -11846,6 +11852,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http 1.3.1",
"http-range",
"jni 0.21.1",
"libc",
"log",
+1 -1
View File
@@ -327,7 +327,7 @@ sha2 = "0.11.0-rc.5"
sha3 = "0.10.8"
speculoos = "0.11.0"
strum = { version = "0.28.0", features = ["derive"] }
tauri = { version = "2.8.3", features = ["unstable", "devtools"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.3.3"
tauri-plugin-fs = "2.4.2"
tauri-plugin-log = "2.6.0"
@@ -0,0 +1,22 @@
# artcraft_api_defs
Shared HTTP API type definitions used by both the server (`storyteller_web`) and clients.
## Structure
Organized by feature: `users/`, `media_file/`, `jobs/`, `moderation/`, etc.
## Conventions
- Request types: `#[derive(Deserialize, ToSchema)]`
- Response types: `#[derive(Serialize, ToSchema)]`
- Path params: `#[derive(Deserialize, ToSchema)]` with `PathInfo` suffix
- Query params: `#[derive(Deserialize, IntoParams)]`
## Orphan Rule
Types that need `ResponseError` impl (actix-web) CANNOT live here because of Rust's orphan rule. Keep error response types in `storyteller_web` and only put data types here.
## Dependencies
Only `serde`, `serde_derive`, `utoipa`, `chrono`, `url`, and internal schema crates (`enums`, `tokens`). No actix-web dependency.
+1
View File
@@ -5,3 +5,4 @@ pub mod error;
pub mod model;
pub mod requests;
pub mod utils;
pub mod webhook_api;
@@ -0,0 +1,3 @@
pub mod parse_webhook_inner_payload;
pub mod parse_webhook_payload;
pub mod payload;
@@ -0,0 +1,240 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
use crate::webhook_api::payload::webhook_inner_payload::{ErrorData, PayloadErrorData, SuccessData, WebhookInnerPayload};
use crate::webhook_api::payload::webhook_payload::{WebhookPayload, WebhookStatus};
/// Parse the inner payload of a FAL webhook into one of three cases.
///
/// 1. If status is ERROR, parse out the first `detail` entry's `msg` and `type`.
/// 2. If status is OK but there's no payload and there is a `payload_error`, return PayloadError.
/// 3. Otherwise, return Success with the payload.
pub fn parse_webhook_inner_payload(webhook: &WebhookPayload) -> WebhookInnerPayload {
match webhook.status {
WebhookStatus::Error => {
let (message, error_type) = extract_error_first_detail(&webhook.payload);
WebhookInnerPayload::Error(ErrorData {
message,
error_type,
})
}
WebhookStatus::Ok => {
// Check for payload_error case: status=OK but no payload, has payload_error.
if webhook.payload.is_none() {
if let Some(ref payload_error) = webhook.payload_error {
return WebhookInnerPayload::PayloadError(PayloadErrorData {
payload_error: payload_error.clone(),
});
}
}
WebhookInnerPayload::Success(SuccessData {
payload: webhook.payload.clone().unwrap_or(Value::Null),
})
}
}
}
/// Extract the first `msg` and `type` from `payload.detail[]`.
fn extract_error_first_detail(payload: &Option<Value>) -> (Option<String>, Option<WebhookErrorType>) {
let payload = match payload {
Some(p) => p,
None => return (None, None),
};
let detail_array = match payload.get("detail").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return (None, None),
};
let first = match detail_array.first() {
Some(item) => item,
None => return (None, None),
};
let message = first.get("msg").and_then(|v| v.as_str()).map(|s| s.to_string());
let error_type = first
.get("type")
.and_then(|v| v.as_str())
.map(WebhookErrorType::from_str);
(message, error_type)
}
#[cfg(test)]
mod tests {
use super::*;
fn load_test_webhook(filename: &str) -> WebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn gpt_image_1p5_content_policy_violation() {
let webhook = load_test_webhook("gpt_image_1p5_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
);
assert_eq!(
data.message.as_deref(),
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn gpt_image_invalid_api_key() {
let webhook = load_test_webhook("gpt_image_fail_invalid_api_key.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::InvalidApiKey),
);
assert_eq!(
data.message.as_deref(),
Some("Invalid API key"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn kling_1p6_pro_file_too_large() {
let webhook = load_test_webhook("kling_1p6_pro_file_too_large_error.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::FileTooLarge),
);
assert_eq!(
data.message.as_deref(),
Some("File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn kling_3p0_pro_content_policy_violation() {
let webhook = load_test_webhook("kling_3p0_pro_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
);
assert_eq!(
data.message.as_deref(),
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn nano_banana_pro_no_media_generated() {
let webhook = load_test_webhook("nano_banana_pro_fail_no_media_generated.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::NoMediaGenerated),
);
assert!(
data.message.as_deref().unwrap().starts_with("The model did not generate"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn payload_error_case() {
let webhook = WebhookPayload {
request_id: "test-123".to_string(),
gateway_request_id: "test-123".to_string(),
status: WebhookStatus::Ok,
error: None,
payload: None,
payload_error: Some("encoding error occurred".to_string()),
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::PayloadError(data) => {
assert_eq!(data.payload_error, "encoding error occurred");
}
other => panic!("Expected WebhookInnerPayload::PayloadError, got {:?}", other),
}
}
#[test]
fn success_case() {
let webhook = WebhookPayload {
request_id: "test-456".to_string(),
gateway_request_id: "test-456".to_string(),
status: WebhookStatus::Ok,
error: None,
payload: Some(serde_json::json!({"images": [{"url": "https://example.com/img.png"}]})),
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Success(data) => {
assert!(data.payload.get("images").is_some());
}
other => panic!("Expected WebhookInnerPayload::Success, got {:?}", other),
}
}
#[test]
fn error_with_no_payload() {
let webhook = WebhookPayload {
request_id: "test-789".to_string(),
gateway_request_id: "test-789".to_string(),
status: WebhookStatus::Error,
error: Some("Internal server error".to_string()),
payload: None,
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(data.message, None);
assert_eq!(data.error_type, None);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
}
@@ -0,0 +1,5 @@
use crate::webhook_api::payload::webhook_payload::WebhookPayload;
pub fn parse_webhook_payload(json: &str) -> Result<WebhookPayload, serde_json::Error> {
serde_json::from_str(json)
}
@@ -0,0 +1,3 @@
pub mod webhook_inner_payload;
pub mod webhook_payload;
pub mod webhook_error_type;
@@ -0,0 +1,104 @@
use serde::Deserialize;
/// Machine-readable error type from FAL webhook error payloads.
///
/// These are the `type` field values from the `detail` array in FAL error responses.
/// See: https://fal.ai/docs/documentation/model-apis/errors
#[derive(Clone, Debug, Eq, PartialEq, Hash, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookErrorType {
FileTooLarge,
InternalServerError,
GenerationTimeout,
DownstreamServiceError,
DownstreamServiceUnavailable,
ContentPolicyViolation,
NoMediaGenerated,
ImageTooSmall,
ImageTooLarge,
ImageLoadError,
FileDownloadError,
FaceDetectionError,
GreaterThan,
GreaterThanEqual,
LessThan,
LessThanEqual,
MultipleOf,
SequenceTooShort,
SequenceTooLong,
OneOf,
FeatureNotSupported,
InvalidArchive,
ArchiveFileCountBelowMinimum,
ArchiveFileCountExceedsMaximum,
InvalidApiKey,
/// Catch-all for unrecognized error types from FAL.
#[serde(untagged)]
Unknown(String),
}
impl WebhookErrorType {
pub fn from_str(value: &str) -> Self {
serde_json::from_value(serde_json::Value::String(value.to_string()))
.unwrap_or_else(|_| Self::Unknown(value.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_known_types() {
let cases = vec![
("\"file_too_large\"", WebhookErrorType::FileTooLarge),
("\"internal_server_error\"", WebhookErrorType::InternalServerError),
("\"generation_timeout\"", WebhookErrorType::GenerationTimeout),
("\"downstream_service_error\"", WebhookErrorType::DownstreamServiceError),
("\"downstream_service_unavailable\"", WebhookErrorType::DownstreamServiceUnavailable),
("\"content_policy_violation\"", WebhookErrorType::ContentPolicyViolation),
("\"no_media_generated\"", WebhookErrorType::NoMediaGenerated),
("\"image_too_small\"", WebhookErrorType::ImageTooSmall),
("\"image_too_large\"", WebhookErrorType::ImageTooLarge),
("\"image_load_error\"", WebhookErrorType::ImageLoadError),
("\"file_download_error\"", WebhookErrorType::FileDownloadError),
("\"face_detection_error\"", WebhookErrorType::FaceDetectionError),
("\"greater_than\"", WebhookErrorType::GreaterThan),
("\"greater_than_equal\"", WebhookErrorType::GreaterThanEqual),
("\"less_than\"", WebhookErrorType::LessThan),
("\"less_than_equal\"", WebhookErrorType::LessThanEqual),
("\"multiple_of\"", WebhookErrorType::MultipleOf),
("\"sequence_too_short\"", WebhookErrorType::SequenceTooShort),
("\"sequence_too_long\"", WebhookErrorType::SequenceTooLong),
("\"one_of\"", WebhookErrorType::OneOf),
("\"feature_not_supported\"", WebhookErrorType::FeatureNotSupported),
("\"invalid_archive\"", WebhookErrorType::InvalidArchive),
("\"archive_file_count_below_minimum\"", WebhookErrorType::ArchiveFileCountBelowMinimum),
("\"archive_file_count_exceeds_maximum\"", WebhookErrorType::ArchiveFileCountExceedsMaximum),
("\"invalid_api_key\"", WebhookErrorType::InvalidApiKey),
];
for (json, expected) in cases {
let deserialized: WebhookErrorType = serde_json::from_str(json).unwrap();
assert_eq!(deserialized, expected, "failed for {}", json);
}
}
#[test]
fn deserialize_unknown_type() {
let deserialized: WebhookErrorType = serde_json::from_str("\"some_new_error_type\"").unwrap();
assert_eq!(deserialized, WebhookErrorType::Unknown("some_new_error_type".to_string()));
}
#[test]
fn from_str_known() {
assert_eq!(WebhookErrorType::from_str("content_policy_violation"), WebhookErrorType::ContentPolicyViolation);
assert_eq!(WebhookErrorType::from_str("face_detection_error"), WebhookErrorType::FaceDetectionError);
}
#[test]
fn from_str_unknown() {
assert_eq!(WebhookErrorType::from_str("brand_new_type"), WebhookErrorType::Unknown("brand_new_type".to_string()));
}
}
@@ -0,0 +1,32 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
/// The parsed inner payload of a FAL webhook.
#[derive(Debug)]
pub enum WebhookInnerPayload {
/// The webhook reported success and has a payload.
Success(SuccessData),
/// The webhook reported an error (status=ERROR) with optional detail info.
Error(ErrorData),
/// The webhook reported success but had no payload and instead had a payload_error.
PayloadError(PayloadErrorData),
}
#[derive(Debug)]
pub struct SuccessData {
pub payload: Value,
}
#[derive(Debug)]
pub struct ErrorData {
/// The first human-readable message from `payload.detail[].msg`, if any.
pub message: Option<String>,
/// The first machine-readable error type from `payload.detail[].type`, if any.
pub error_type: Option<WebhookErrorType>,
}
#[derive(Debug)]
pub struct PayloadErrorData {
pub payload_error: String,
}
@@ -0,0 +1,43 @@
use serde::Deserialize;
use serde_json::Value;
/// The value FAL sends to webhook endpoints.
#[derive(Deserialize, Debug, Clone)]
pub struct WebhookPayload {
/// Value originally used in the queue API
pub request_id: String,
/// Mostly the same as `request_id`, but if the request failed and was retried,
/// then gateway_request_id will have the value of the last tried request,
/// while request_id will be the value used in the queue API.
pub gateway_request_id: String,
/// Status of the request: OK or ERROR.
pub status: WebhookStatus,
/// When an error happens, the status will be ERROR.
/// The error property will contain a message and payload will contain the error details.
/// eg. ""Invalid status code: 422"
pub error: Option<String>,
/// Payload of the webhook (typically present)
/// THIS FIELD IS HIGHLY VARIABLE JSON.
/// This is present for both *success* and *failure* cases,
/// though the payload differs. Sometimes it is absent.
pub payload: Option<Value>,
/// In rare instances, if there was an error encoding the payload,
/// this may be set. This can seemingly be set on "OK" responses.
pub payload_error: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Copy)]
pub enum WebhookStatus {
/// Success case
#[serde(alias = "OK")]
Ok,
/// Failure case
#[serde(alias = "ERROR")]
Error
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4756-130b-7141-a16c-8f411b785844",
"payload": {
"detail": [
{
"input": {
"background": "auto",
"image_size": "1536x1024",
"input_fidelity": null,
"num_images": 1,
"openai_api_key": null,
"output_format": "png",
"prompt": "[redacted nsfw query]",
"quality": "medium"
},
"loc": [
"body",
"prompt"
],
"msg": "The content could not be processed because it contained material flagged by a content checker.",
"type": "content_policy_violation",
"url": "https://docs.fal.ai/errors#content_policy_violation"
}
]
},
"request_id": "019d4756-130b-7141-a16c-8f411b785844",
"status": "ERROR"
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4771-5b51-7480-afc6-67e6e74b9f22",
"payload": {
"detail": [
{
"input": {
"background": "auto",
"image_size": "1536x1024",
"input_fidelity": null,
"num_images": 1,
"openai_api_key": "REDACTED",
"output_format": "png",
"prompt": "[redacted]",
"quality": "high"
},
"loc": [
"body",
"openai_api_key"
],
"msg": "Invalid API key",
"type": "invalid_api_key",
"url": "https://docs.fal.ai/errors#invalid_api_key"
}
]
},
"request_id": "019d4771-5b51-7480-afc6-67e6e74b9f22",
"status": "ERROR"
}
@@ -0,0 +1,23 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d477e-c123-7120-81c7-30d6b58a6720",
"payload": {
"detail": [
{
"ctx": {
"max_size": 10485760
},
"input": "[redacted]",
"loc": [
"body",
"image_url"
],
"msg": "File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file.",
"type": "file_too_large",
"url": "https://docs.fal.ai/errors#file_too_large"
}
]
},
"request_id": "019d477e-c123-7120-81c7-30d6b58a6720",
"status": "ERROR"
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4761-035d-7b50-8c42-1521efdd4f34",
"payload": {
"detail": [
{
"input": {
"aspect_ratio": "16:9",
"cfg_scale": 0.5,
"duration": "3",
"generate_audio": false,
"multi_prompt": null,
"negative_prompt": "blur, distort, and low quality",
"prompt": "[redacted prompt]",
"shot_type": "customize",
"voice_ids": null
},
"loc": [
"body"
],
"msg": "The content could not be processed because it contained material flagged by a content checker.",
"type": "content_policy_violation",
"url": "https://docs.fal.ai/errors#content_policy_violation"
}
]
},
"request_id": "019d4761-035d-7b50-8c42-1521efdd4f34",
"status": "ERROR"
}
@@ -0,0 +1,30 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d475a-291a-7681-81dc-b27d2489beae",
"payload": {
"detail": [
{
"input": {
"aspect_ratio": "16:9",
"enable_web_search": false,
"limit_generations": false,
"num_images": 1,
"output_format": "png",
"prompt": "[nsfw prompt redacted]",
"resolution": "1K",
"safety_tolerance": "4",
"seed": null,
"sync_mode": false
},
"loc": [
"body"
],
"msg": "The model did not generate the expected output for this prompt. This may occur for several reasons, including unsafe content, a prompt that is incompatible with the selected media type (e.g., prompting an image model to generate audio), references to missing attachments, or other cases where the input cannot be processed as the requested output type. Please review your inputs and try again. If the issue persists, contact support.",
"type": "no_media_generated",
"url": "https://docs.fal.ai/errors#no_media_generated"
}
]
},
"request_id": "019d475a-291a-7681-81dc-b27d2489beae",
"status": "ERROR"
}
@@ -1,6 +1,8 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::StatusCode;
#[derive(Debug)]
pub enum Seedance2ProSpecificApiError {
/// The session cookies are expired or invalid.
@@ -8,6 +10,12 @@ pub enum Seedance2ProSpecificApiError {
/// The video generation request was flagged as a content violation.
VideoGenerationViolation(String),
/// The account does not have enough credits to perform the operation.
BillingError {
status_code: StatusCode,
body: String,
},
}
impl Error for Seedance2ProSpecificApiError {}
@@ -17,6 +25,7 @@ impl Display for Seedance2ProSpecificApiError {
match self {
Self::UnauthorizedSessionExpired => write!(f, "Unauthorized: session cookies expired or invalid."),
Self::VideoGenerationViolation(body) => write!(f, "Video generation violation: {}", body),
Self::BillingError { status_code, body } => write!(f, "Billing error (status {}): {}", status_code, body),
}
}
}
@@ -5,6 +5,7 @@ use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use crate::requests::generate_video::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::categorize_seedance2pro_error::categorize_seedance2pro_error;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use wreq::Client;
@@ -308,10 +309,7 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
info!("Response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
return Err(categorize_seedance2pro_error(status, response_body));
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
@@ -0,0 +1,111 @@
use wreq::StatusCode;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
/// Known error message substrings that indicate a billing/credits error.
const BILLING_ERROR_MARKERS: &[&str] = &[
"credits not enough",
];
/// Categorize a non-success HTTP response into a specific or generic error.
///
/// Checks the response body for known patterns (e.g. billing errors) and returns
/// a specific error variant when possible, falling back to
/// `UncategorizedBadResponseWithStatusAndBody` otherwise.
pub fn categorize_seedance2pro_error(
status_code: StatusCode,
body: String,
) -> Seedance2ProError {
if is_billing_error(&body) {
return Seedance2ProSpecificApiError::BillingError {
status_code,
body,
}.into();
}
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}.into()
}
/// Check if the response body contains a known billing error marker.
fn is_billing_error(body: &str) -> bool {
let body_lower = body.to_lowercase();
BILLING_ERROR_MARKERS.iter().any(|marker| body_lower.contains(marker))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::seedance2pro_error::Seedance2ProError;
#[test]
fn real_not_enough_credits_response() {
let body = std::fs::read_to_string("test_data/responses/not_enough_credits.json")
.expect("Failed to read test data file");
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { status_code, body }
) => {
assert_eq!(status_code, StatusCode::BAD_REQUEST);
assert!(body.contains("credits not enough"));
}
other => panic!("Expected BillingError, got: {:?}", other),
}
}
#[test]
fn billing_error_case_insensitive() {
let body = r#"{"error":"Credits Not Enough"}"#.to_string();
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { .. }
) => {}
other => panic!("Expected BillingError, got: {:?}", other),
}
}
#[test]
fn unknown_error_falls_back_to_uncategorized() {
let body = r#"{"error":"something else went wrong"}"#.to_string();
let error = categorize_seedance2pro_error(
StatusCode::INTERNAL_SERVER_ERROR,
body.clone(),
);
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: b }
) => {
assert_eq!(status_code, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(b, body);
}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
#[test]
fn empty_body_falls_back_to_uncategorized() {
let error = categorize_seedance2pro_error(
StatusCode::BAD_REQUEST,
String::new(),
);
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
) => {}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
}
@@ -1 +1,2 @@
pub mod categorize_seedance2pro_error;
pub mod common_headers;
@@ -0,0 +1,25 @@
[
{
"error": {
"json": {
"message": "credits not enough",
"code": -32600,
"data": {
"code": "BAD_REQUEST",
"httpStatus": 400,
"path": "workflow.runTask",
"zodError": null
},
"stack": null
},
"meta": {
"values": {
"stack": [
"undefined"
]
},
"v": 1
}
}
}
]
@@ -10,6 +10,7 @@
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-set-focus",
"core:window:allow-toggle-maximize",
"core:window:default",
"dialog:default",
@@ -34,10 +34,6 @@ pub async fn setup_main_window(
let win_builder = win_builder
.decorations(false); // NB: This breaks Mac! (And breaks resize on Linux)
#[cfg(target_os = "windows")]
let win_builder = win_builder
.drag_and_drop(false); // TODO: Is this necessary on Windows?
let _window = win_builder.build()?;
Ok(())
+5
View File
@@ -14,6 +14,10 @@
"windows": [
],
"security": {
"assetProtocol": {
"enable": true,
"scope": ["**"]
},
"csp": {
"default-src": [
"'self'",
@@ -32,6 +36,7 @@
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"asset:",
"blob:",
"data:",
"http://ipc.localhost",
+40
View File
@@ -0,0 +1,40 @@
# Pager Crate
Alerting/paging system that sends notifications via Rootly.
## Key Types
- `Pager` - Main client, cloneable. Has `enqueue_page()` and `shutdown_worker()`
- `PagerBuilder` - Constructs a Pager with optional background worker
- `NotificationDetails` - Alert payload with title, description, urgency, HTTP context, entity tokens
- `NotificationDetailsBuilder` - Fluent builder for constructing notifications
- `NotificationUrgency` - High, Medium, Low
## Building Notifications
```rust
let notification = NotificationDetailsBuilder::from_title("Something happened".to_string())
.set_description(Some("Details...".to_string()))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some("POST".to_string()))
.set_http_path(Some("/v1/endpoint".to_string()))
.set_inference_job_token(Some(job_token.to_string()))
.build();
```
For errors, use `from_error()` which auto-populates title and description from the error:
```rust
NotificationDetailsBuilder::from_error(&err)
.set_title("Custom title".to_string())
.set_urgency(Some(NotificationUrgency::Medium))
.build()
```
## Deduplication
`NotificationDetails::to_deduplication_key()` generates a SHA-256 hash for dedup. Same title + HTTP context + hour = same key.
## Worker Thread
The pager worker uses `Condvar::wait()` (blocking). It MUST run on a dedicated OS thread, not a tokio task.
+49 -85
View File
@@ -66,7 +66,11 @@ pub struct PageSentResult {
pub short_id: Option<String>,
}
// =============== Implementation ===============
impl PagerClient {
// --- Constructor ---
pub fn new(
client_config: PagerClientConfig,
application_name: Option<String>,
@@ -77,6 +81,8 @@ impl PagerClient {
Self { client_config, application_name, environment, hostname, service_id }
}
// --- Public interface ---
pub fn is_noop(&self) -> bool {
matches!(self.client_config, PagerClientConfig::NoOp)
}
@@ -94,6 +100,8 @@ impl PagerClient {
}
}
// --- Private helpers (called by send_page) ---
async fn send_page_via_rootly(
&self,
notification: &NotificationDetails,
@@ -115,92 +123,13 @@ impl PagerClient {
debug!("Sending page via Rootly (source={}): {}", source, notification.title);
let mut labels: Vec<(String, String)> = Vec::new();
let labels = self.build_labels(notification);
if let Some(name) = &self.application_name {
labels.push(("application_name".to_string(), name.clone()));
}
if let Some(id) = &self.service_id {
labels.push(("service_id".to_string(), id.clone()));
}
if let Some(env) = &self.environment {
labels.push(("environment".to_string(), env.clone()));
}
if let Some(h) = &self.hostname {
labels.push(("hostname".to_string(), h.clone()));
}
if let Some(method) = &notification.http_method {
labels.push(("http_method".to_string(), method.clone()));
}
if let Some(path) = &notification.http_path {
labels.push(("http_path".to_string(), path.clone()));
}
if let Some(status_code) = notification.http_status_code {
labels.push(("http_status_code".to_string(), status_code.to_string()));
}
let labels = if labels.is_empty() { None } else { Some(labels) };
// Enrich the description with context and hostname if present.
let description = match &notification.description {
Some(desc) => {
let mut parts = vec![desc.clone()];
match (&self.application_name, &self.service_id) {
(Some(name), Some(id)) => {
parts.push(format!("Application: {} (service_id: {})", name, id));
}
(Some(name), None) => {
parts.push(format!("Application: {}", name));
}
(None, Some(id)) => {
parts.push(format!("Service ID: {}", id));
}
(None, None) => {}
}
if let Some(method) = &notification.http_method {
parts.push(format!("HTTP Method: {}", method));
}
if let Some(path) = &notification.http_path {
parts.push(format!("HTTP Path: {}", path));
}
if let Some(status_code) = notification.http_status_code {
parts.push(format!("HTTP Status Code: {}", status_code));
}
if let Some(user_token) = &notification.user_token {
parts.push(format!("User Token: {}", user_token));
}
if let Some(media_file_token) = &notification.media_file_token {
parts.push(format!("Media File Token: {}", media_file_token));
}
if let Some(inference_job_token) = &notification.inference_job_token {
parts.push(format!("Inference Job Token: {}", inference_job_token));
}
if let Some(third_party_id) = &notification.third_party_id {
parts.push(format!("Third Party ID: {}", third_party_id));
}
if let Some(h) = &self.hostname {
parts.push(format!("Hostname: {}", h));
}
Some(parts.join("\n\n"))
}
None => None,
};
let description = notification.build_enriched_description(
self.application_name.as_deref(),
self.service_id.as_deref(),
self.hostname.as_deref(),
);
// https://docs.rootly.com/api-reference/alerts/creates-an-alert
let result = create_alert(CreateAlertArgs {
@@ -241,4 +170,39 @@ impl PagerClient {
}
}
}
/// Build structured labels from the client config and notification fields.
fn build_labels(&self, notification: &NotificationDetails) -> Option<Vec<(String, String)>> {
let mut labels: Vec<(String, String)> = Vec::new();
if let Some(name) = &self.application_name {
labels.push(("application_name".to_string(), name.clone()));
}
if let Some(id) = &self.service_id {
labels.push(("service_id".to_string(), id.clone()));
}
if let Some(env) = &self.environment {
labels.push(("environment".to_string(), env.clone()));
}
if let Some(h) = &self.hostname {
labels.push(("hostname".to_string(), h.clone()));
}
if let Some(method) = &notification.http_method {
labels.push(("http_method".to_string(), method.clone()));
}
if let Some(path) = &notification.http_path {
labels.push(("http_path".to_string(), path.clone()));
}
if let Some(status_code) = notification.http_status_code {
labels.push(("http_status_code".to_string(), status_code.to_string()));
}
if labels.is_empty() { None } else { Some(labels) }
}
}
@@ -63,6 +63,7 @@ mod tests {
media_file_token: None,
inference_job_token: None,
third_party_id: None,
maybe_error: None,
}
}
@@ -222,6 +223,7 @@ mod tests {
media_file_token: None,
inference_job_token: None,
third_party_id: None,
maybe_error: None,
}
}
@@ -1,162 +1,220 @@
use chrono::{DateTime, Utc};
use std::fmt::{Debug, Display};
use crate::notification::generate_deduplication_key::generate_deduplication_key;
use crate::notification::notification_urgency::NotificationUrgency;
/// Details for a pager notification.
#[derive(Clone, Debug)]
#[derive(Debug)]
pub struct NotificationDetails {
/// Title or summary of the alert.
pub title: String,
pub(crate) title: String,
/// Full details for the alert.
pub description: Option<String>,
/// When the event occurred.
pub event_time: DateTime<Utc>,
/// HTTP method associated with the event, if any.
pub http_method: Option<String>,
/// HTTP endpoint path associated with the event, if any.
pub http_path: Option<String>,
/// HTTP status code associated with the event, if any.
pub http_status_code: Option<u16>,
/// Whether this notification originated from an error.
pub is_from_error: bool,
pub(crate) description: Option<String>,
/// Urgency level for the notification.
pub urgency: Option<NotificationUrgency>,
pub(crate) urgency: Option<NotificationUrgency>,
/// When the event occurred.
pub(crate) event_time: DateTime<Utc>,
/// The error that triggered this notification, if any.
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,
/// HTTP method associated with the event, if any.
pub(crate) http_method: Option<String>,
/// HTTP endpoint path associated with the event, if any.
pub(crate) http_path: Option<String>,
/// HTTP status code associated with the event, if any.
pub(crate) http_status_code: Option<u16>,
/// User token associated with the event, if any.
pub user_token: Option<String>,
pub(crate) user_token: Option<String>,
/// Media file token associated with the event, if any.
pub media_file_token: Option<String>,
pub(crate) media_file_token: Option<String>,
/// Inference job token associated with the event, if any.
pub inference_job_token: Option<String>,
pub(crate) inference_job_token: Option<String>,
/// Third-party identifier associated with the event, if any.
pub third_party_id: Option<String>,
pub(crate) third_party_id: Option<String>,
}
impl NotificationDetails {
/// Return a key that can group notifications for deduplication,
/// which will prevent spamming the same page over and over.
pub fn to_deduplication_key(&self) -> String {
generate_deduplication_key(self)
}
/// Create a notification with a title and description.
pub fn with_title_and_description(title: String, description: String) -> Self {
Self {
title,
description: Some(description),
event_time: Utc::now(),
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: false,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
}
// =============== Description Building ===============
/// Create a notification with just a title.
pub fn with_title(title: String) -> Self {
Self {
title,
description: None,
event_time: Utc::now(),
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: false,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
}
/// Create a notification from any error type.
/// Build the full enriched description for this notification.
///
/// Formats the error into a structured description that includes:
/// - The error message
/// - The error's source chain (if any)
/// - A backtrace (if available via `std::backtrace`)
/// - The timestamp of the event
pub fn from_error<E: Debug + Display>(error: &E) -> Self {
let title = format!("{}", error);
/// Assembles atomic sections in a consistent order:
/// 1. User-provided description
/// 2. Error chain (if a boxed error is attached)
/// 3. Event time
/// 4. HTTP context
/// 5. Service tokens
/// 6. Application/service identity and hostname
pub(crate) fn build_enriched_description(
&self,
application_name: Option<&str>,
service_id: Option<&str>,
hostname: Option<&str>,
) -> Option<String> {
let mut sections: Vec<String> = Vec::new();
// Truncate the title to a reasonable length for alert titles.
let title = if title.len() > 200 {
format!("{}...", &title[..197])
// 1. User-provided description
if let Some(desc) = &self.description {
sections.push(desc.clone());
}
// 2. Error chain
if let Some(error_section) = self.format_error_chain() {
sections.push(error_section);
}
// 3. Event time
sections.push(self.format_event_time());
// 4. HTTP context
if let Some(http_section) = self.format_http_context() {
sections.push(http_section);
}
// 5. Service tokens
if let Some(tokens_section) = self.format_service_tokens() {
sections.push(tokens_section);
}
// 6. Application/service identity and hostname
if let Some(identity_section) = Self::format_app_identity(application_name, service_id, hostname) {
sections.push(identity_section);
}
if sections.is_empty() {
None
} else {
title
};
Some(sections.join("\n\n"))
}
}
let event_time = Utc::now();
/// Format the error chain from the boxed error, walking the source chain.
fn format_error_chain(&self) -> Option<String> {
let error = self.maybe_error.as_ref()?;
let mut description_parts: Vec<String> = Vec::new();
let mut parts: Vec<String> = Vec::new();
description_parts.push(format!("Event time: {}", event_time.format("%Y-%m-%d %H:%M:%S UTC")));
description_parts.push(String::new());
description_parts.push(format!("Error: {}", error));
// Top-level error
parts.push(format!("Error: {}", error));
// Include the Debug representation if it differs from Display (often has more detail).
// Debug representation if it differs
let debug_repr = format!("{:?}", error);
let display_repr = format!("{}", error);
if debug_repr != display_repr {
description_parts.push(String::new());
description_parts.push(format!("Debug: {}", debug_repr));
parts.push(format!("Debug: {}", debug_repr));
}
// Attempt to walk the error source chain.
// NB: We use the Debug trait here since we can't require std::error::Error
// (that would require E: 'static + Error which is too restrictive for callers).
// Walk the source chain
let mut depth = 0;
let mut source = error.source();
while let Some(cause) = source {
depth += 1;
parts.push(format!("Caused by ({depth}): {cause}"));
// Try to capture a backtrace from the current call site.
#[cfg(feature = "backtrace")]
{
let bt = std::backtrace::Backtrace::capture();
if bt.status() == std::backtrace::BacktraceStatus::Captured {
description_parts.push(String::new());
description_parts.push(format!("Backtrace:\n{}", bt));
let cause_debug = format!("{:?}", cause);
let cause_display = format!("{}", cause);
if cause_debug != cause_display {
parts.push(format!(" Debug ({depth}): {cause_debug}"));
}
source = cause.source();
}
let description = description_parts.join("\n");
Self {
title,
description: Some(description),
event_time,
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: true,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
Some(parts.join("\n"))
}
/// Create a notification from an error, with a custom title prefix.
pub fn from_error_with_context<E: Debug + Display>(context: &str, error: &E) -> Self {
let mut notification = Self::from_error(error);
notification.title = format!("{}: {}", context, notification.title);
if notification.title.len() > 200 {
notification.title = format!("{}...", &notification.title[..197]);
/// Format the event time.
fn format_event_time(&self) -> String {
format!("Event time: {}", self.event_time.format("%Y-%m-%d %H:%M:%S UTC"))
}
/// Format HTTP context (method, path, status code) if any fields are present.
fn format_http_context(&self) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(method) = &self.http_method {
parts.push(format!("HTTP Method: {}", method));
}
notification
if let Some(path) = &self.http_path {
parts.push(format!("HTTP Path: {}", path));
}
if let Some(status_code) = self.http_status_code {
parts.push(format!("HTTP Status Code: {}", status_code));
}
if parts.is_empty() { None } else { Some(parts.join("\n")) }
}
/// Format service tokens (user, media file, inference job, third party) if any are present.
fn format_service_tokens(&self) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(user_token) = &self.user_token {
parts.push(format!("User Token: {}", user_token));
}
if let Some(media_file_token) = &self.media_file_token {
parts.push(format!("Media File Token: {}", media_file_token));
}
if let Some(inference_job_token) = &self.inference_job_token {
parts.push(format!("Inference Job Token: {}", inference_job_token));
}
if let Some(third_party_id) = &self.third_party_id {
parts.push(format!("Third Party ID: {}", third_party_id));
}
if parts.is_empty() { None } else { Some(parts.join("\n")) }
}
/// Format the application name, service ID, and hostname.
fn format_app_identity(
application_name: Option<&str>,
service_id: Option<&str>,
hostname: Option<&str>,
) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
match (application_name, service_id) {
(Some(name), Some(id)) => {
parts.push(format!("Application: {} (service_id: {})", name, id));
}
(Some(name), None) => {
parts.push(format!("Application: {}", name));
}
(None, Some(id)) => {
parts.push(format!("Service ID: {}", id));
}
(None, None) => {}
}
if let Some(h) = hostname {
parts.push(format!("Hostname: {}", h));
}
if parts.is_empty() { None } else { Some(parts.join("\n")) }
}
}
@@ -1,22 +1,28 @@
use chrono::{DateTime, Utc};
use std::fmt::{Debug, Display};
use std::sync::Arc;
use chrono::{DateTime, Utc};
use crate::notification::notification_details::NotificationDetails;
use crate::notification::notification_urgency::NotificationUrgency;
pub struct NotificationDetailsBuilder {
title: String,
description: Option<String>,
event_time: DateTime<Utc>,
http_method: Option<String>,
http_path: Option<String>,
http_status_code: Option<u16>,
is_from_error: bool,
urgency: Option<NotificationUrgency>,
user_token: Option<String>,
media_file_token: Option<String>,
inference_job_token: Option<String>,
third_party_id: Option<String>,
pub(crate) description: Option<String>,
pub(crate) urgency: Option<NotificationUrgency>,
pub(crate) event_time: DateTime<Utc>,
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>,
pub(crate) http_path: Option<String>,
pub(crate) http_status_code: Option<u16>,
pub(crate) user_token: Option<String>,
pub(crate) media_file_token: Option<String>,
pub(crate) inference_job_token: Option<String>,
pub(crate) third_party_id: Option<String>,
}
impl NotificationDetailsBuilder {
@@ -25,12 +31,13 @@ impl NotificationDetailsBuilder {
Self {
title,
description: None,
urgency: None,
event_time: Utc::now(),
maybe_error: None,
is_from_error: false,
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: false,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
@@ -38,29 +45,30 @@ impl NotificationDetailsBuilder {
}
}
/// Create a builder from an error.
///
/// Sets `is_from_error` to true and derives the summary and description
/// from the error, matching the behavior of `NotificationDetails::from_error`.
pub fn from_error<E: Debug + Display>(error: &E) -> Self {
// TODO(bt,2026-03-30): Clean this up
let details = NotificationDetails::from_error(error);
/// Create a builder from a boxed error. Converts to `Arc` internally for shareability.
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.
pub fn from_error(error: Arc<dyn std::error::Error + Send + Sync + 'static>) -> Self {
Self {
title: details.title,
description: details.description,
event_time: details.event_time,
title: "Notification from Error".to_string(),
description: None,
urgency: None,
event_time: Utc::now(),
maybe_error: Some(error),
is_from_error: true,
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: true,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
}
pub fn set_title(mut self, title: String) -> Self {
self.title = title;
self
@@ -71,6 +79,16 @@ impl NotificationDetailsBuilder {
self
}
pub fn set_urgency(mut self, urgency: Option<NotificationUrgency>) -> Self {
self.urgency = urgency;
self
}
pub fn set_error(mut self, error: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
self.maybe_error = error;
self
}
pub fn set_http_method(mut self, http_method: Option<String>) -> Self {
self.http_method = http_method;
self
@@ -86,11 +104,6 @@ impl NotificationDetailsBuilder {
self
}
pub fn set_urgency(mut self, urgency: Option<NotificationUrgency>) -> Self {
self.urgency = urgency;
self
}
pub fn set_user_token(mut self, user_token: Option<String>) -> Self {
self.user_token = user_token;
self
@@ -115,12 +128,13 @@ impl NotificationDetailsBuilder {
NotificationDetails {
title: self.title,
description: self.description,
urgency: self.urgency,
event_time: self.event_time,
maybe_error: self.maybe_error,
is_from_error: self.is_from_error,
http_method: self.http_method,
http_path: self.http_path,
http_status_code: self.http_status_code,
is_from_error: self.is_from_error,
urgency: self.urgency,
user_token: self.user_token,
media_file_token: self.media_file_token,
inference_job_token: self.inference_job_token,
@@ -6,7 +6,9 @@ pub fn bcrypt_confirm_password(password: String, bcrypt_hash: &str) -> Result<bo
return Err(PasswordConfirmError::HashNotProvided);
}
if bcrypt_hash == PASSWORD_HASH_SENTINEL_VALUE {
// The database stores the sentinel "*" in a BINARY(60) column, which pads with null bytes.
// This reproduces the real-world value: "*" followed by 59 null bytes.
if bcrypt_hash.trim_end_matches('\0') == PASSWORD_HASH_SENTINEL_VALUE {
return Err(PasswordConfirmError::HashIsSentinelValue);
}
@@ -20,7 +22,7 @@ mod tests {
use crate::errors::password_confirm_error::PasswordConfirmError;
#[test]
fn test_confirmation() {
fn test_several_password_hash_pairs() {
// NB: This is to make sure we don't migrate the algorithm to a state where it won't work anymore.
// NB: Be mindful that these are expensive to compute and don't add too many test cases.
let hash_to_passwords = [
@@ -47,4 +49,18 @@ mod tests {
let result = bcrypt_confirm_password("password".to_string(), "*");
assert!(matches!(result, Err(PasswordConfirmError::HashIsSentinelValue)));
}
#[test]
fn test_sentinel_value_with_null_bytes_returns_bcrypt_error() {
// The database stores the sentinel "*" in a BINARY(60) column, which pads with null bytes.
// This reproduces the real-world value: "*" followed by 59 null bytes.
// It's currently paging me (until I fix it):
// [2026-03-31T16:03:00Z WARN storyteller_web::http_server::endpoints::users::login_handler] Login hash comparison error: BcryptVerifyError(InvalidHash("*\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"))
let mut hash_with_nulls = String::from("*");
for _ in 0..59 {
hash_with_nulls.push('\0');
}
let result = bcrypt_confirm_password("password".to_string(), &hash_with_nulls);
assert!(matches!(result, Err(PasswordConfirmError::HashIsSentinelValue)));
}
}
+26
View File
@@ -0,0 +1,26 @@
# Enums Crate
Database-backed enums stored as VARCHAR fields in MySQL.
## Adding a New Enum
1. Create file: `src/by_table/{table_name}/{field_name}.rs`
2. Define the enum with required derives:
```rust
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
pub enum MyEnum { ... }
```
3. Implement `to_str()` and `from_str()` manually
4. Add macros: `impl_enum_display_and_debug_using_to_str!(MyEnum);` and `impl_mysql_enum_coders!(MyEnum);`
5. Add to parent `mod.rs`
6. Write tests: serialization, round-trip, `all_variants()` count
## Rules
- **NEVER change existing variant string values** - they are stored in the database
- New variants CAN be added freely - but be very careful about the Tauri API callers
ingesting new values. Make sure to pair the client with variants with an "Unknown(String)"
catch-all / future-proof bucket.
- All string values must be snake_case
- Use `#[serde(rename = "snake_case_value")]` on each variant
+22
View File
@@ -0,0 +1,22 @@
# Tokens Crate
Strongly-typed primary key identifiers with Stripe-like prefixes (e.g. `user_abc123`, `prompt_xyz789`, etc.)
## Adding a New Token
1. Create file in `src/tokens/{entity_name}.rs`
2. Define as a newtype wrapper:
```rust
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
#[cfg_attr(feature = "database", derive(sqlx::Type))]
#[cfg_attr(feature = "database", sqlx(transparent))]
pub struct MyToken(pub String);
```
3. Apply macros:
```rust
impl_string_token!(MyToken);
impl_mysql_token_from_row!(MyToken);
impl_crockford_generator!(MyToken, 32usize, TokenPrefix::MyEntity, CrockfordLower);
```
4. Add the prefix to `TokenPrefix` enum in `src/token_prefix.rs`
5. Add to parent `mod.rs`
+42
View File
@@ -0,0 +1,42 @@
# Background Jobs
Background job crates that run as standalone processes alongside storyteller-web.
## Common Pattern
All jobs follow the same structure:
1. `main.rs` - Bootstrap, DB pool, bucket client, pager setup, thread spawning
2. `job_dependencies.rs` - Shared state struct passed to the main loop
3. `startup/build_pager.rs` - Pager initialization from env vars
4. `http_server/` - Health check endpoint for k8s liveness probes
5. `job/` or `jobs/` - Main processing logic
## Threading Model
- **Main thread** (`#[tokio::main]`): runs the job's main loop
- **HTTP server thread** (`std::thread::spawn`): dedicated OS thread with its own `actix_web::rt::System`
- **Pager worker thread** (`std::thread::spawn`): dedicated OS thread (uses blocking `Condvar::wait`)
- **Signal handler** (`tokio::spawn`): listens for SIGTERM/SIGINT to set `application_shutdown` flag
Important: Do NOT use `#[actix_web::main]` - it registers signal handlers that steal SIGINT. Use `#[tokio::main]` instead.
Important: Do NOT spawn the pager worker via `tokio::spawn` - its `Condvar::wait` blocks the runtime. Use a dedicated OS thread.
## Pager Integration
Use the `alert_pager_and_return_err()` helper pattern for error reporting:
```rust
if let Err(err) = some_operation().await {
error!("Descriptive message: {:?}", err);
return alert_pager_and_return_err(&deps.pager, "Alert title", err, optional_job_ref);
}
```
## Environment Variables
Shared across jobs (via `shared_env_var_config`):
- `ENABLE_PAGING` - Master switch for pager alerts
- `ROOTLY_API_KEY` - Rootly API key for sending alerts
- `ROOTLY_NOTIFICATION_TARGET_TYPE` / `ROOTLY_NOTIFICATION_TARGET_ID`
@@ -11,7 +11,9 @@ pub fn alert_pager_and_return_err<T>(
err: anyhow::Error,
job: Option<&PendingSeedance2ProJob>,
) -> anyhow::Result<T> {
let mut builder = NotificationDetailsBuilder::from_error(&err)
let err_message = format!("{:#}", err);
let mut builder = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title(title.to_string())
.set_urgency(Some(NotificationUrgency::Medium));
@@ -28,5 +30,5 @@ pub fn alert_pager_and_return_err<T>(
error!("Failed to enqueue pager alert: {:?}", pager_err);
}
Err(err)
Err(anyhow::anyhow!(err_message))
}
@@ -85,7 +85,7 @@ pub async fn process_failed_job(
err,
);
let notification = NotificationDetailsBuilder::from_error(&err)
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)
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Seedance2Pro mark job failed error".to_string())
.set_urgency(Some(NotificationUrgency::Medium))
.build();
@@ -9,7 +9,9 @@ pub fn alert_pager_and_return_err<T>(
title: &str,
err: anyhow::Error,
) -> anyhow::Result<T> {
let notification = NotificationDetailsBuilder::from_error(&err)
let err_message = format!("{:#}", err);
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title(title.to_string())
.set_urgency(Some(NotificationUrgency::Medium))
.build();
@@ -18,5 +20,5 @@ pub fn alert_pager_and_return_err<T>(
error!("Failed to enqueue pager alert: {:?}", pager_err);
}
Err(err)
Err(anyhow::anyhow!(err_message))
}
@@ -0,0 +1,44 @@
# storyteller-web
The main HTTP API monolith. This is the backend for the ArtCraft application.
## Building
```
cargo check -p storyteller-web
```
There are ~370 pre-existing warnings. Check the last 8-10 lines of output for actual errors.
## Structure
- `src/http_server/endpoints/` - HTTP handlers organized by feature
- `src/http_server/middleware/` - Actix middleware (error alerting, etc.)
- `src/http_server/common_responses/` - Shared error types (`CommonWebError`)
- `src/http_server/web_utils/` - Session checking, auth helpers
- `src/state/` - Server state, feature flags
- `src/startup/` - Initialization (pager, etc.)
- `src/threads/` - Background threads (health checker, etc.)
- `src/docs/` - OpenAPI/Swagger documentation (`api_doc.rs`)
## Handler Pattern
Handlers return `Result<Json<Response>, ErrorType>`. The `Result<HttpResponse, ErrorType>` pattern is
deprecated or only used when setting other HTTP headers, like cookies.
Prefer `CommonWebError` for new handlers unless you need custom error variants.
When a handler uses `require_user_session`, prefer `require_user_session_using_connection`
which reuses an existing DB connection.
## api_doc.rs
When adding new request/response/error types, add them to `src/docs/api_doc.rs` in the
schemas section (alphabetically sorted). Types from `artcraft_api_defs` need explicit imports.
## Pager Integration
- The `Pager` is available on `ServerState` as `server_state.pager`
- Health check handler sends alerts when unhealthy
- Error alerting middleware sends alerts on HTTP 500s (when enabled)
- Use `NotificationDetailsBuilder` for constructing alerts
@@ -307,7 +307,6 @@ use artcraft_api_defs::moderation::wallets::moderator_create_wallet_for_user::*;
use artcraft_api_defs::moderation::wallets::moderator_get_wallet::*;
use artcraft_api_defs::web_referrals::log_web_referral::*;
use crate::http_server::endpoints::web_referrals::log_web_referral_handler::*;
use crate::http_server::endpoints::webhooks::fal_webhook_handler::*;
use crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::*;
use crate::http_server::endpoints::voice_conversion::enqueue_seed_vc_inference_handler::*;
use crate::http_server::endpoints::credits::get_session_credits_handler::*;
@@ -515,8 +514,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::subscriptions::get_session_subscription_handler::get_session_subscription_handler,
// Web Referrals
crate::http_server::endpoints::web_referrals::log_web_referral_handler::log_web_referral_handler,
// Webhooks
crate::http_server::endpoints::webhooks::fal_webhook_handler::fal_webhook_handler,
// Image Studio
crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::update_gpt_image_job_status_handler,
),
@@ -0,0 +1,332 @@
use std::fmt::{Display, Formatter};
use std::sync::Arc;
use actix_http::StatusCode;
use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError};
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.
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))
}
/// 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),
}
}
}
// =============== 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,
}
// =============== 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;
@@ -14,8 +14,6 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
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::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
@@ -32,7 +30,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use sqlx::Acquire;
use utoipa::ToSchema;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
@@ -89,28 +86,6 @@ pub async fn gemini_25_flash_edit_image_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
const CAN_SEE_DELETED: bool = false;
let tokens = match request.image_media_tokens.as_ref() {
@@ -177,9 +152,6 @@ pub async fn gemini_25_flash_edit_image_handler(
None => Gemini25FlashEditNumImages::One, // Default to One
};
if downgrade_for_free_user {
num_images = Gemini25FlashEditNumImages::One;
}
let args = Gemini25FlashEditArgs {
image_urls,
@@ -15,8 +15,6 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
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::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
@@ -34,7 +32,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use sqlx::Acquire;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::ToSchema;
@@ -88,29 +85,6 @@ pub async fn gpt_image_1_edit_image_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
&user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
&user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
const CAN_SEE_DELETED: bool = false;
let tokens = match request.image_media_tokens.as_ref() {
@@ -196,9 +170,6 @@ pub async fn gpt_image_1_edit_image_handler(
let openai_api_key = OpenAiApiKey::from_str(&server_state.openai.api_key);
if downgrade_for_free_user {
num_images = GptEditImageNumImages::One;
}
let args = GptEditImageByokArgs {
image_urls,
@@ -18,8 +18,6 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
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::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
@@ -37,7 +35,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use server_environment::ServerEnvironment;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
@@ -93,29 +90,6 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
let mut query_media_tokens = None;
if let Some(media_tokens) = request.image_media_tokens.as_ref() {
@@ -216,10 +190,6 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
None => EnqueueGptImage1p5EditImageInputFidelity::High,
};
if downgrade_for_free_user {
num_images = EnqueueGptImage1p5EditImageNumImages::One;
}
let args = EnqueueGptImage1p5EditImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
image_urls: input_image_urls.to_owned(),
@@ -285,10 +255,6 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
None => EnqueueGptImage1p5TextToImageQuality::High,
};
if downgrade_for_free_user {
num_images = EnqueueGptImage1p5TextToImageNumImages::One;
}
let args = EnqueueGptImage1p5TextToImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
num_images,
@@ -19,8 +19,6 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
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::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
@@ -40,7 +38,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use server_environment::ServerEnvironment;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
@@ -96,29 +93,6 @@ pub async fn nano_banana_multi_function_image_gen_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
let image_urls = match request.image_media_tokens.as_ref() {
Some(media_tokens) => {
info!("Looking up image media tokens: {:?}", media_tokens);
@@ -173,10 +147,6 @@ pub async fn nano_banana_multi_function_image_gen_handler(
None => Gemini25FlashEditAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = Gemini25FlashEditNumImages::One;
}
let args = Gemini25FlashEditArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
image_urls: input_image_urls.to_owned(),
@@ -231,10 +201,6 @@ pub async fn nano_banana_multi_function_image_gen_handler(
None => Gemini25FlashTextToImageAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = Gemini25FlashTextToImageNumImages::One;
}
let args = Gemini25FlashTextToImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
num_images,
@@ -18,8 +18,6 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
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::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
@@ -38,7 +36,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use server_environment::ServerEnvironment;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
@@ -94,29 +91,6 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
let image_urls = match request.image_media_tokens.as_ref() {
Some(media_tokens) => {
info!("Looking up image media tokens: {:?}", media_tokens);
@@ -178,11 +152,6 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
None => EnqueueNanoBananaProEditImageAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = EnqueueNanoBananaProEditImageNumImages::One;
resolution = EnqueueNanoBananaProEditImageResolution::OneK;
}
let args = EnqueueNanoBananaProEditImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
image_urls: input_image_urls.to_owned(),
@@ -245,11 +214,6 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
None => EnqueueNanoBananaProTextToImageAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = EnqueueNanoBananaProTextToImageNumImages::One;
resolution = EnqueueNanoBananaProTextToImageResolution::OneK;
}
let args = EnqueueNanoBananaProTextToImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
num_images,
@@ -14,8 +14,6 @@ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path:
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::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
@@ -32,7 +30,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use sqlx::Acquire;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::ToSchema;
@@ -85,29 +82,6 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
&user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
&user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
const CAN_SEE_DELETED: bool = false;
let media_domain = get_media_domain(&http_request);
@@ -146,10 +120,6 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
None => GptTextToImageQuality::High, // Default to High
};
if downgrade_for_free_user {
num_images = GptTextToImageNumImages::One;
}
let openai_api_key = OpenAiApiKey::from_str(&server_state.openai.api_key);
let args = GptTextToImageByokArgs {
@@ -222,7 +222,7 @@ 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)
NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build()
@@ -256,7 +256,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
Err(err) => {
warn!("Error calling seedance2pro generate_video: {:?}", err);
if let Err(page_err) = server_state.pager.enqueue_page(
NotificationDetailsBuilder::from_error(&err)
NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Seedance 2.0 generation failed".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build()
@@ -2,43 +2,24 @@ use std::fmt;
use std::sync::Arc;
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_sucessful_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 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 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 serde_json::Value;
use utoipa::ToSchema;
// TODO(bt, 2025-06-03): Handle webhook crypto authentication
#[derive(Debug, Deserialize, ToSchema)]
pub struct FalWebhookRequest {
pub status: FalWebhookStatus,
pub request_id: Option<String>,
pub gateway_request_id: Option<String>,
pub error: Option<String>,
/// Payload of the webhook, if any.
pub payload: Option<Value>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub enum FalWebhookStatus {
#[serde(alias = "OK")]
Ok,
#[serde(alias = "ERROR")]
Error
}
// =============== Error Response ===============
#[derive(Debug, Serialize, ToSchema)]
@@ -64,6 +45,8 @@ impl ResponseError for FalWebhookError {
}
}
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 {
@@ -80,77 +63,112 @@ impl From<anyhow::Error> for FalWebhookError {
// =============== Handler ===============
/// Fal webhook
#[utoipa::path(
post,
tag = "Webhooks",
path = "/v1/webhooks/fal",
responses(
(status = 200, description = "Success", body = SimpleGenericJsonSuccess),
(status = 400, description = "Bad input", body = FalWebhookError),
(status = 401, description = "Not authorized", body = FalWebhookError),
(status = 500, description = "Server error", body = FalWebhookError),
),
params(
("request" = FalWebhookRequest, description = "Payload for Request"),
)
)]
// TODO(bt, 2025-06-03): Handle webhook crypto authentication
pub async fn fal_webhook_handler(
http_request: HttpRequest,
request_body_bytes: Bytes,
server_state: web::Data<Arc<ServerState>>,
request: Json<FalWebhookRequest>,
) -> Result<Json<SimpleGenericJsonSuccess>, FalWebhookError> {
info!("Received FAL webhook body: {:?}", request);
let request_id = request.request_id
.as_deref()
.ok_or_else(|| FalWebhookError::BadInput("Missing request_id".to_string()))?;
info!("FAL webhook request_id: {} (status: {:?})", request_id, request.status);
// TODO(bt): Longer term, we should just use `fal_client` to parse webhooks and add lots of integration tests
// across dozens of real messages.
let payload = request.payload
.as_ref()
.ok_or_else(|| {
warn!("FAL webhook missing payload for request_id: {}", request_id);
FalWebhookError::BadInput("Missing payload".to_string())
// 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())
})?;
let result = match request.status {
FalWebhookStatus::Ok => {
handle_sucessful_fal_webhook(&server_state, request_id, payload).await
info!("Received FAL webhook body: {}", raw_body);
// Step 2: Parse into WebhookPayload.
let webhook_payload = parse_webhook_payload(&raw_body)
.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())
})?;
let request_id = webhook_payload.request_id.as_str();
info!("FAL webhook request_id: {} (status: {:?})", request_id, webhook_payload.status);
// Step 3: Parse the inner payload.
let inner_payload = parse_webhook_inner_payload(&webhook_payload);
// Step 4 & 5: Branch on the inner payload type.
let result = match inner_payload {
WebhookInnerPayload::Success(success_data) => {
handle_successful_fal_webhook(&server_state, request_id, &success_data.payload).await
}
FalWebhookStatus::Error => {
WebhookInnerPayload::Error(error_data) => {
handle_failed_fal_webhook(
&server_state,
request_id,
payload,
request.error.as_deref(),
&error_data,
webhook_payload.error.as_deref(),
).await
}
WebhookInnerPayload::PayloadError(payload_error_data) => {
warn!(
"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)
}
};
if let Err(ref err) = result {
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
match result {
Ok(success) => Ok(success),
Err(err) => {
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
let notification = NotificationDetailsBuilder::from_error(err)
.set_title("FAL webhook processing failed".to_string())
.set_description(Some(format!(
"FAL webhook failed for request_id: {}\n\nError: {:?}",
request_id, err,
)))
.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();
let description = format!(
"FAL webhook failed for request_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
request_id, err, raw_body,
);
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
error!("Failed to enqueue FAL webhook pager alert: {:?}", pager_err);
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("FAL webhook processing failed".to_string())
.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();
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
error!("Failed to enqueue FAL webhook pager alert: {:?}", pager_err);
}
Err(FalWebhookError::ServerError)
}
}
}
result
/// 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,
http_request: &HttpRequest,
context: &str,
err: &E,
maybe_raw_body: Option<&str>,
) {
let description = match maybe_raw_body {
Some(body) => format!("Error: {:?}\n\nWebhook JSON Payload: {}", err, body),
None => format!("Error: {:?}", err),
};
let notification = NotificationDetailsBuilder::from_title(
format!("FAL webhook parse failure: {}", context))
.set_description(Some(description))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some(http_request.method().to_string()))
.set_http_path(Some(http_request.path().to_string()))
.build();
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
error!("Failed to enqueue FAL webhook parse error alert: {:?}", pager_err);
}
}
@@ -1,142 +0,0 @@
use serde_json::Value;
/// A single error detail item from a FAL webhook error payload.
///
/// See: https://fal.ai/docs/documentation/model-apis/errors
#[derive(Debug, Clone)]
pub struct FalErrorDetail {
/// Human-readable error message. Should not be parsed for logic.
pub msg: Option<String>,
/// Machine-readable error type (e.g. "image_too_small", "image_too_large").
pub error_type: Option<String>,
}
/// Attempt to parse the "detail" array from a FAL webhook payload.
///
/// The payload may contain a "detail" key with an array of error objects,
/// each having optional "msg" and "type" fields.
///
/// Returns an empty vec if the "detail" key is missing or not an array.
pub fn parse_fal_error_details(payload: &Value) -> Vec<FalErrorDetail> {
let detail_array = match payload.get("detail").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return Vec::new(),
};
detail_array
.iter()
.map(|item| FalErrorDetail {
msg: item.get("msg").and_then(|v| v.as_str()).map(|s| s.to_string()),
error_type: item.get("type").and_then(|v| v.as_str()).map(|s| s.to_string()),
})
.collect()
}
/// Build a human-readable summary of the error details for logging and alerting.
pub fn summarize_fal_error_details(details: &[FalErrorDetail]) -> String {
if details.is_empty() {
return "No error details provided.".to_string();
}
details
.iter()
.enumerate()
.map(|(i, detail)| {
let error_type = detail.error_type.as_deref().unwrap_or("unknown");
let msg = detail.msg.as_deref().unwrap_or("no message");
format!("[{}] type={}, msg={}", i + 1, error_type, msg)
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_typical_error_detail() {
let payload = json!({
"detail": [
{
"loc": ["body", "image_url"],
"msg": "Image too small",
"type": "image_too_small",
"url": "https://docs.fal.ai/errors/#image_too_small",
"ctx": { "min_height": 512, "min_width": 512 }
}
]
});
let details = parse_fal_error_details(&payload);
assert_eq!(details.len(), 1);
assert_eq!(details[0].msg.as_deref(), Some("Image too small"));
assert_eq!(details[0].error_type.as_deref(), Some("image_too_small"));
}
#[test]
fn parse_multiple_error_details() {
let payload = json!({
"detail": [
{ "msg": "First error", "type": "err_one" },
{ "msg": "Second error", "type": "err_two" }
]
});
let details = parse_fal_error_details(&payload);
assert_eq!(details.len(), 2);
assert_eq!(details[0].error_type.as_deref(), Some("err_one"));
assert_eq!(details[1].error_type.as_deref(), Some("err_two"));
}
#[test]
fn parse_missing_detail_field() {
let payload = json!({ "images": [] });
let details = parse_fal_error_details(&payload);
assert!(details.is_empty());
}
#[test]
fn parse_detail_not_array() {
let payload = json!({ "detail": "some string" });
let details = parse_fal_error_details(&payload);
assert!(details.is_empty());
}
#[test]
fn parse_detail_with_missing_fields() {
let payload = json!({
"detail": [
{ "msg": "Only message" },
{ "type": "only_type" },
{}
]
});
let details = parse_fal_error_details(&payload);
assert_eq!(details.len(), 3);
assert_eq!(details[0].msg.as_deref(), Some("Only message"));
assert_eq!(details[0].error_type, None);
assert_eq!(details[1].msg, None);
assert_eq!(details[1].error_type.as_deref(), Some("only_type"));
assert_eq!(details[2].msg, None);
assert_eq!(details[2].error_type, None);
}
#[test]
fn summarize_empty() {
assert_eq!(summarize_fal_error_details(&[]), "No error details provided.");
}
#[test]
fn summarize_single() {
let details = vec![FalErrorDetail {
msg: Some("Image too small".to_string()),
error_type: Some("image_too_small".to_string()),
}];
let summary = summarize_fal_error_details(&details);
assert_eq!(summary, "[1] type=image_too_small, msg=Image too small");
}
}
@@ -3,55 +3,47 @@ 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 serde_json::Value;
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::http_server::endpoints::webhooks::process_failure::fal_error_detail::{FalErrorDetail, parse_fal_error_details, summarize_fal_error_details};
use crate::state::server_state::ServerState;
/// Map FAL error type strings to frontend failure categories.
/// Map a FAL WebhookErrorType to a frontend failure category.
///
/// Returns `GenerationFailed` as the default if no error details match a known type.
fn guess_failure_category(details: &[FalErrorDetail]) -> FrontendFailureCategory {
for detail in details {
if let Some(error_type) = &detail.error_type {
match error_type.as_str() {
"content_policy_violation" => return FrontendFailureCategory::RuleBansUserContent,
"face_detection_error" => return FrontendFailureCategory::FaceNotDetected,
// These all map to GenerationFailed for now, but are listed explicitly
// so we can refine them later.
"no_media_generated"
| "image_too_small"
| "image_too_large"
| "image_load_error"
| "file_download_error"
| "file_too_large" => return FrontendFailureCategory::GenerationFailed,
_ => {}
}
}
/// 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,
}
FrontendFailureCategory::GenerationFailed
}
/// Handle a FAL webhook with status ERROR.
///
/// Parses the error details from the payload, looks up the job, and marks it as failed.
/// Looks up the job by request_id and marks it as failed using the parsed error data.
pub async fn handle_failed_fal_webhook(
server_state: &ServerState,
request_id: &str,
payload: &Value,
error_data: &ErrorData,
maybe_top_level_error: Option<&str>,
) -> Result<Json<SimpleGenericJsonSuccess>, FalWebhookError> {
// Parse the "detail" array from the payload.
let error_details = parse_fal_error_details(payload);
let error_summary = summarize_fal_error_details(&error_details);
info!(
"FAL webhook ERROR for request_id {}: top_level_error={:?}, details=[{}]",
"FAL webhook ERROR for request_id {}: top_level_error={:?}, error_type={:?}, message={:?}",
request_id,
maybe_top_level_error,
error_summary,
error_data.error_type,
error_data.message,
);
// Look up the job record.
@@ -67,12 +59,9 @@ pub async fn handle_failed_fal_webhook(
}
};
// Build a failure reason from the error details or the top-level error string.
let public_failure_reason = if !error_details.is_empty() {
error_details.iter()
.filter_map(|d| d.msg.as_deref())
.collect::<Vec<_>>()
.join("; ")
// Build a failure reason from the error data or the top-level error string.
let public_failure_reason = if let Some(msg) = &error_data.message {
msg.clone()
} else if let Some(top_level) = maybe_top_level_error {
top_level.to_string()
} else {
@@ -80,13 +69,14 @@ pub async fn handle_failed_fal_webhook(
};
let internal_failure_reason = format!(
"FAL request_id={}, top_level_error={:?}, details=[{}]",
"FAL request_id={}, top_level_error={:?}, error_type={:?}, message={:?}",
request_id,
maybe_top_level_error,
error_summary,
error_data.error_type,
error_data.message,
);
let failure_category = guess_failure_category(&error_details);
let failure_category = guess_failure_category(error_data.error_type.as_ref());
info!(
"Marking job {} as failed for request_id {}. Category: {:?}, Reason: {}",
@@ -1,2 +1 @@
pub mod fal_error_detail;
pub mod handle_failed_fal_webhook;
@@ -14,7 +14,7 @@ use super::process_model_glb_payload::process_model_glb_payload;
use super::process_model_mesh_payload::process_model_mesh_payload;
use super::process_video_payload::process_video_payload;
pub async fn handle_sucessful_fal_webhook(
pub async fn handle_successful_fal_webhook(
server_state: &ServerState,
request_id: &str,
payload: &Value,
@@ -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
}
@@ -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,3 +1,4 @@
mod check_advanced_common_web_error;
mod check_common_web_error;
mod check_status_code_fallback;
@@ -35,7 +35,7 @@ pub async fn db_health_checker_thread(
Ok(_) => {},
}
let notification = NotificationDetailsBuilder::from_error(&database_error)
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();
@@ -6,6 +6,7 @@ import {
faGithubAlt,
faLinkedinIn,
faInstagram,
faRedditAlien,
} from "@fortawesome/free-brands-svg-icons";
import { SOCIAL_LINKS } from "../../config/links";
import { getFaqItems, getTutorialItems } from "@storyteller/markdown-content";
@@ -51,6 +52,11 @@ const navigation = {
href: SOCIAL_LINKS.LINKEDIN,
icon: (props: any) => <FontAwesomeIcon icon={faLinkedinIn} {...props} />,
},
{
name: "Reddit",
href: SOCIAL_LINKS.REDDIT,
icon: (props: any) => <FontAwesomeIcon icon={faRedditAlien} {...props} />,
},
],
};
@@ -5,4 +5,5 @@ export const SOCIAL_LINKS = {
GITHUB: "https://github.com/storytold/artcraft",
INSTAGRAM: "https://www.instagram.com/get_artcraft",
LINKEDIN: "https://www.linkedin.com/company/artcraft-ai",
REDDIT: "https://www.reddit.com/r/ArtCraftAI/",
} as const;
@@ -96,6 +96,7 @@ const Pricing = () => {
<PricingTable
title="Invest in Yourself"
subtitle="You'll get a ton of generations and you'll be investing in a tool that you'll always own."
showSeedanceFeatures
showEnterprise
/>
</main>
@@ -107,8 +108,6 @@ const Pricing = () => {
&dagger; ArtCraft can be used without paying for a subscription. You
can bring your own compute and third party subscriptions. We hope
you'll subscribe, though, as that helps accelerate our development.
Pricing table is a placeholder. We charge below FAL API pricing, which
is highly competitive.
</p>
</div>
@@ -0,0 +1,171 @@
import { useEffect, useRef, useState } from "react";
import { faCube, faImages, faUpRightAndDownLeftFromCenter } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UploadModal3D } from "../reusable/UploadModal3D/UploadModal3D";
import { UploadModalImage } from "../reusable/UploadModalImage/UploadModalImage";
import { UploadModalSplat } from "../reusable/UploadModalSplat/UploadModalSplat";
import { FilterEngineCategories } from "../../enums";
type ModalType = "3d" | "image" | "splat" | null;
function getModalTypeForFileName(name: string): ModalType {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
if (ext === "glb") return "3d";
if (ext === "png" || ext === "jpg" || ext === "jpeg") return "image";
if (ext === "spz") return "splat";
return null;
}
function isAnyModalOpen(): boolean {
return !!document.querySelector("[data-radix-dialog-content]");
}
const isTauri = typeof window !== "undefined" && "__TAURI__" in window;
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 closeModal = () => {
setModalType(null);
setPendingFile(null);
};
useEffect(() => {
if (isTauri) {
const unlisteners: Array<() => void> = [];
const setup = async () => {
try {
const { getCurrentWebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const { convertFileSrc } = await import("@tauri-apps/api/core");
const appWindow = getCurrentWebviewWindow();
const unlisten = await appWindow.onDragDropEvent(async (event) => {
const payload = event.payload;
if (payload.type === "enter") {
if (!isAnyModalOpen()) setIsDragging(true);
} else if (payload.type === "over") {
// overlay stays visible
} else if (payload.type === "drop") {
setIsDragging(false);
if (isAnyModalOpen()) return;
const path = payload.paths[0];
if (!path) return;
const fileName = path.split(/[/\\]/).pop() ?? "file";
const type = getModalTypeForFileName(fileName);
if (type === null) return;
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);
await appWindow.setFocus();
setModalType(type);
setPendingFile(file);
} catch (err) {
console.error("[DragDrop] file read failed:", err);
}
} else {
setIsDragging(false);
}
});
unlisteners.push(unlisten);
} catch (err) {
console.error("[DragDrop] setup failed:", err);
}
};
setup();
return () => { unlisteners.forEach((fn) => fn()); };
} else {
// HTML5 path (browser dev mode)
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
if (!e.dataTransfer?.types.includes("Files")) return;
if (isAnyModalOpen()) return;
dragCounter.current++;
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent) => {
if (!e.dataTransfer?.types.includes("Files")) return;
dragCounter.current--;
if (dragCounter.current <= 0) { dragCounter.current = 0; setIsDragging(false); }
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
};
const handleDrop = (e: DragEvent) => {
if (!e.dataTransfer?.types.includes("Files")) return;
e.preventDefault();
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);
};
window.addEventListener("dragenter", handleDragEnter);
window.addEventListener("dragleave", handleDragLeave);
window.addEventListener("dragover", handleDragOver);
window.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("dragenter", handleDragEnter);
window.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("dragover", handleDragOver);
window.removeEventListener("drop", handleDrop);
};
}
}, []);
return (
<>
{isDragging && modalType === null && (
<div className="pointer-events-none fixed inset-0 z-[9999] flex items-center justify-center bg-black/40">
<div className="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-white/60 bg-black/30 px-16 py-12 text-white backdrop-blur-sm">
<FontAwesomeIcon icon={faUpRightAndDownLeftFromCenter} className="text-4xl opacity-80" />
<div className="text-xl font-semibold">Drop to Upload</div>
<div className="text-sm opacity-60">GLB, PNG, JPG, JPEG, SPZ</div>
</div>
</div>
)}
<UploadModal3D
isOpen={modalType === "3d"}
initialFile={pendingFile ?? undefined}
onClose={closeModal}
onSuccess={(_category: FilterEngineCategories) => closeModal()}
title="Upload a 3D Model"
titleIcon={faCube}
/>
<UploadModalImage
isOpen={modalType === "image"}
initialFile={pendingFile ?? undefined}
onClose={closeModal}
onSuccess={() => closeModal()}
title="Upload an Image"
titleIcon={faImages}
/>
<UploadModalSplat
isOpen={modalType === "splat"}
initialFile={pendingFile ?? undefined}
onClose={closeModal}
onSuccess={() => {}}
title="Upload a Splat"
titleIcon={faCube}
/>
</>
);
}
@@ -19,6 +19,9 @@ interface UploadEntryCardProps {
secondaryLabel?: string;
secondaryIcon?: IconDefinition;
onSecondaryClick?: () => void;
tertiaryLabel?: string;
tertiaryIcon?: IconDefinition;
onTertiaryClick?: () => void;
disabled?: boolean;
}
@@ -36,6 +39,9 @@ export const UploadEntryCard = ({
secondaryLabel,
secondaryIcon,
onSecondaryClick,
tertiaryLabel,
tertiaryIcon,
onTertiaryClick,
disabled,
}: UploadEntryCardProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -162,6 +168,17 @@ export const UploadEntryCard = ({
{secondaryLabel}
</Button>
)}
{tertiaryLabel && onTertiaryClick && (
<Button
variant="action"
icon={tertiaryIcon}
onClick={onTertiaryClick}
className="border-2 px-8 py-3 text-base font-semibold"
disabled={disabled}
>
{tertiaryLabel}
</Button>
)}
</div>
</div>
</div>
@@ -13,6 +13,7 @@ interface Props {
title: string;
fileTypes: string[];
engineCategory: FilterEngineCategories;
initialFile?: File;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -25,6 +26,7 @@ interface Props {
export const UploadFiles3D = ({
fileTypes,
engineCategory,
initialFile,
options,
onClose,
onUploadProgress,
@@ -49,7 +51,7 @@ export const UploadFiles3D = ({
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
}>({ value: initialFile ?? null });
const [previewStatus, setPreviewStatus] = useState<{
type: string;
@@ -143,7 +145,7 @@ export const UploadFiles3D = ({
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
<canvas
className="pointer-events-none h-full w-full"
className="pointer-events-none h-full !w-full"
ref={canvasCallbackRef}
/>
{!assetFile.value && (
@@ -22,6 +22,7 @@ interface Props {
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
initialFile?: File;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -32,7 +33,7 @@ interface Props {
const objectFileTypes = Object.values(OBJECT_FILE_TYPE);
export function UploadModal3D(props: Props) {
const { isOpen, onClose, onSuccess, title, titleIcon, options } = props;
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile, options } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
const [isCharacter, setIsCharacter] = useState(false);
@@ -101,6 +102,7 @@ export function UploadModal3D(props: Props) {
title={title}
engineCategory={selectedCategory}
fileTypes={objectFileTypes}
initialFile={initialFile}
options={{
...(options ?? {}),
fileSubtypes: characterAnimationOptions,
@@ -7,19 +7,21 @@ import { UploaderState } from "../../../models";
interface Props {
title: string;
fileTypes: string[];
initialFile?: File;
onClose: () => void;
onUploadProgress: (newState: UploaderState) => void;
}
export const UploadFilesImage = ({
fileTypes,
initialFile,
onClose,
onUploadProgress,
}: Props) => {
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
}>({ value: initialFile ?? null });
const handleSubmit = () => {
if (!assetFile.value) {
@@ -21,12 +21,13 @@ interface Props {
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
initialFile?: File;
}
const imageFileTypes = Object.values(IMAGEPLANE_FILE_TYPE);
export function UploadModalImage(props: Props) {
const { isOpen, onClose, onSuccess, title, titleIcon } = props;
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
@@ -67,6 +68,7 @@ export function UploadModalImage(props: Props) {
<UploadFilesImage
title={title}
fileTypes={imageFileTypes}
initialFile={initialFile}
onClose={onClose}
onUploadProgress={updateUploaderState}
/>
@@ -14,6 +14,7 @@ interface Props {
title: string;
fileTypes: string[];
engineCategory: FilterEngineCategories;
initialFile?: File;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -27,6 +28,7 @@ interface Props {
export const UploadFilesSplat = ({
fileTypes,
engineCategory,
initialFile,
options,
onClose,
onUploadProgress,
@@ -52,7 +54,7 @@ export const UploadFilesSplat = ({
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
}>({ value: initialFile ?? null });
const [previewStatus, setPreviewStatus] = useState<{
type: string;
@@ -166,7 +168,7 @@ export const UploadFilesSplat = ({
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
<canvas
className="pointer-events-none h-full w-full"
className="pointer-events-none h-full !w-full"
ref={canvasCallbackRef}
/>
{!assetFile.value && (
@@ -22,6 +22,7 @@ interface Props {
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
initialFile?: File;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -33,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, options } = props;
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile, options } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
@@ -72,6 +73,7 @@ export function UploadModalSplat(props: Props) {
title={title}
engineCategory={selectedCategory}
fileTypes={splatFileTypes}
initialFile={initialFile}
options={{
...(options ?? {}),
}}
@@ -11,6 +11,8 @@ import {
faBomb,
faCircleExclamation,
faTriangleExclamation,
faCopy,
faCheck,
} from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import {
@@ -222,7 +224,7 @@ const InProgressCard = ({
</div>
{onDismiss && (
<button
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
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();
@@ -302,7 +304,7 @@ const CompletedCard = ({
</div>
{onDismiss && (
<button
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
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();
@@ -344,6 +346,14 @@ 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;
@@ -454,19 +464,73 @@ const FailedCard = ({
</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>
)}
</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>
)}
{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 h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
</div>
</div>
);
@@ -59,7 +59,7 @@ import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { APP_DESCRIPTORS } from "~/config/appMenu";
import { useSceneStore } from "~/pages/PageDraw/stores/SceneState";
import { useSceneStore } from "@storyteller/ui-pagedraw";
import {
is3DEditorInitialized,
is3DSceneLoaded,
@@ -71,7 +71,7 @@ import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBac
import { TabId, useTabStore } from "~/pages/Stores/TabState";
import { authentication } from "~/signals";
import { setLogoutStates } from "~/signals/authentication/utilities";
import { BaseSelectorImage } from "../../../pages/PageEdit/BaseImageSelector";
import type { BaseSelectorImage } from "@storyteller/ui-pagedraw";
import { AppsQuickMenu } from "./AppsQuickMenu";
import { SceneTitleInput } from "./SceneTitleInput";
import { TaskQueue } from "./TaskQueue";
+2
View File
@@ -2,6 +2,7 @@ import { StrictMode, useEffect } from "react";
import { useSignals, useSignalEffect } from "@preact/signals-react/runtime";
import { BrowserRouter } from "react-router-dom";
import { PageEnigma } from "./pages/PageEnigma/PageEnigma";
import { GlobalFileDropHandler } from "./components/GlobalFileDropHandler/GlobalFileDropHandler";
import { createRoot } from "react-dom/client";
import "./styles/normalize.css";
import "./styles/tailwind.css";
@@ -94,6 +95,7 @@ createRoot(document.getElementById("root")!).render(
<GlobalSettingsManager env={ENV} />
<div className="topbar-spacer" data-tauri-drag-region={true} />
<PageEnigma />
<GlobalFileDropHandler />
</BrowserRouter>
</StrictMode>
</>,
@@ -43,8 +43,7 @@ import {
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { HistoryStack, ImageBundle } from "../PageEdit/HistoryStack";
import type { BaseSelectorImage } from "../PageEdit/BaseImageSelector";
import { HistoryStack, type ImageBundle, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
const ANGLES_MODEL_PAGE = ModelPage.Angles;
@@ -562,12 +561,6 @@ export const Angles = () => {
selectedImageToken={selectedImageToken}
onImageSelect={handleHistoryImageSelect}
onImageRemove={handleHistoryImageRemove}
onNewImageBundle={() => {
/* handled by our own event listener */
}}
onResolvePending={() => {
/* handled by our own event listener */
}}
/>
</div>
@@ -1,157 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* @import "tw-animate-css"; */
@custom-variant dark (&:is(.dark *));
.pegboard-bg {
inset: 0;
z-index: 0;
background-color: #1b1b1b;
/* background-image: radial-gradient(#bbb 1px, transparent 1px),
radial-gradient(#bbb 1px, transparent 1px); */
background-size: 50px 50px;
background-position:
0 0,
15px 15px;
}
/*
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
} */
/* @layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
} */
.brush-picker .react-colorful {
border: 1px solid rgb(255, 255, 255);
margin-bottom: 16px;
}
.brush-picker .react-colorful__saturation {
border: 1px solid rgb(255, 255, 255);
border-radius: 15px 15px 15px 15px; /* top-rounded square */
margin-bottom: 12px;
}
.brush-picker .react-colorful__hue {
border: 1px solid rgba(255, 255, 255, 0.2); /* bar thickness */
border-radius: 30px 30px 30px 30px; /* rounded bottom only */
margin-bottom: 12px;
}
.brush-picker .react-colorful__alpha {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 15px 15px 15px 15px;
margin-bottom: 12px;
}
@@ -1,4 +1,4 @@
import { faImages, faPencil } from "@fortawesome/pro-solid-svg-icons";
import { faExpand, faImages, faPencil } from "@fortawesome/pro-solid-svg-icons";
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { useState } from "react";
@@ -10,35 +10,23 @@ import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { ModelPage } from "@storyteller/ui-model-selector";
import { UploadEntryCard } from "~/components/media/UploadEntryCard";
export interface BaseSelectorImage {
url: string;
mediaToken: string;
// Link to the thumbnail *template* URL of the image
// This is not a URL itself! It must be converted into a URL by replacing parameters.
thumbnailUrlTemplate?: string;
// Link to the full image URL of the full image asset
fullImageUrl?: string;
}
export type BaseImageSelectorProps = {
onImageSelect: (imageUrl: BaseSelectorImage) => void;
showLoading?: boolean;
};
import { BlankCanvasModal, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
const MAX_GALLERY_SELECTIONS = 1;
type BaseImageSelectorProps = {
onImageSelect: (image: BaseSelectorImage) => void;
showLoading?: boolean;
};
export const BaseImageSelector = ({
onImageSelect,
showLoading = false,
}: BaseImageSelectorProps) => {
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[],
);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isBlankCanvasModalOpen, setIsBlankCanvasModalOpen] = useState(false);
const handleGalleryClick = () => setIsGalleryModalOpen(true);
@@ -49,11 +37,9 @@ export const BaseImageSelector = ({
const handleImageSelect = (mediaToken: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(mediaToken))
return prev.filter((x) => x !== mediaToken);
const maxSelections = 1;
if (prev.length >= maxSelections) {
return maxSelections === 1 ? [mediaToken] : prev;
if (prev.includes(mediaToken)) return prev.filter((x) => x !== mediaToken);
if (prev.length >= MAX_GALLERY_SELECTIONS) {
return MAX_GALLERY_SELECTIONS === 1 ? [mediaToken] : prev;
}
return [...prev, mediaToken];
});
@@ -65,13 +51,25 @@ export const BaseImageSelector = ({
toast.error("No image selected");
return;
}
const referenceImage: BaseSelectorImage = {
url: item.fullImage,
mediaToken: item.id,
thumbnailUrlTemplate: item.thumbnailUrlTemplate,
};
sendImageEvent(referenceImage);
handleGalleryClose();
onImageSelect(referenceImage);
};
const handleBlankCanvasConfirm = (width: number, height: number) => {
setIsBlankCanvasModalOpen(false);
const blankImage: BaseSelectorImage = {
url: "",
mediaToken: `blank_canvas_${width}x${height}_${Math.random().toString(36).substring(2, 8)}`,
isBlankCanvas: true,
blankCanvasWidth: width,
blankCanvasHeight: height,
};
onImageSelect(blankImage);
};
const handleFileUpload = (files: FileList) => {
@@ -81,12 +79,9 @@ export const BaseImageSelector = ({
const reader = new FileReader();
reader.onloadend = () => {
uploadImage({
title: `reference-image-${Math.random()
.toString(36)
.substring(2, 15)}`,
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
progressCallback: (newState) => {
console.debug("Upload progress:", newState.data);
if (newState.status === UploaderStates.success && newState.data) {
const mediaToken = newState.data || "";
(async () => {
@@ -94,40 +89,29 @@ export const BaseImageSelector = ({
let thumbnailUrlTemplate = undefined;
try {
const api = new MediaFilesApi();
const result = await api.GetMediaFileByToken({
mediaFileToken: mediaToken,
});
const result = await api.GetMediaFileByToken({ mediaFileToken: mediaToken });
if (result.success && result.data) {
finalUrl =
result.data.media_links?.cdn_url ||
result.data.public_bucket_url ||
finalUrl;
const mediaLinks = result.data.media_links as
| {
thumbnail_template?: string;
maybe_thumbnail_template?: string;
}
| { thumbnail_template?: string; maybe_thumbnail_template?: string }
| undefined;
thumbnailUrlTemplate =
mediaLinks?.thumbnail_template ||
mediaLinks?.maybe_thumbnail_template;
mediaLinks?.thumbnail_template || mediaLinks?.maybe_thumbnail_template;
}
} catch (e) {
console.warn(
"Falling back to data URL for uploaded image",
e,
);
console.warn("Falling back to data URL for uploaded image", e);
}
const referenceImage: BaseSelectorImage = {
mediaToken,
url: finalUrl,
fullImageUrl: finalUrl,
thumbnailUrlTemplate: thumbnailUrlTemplate,
thumbnailUrlTemplate,
};
toast.success("Image uploaded successfully!");
sendImageEvent(referenceImage);
onImageSelect(referenceImage);
setIsLoading(false);
})();
} else if (
@@ -140,16 +124,10 @@ export const BaseImageSelector = ({
},
});
};
reader.readAsDataURL(file);
});
};
const sendImageEvent = (image: BaseSelectorImage) => {
handleGalleryClose();
onImageSelect(image);
};
return (
<>
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-ui-panel text-base-fg">
@@ -167,6 +145,9 @@ export const BaseImageSelector = ({
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={handleGalleryClick}
tertiaryLabel="Blank Canvas"
tertiaryIcon={faExpand}
onTertiaryClick={() => setIsBlankCanvasModalOpen(true)}
disabled={isLoading || showLoading}
/>
</div>
@@ -186,6 +167,11 @@ export const BaseImageSelector = ({
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
<BlankCanvasModal
isOpen={isBlankCanvasModalOpen}
onClose={() => setIsBlankCanvasModalOpen(false)}
onConfirm={handleBlankCanvasConfirm}
/>
</>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,118 +0,0 @@
import React from "react";
import { SparklesIcon, Undo2, Smartphone, LayoutPanelTop } from "lucide-react";
import { PromptTextAreaProps, AspectRatio } from "./types";
import ImagePreview from "./ImagePreview";
const PromptTextArea: React.FC<
PromptTextAreaProps & {
aspectRatio: AspectRatio;
onAspectRatioChange: (r: AspectRatio) => void;
onStyleClick: () => void;
onImageStyleClick: () => void;
onRandomizeClick: () => void;
onVaryClick: () => void;
}
> = ({
value,
onChange,
images,
onImageUpdate,
onImageRemove,
aspectRatio,
onAspectRatioChange,
onStyleClick,
onImageStyleClick,
onRandomizeClick,
onVaryClick,
}) => {
/* helper to cycle aspect ratios */
const getNextAspectRatio = (): AspectRatio => {
const ratios: AspectRatio[] = ["1:1", "3:2", "2:3"];
const i = ratios.indexOf(aspectRatio);
return ratios[(i + 1) % ratios.length];
};
/* button styling – matches existing toolbar */
const buttonClass =
"flex items-center gap-1 rounded-lg px-3 py-1 " +
"bg-[#3A3A3A] text-sm text-white hover:bg-[#4A4A4A] " +
"transition-colors";
/* ───────── render ───────── */
return (
<div className="flex w-full overflow-hidden rounded-2xl bg-[#2A2A2A]">
{/* LEFT ▸ prompt + toolbar */}
<div className="flex flex-grow flex-col">
{/* prompt field */}
<div className="flex-grow px-7 pt-5">
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter your prompt here…"
className="
max-h-[175px] min-h-[120px] w-full flex-grow resize-y
border-none bg-transparent text-xl
text-white
placeholder-gray-400 outline-none
"
/>
</div>
{/* bottom toolbar */}
<div className="flex items-center gap-4 px-5 py-3">
{/* Style */}
<button onClick={onStyleClick} className={buttonClass}>
<SparklesIcon size={16} />
<span>Style</span>
</button>
{/* Image Style */}
<button onClick={onImageStyleClick} className={buttonClass}>
<LayoutPanelTop size={16} />
<span>Image&nbsp;Style</span>
</button>
{/* Aspect Ratio */}
<button
onClick={() => onAspectRatioChange(getNextAspectRatio())}
className={buttonClass}
>
<Smartphone
size={16}
className={aspectRatio !== "1:1" ? "rotate-90" : ""}
/>
<span>{aspectRatio}</span>
</button>
{/* Randomize */}
<button onClick={onRandomizeClick} className={buttonClass}>
<div className="flex h-4 w-4 items-center justify-center">🎲</div>
<span>Randomize</span>
</button>
{/* Vary */}
<button onClick={onVaryClick} className={buttonClass}>
<Undo2 size={16} />
<span>Vary</span>
</button>
</div>
</div>
{/* RIGHT ▸ image strip (optional) */}
{images.length > 0 && (
<div className="flex w-24 flex-col gap-2 bg-[#232323] p-2">
{images.map((img) => (
<ImagePreview
key={img.id}
image={img}
onUpdate={onImageUpdate}
onRemove={onImageRemove}
/>
))}
</div>
)}
</div>
);
};
export default PromptTextArea;
@@ -1,122 +0,0 @@
// AIStrengthSlider.tsx
import React, { useEffect, useRef, useState } from 'react';
import { AIStrengthSliderProps } from './types';
const Slider: React.FC<AIStrengthSliderProps> = ({
value,
onChange,
width = '100%',
height = 48,
inset = false,
className = '',
}) => {
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [hover, setHover] = useState(false);
const [dragging, setDragging] = useState(false); // ← new
/* ----- paint helpers ----- */
const clamp = (v: number) => Math.max(0, Math.min(1, v));
const paint = (v: number) => {
fillRef.current!.style.transform = `scaleX(${v})`;
handleRef.current!.style.left = `${v * 100}%`;
};
useEffect(() => paint(value), [value]);
/* ----- pointer logic ----- */
const valAt = (x: number) => {
const { left, width } = trackRef.current!.getBoundingClientRect();
return clamp((x - left) / width);
};
const start = (e: React.PointerEvent) => {
setDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const v = valAt(e.clientX);
paint(v);
onChange(v);
};
const move = (e: PointerEvent) => {
if (!dragging) return;
const v = valAt(e.clientX);
paint(v);
onChange(v); // fine to call every frame now
};
const stop = () => setDragging(false);
useEffect(() => {
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
return () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
};
}, [dragging]);
/* ----- derived sizes ----- */
const trackH = typeof height === 'number' ? `${height}px` : height;
/* ----- render ----- */
const showHandle = hover || dragging;
return (
<div className="relative select-none" style={{ width }}>
<div
ref={trackRef}
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value * 100)}
onPointerDown={start}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={`
group flex items-center rounded-xl cursor-pointer overflow-hidden
bg-[#1A1A1A] hover:bg-[#2A2A2A] transition-colors relative
${inset ? 'shadow-inner' : ''}
${className}
`}
style={{ height: trackH }}
>
{/* fill */}
<div
ref={fillRef}
className="absolute inset-0 bg-white/15 will-change-transform"
style={{ transformOrigin: 'left' }}
/>
{/* handle — visible only on hover OR drag */}
<div
ref={handleRef}
className={`
absolute translate-x-1/2 bg-white will-change-transform
transition-opacity duration-150
${showHandle ? 'opacity-100' : 'opacity-50'}
`}
style={{ width: '1px', height: '60%' }}
/>
{/* left-aligned, vertically centred label */}
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-sm font-medium text-[#ACACAC]">
AI Strength
</span>
{/* percentage on hover */}
<span className="
absolute right-4 top-1/2 -translate-y-1/2 text-sm text-white
opacity-0 group-hover:opacity-100 transition-opacity
min-w-[40px] text-right
">
{Math.round(value * 100)}
</span>
</div>
</div>
);
};
export default Slider;
@@ -1,2 +0,0 @@
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,59 +0,0 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "../../lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -1,37 +0,0 @@
import React, { createContext, useContext, useState } from "react";
import { JobContextType } from "@storyteller/common";
const JobContext = createContext<JobContextType | undefined>(undefined);
export const JobProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [jobTokens, setJobTokens] = useState<string[]>([]);
const addJobToken = (token: string) => {
setJobTokens((prevTokens) => [...prevTokens, token]);
};
const removeJobToken = (token: string) => {
setJobTokens((prevTokens) => prevTokens.filter((t) => t !== token));
};
const clearJobTokens = () => {
setJobTokens([]);
};
return (
<JobContext.Provider
value={{ jobTokens, addJobToken, removeJobToken, clearJobTokens }}
>
{children}
</JobContext.Provider>
);
};
export const useJobContext = () => {
const context = useContext(JobContext);
if (context === undefined) {
throw new Error("useJobContext must be used within a JobProvider");
}
return context;
};
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -1,30 +0,0 @@
/**
* This decodes Base64 encoded PNG images into an image element.
* @param base64String a standard (not "web safe") base64-encoded string
*/
export const DecodeBase64ToImage = async (base64String: string) : Promise<ImageBitmap> => {
// Create an image element
const img = document.createElement("img");
// Convert base64 to data URL if it doesn't include the prefix
const dataUrl = base64String.startsWith("data:")
? base64String
: `data:image/png;base64,${base64String}`;
// Create a promise to handle the image loading
return new Promise((resolve, reject) => {
img.onload = async () => {
try {
const bitmap = await createImageBitmap(img);
resolve(bitmap);
} catch (error) {
reject(error);
}
};
img.onerror = () => reject(new Error("Failed to load image"));
// Set the source to trigger loading
img.src = dataUrl;
});
}
@@ -1,16 +0,0 @@
export const EncodeImageBitmapToArray = async (imageBitmap: ImageBitmap): Promise<Uint8Array> => {
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error("Failed to get 2D context");
}
ctx.drawImage(imageBitmap, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/png' });
const arrayBuffer = await blob.arrayBuffer();
return new Uint8Array(arrayBuffer);
}
@@ -1,48 +0,0 @@
export class FileUtilities {
static async downloadBlobZip(blob: Blob) {
console.log("Download Blob");
// Create a temporary URL for the Blob
const url = URL.createObjectURL(blob);
// Create an anchor element to simulate the download
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "Test.zip";
// Append the anchor to the body
document.body.appendChild(anchor);
// Programmatically click the anchor to trigger the download
anchor.click();
// Clean up by removing the anchor and revoking the object URL
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
static async blobToFileJpeg(blob: Blob, index: string) {
try {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
const formattedIndex = String(index).padStart(4, "0");
link.download = `${formattedIndex}.jpg`;
// Trigger the download
link.click();
// Clean up the URL object
URL.revokeObjectURL(link.href);
console.log("Done");
} catch (error) {
console.log(error);
}
}
static async createImageFileFromUrl(
url: string,
fileName: string = "image.jpg",
mimeType: string = "image/jpeg",
): Promise<File> {
const response = await fetch(url);
const blob = await response.blob();
return new File([blob], fileName, { type: mimeType });
}
}
@@ -1,19 +0,0 @@
export const SaveImageBitmapToFile = async (imageBitmap: ImageBitmap): Promise<void> => {
try {
const canvas = document.createElement("canvas");
canvas.width = imageBitmap.width;
canvas.height = imageBitmap.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Failed to get 2D context");
ctx.drawImage(imageBitmap, 0, 0);
const blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((b) => resolve(b!), "image/png");
});
await FileUtilities.blobToFileJpeg(blob, "output");
} catch (error) {
console.error("Error saving output:", error);
}
}
@@ -1,18 +0,0 @@
export const kebabCase = (str: string) =>
str
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replace(/[\s_]+/g, "-")
.toLowerCase();
export const getFileName = (file: File) => {
return file.name.substring(0, file.name.lastIndexOf("."));
};
export const getFileExtension = (file: File) => {
return file.name.substring(file.name.lastIndexOf(".") + 1);
};
export const clamp = (num: number, min: number, max: number) => {
return Math.min(Math.max(num, min), max);
}
export const normalize = (num: number, min: number, max: number) => {
return (num - min) / (max - min);
}
File diff suppressed because it is too large Load Diff
@@ -1,61 +0,0 @@
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark, faTrashXmark } from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
showActionReminder,
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
interface LeftResetStackProps {
onReset: () => void;
className?: string;
}
const LeftResetStack: React.FC<LeftResetStackProps> = ({
onReset,
className = "",
}) => {
return (
<div
className={`glass rounded-xl border-2 border-red/50 shadow-lg hover:border-red/80 ${className}`}
>
<div className="relative h-full">
<Tooltip
content="Reset All"
position="right"
closeOnClick={true}
className="ms-1 rounded-md bg-red px-3 py-1"
delay={100}
>
<button
className="text-base-fg flex h-10 w-10 items-center justify-center rounded-lg border-2 border-transparent transition-colors hover:bg-red/50"
onClick={() =>
showActionReminder({
reminderType: "default",
title: "Reset All",
primaryActionIcon: faTrashXmark,
primaryActionBtnClassName: "bg-red hover:bg-red/80",
message: (
<p className="text-base-fg text-sm opacity-70">
Are you sure you want to reset all? This will clear all your
work and cannot be undone.
</p>
),
primaryActionText: "Reset all",
onPrimaryAction: () => {
onReset();
isActionReminderOpen.value = false;
},
})
}
>
<FontAwesomeIcon icon={faXmark} className="h-5 w-5 text-xl" />
</button>
</Tooltip>
</div>
</div>
);
};
export default LeftResetStack;
@@ -1,105 +0,0 @@
import { useState, useRef } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEyeDropper } from "@fortawesome/pro-solid-svg-icons";
import { SliderV2 } from "@storyteller/ui-sliderv2";
import { HsvaColorPicker, HsvaColor } from "react-colorful";
import { hsvaToHex, hexToHsva } from "@uiw/color-convert";
export interface MarkerToolControlBarProps {
currentSize: number;
currentColor: string;
activeTool: string;
showColorPicker: boolean;
onSizeChange?: (size: number) => void;
onColorChange?: (color: string) => void;
}
function useDebounced<T extends (...args: A) => void, A extends unknown[]>(
fn: T,
ms = 75,
) {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
return (...args: A) => {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => fn(...args), ms);
};
}
const MarkerToolControlBar = ({
currentSize,
currentColor,
activeTool,
showColorPicker: showColorPickerProp,
onSizeChange,
onColorChange,
}: MarkerToolControlBarProps) => {
const [showColorPickerPanel, setShowColorPickerPanel] = useState(false);
const [hsva, setHsva] = useState<HsvaColor>(hexToHsva(currentColor));
const isEraser = activeTool === "eraser";
const shouldShowColorPicker = showColorPickerProp && !isEraser;
const maxSize = isEraser ? 50 : 20;
const sendColor = useDebounced<(hex: string) => void, [string]>(
(hex: string) => {
onColorChange?.(hex);
},
75,
);
return (
<div className="absolute left-1/2 top-20 flex -translate-x-1/2 flex-col gap-3">
<div className="glass flex w-[400px] items-center gap-2 rounded-xl p-2">
{shouldShowColorPicker && (
<div className="relative">
<button
onClick={() => setShowColorPickerPanel(!showColorPickerPanel)}
className="flex h-10 w-10 items-center justify-center rounded-lg border-2 border-transparent text-white transition-colors hover:bg-white/10"
>
<span
className="inline-block h-5 w-5 rounded-full border-2 border-white"
style={{ backgroundColor: currentColor }}
/>
</button>
{showColorPickerPanel && (
<div
className="absolute left-0 top-12 z-50 rounded-xl border border-[#404040] bg-[#303030] transition-all duration-200 ease-in-out"
onMouseLeave={() => setShowColorPickerPanel(false)}
>
<div className="glass relative w-fit rounded-2xl p-4 shadow-lg">
<button className="bg-zinc-700 text-zinc-300 hover:bg-zinc-600 absolute left-4 top-4 flex h-8 w-8 items-center justify-center rounded-full">
<FontAwesomeIcon icon={faEyeDropper} size="sm" />
</button>
<HsvaColorPicker
color={hsva}
onChange={(c) => {
setHsva(c);
const hex = hsvaToHex(c);
sendColor(hex);
}}
className="brush-picker"
/>
</div>
</div>
)}
</div>
)}
<SliderV2
min={1}
max={maxSize}
value={currentSize}
onChange={(size) => onSizeChange?.(size)}
step={1}
innerLabel={"Size " + currentSize + "pt"}
showDecrement
showIncrement
/>
</div>
</div>
);
};
export default MarkerToolControlBar;
@@ -1,762 +0,0 @@
import { useState, useRef, useEffect, useCallback } from "react";
import Konva from "konva"; // just for types
import {
EnqueueEditImage,
EnqueueImageInpaint,
EnqueueEditImageSize,
EnqueueEditImageResolution,
EnqueueEditImageRequest,
EnqueueImageInpaintRequest,
} from "@storyteller/tauri-api";
import { PromptsApi } from "@storyteller/api";
import { RefImage, usePromptEditStore } from "@storyteller/ui-promptbox";
import { ContextMenuContainer } from "../PageDraw/components/ui/ContextMenu";
import { useCopyPasteHotkeys } from "../PageDraw/hooks/useCopyPasteHotkeys";
import { useDeleteHotkeys } from "../PageDraw/hooks/useDeleteHotkeys";
import { useUndoRedoHotkeys } from "../PageDraw/hooks/useUndoRedoHotkeys";
import PromptEditor from "./PromptEditor/PromptEditor";
import { ActiveEditTool, useEditStore } from "./stores/EditState";
import { EditPaintSurface } from "./EditPaintSurface";
import { normalizeCanvas } from "../../Helpers/CanvasHelpers";
import { BaseImageSelector, BaseSelectorImage } from "./BaseImageSelector";
import MarkerToolControlBar from "./MarkerToolControlBar";
import {
ClassyModelSelector,
useSelectedImageModel,
useSelectedProviderForModel,
ModelPage,
IMAGE_EDITOR_PAGE_MODEL_LIST,
//ProviderSelector,
//PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { CommonAspectRatio, ImageModel } from "@storyteller/model-list";
import { HistoryStack, ImageBundle } from "./HistoryStack";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import {
CostCalculatorButton,
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { GenerationProvider } from "@storyteller/api-enums";
const PAGE_ID: ModelPage = ModelPage.ImageEditor;
const PageEdit = () => {
//useStateSceneLoader();
const selectedImageModel: ImageModel | undefined =
useSelectedImageModel(PAGE_ID);
const selectedProvider: GenerationProvider | undefined =
useSelectedProviderForModel(PAGE_ID, selectedImageModel?.id);
const imageCredits = useCostBreakdownModalStore(
(s) => s.estimatedCreditsByPage[PAGE_ID],
);
// State for canvas dimensions
const canvasWidth = useRef<number>(1024);
const canvasHeight = useRef<number>(1024);
// Add new state to track if user is selecting
const [isSelecting, setIsSelecting] = useState<boolean>(false);
// Create refs for stage and image
const stageRef = useRef<Konva.Stage>({} as Konva.Stage);
const leftPanelRef = useRef<Konva.Layer>({} as Konva.Layer);
const baseImageKonvaRef = useRef<Konva.Image>({} as Konva.Image);
const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({});
const [pendingGenerations, setPendingGenerations] = useState<
{ id: string; count: number }[]
>([]);
const [generationCount, setGenerationCount] = useState<number>(1);
// New aspect ratio state will begin to phase out old aspect ratio
const [commonAspectRatio, setCommonAspectRatio] = useState<
CommonAspectRatio | undefined
>(undefined);
// Use the Zustand store
const store = useEditStore();
const baseImageUrl = store.baseImageInfo?.url;
const addHistoryImageBundle = useEditStore(
(state) => state.addHistoryImageBundle,
);
const removeHistoryImage = useEditStore((state) => state.removeHistoryImage);
const historyImageBundles = useEditStore(
(state) => state.historyImageBundles,
);
const referenceImages = usePromptEditStore((s) => s.referenceImages);
// Pass store actions directly as callbacks
useDeleteHotkeys({ onDelete: store.deleteSelectedItems });
useUndoRedoHotkeys({ undo: store.undo, redo: store.redo });
useCopyPasteHotkeys({
onCopy: store.copySelectedItems,
onPaste: store.pasteItems,
});
// Listen for gallery drag and drop events
useEffect(() => {
const handleGallery2DDrop = (event: CustomEvent) => {
const { item, canvasPosition } = event.detail;
console.log("Received 2D gallery drop:", { item, canvasPosition });
// Get the stage to transform coordinates properly
const stage = stageRef.current;
if (!stage) {
console.error(
"Stage reference not available for coordinate transformation",
);
return;
}
// Transform canvas coordinates to stage coordinates
// Account for stage position, scale, and transformations
const stageX = stage.x();
const stageY = stage.y();
const scaleX = stage.scaleX();
const scaleY = stage.scaleY();
const stagePoint = {
x: (canvasPosition.x - stageX) / scaleX,
y: (canvasPosition.y - stageY) / scaleY,
};
console.log("Transformed stage coordinates:", stagePoint);
// Use the direct URL for the image
const imageUrl = item.fullImage || item.thumbnail;
if (!imageUrl) {
console.error("No image URL available for dropped item");
return;
}
console.log("Creating image from URL:", imageUrl);
// Use the store's createImageFromUrl method directly
store.createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl);
console.log(
`Created image "${item.label}" at stage position:`,
stagePoint,
);
};
// Add event listener
window.addEventListener(
"gallery-2d-drop",
handleGallery2DDrop as EventListener,
);
// Cleanup
return () => {
window.removeEventListener(
"gallery-2d-drop",
handleGallery2DDrop as EventListener,
);
};
}, [store]);
const onFitPressed = useCallback(async () => {
// Get the stage and its container dimensions
const stage = stageRef.current;
if (!stage) return;
// Get container dimensions
const containerWidth = stage.container().offsetWidth;
const containerHeight = stage.container().offsetHeight;
// Get canvas dimensions from store aspect ratio
const canvasW = store.getAspectRatioDimensions().width;
const canvasH = store.getAspectRatioDimensions().height;
// Account for top toolbar and bottom prompt box
// Top toolbar (MarkerToolControlBar): ~140px from top (toolbar + padding)
// Bottom prompt box: ~200px from bottom (can vary with reference images)
// Mode selector above prompt: ~60px
const hasTopToolbar =
store.activeTool === "marker" ||
store.activeTool === "eraser" ||
store.activeTool === "edit";
const topToolbarHeight = hasTopToolbar ? 140 : 80;
// Check if model supports masking or is nano banana (has mode selector)
const hasModeSelectorUI =
selectedImageModel?.usesInpaintingMask ||
selectedImageModel?.isNanoBananaModel();
const bottomUIHeight = hasModeSelectorUI ? 260 : 200;
const totalVerticalReserved = topToolbarHeight + bottomUIHeight;
// Calculate optimal scale to fit the canvas in the container
const paddingFactor = 0.95; // horizontal padding (left/right)
const scaleX = (containerWidth * paddingFactor) / canvasW;
const availableHeight = Math.max(
0,
containerHeight - totalVerticalReserved,
);
const scaleY = availableHeight / canvasH;
// Use the smaller scale to ensure the canvas fits in both dimensions
const scale = Math.min(scaleX, scaleY);
// Limit the scale between reasonable bounds (0.1 to 3.0)
const boundedScale = Math.max(0.1, Math.min(3.0, scale));
// Apply the calculated scale
stage.scale({ x: boundedScale, y: boundedScale });
// Calculate position to center canvas in available space (between toolbars)
const verticalOffset = (topToolbarHeight - bottomUIHeight) / 2;
stage.position({
x: (containerWidth - canvasW * boundedScale) / 2,
y: (containerHeight - canvasH * boundedScale) / 2 + verticalOffset,
});
}, [selectedImageModel, store]);
// Auto-fit when the base image becomes available and stage is ready
// Also re-fit when active tool or model changes (as UI layout changes)
useEffect(() => {
if (!store.baseImageBitmap) return;
if (!stageRef.current) return;
// Defer to ensure container has laid out with correct size and toolbars have rendered
const id = requestAnimationFrame(() => {
const id2 = requestAnimationFrame(() => {
void onFitPressed();
});
return () => cancelAnimationFrame(id2);
});
return () => cancelAnimationFrame(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
store.baseImageBitmap,
stageRef.current,
store.activeTool,
selectedImageModel,
]);
// Re-center on window resize when an image is loaded
useEffect(() => {
let timeoutId: number | undefined;
const handleResize = () => {
if (!store.baseImageBitmap) return;
if (!stageRef.current) return;
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
requestAnimationFrame(() => {
void onFitPressed();
});
}, 150);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store.baseImageBitmap, stageRef.current, onFitPressed]);
// Create a function to use the left layer ref and download the bitmap from it
const getMaskArrayBuffer = async (): Promise<Uint8Array> => {
if (
!stageRef.current ||
!leftPanelRef.current ||
!baseImageKonvaRef.current
) {
console.error("Stage or left panel ref is not available");
throw new Error("Stage or left panel or base image ref is not available");
}
const layer = leftPanelRef.current;
// Get the canvas area that's covered by the image/rectangle
const rect = baseImageKonvaRef.current;
const layerCrop = layer.toCanvas({
x: stageRef.current.x(),
y: stageRef.current.y(),
width: rect.width() * stageRef.current.scaleX(),
height: rect.height() * stageRef.current.scaleY(),
pixelRatio: 1 / stageRef.current.scaleX(),
});
// Using the pixelRatio scaling may result in off-by-one rounding errors,
// So we re-fit the image to a canvas of precise size.
const fittedCanvas = normalizeCanvas(
layerCrop,
rect.width(),
rect.height(),
);
// Convert colored canvas to alpha mask
// NOTE: This isn't needed because the tauri backend uses the alpha channel anyway
// drawAlphaMask(fittedCanvas, rect.width(), rect.height());
const blob = await fittedCanvas.convertToBlob({ type: "image/png" });
const arrayBuffer = await blob.arrayBuffer();
return new Uint8Array(arrayBuffer);
};
const getCompositeCanvasFile = async (): Promise<File | null> => {
if (
!stageRef.current ||
!leftPanelRef.current ||
!baseImageKonvaRef.current ||
!store.baseImageBitmap
) {
return null;
}
const rect = baseImageKonvaRef.current;
const width = rect.width();
const height = rect.height();
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(store.baseImageBitmap, 0, 0, width, height);
const markerLayerCanvas = leftPanelRef.current.toCanvas({
x: stageRef.current.x(),
y: stageRef.current.y(),
width: rect.width() * stageRef.current.scaleX(),
height: rect.height() * stageRef.current.scaleY(),
pixelRatio: 1 / stageRef.current.scaleX(),
});
const fittedMarkerCanvas = normalizeCanvas(
markerLayerCanvas,
width,
height,
);
ctx.drawImage(fittedMarkerCanvas, 0, 0, width, height);
const blob = await canvas.convertToBlob({ type: "image/png" });
const uuid = crypto.randomUUID();
return new File([blob], `${uuid}.png`, { type: "image/png" });
};
const handleGenerate = useCallback(
async (
prompt: string,
options?: {
aspectRatio?: string;
resolution?: string;
images?: RefImage[];
selectedProvider?: GenerationProvider;
},
) => {
const editedImageToken = store.baseImageInfo?.mediaToken;
if (!editedImageToken) {
console.error("Base image is not available");
return;
}
// Helper to map aspect ratio string to enum
const mapAspectRatio = (
ratio?: string,
): EnqueueEditImageSize | undefined => {
switch (ratio) {
case "auto":
return EnqueueEditImageSize.Auto;
case "wide":
return EnqueueEditImageSize.Wide;
case "tall":
return EnqueueEditImageSize.Tall;
case "square":
return EnqueueEditImageSize.Square;
default:
return undefined;
}
};
// Helper to map resolution string to enum
const mapResolution = (
res?: string,
): EnqueueEditImageResolution | undefined => {
switch (res) {
case "1k":
return EnqueueEditImageResolution.OneK;
case "2k":
return EnqueueEditImageResolution.TwoK;
case "4k":
return EnqueueEditImageResolution.FourK;
default:
return undefined;
}
};
const subscriberId: string =
crypto?.randomUUID?.() ??
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
window.__storeTaskEnqueueMeta?.({
prompt,
refImageUrls: (options?.images || [])
.map((img) => img.url)
.filter(Boolean),
modelType:
(selectedImageModel as any)?.tauriId || String(selectedImageModel),
timestamp: Date.now(),
});
try {
let result;
if (selectedImageModel?.editingIsInpainting) {
// CASE 1 - INPAINTING (Only a few models do this!)
const arrayBuffer = await getMaskArrayBuffer();
const request: EnqueueImageInpaintRequest = {
model: selectedImageModel,
image_media_token: editedImageToken,
mask_image_raw_bytes: arrayBuffer,
prompt: prompt,
image_count: generationCount,
frontend_caller: "image_editor",
frontend_subscriber_id: subscriberId,
};
if (options?.selectedProvider) {
request.provider = options.selectedProvider;
}
//if (selectedImageModel?.supportsNewAspectRatio()) {
// request.common_aspect_ratio = commonAspectRatio;
//}
result = await EnqueueImageInpaint(request);
} else if (selectedImageModel?.isNanoBananaModel()) {
// CASE 2 - NANO BANANA
const compositeFile = await getCompositeCanvasFile();
if (!compositeFile) {
console.error("Failed to create composite canvas");
return;
}
const api = new PromptsApi();
const snapshotResult = await api.uploadSceneSnapshot({
screenshot: compositeFile,
});
if (!snapshotResult.data) {
console.error("Failed to upload scene snapshot");
return;
}
const imgs = options?.images || [];
const request: EnqueueEditImageRequest = {
model: selectedImageModel,
scene_image_media_token: snapshotResult.data,
image_media_tokens: imgs
.map((img) => img.mediaToken)
.filter((t) => t.length > 0),
prompt: prompt,
image_count: generationCount,
frontend_caller: "image_editor",
frontend_subscriber_id: subscriberId,
aspect_ratio: mapAspectRatio(options?.aspectRatio),
image_resolution: mapResolution(options?.resolution),
};
if (options?.selectedProvider) {
request.provider = options.selectedProvider;
}
if (selectedImageModel?.supportsNewAspectRatio()) {
request.common_aspect_ratio = commonAspectRatio;
}
result = await EnqueueEditImage(request);
} else {
// CASE 3 - DEFAULT
const imgs = options?.images || [];
const request: EnqueueEditImageRequest = {
model: selectedImageModel,
image_media_tokens: [
editedImageToken,
...imgs
.filter((img) => img.mediaToken !== editedImageToken)
.map((img) => img.mediaToken),
].filter((t) => t.length > 0),
disable_system_prompt: true,
prompt: prompt,
image_count: generationCount,
frontend_caller: "image_editor",
frontend_subscriber_id: subscriberId,
aspect_ratio: mapAspectRatio(options?.aspectRatio),
image_resolution: mapResolution(options?.resolution),
};
if (options?.selectedProvider) {
request.provider = options.selectedProvider;
}
if (selectedImageModel?.supportsNewAspectRatio()) {
request.common_aspect_ratio = commonAspectRatio;
}
result = await EnqueueEditImage(request);
}
if (result?.status === "success") {
setPendingGenerations((prev) => [
...prev,
{ id: subscriberId as string, count: generationCount },
]);
}
} catch (error) {
setPendingGenerations((prev) =>
prev.filter((p) => p.id !== subscriberId),
);
throw error;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
generationCount,
selectedImageModel,
store.baseImageInfo?.mediaToken,
referenceImages,
],
);
const isNanoBananaModel = selectedImageModel?.isNanoBananaModel();
const supportsMaskedInpainting =
selectedImageModel?.usesInpaintingMask ?? false;
useEffect(() => {
if (isNanoBananaModel) {
if (store.activeTool !== "marker" && store.activeTool !== "eraser") {
store.setActiveTool("marker");
}
} else if (
!supportsMaskedInpainting &&
(store.activeTool !== "select" || store.lineNodes.length > 0)
) {
store.setActiveTool("select");
store.clearLineNodes();
} else if (
supportsMaskedInpainting &&
(store.activeTool === "select" || store.activeTool === "marker")
) {
store.setActiveTool("edit");
}
}, [store, supportsMaskedInpainting, isNanoBananaModel]);
/*
*
* Only component logic below this
*
*
*
*
*/
// Display image selector on launch, otherwise hide it
// Also show loading state if info is set but image is loading
if (!store.baseImageInfo || !store.baseImageBitmap) {
return (
<div
className={
"bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full items-center justify-center p-8"
}
>
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<BaseImageSelector
onImageSelect={(image: BaseSelectorImage) => {
addHistoryImageBundle({ images: [image] });
store.setBaseImageInfo(image);
}}
showLoading={
store.baseImageInfo !== null && store.baseImageInfo === null
}
/>
</div>
</div>
</div>
);
}
return (
<>
<div className="fixed inset-0 -z-10 bg-ui-background" />
{(store.activeTool === "marker" || store.activeTool === "eraser") &&
isNanoBananaModel && (
<div
className={`preserve-aspect-ratio fixed left-1/2 top-0 z-10 -translate-x-1/2 transform ${
isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
>
<MarkerToolControlBar
currentSize={
store.activeTool === "eraser"
? store.eraserBrushSize
: store.markerBrushSize
}
currentColor={store.markerColor}
activeTool={store.activeTool}
showColorPicker={true}
onSizeChange={
store.activeTool === "eraser"
? store.setEraserBrushSize
: store.setMarkerBrushSize
}
onColorChange={store.setMarkerColor}
/>
</div>
)}
{(store.activeTool === "edit" || store.activeTool === "eraser") &&
supportsMaskedInpainting && (
<div
className={`preserve-aspect-ratio fixed left-1/2 top-0 z-10 -translate-x-1/2 transform ${
isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
>
<MarkerToolControlBar
currentSize={
store.activeTool === "eraser"
? store.eraserBrushSize
: store.brushSize
}
currentColor={store.brushColor}
activeTool={store.activeTool}
showColorPicker={false}
onSizeChange={
store.activeTool === "eraser"
? store.setEraserBrushSize
: store.setBrushSize
}
onColorChange={store.setBrushColor}
/>
</div>
)}
<div
className={`preserve-aspect-ratio fixed right-4 top-1/2 z-10 -translate-y-1/2 transform ${
isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
>
<HistoryStack
onClear={() => {
store.RESET();
setPendingGenerations([]);
}}
imageBundles={historyImageBundles}
pendingPlaceholders={pendingGenerations}
blurredBackgroundUrl={baseImageUrl}
onImageSelect={(baseImage) => {
store.clearLineNodes();
store.setBaseImageInfo(baseImage);
}}
onImageRemove={(baseImage) => {
if (
pendingGenerations.length === 0 &&
store.historyImageBundles.length === 1 &&
store.historyImageBundles[0].images.length <= 1
) {
store.RESET();
} else {
removeHistoryImage(baseImage);
}
}}
onNewImageBundle={(newBundle: ImageBundle) => {
addHistoryImageBundle(newBundle);
}}
onResolvePending={(id: string) =>
setPendingGenerations((prev) => prev.filter((p) => p.id !== id))
}
selectedImageToken={store.baseImageInfo?.mediaToken}
/>
</div>
<div
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${
isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
>
<PromptEditor
selectedImageModel={selectedImageModel}
selectedProvider={selectedProvider}
commonAspectRatio={commonAspectRatio}
setCommonAspectRatio={setCommonAspectRatio}
onModeChange={(mode: string) => {
store.setActiveTool(mode as ActiveEditTool);
}}
selectedMode={store.activeTool}
onGenerateClick={handleGenerate}
onFitPressed={onFitPressed}
isDisabled={false}
generationCount={generationCount}
onGenerationCountChange={setGenerationCount}
supportsMaskedInpainting={supportsMaskedInpainting}
isNanoBananaModel={isNanoBananaModel}
onUndo={store.undo}
onRedo={store.redo}
credits={imageCredits}
/>
</div>
<div className="relative z-0">
<ContextMenuContainer
onAction={(e, action) => {
if (action === "contextMenu") {
const hasSelection = store.selectedNodeIds.length > 0;
if (hasSelection) {
console.log("An item is selected.");
// You can add additional actions here based on the selection
return true;
} else {
console.log("No item is selected.");
return false;
}
}
return false;
}}
onMenuAction={async (action) => {
switch (action) {
case "DUPLICATE":
store.copySelectedItems();
store.pasteItems();
break;
case "DELETE":
store.deleteSelectedItems();
break;
default:
// No action needed for unhandled cases
}
}}
isLocked={store.selectedNodeIds.some((id) => {
const node = store.nodes.find((n) => n.id === id);
const lineNode = store.lineNodes.find((n) => n.id === id);
return (node?.locked || lineNode?.locked) ?? false;
})}
>
<EditPaintSurface
nodes={store.nodes}
selectedNodeIds={store.selectedNodeIds}
onCanvasSizeChange={(width: number, height: number): void => {
canvasWidth.current = width;
canvasHeight.current = height;
}}
//fillColor={store.fillColor}
activeTool={store.activeTool}
brushColor={"rgba(39, 187, 245, 0.54)"}
brushSize={store.brushSize}
markerBrushSize={store.markerBrushSize}
eraserBrushSize={store.eraserBrushSize}
markerColor={store.markerColor}
onSelectionChange={setIsSelecting}
stageRef={stageRef}
transformerRefs={transformerRefs}
leftPanelRef={leftPanelRef}
baseImageRef={baseImageKonvaRef}
/>
</ContextMenuContainer>
</div>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={IMAGE_EDITOR_PAGE_MODEL_LIST}
page={PAGE_ID}
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-base-fg opacity-80 hover:opacity-100"
showIconsInList
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={PAGE_ID} />
<HelpMenuButton />
</div>
</>
);
};
export default PageEdit;
@@ -1,13 +0,0 @@
import React from "react";
import { PromptBoxEdit, PromptBoxEditProps } from "@storyteller/ui-promptbox";
import { uploadImage } from "../../../components/reusable/UploadModalMedia/uploadImage";
const PromptEditor: React.FC<PromptBoxEditProps> = (props) => {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col space-y-2">
<PromptBoxEdit {...props} uploadImage={uploadImage} />
</div>
);
};
export default React.memo(PromptEditor);
@@ -1,43 +0,0 @@
export type AspectRatio = '1:1' | '3:2' | '2:3';
export interface ImageStyle {
id: string;
url: string;
weight: number;
}
export interface PromptTextAreaProps {
value: string;
onChange: (value: string) => void;
images: ImageStyle[];
onImageUpdate: (id: string, updates: Partial<ImageStyle>) => void;
onImageRemove: (id: string) => void;
}
export interface AIStrengthSliderProps {
value: number;
onChange: (value: number) => void;
width?: string | number;
height?: string | number;
inset?: boolean;
className?: string;
}
export interface ControlButtonsProps {
aspectRatio: AspectRatio;
onAspectRatioChange: (ratio: AspectRatio) => void;
onStyleClick: () => void;
onImageStyleClick: () => void;
onRandomizeClick: () => void;
onVaryClick: () => void;
}
export interface ImageStyleSelectorProps {
onImageSelect: (image: File) => void;
}
export interface ImagePreviewProps {
image: ImageStyle;
onUpdate: (id: string, updates: Partial<ImageStyle>) => void;
onRemove: (id: string) => void;
}
File diff suppressed because it is too large Load Diff
@@ -86,7 +86,6 @@ import {
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
import PageDraw from "../PageDraw/PageDraw";
import { useTabStore } from "../Stores/TabState";
import PageEdit from "../PageEdit/PageEdit";
import { ImageModel } from "@storyteller/model-list";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import {
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { useSceneStore } from "../PageDraw/stores/SceneState";
import { useSceneStore } from "@storyteller/ui-pagedraw";
export type TabId =
| "2D"
@@ -22,10 +22,15 @@ export default {
"0%": { backgroundPosition: "200% 0" },
"100%": { backgroundPosition: "-200% 0" },
},
marquee: {
"0%": { transform: "translateX(0%)" },
"100%": { transform: "translateX(-100%)" },
},
},
animation: {
fadeIn: "fadeIn 0.2s ease-in-out",
shimmer: "shimmer 2s ease-in-out infinite",
marquee: "marquee 8s linear infinite paused",
},
},
colors: {

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