mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 17:49:30 -04:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc32d7c2ee | ||
|
|
bf0703f0c1 | ||
|
|
c7943bce59 | ||
|
|
c5f62daa2b | ||
|
|
7122e4507f | ||
|
|
238cb10499 | ||
|
|
1ebb124f05 | ||
|
|
e7bb263755 | ||
|
|
bbfea2ae96 | ||
|
|
3bc55c761c | ||
|
|
8348a91693 | ||
|
|
d1adc0b179 | ||
|
|
c0ac3cbc9c | ||
|
|
0a13d36070 | ||
|
|
8662e79824 | ||
|
|
dc30cf9669 | ||
|
|
b09a0fd854 | ||
|
|
400bccc155 | ||
|
|
11011dacaf | ||
|
|
2f73e861d1 | ||
|
|
0151a2e28d |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
+30
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -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(())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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) = ¬ification.http_method {
|
||||
labels.push(("http_method".to_string(), method.clone()));
|
||||
}
|
||||
|
||||
if let Some(path) = ¬ification.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 ¬ification.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) = ¬ification.http_method {
|
||||
parts.push(format!("HTTP Method: {}", method));
|
||||
}
|
||||
|
||||
if let Some(path) = ¬ification.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) = ¬ification.user_token {
|
||||
parts.push(format!("User Token: {}", user_token));
|
||||
}
|
||||
|
||||
if let Some(media_file_token) = ¬ification.media_file_token {
|
||||
parts.push(format!("Media File Token: {}", media_file_token));
|
||||
}
|
||||
|
||||
if let Some(inference_job_token) = ¬ification.inference_job_token {
|
||||
parts.push(format!("Inference Job Token: {}", inference_job_token));
|
||||
}
|
||||
|
||||
if let Some(third_party_id) = ¬ification.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) = ¬ification.http_method {
|
||||
labels.push(("http_method".to_string(), method.clone()));
|
||||
}
|
||||
|
||||
if let Some(path) = ¬ification.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!("{}...", ¬ification.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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
+332
@@ -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;
|
||||
|
||||
-28
@@ -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,
|
||||
|
||||
-29
@@ -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,
|
||||
|
||||
-34
@@ -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,
|
||||
|
||||
-34
@@ -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,
|
||||
|
||||
-36
@@ -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,
|
||||
|
||||
-30
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+95
-77
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-142
@@ -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");
|
||||
}
|
||||
}
|
||||
+29
-39
@@ -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
@@ -1,2 +1 @@
|
||||
pub mod fal_error_detail;
|
||||
pub mod handle_failed_fal_webhook;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
use log::{debug, warn};
|
||||
|
||||
use pager::client::pager::Pager;
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
/// Check `AdvancedCommonWebError` and alert on uncaught server errors.
|
||||
/// Returns `true` if the error was handled (alerted or intentionally skipped).
|
||||
pub(super) fn check_advanced_common_web_error(
|
||||
pager: &Pager,
|
||||
method: &str,
|
||||
path: &str,
|
||||
error: &AdvancedCommonWebError,
|
||||
) -> bool {
|
||||
if !error.is_server_error() {
|
||||
// Non-500 errors (400, 401, 404, 402) are intentional — don't alert.
|
||||
return true;
|
||||
}
|
||||
|
||||
let title = format!("UncaughtServerError on {} {}", method, path);
|
||||
|
||||
// Clone the Arc'd causal error so the notification owns a reference to it.
|
||||
let cause_arc = error.clone_cause_arc();
|
||||
|
||||
let mut builder = if let Some(arc_err) = cause_arc {
|
||||
NotificationDetailsBuilder::from_error(arc_err)
|
||||
.set_title(title)
|
||||
} else {
|
||||
NotificationDetailsBuilder::from_title(title)
|
||||
};
|
||||
|
||||
builder = builder
|
||||
.set_http_method(Some(method.to_string()))
|
||||
.set_http_path(Some(path.to_string()))
|
||||
.set_http_status_code(Some(500))
|
||||
.set_urgency(Some(NotificationUrgency::Medium));
|
||||
|
||||
let notification = builder.build();
|
||||
|
||||
if let Err(err) = pager.enqueue_page(notification) {
|
||||
warn!("Error alerting middleware: failed to enqueue page: {:?}", err);
|
||||
} else {
|
||||
debug!("Error alerting middleware: enqueued alert for AdvancedCommonWebError::UncaughtServerError");
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
+14
@@ -6,8 +6,10 @@ use actix_web::Error;
|
||||
|
||||
use pager::client::pager::Pager;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::state::flags::paging_flags::PagingFlags;
|
||||
use super::check_advanced_common_web_error::check_advanced_common_web_error;
|
||||
use super::check_common_web_error::check_common_web_error;
|
||||
use super::check_status_code_fallback::check_status_code_fallback;
|
||||
|
||||
@@ -121,6 +123,12 @@ fn check_ok_response_for_alerts<B>(
|
||||
if let Some(err) = response.response().error() {
|
||||
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
|
||||
|
||||
if let Some(advanced_err) = err.as_error::<AdvancedCommonWebError>() {
|
||||
if check_advanced_common_web_error(pager, method, path, advanced_err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(common_err) = err.as_error::<CommonWebError>() {
|
||||
if check_common_web_error(pager, method, path, common_err) {
|
||||
return;
|
||||
@@ -141,6 +149,12 @@ fn check_err_for_alerts(
|
||||
) {
|
||||
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
|
||||
|
||||
if let Some(advanced_err) = err.as_error::<AdvancedCommonWebError>() {
|
||||
if check_advanced_common_web_error(pager, method, path, advanced_err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(common_err) = err.as_error::<CommonWebError>() {
|
||||
if check_common_web_error(pager, method, path, common_err) {
|
||||
return;
|
||||
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
mod check_advanced_common_web_error;
|
||||
mod check_common_web_error;
|
||||
mod check_status_code_fallback;
|
||||
|
||||
|
||||
+1
-1
@@ -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 = () => {
|
||||
† 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>
|
||||
|
||||
|
||||
+171
@@ -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,
|
||||
|
||||
+3
-1
@@ -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) {
|
||||
|
||||
+3
-1
@@ -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}
|
||||
/>
|
||||
|
||||
+4
-2
@@ -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 && (
|
||||
|
||||
+3
-1
@@ -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,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;
|
||||
}
|
||||
+41
-55
@@ -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 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
Reference in New Issue
Block a user