mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 02:29:32 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e8875b07c | ||
|
|
e391bbff04 |
@@ -1,47 +0,0 @@
|
||||
# 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
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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,14 +62,3 @@ 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,12 +5163,6 @@ 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"
|
||||
@@ -11852,7 +11846,6 @@ 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", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
|
||||
tauri = { version = "2.8.3", features = ["unstable", "devtools"] } # 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"
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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,4 +5,3 @@ pub mod error;
|
||||
pub mod model;
|
||||
pub mod requests;
|
||||
pub mod utils;
|
||||
pub mod webhook_api;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod parse_webhook_inner_payload;
|
||||
pub mod parse_webhook_payload;
|
||||
pub mod payload;
|
||||
@@ -1,240 +0,0 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod webhook_inner_payload;
|
||||
pub mod webhook_payload;
|
||||
pub mod webhook_error_type;
|
||||
@@ -1,104 +0,0 @@
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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,8 +1,6 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use wreq::StatusCode;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Seedance2ProSpecificApiError {
|
||||
/// The session cookies are expired or invalid.
|
||||
@@ -10,12 +8,6 @@ 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 {}
|
||||
@@ -25,7 +17,6 @@ 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -5,7 +5,6 @@ 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;
|
||||
@@ -309,7 +308,10 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
|
||||
info!("Response status: {}, body: {}", status, response_body);
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(categorize_seedance2pro_error(status, response_body));
|
||||
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status,
|
||||
body: response_body,
|
||||
}.into());
|
||||
}
|
||||
|
||||
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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,2 +1 @@
|
||||
pub mod categorize_seedance2pro_error;
|
||||
pub mod common_headers;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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,7 +10,6 @@
|
||||
"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,6 +34,10 @@ 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,10 +14,6 @@
|
||||
"windows": [
|
||||
],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**"]
|
||||
},
|
||||
"csp": {
|
||||
"default-src": [
|
||||
"'self'",
|
||||
@@ -36,7 +32,6 @@
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
"'unsafe-inline'",
|
||||
"asset:",
|
||||
"blob:",
|
||||
"data:",
|
||||
"http://ipc.localhost",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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,11 +66,7 @@ pub struct PageSentResult {
|
||||
pub short_id: Option<String>,
|
||||
}
|
||||
|
||||
// =============== Implementation ===============
|
||||
|
||||
impl PagerClient {
|
||||
// --- Constructor ---
|
||||
|
||||
pub fn new(
|
||||
client_config: PagerClientConfig,
|
||||
application_name: Option<String>,
|
||||
@@ -81,8 +77,6 @@ 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)
|
||||
}
|
||||
@@ -100,8 +94,6 @@ impl PagerClient {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private helpers (called by send_page) ---
|
||||
|
||||
async fn send_page_via_rootly(
|
||||
&self,
|
||||
notification: &NotificationDetails,
|
||||
@@ -123,13 +115,92 @@ impl PagerClient {
|
||||
|
||||
debug!("Sending page via Rootly (source={}): {}", source, notification.title);
|
||||
|
||||
let labels = self.build_labels(notification);
|
||||
let mut labels: Vec<(String, String)> = Vec::new();
|
||||
|
||||
let description = notification.build_enriched_description(
|
||||
self.application_name.as_deref(),
|
||||
self.service_id.as_deref(),
|
||||
self.hostname.as_deref(),
|
||||
);
|
||||
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,
|
||||
};
|
||||
|
||||
// https://docs.rootly.com/api-reference/alerts/creates-an-alert
|
||||
let result = create_alert(CreateAlertArgs {
|
||||
@@ -170,39 +241,4 @@ 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,7 +63,6 @@ mod tests {
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
maybe_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +222,6 @@ mod tests {
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
maybe_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,220 +1,162 @@
|
||||
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(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NotificationDetails {
|
||||
/// Title or summary of the alert.
|
||||
pub(crate) title: String,
|
||||
pub title: String,
|
||||
|
||||
/// Full details for the alert.
|
||||
pub(crate) description: Option<String>,
|
||||
|
||||
/// Urgency level for the notification.
|
||||
pub(crate) urgency: Option<NotificationUrgency>,
|
||||
pub description: Option<String>,
|
||||
|
||||
/// 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,
|
||||
pub event_time: DateTime<Utc>,
|
||||
|
||||
/// HTTP method associated with the event, if any.
|
||||
pub(crate) http_method: Option<String>,
|
||||
pub http_method: Option<String>,
|
||||
|
||||
/// HTTP endpoint path associated with the event, if any.
|
||||
pub(crate) http_path: Option<String>,
|
||||
pub http_path: Option<String>,
|
||||
|
||||
/// HTTP status code associated with the event, if any.
|
||||
pub(crate) http_status_code: Option<u16>,
|
||||
pub http_status_code: Option<u16>,
|
||||
|
||||
/// Whether this notification originated from an error.
|
||||
pub is_from_error: bool,
|
||||
|
||||
/// Urgency level for the notification.
|
||||
pub urgency: Option<NotificationUrgency>,
|
||||
|
||||
/// User token associated with the event, if any.
|
||||
pub(crate) user_token: Option<String>,
|
||||
pub user_token: Option<String>,
|
||||
|
||||
/// Media file token associated with the event, if any.
|
||||
pub(crate) media_file_token: Option<String>,
|
||||
pub media_file_token: Option<String>,
|
||||
|
||||
/// Inference job token associated with the event, if any.
|
||||
pub(crate) inference_job_token: Option<String>,
|
||||
pub inference_job_token: Option<String>,
|
||||
|
||||
/// Third-party identifier associated with the event, if any.
|
||||
pub(crate) third_party_id: Option<String>,
|
||||
pub 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)
|
||||
}
|
||||
|
||||
// =============== Description Building ===============
|
||||
|
||||
/// Build the full enriched description for this notification.
|
||||
///
|
||||
/// 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();
|
||||
|
||||
// 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 {
|
||||
Some(sections.join("\n\n"))
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()?;
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
/// Create a notification from any error type.
|
||||
///
|
||||
/// 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);
|
||||
|
||||
// Top-level error
|
||||
parts.push(format!("Error: {}", error));
|
||||
// Truncate the title to a reasonable length for alert titles.
|
||||
let title = if title.len() > 200 {
|
||||
format!("{}...", &title[..197])
|
||||
} else {
|
||||
title
|
||||
};
|
||||
|
||||
// Debug representation if it differs
|
||||
let event_time = Utc::now();
|
||||
|
||||
let mut description_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));
|
||||
|
||||
// Include the Debug representation if it differs from Display (often has more detail).
|
||||
let debug_repr = format!("{:?}", error);
|
||||
let display_repr = format!("{}", error);
|
||||
if debug_repr != display_repr {
|
||||
parts.push(format!("Debug: {}", debug_repr));
|
||||
description_parts.push(String::new());
|
||||
description_parts.push(format!("Debug: {}", debug_repr));
|
||||
}
|
||||
|
||||
// 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}"));
|
||||
// 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).
|
||||
|
||||
let cause_debug = format!("{:?}", cause);
|
||||
let cause_display = format!("{}", cause);
|
||||
if cause_debug != cause_display {
|
||||
parts.push(format!(" Debug ({depth}): {cause_debug}"));
|
||||
// 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));
|
||||
}
|
||||
|
||||
source = cause.source();
|
||||
}
|
||||
|
||||
Some(parts.join("\n"))
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
/// 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]);
|
||||
}
|
||||
|
||||
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")) }
|
||||
notification
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
use crate::notification::notification_details::NotificationDetails;
|
||||
use crate::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
pub struct NotificationDetailsBuilder {
|
||||
title: String,
|
||||
pub(crate) 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>,
|
||||
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>,
|
||||
}
|
||||
|
||||
impl NotificationDetailsBuilder {
|
||||
@@ -31,37 +25,12 @@ impl NotificationDetailsBuilder {
|
||||
Self {
|
||||
title,
|
||||
description: None,
|
||||
urgency: None,
|
||||
event_time: Utc::now(),
|
||||
maybe_error: None,
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
is_from_error: false,
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: "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,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
@@ -69,6 +38,29 @@ 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);
|
||||
Self {
|
||||
title: details.title,
|
||||
description: details.description,
|
||||
event_time: details.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,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_title(mut self, title: String) -> Self {
|
||||
self.title = title;
|
||||
self
|
||||
@@ -79,16 +71,6 @@ 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
|
||||
@@ -104,6 +86,11 @@ 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
|
||||
@@ -128,13 +115,12 @@ 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,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# 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
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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,9 +11,7 @@ pub fn alert_pager_and_return_err<T>(
|
||||
err: anyhow::Error,
|
||||
job: Option<&PendingSeedance2ProJob>,
|
||||
) -> anyhow::Result<T> {
|
||||
let err_message = format!("{:#}", err);
|
||||
|
||||
let mut builder = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
let mut builder = NotificationDetailsBuilder::from_error(&err)
|
||||
.set_title(title.to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium));
|
||||
|
||||
@@ -30,5 +28,5 @@ pub fn alert_pager_and_return_err<T>(
|
||||
error!("Failed to enqueue pager alert: {:?}", pager_err);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(err_message))
|
||||
Err(err)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ pub async fn process_failed_job(
|
||||
err,
|
||||
);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
let notification = NotificationDetailsBuilder::from_error(&err)
|
||||
.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_boxed_error(err.into())
|
||||
let notification = NotificationDetailsBuilder::from_error(&err)
|
||||
.set_title("Seedance2Pro mark job failed error".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
|
||||
@@ -9,9 +9,7 @@ pub fn alert_pager_and_return_err<T>(
|
||||
title: &str,
|
||||
err: anyhow::Error,
|
||||
) -> anyhow::Result<T> {
|
||||
let err_message = format!("{:#}", err);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
let notification = NotificationDetailsBuilder::from_error(&err)
|
||||
.set_title(title.to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
@@ -20,5 +18,5 @@ pub fn alert_pager_and_return_err<T>(
|
||||
error!("Failed to enqueue pager alert: {:?}", pager_err);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(err_message))
|
||||
Err(err)
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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,6 +307,7 @@ 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::*;
|
||||
@@ -514,6 +515,8 @@ 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
@@ -1,332 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
pub mod advanced_common_web_error;
|
||||
pub mod common_web_error;
|
||||
pub mod media;
|
||||
pub mod media_file_origin_details;
|
||||
|
||||
+28
@@ -14,6 +14,8 @@ 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;
|
||||
@@ -30,6 +32,7 @@ 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;
|
||||
@@ -86,6 +89,28 @@ 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() {
|
||||
@@ -152,6 +177,9 @@ 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,6 +15,8 @@ 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;
|
||||
@@ -32,6 +34,7 @@ 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,6 +88,29 @@ 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() {
|
||||
@@ -170,6 +196,9 @@ 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,6 +18,8 @@ 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;
|
||||
@@ -35,6 +37,7 @@ 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};
|
||||
@@ -90,6 +93,29 @@ 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() {
|
||||
@@ -190,6 +216,10 @@ 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(),
|
||||
@@ -255,6 +285,10 @@ 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,6 +19,8 @@ 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,6 +40,7 @@ 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,6 +96,29 @@ 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);
|
||||
@@ -147,6 +173,10 @@ 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(),
|
||||
@@ -201,6 +231,10 @@ 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,6 +18,8 @@ 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;
|
||||
@@ -36,6 +38,7 @@ 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};
|
||||
@@ -91,6 +94,29 @@ 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);
|
||||
@@ -152,6 +178,11 @@ 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(),
|
||||
@@ -214,6 +245,11 @@ 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,6 +14,8 @@ 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;
|
||||
@@ -30,6 +32,7 @@ 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;
|
||||
@@ -82,6 +85,29 @@ 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);
|
||||
@@ -120,6 +146,10 @@ 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_boxed_error(err.into())
|
||||
NotificationDetailsBuilder::from_error(&err)
|
||||
.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_boxed_error(err.into())
|
||||
NotificationDetailsBuilder::from_error(&err)
|
||||
.set_title("Seedance 2.0 generation failed".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build()
|
||||
|
||||
+77
-95
@@ -2,24 +2,43 @@ 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_successful_fal_webhook;
|
||||
use crate::http_server::endpoints::webhooks::process_success::handle_successful_fal_webhook::handle_sucessful_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)]
|
||||
@@ -45,8 +64,6 @@ 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 {
|
||||
@@ -63,112 +80,77 @@ impl From<anyhow::Error> for FalWebhookError {
|
||||
|
||||
// =============== Handler ===============
|
||||
|
||||
// TODO(bt, 2025-06-03): Handle webhook crypto authentication
|
||||
/// 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"),
|
||||
)
|
||||
)]
|
||||
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> {
|
||||
|
||||
// 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())
|
||||
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())
|
||||
})?;
|
||||
|
||||
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
|
||||
let result = match request.status {
|
||||
FalWebhookStatus::Ok => {
|
||||
handle_sucessful_fal_webhook(&server_state, request_id, payload).await
|
||||
}
|
||||
WebhookInnerPayload::Error(error_data) => {
|
||||
FalWebhookStatus::Error => {
|
||||
handle_failed_fal_webhook(
|
||||
&server_state,
|
||||
request_id,
|
||||
&error_data,
|
||||
webhook_payload.error.as_deref(),
|
||||
payload,
|
||||
request.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)
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(success) => Ok(success),
|
||||
Err(err) => {
|
||||
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
|
||||
if let Err(ref err) = result {
|
||||
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
|
||||
|
||||
let description = format!(
|
||||
"FAL webhook failed for request_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
|
||||
request_id, err, raw_body,
|
||||
);
|
||||
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 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)
|
||||
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
|
||||
error!("Failed to enqueue FAL webhook pager alert: {:?}", pager_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
+39
-29
@@ -3,47 +3,55 @@ 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 a FAL WebhookErrorType to a frontend failure category.
|
||||
/// Map FAL error type strings to frontend failure categories.
|
||||
///
|
||||
/// 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,
|
||||
/// 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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
FrontendFailureCategory::GenerationFailed
|
||||
}
|
||||
|
||||
/// Handle a FAL webhook with status ERROR.
|
||||
///
|
||||
/// Looks up the job by request_id and marks it as failed using the parsed error data.
|
||||
/// Parses the error details from the payload, looks up the job, and marks it as failed.
|
||||
pub async fn handle_failed_fal_webhook(
|
||||
server_state: &ServerState,
|
||||
request_id: &str,
|
||||
error_data: &ErrorData,
|
||||
payload: &Value,
|
||||
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={:?}, error_type={:?}, message={:?}",
|
||||
"FAL webhook ERROR for request_id {}: top_level_error={:?}, details=[{}]",
|
||||
request_id,
|
||||
maybe_top_level_error,
|
||||
error_data.error_type,
|
||||
error_data.message,
|
||||
error_summary,
|
||||
);
|
||||
|
||||
// Look up the job record.
|
||||
@@ -59,9 +67,12 @@ pub async fn handle_failed_fal_webhook(
|
||||
}
|
||||
};
|
||||
|
||||
// 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()
|
||||
// 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("; ")
|
||||
} else if let Some(top_level) = maybe_top_level_error {
|
||||
top_level.to_string()
|
||||
} else {
|
||||
@@ -69,14 +80,13 @@ pub async fn handle_failed_fal_webhook(
|
||||
};
|
||||
|
||||
let internal_failure_reason = format!(
|
||||
"FAL request_id={}, top_level_error={:?}, error_type={:?}, message={:?}",
|
||||
"FAL request_id={}, top_level_error={:?}, details=[{}]",
|
||||
request_id,
|
||||
maybe_top_level_error,
|
||||
error_data.error_type,
|
||||
error_data.message,
|
||||
error_summary,
|
||||
);
|
||||
|
||||
let failure_category = guess_failure_category(error_data.error_type.as_ref());
|
||||
let failure_category = guess_failure_category(&error_details);
|
||||
|
||||
info!(
|
||||
"Marking job {} as failed for request_id {}. Category: {:?}, Reason: {}",
|
||||
|
||||
+1
@@ -1 +1,2 @@
|
||||
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_successful_fal_webhook(
|
||||
pub async fn handle_sucessful_fal_webhook(
|
||||
server_state: &ServerState,
|
||||
request_id: &str,
|
||||
payload: &Value,
|
||||
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
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,10 +6,8 @@ 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;
|
||||
|
||||
@@ -123,12 +121,6 @@ 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;
|
||||
@@ -149,12 +141,6 @@ 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,4 +1,3 @@
|
||||
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_boxed_error(database_error.into())
|
||||
let notification = NotificationDetailsBuilder::from_error(&database_error)
|
||||
.set_title("DB health check thread failed check".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build();
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
faGithubAlt,
|
||||
faLinkedinIn,
|
||||
faInstagram,
|
||||
faRedditAlien,
|
||||
} from "@fortawesome/free-brands-svg-icons";
|
||||
import { SOCIAL_LINKS } from "../../config/links";
|
||||
import { getFaqItems, getTutorialItems } from "@storyteller/markdown-content";
|
||||
@@ -52,11 +51,6 @@ 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,5 +5,4 @@ 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,7 +96,6 @@ 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>
|
||||
@@ -108,6 +107,8 @@ 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
@@ -1,171 +0,0 @@
|
||||
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,9 +19,6 @@ interface UploadEntryCardProps {
|
||||
secondaryLabel?: string;
|
||||
secondaryIcon?: IconDefinition;
|
||||
onSecondaryClick?: () => void;
|
||||
tertiaryLabel?: string;
|
||||
tertiaryIcon?: IconDefinition;
|
||||
onTertiaryClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -39,9 +36,6 @@ export const UploadEntryCard = ({
|
||||
secondaryLabel,
|
||||
secondaryIcon,
|
||||
onSecondaryClick,
|
||||
tertiaryLabel,
|
||||
tertiaryIcon,
|
||||
onTertiaryClick,
|
||||
disabled,
|
||||
}: UploadEntryCardProps) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -168,17 +162,6 @@ 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,7 +13,6 @@ interface Props {
|
||||
title: string;
|
||||
fileTypes: string[];
|
||||
engineCategory: FilterEngineCategories;
|
||||
initialFile?: File;
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
@@ -26,7 +25,6 @@ interface Props {
|
||||
export const UploadFiles3D = ({
|
||||
fileTypes,
|
||||
engineCategory,
|
||||
initialFile,
|
||||
options,
|
||||
onClose,
|
||||
onUploadProgress,
|
||||
@@ -51,7 +49,7 @@ export const UploadFiles3D = ({
|
||||
const [assetFile, setAssetFile] = useState<{
|
||||
value: File | null;
|
||||
error?: string;
|
||||
}>({ value: initialFile ?? null });
|
||||
}>({ value: null });
|
||||
|
||||
const [previewStatus, setPreviewStatus] = useState<{
|
||||
type: string;
|
||||
@@ -145,7 +143,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,7 +22,6 @@ interface Props {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
titleIcon: IconDefinition;
|
||||
initialFile?: File;
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
@@ -33,7 +32,7 @@ interface Props {
|
||||
const objectFileTypes = Object.values(OBJECT_FILE_TYPE);
|
||||
|
||||
export function UploadModal3D(props: Props) {
|
||||
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile, options } = props;
|
||||
const { isOpen, onClose, onSuccess, title, titleIcon, options } = props;
|
||||
const [uploaderState, setUploaderState] =
|
||||
useState<UploaderState>(initialUploaderState);
|
||||
const [isCharacter, setIsCharacter] = useState(false);
|
||||
@@ -102,7 +101,6 @@ export function UploadModal3D(props: Props) {
|
||||
title={title}
|
||||
engineCategory={selectedCategory}
|
||||
fileTypes={objectFileTypes}
|
||||
initialFile={initialFile}
|
||||
options={{
|
||||
...(options ?? {}),
|
||||
fileSubtypes: characterAnimationOptions,
|
||||
|
||||
+1
-3
@@ -7,21 +7,19 @@ 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: initialFile ?? null });
|
||||
}>({ value: null });
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!assetFile.value) {
|
||||
|
||||
+1
-3
@@ -21,13 +21,12 @@ 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, initialFile } = props;
|
||||
const { isOpen, onClose, onSuccess, title, titleIcon } = props;
|
||||
const [uploaderState, setUploaderState] =
|
||||
useState<UploaderState>(initialUploaderState);
|
||||
|
||||
@@ -68,7 +67,6 @@ export function UploadModalImage(props: Props) {
|
||||
<UploadFilesImage
|
||||
title={title}
|
||||
fileTypes={imageFileTypes}
|
||||
initialFile={initialFile}
|
||||
onClose={onClose}
|
||||
onUploadProgress={updateUploaderState}
|
||||
/>
|
||||
|
||||
+2
-4
@@ -14,7 +14,6 @@ interface Props {
|
||||
title: string;
|
||||
fileTypes: string[];
|
||||
engineCategory: FilterEngineCategories;
|
||||
initialFile?: File;
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
@@ -28,7 +27,6 @@ interface Props {
|
||||
export const UploadFilesSplat = ({
|
||||
fileTypes,
|
||||
engineCategory,
|
||||
initialFile,
|
||||
options,
|
||||
onClose,
|
||||
onUploadProgress,
|
||||
@@ -54,7 +52,7 @@ export const UploadFilesSplat = ({
|
||||
const [assetFile, setAssetFile] = useState<{
|
||||
value: File | null;
|
||||
error?: string;
|
||||
}>({ value: initialFile ?? null });
|
||||
}>({ value: null });
|
||||
|
||||
const [previewStatus, setPreviewStatus] = useState<{
|
||||
type: string;
|
||||
@@ -168,7 +166,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 && (
|
||||
|
||||
+1
-3
@@ -22,7 +22,6 @@ interface Props {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
titleIcon: IconDefinition;
|
||||
initialFile?: File;
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
@@ -34,7 +33,7 @@ const splatFileTypes = Object.values(SPLAT_FILE_TYPE);
|
||||
|
||||
export function UploadModalSplat(props: Props) {
|
||||
const selectedCategory = FilterEngineCategories.SPLAT;
|
||||
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile, options } = props;
|
||||
const { isOpen, onClose, onSuccess, title, titleIcon, options } = props;
|
||||
const [uploaderState, setUploaderState] =
|
||||
useState<UploaderState>(initialUploaderState);
|
||||
|
||||
@@ -73,7 +72,6 @@ export function UploadModalSplat(props: Props) {
|
||||
title={title}
|
||||
engineCategory={selectedCategory}
|
||||
fileTypes={splatFileTypes}
|
||||
initialFile={initialFile}
|
||||
options={{
|
||||
...(options ?? {}),
|
||||
}}
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
faBomb,
|
||||
faCircleExclamation,
|
||||
faTriangleExclamation,
|
||||
faCopy,
|
||||
faCheck,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import {
|
||||
@@ -224,7 +222,7 @@ const InProgressCard = ({
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
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();
|
||||
@@ -304,7 +302,7 @@ const CompletedCard = ({
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
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();
|
||||
@@ -346,14 +344,6 @@ 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;
|
||||
@@ -464,73 +454,19 @@ 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 "@storyteller/ui-pagedraw";
|
||||
import { useSceneStore } from "~/pages/PageDraw/stores/SceneState";
|
||||
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 type { BaseSelectorImage } from "@storyteller/ui-pagedraw";
|
||||
import { BaseSelectorImage } from "../../../pages/PageEdit/BaseImageSelector";
|
||||
import { AppsQuickMenu } from "./AppsQuickMenu";
|
||||
import { SceneTitleInput } from "./SceneTitleInput";
|
||||
import { TaskQueue } from "./TaskQueue";
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -95,7 +94,6 @@ createRoot(document.getElementById("root")!).render(
|
||||
<GlobalSettingsManager env={ENV} />
|
||||
<div className="topbar-spacer" data-tauri-drag-region={true} />
|
||||
<PageEnigma />
|
||||
<GlobalFileDropHandler />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
</>,
|
||||
|
||||
@@ -43,7 +43,8 @@ import {
|
||||
useCostBreakdownModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import { HistoryStack, type ImageBundle, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
|
||||
import { HistoryStack, ImageBundle } from "../PageEdit/HistoryStack";
|
||||
import type { BaseSelectorImage } from "../PageEdit/BaseImageSelector";
|
||||
|
||||
const ANGLES_MODEL_PAGE = ModelPage.Angles;
|
||||
|
||||
@@ -561,6 +562,12 @@ export const Angles = () => {
|
||||
selectedImageToken={selectedImageToken}
|
||||
onImageSelect={handleHistoryImageSelect}
|
||||
onImageRemove={handleHistoryImageRemove}
|
||||
onNewImageBundle={() => {
|
||||
/* handled by our own event listener */
|
||||
}}
|
||||
onResolvePending={() => {
|
||||
/* handled by our own event listener */
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
@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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+3
-13
@@ -21,14 +21,14 @@ import Konva from "konva";
|
||||
import { LineNode, SceneState, useSceneStore } from "./stores/SceneState";
|
||||
import { Node } from "./Node";
|
||||
import { useStageSnapshot } from "./hooks/useUpdateSnapshot";
|
||||
import "./pagedraw.css";
|
||||
import "./App.css";
|
||||
import SplitPane from "./components/ui/SplitPane";
|
||||
import { useRightPanelLayoutManagement } from "./hooks/useRightPanelLayoutManagement";
|
||||
import { useStageCentering } from "./hooks/useCenteredStage";
|
||||
import { useGlobalMouseUp } from "./hooks/useGlobalMouseUp";
|
||||
import { loadImageFromUrl } from "./utilities/imageHelpers";
|
||||
import { loadImageFromUrl } from "~/Helpers/ImageHelpers";
|
||||
import { checkerboard } from "@storyteller/common";
|
||||
import { DragState } from "./types";
|
||||
import { DragState } from "../PageEdit/EditPaintSurface";
|
||||
|
||||
export type MiraiProps = {
|
||||
drawNodes: (Node | LineNode)[];
|
||||
@@ -1684,16 +1684,6 @@ export const PaintSurface = ({
|
||||
zIndex={-1}
|
||||
/>
|
||||
|
||||
{!baseImageBitmap && (
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={getAspectRatioDimensions().width}
|
||||
height={getAspectRatioDimensions().height}
|
||||
fill="white"
|
||||
listening={false}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
ref={baseImageRef}
|
||||
x={0}
|
||||
+7
-2
@@ -1,9 +1,14 @@
|
||||
import React, { useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import ImageStyleSelector from "./ImageStyleSelector";
|
||||
import { ImageStyle } from "./types";
|
||||
import { PromptEditorProps, ImageStyle } from "./types";
|
||||
import { PromptBox2D, PromptBox2DProps } from "@storyteller/ui-promptbox";
|
||||
import { JobProvider } from "../JobContext";
|
||||
import { uploadImage } from "../../../components/reusable/UploadModalMedia/uploadImage";
|
||||
import { EncodeImageBitmapToBase64 } from "../utilities/EncodeImageBitmapToBase64";
|
||||
import { JobProvider, useJobContext } from "../JobContext";
|
||||
|
||||
// Set this value on when enqueue is pressed nasty global variable.
|
||||
import { getCanvasRenderBitmap } from "../../../signals/canvasRenderBitmap";
|
||||
|
||||
const PromptEditor: React.FC<PromptBox2DProps> = ({
|
||||
uploadImage,
|
||||
@@ -0,0 +1,118 @@
|
||||
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;
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
+1
-1
@@ -20,7 +20,7 @@ import {
|
||||
faTriangle,
|
||||
faDroplet,
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
import "../../pagedraw.css";
|
||||
import "../../App.css";
|
||||
import { HsvaColorPicker, HsvaColor } from "react-colorful";
|
||||
import { hsvaToHex } from "@uiw/color-convert";
|
||||
import SliderWithIndicator from "./SliderWithIndicator";
|
||||
@@ -0,0 +1,59 @@
|
||||
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
-1
@@ -1,6 +1,6 @@
|
||||
import { Vector2d } from "konva/lib/types";
|
||||
import { useEffect } from "react";
|
||||
import { DragState } from "../types";
|
||||
import { DragState } from "~/pages/PageEdit/EditPaintSurface";
|
||||
// This piece of code ensures that when you are selecting or dragging and go off the stage,
|
||||
// it releases the controls mouse drag.
|
||||
export const useGlobalMouseUp = (
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user