Compare commits

..
Author SHA1 Message Date
Tanish Manku 515ed08e3a Add copy prompt button and prompt preview to failed task notifications
- Copy button on failed task cards copies the prompt to clipboard with a 3s check/green feedback
- Prompt preview row with marquee scroll on hover (only when text overflows), tooltip showing full prompt up to 300 chars
- Fix dismiss and action button icon centering across all card types (InProgress, Completed, Failed)
- Add marquee keyframe animation to tailwind.config.ts
2026-04-01 19:08:14 -07:00
23 changed files with 336 additions and 993 deletions
-27
View File
@@ -11,33 +11,6 @@
- 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`)
-11
View File
@@ -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
@@ -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),
}
}
}
@@ -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
}
}
}
]
+85 -49
View File
@@ -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) = &notification.http_method {
labels.push(("http_method".to_string(), method.clone()));
}
if let Some(path) = &notification.http_path {
labels.push(("http_path".to_string(), path.clone()));
}
if let Some(status_code) = notification.http_status_code {
labels.push(("http_status_code".to_string(), status_code.to_string()));
}
let labels = if labels.is_empty() { None } else { Some(labels) };
// Enrich the description with context and hostname if present.
let description = match &notification.description {
Some(desc) => {
let mut parts = vec![desc.clone()];
match (&self.application_name, &self.service_id) {
(Some(name), Some(id)) => {
parts.push(format!("Application: {} (service_id: {})", name, id));
}
(Some(name), None) => {
parts.push(format!("Application: {}", name));
}
(None, Some(id)) => {
parts.push(format!("Service ID: {}", id));
}
(None, None) => {}
}
if let Some(method) = &notification.http_method {
parts.push(format!("HTTP Method: {}", method));
}
if let Some(path) = &notification.http_path {
parts.push(format!("HTTP Path: {}", path));
}
if let Some(status_code) = notification.http_status_code {
parts.push(format!("HTTP Status Code: {}", status_code));
}
if let Some(user_token) = &notification.user_token {
parts.push(format!("User Token: {}", user_token));
}
if let Some(media_file_token) = &notification.media_file_token {
parts.push(format!("Media File Token: {}", media_file_token));
}
if let Some(inference_job_token) = &notification.inference_job_token {
parts.push(format!("Inference Job Token: {}", inference_job_token));
}
if let Some(third_party_id) = &notification.third_party_id {
parts.push(format!("Third Party ID: {}", third_party_id));
}
if let Some(h) = &self.hostname {
parts.push(format!("Hostname: {}", h));
}
Some(parts.join("\n\n"))
}
None => None,
};
// 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) = &notification.http_method {
labels.push(("http_method".to_string(), method.clone()));
}
if let Some(path) = &notification.http_path {
labels.push(("http_path".to_string(), path.clone()));
}
if let Some(status_code) = notification.http_status_code {
labels.push(("http_status_code".to_string(), status_code.to_string()));
}
if labels.is_empty() { None } else { Some(labels) }
}
}
@@ -63,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!("{}...", &notification.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,
@@ -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,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;
@@ -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()
@@ -45,8 +45,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 {
@@ -119,32 +117,27 @@ pub async fn fal_webhook_handler(
}
};
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: {:?}\n\nWebhook JSON Payload: {}",
request_id, err, raw_body,
)))
.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);
}
}
result
}
/// Send a pager alert for early parse failures (before we have a request_id).
@@ -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
}
@@ -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,4 +1,3 @@
mod check_advanced_common_web_error;
mod check_common_web_error;
mod check_status_code_fallback;
@@ -35,7 +35,7 @@ pub async fn db_health_checker_thread(
Ok(_) => {},
}
let notification = NotificationDetailsBuilder::from_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();
@@ -12,6 +12,8 @@ import {
EnqueueImageToVideoRequest,
} from "@storyteller/tauri-api";
import {
faMessageXmark,
faMessageCheck,
faWaveformLines,
faClock,
faTriangleExclamation,
@@ -40,17 +42,6 @@ import { twMerge } from "tailwind-merge";
import { toast } from "@storyteller/ui-toaster";
import { GenerationProvider } from "@storyteller/api-enums";
declare global {
interface Window {
__storeTaskEnqueueMeta?: (meta: {
prompt?: string;
refImageUrls?: string[];
modelType?: string;
timestamp: number;
}) => void;
}
}
type GROK_ASPECT_RATIO = "landscape" | "portrait" | "square";
const DEFAULT_RESOLUTIONS: SizeOption[] = [
@@ -115,6 +106,8 @@ export const PromptBoxVideo = ({
const [content, setContent] = useState<React.ReactNode>(null);
const prompt = usePromptVideoStore((s) => s.prompt);
const setPrompt = usePromptVideoStore((s) => s.setPrompt);
const useSystemPrompt = usePromptVideoStore((s) => s.useSystemPrompt);
const setUseSystemPrompt = usePromptVideoStore((s) => s.setUseSystemPrompt);
const generateWithSound = usePromptVideoStore((s) => s.generateWithSound);
const setGenerateWithSound = usePromptVideoStore(
(s) => s.setGenerateWithSound,
@@ -231,67 +224,52 @@ export const PromptBoxVideo = ({
);
};
// Sync duration with model default when switching models.
// Read duration from the store directly to avoid stale closure issues
// when the model and duration are updated together (e.g. during recreate).
// Sync duration with model default when switching models
useEffect(() => {
const currentDuration = usePromptVideoStore.getState().duration;
if (selectedModel?.durationOptions && selectedModel.defaultDuration) {
if (
currentDuration === null ||
!selectedModel.durationOptions.includes(currentDuration)
duration === null ||
!selectedModel.durationOptions.includes(duration)
) {
setDuration(selectedModel.defaultDuration);
}
} else if (currentDuration !== null) {
} else if (duration !== null) {
setDuration(null);
}
}, [selectedModel]);
// Sync resolution with model default when switching models.
// Read from store directly to avoid stale closure (same as duration above).
// Sync resolution with model default when switching models
useEffect(() => {
const currentResolution = usePromptVideoStore.getState().resolution;
if (selectedModel?.resolutionOptions && selectedModel.defaultResolution) {
if (
!selectedModel.resolutionOptions.includes(currentResolution as string)
) {
if (!selectedModel.resolutionOptions.includes(resolution as string)) {
setResolution(selectedModel.defaultResolution);
}
}
}, [selectedModel]);
// Reset input mode when switching to a model that doesn't support reference.
// Read from store directly to avoid stale closure (same as duration above).
// Reset input mode when switching to a model that doesn't support reference
useEffect(() => {
const currentInputMode = usePromptVideoStore.getState().inputMode;
if (
!selectedModel?.supportsReferenceMode &&
currentInputMode === "reference"
) {
if (!selectedModel?.supportsReferenceMode && inputMode === "reference") {
setInputMode("keyframe");
setReferenceVideos([]);
setReferenceAudios([]);
}
}, [selectedModel]);
// Reset generation count when switching away from seedance 2.0.
// Read from store directly to avoid stale closure (same as duration above).
// Reset generation count when switching away from seedance 2.0
useEffect(() => {
const currentGenerationCount =
usePromptVideoStore.getState().generationCount;
if (selectedModel?.id !== "seedance_2p0" && currentGenerationCount > 1) {
if (selectedModel?.id !== "seedance_2p0" && generationCount > 1) {
setGenerationCount(1);
}
}, [selectedModel]);
const durationRange = selectedModel?.durationOptions?.length
? {
min: selectedModel.durationOptions[0]!,
max: selectedModel.durationOptions[
selectedModel.durationOptions.length - 1
]!,
}
min: selectedModel.durationOptions[0]!,
max: selectedModel.durationOptions[
selectedModel.durationOptions.length - 1
]!,
}
: null;
const effectiveDuration = duration ?? selectedModel?.defaultDuration ?? 5;
const [localDuration, setLocalDuration] = useState(effectiveDuration);
@@ -308,9 +286,9 @@ export const PromptBoxVideo = ({
const resolutionPickerOptions: PopoverItem[] | null =
selectedModel?.resolutionOptions
? selectedModel.resolutionOptions.map((r) => ({
label: r,
selected: r === resolution,
}))
label: r,
selected: r === resolution,
}))
: null;
const handleResolutionSelect = (selectedItem: PopoverItem) => {
@@ -320,17 +298,17 @@ export const PromptBoxVideo = ({
const inputModeOptions: PopoverItem[] | null =
selectedModel?.supportsReferenceMode
? [
{
label: "Keyframe",
description: "First/Last frame",
selected: inputMode === "keyframe",
},
{
label: "Reference",
description: "Multi-media ref",
selected: inputMode === "reference",
},
]
{
label: "Keyframe",
description: "First/Last frame",
selected: inputMode === "keyframe",
},
{
label: "Reference",
description: "Multi-media ref",
selected: inputMode === "reference",
},
]
: null;
const handleInputModeSelect = (selectedItem: PopoverItem) => {
@@ -435,26 +413,26 @@ export const PromptBoxVideo = ({
const mentionItems = isReferenceMode
? [
...referenceImages.map((img, i) => ({
label: `@Image${i + 1}`,
type: "image" as const,
preview: img.url,
})),
...referenceVideos.map((vid, i) => ({
label: `@Video${i + 1}`,
type: "video" as const,
preview: vid.url,
})),
...referenceAudios.map((_aud, i) => ({
label: `@Audio${i + 1}`,
type: "audio" as const,
preview: undefined as string | undefined,
})),
].filter((item) =>
mentionFilter
? item.label.toLowerCase().includes(mentionFilter.toLowerCase())
: true,
)
...referenceImages.map((img, i) => ({
label: `@Image${i + 1}`,
type: "image" as const,
preview: img.url,
})),
...referenceVideos.map((vid, i) => ({
label: `@Video${i + 1}`,
type: "video" as const,
preview: vid.url,
})),
...referenceAudios.map((_aud, i) => ({
label: `@Audio${i + 1}`,
type: "audio" as const,
preview: undefined as string | undefined,
})),
].filter((item) =>
mentionFilter
? item.label.toLowerCase().includes(mentionFilter.toLowerCase())
: true,
)
: [];
const insertMention = (label: string) => {
@@ -525,9 +503,7 @@ export const PromptBoxVideo = ({
return;
}
if (prompt.length > maxLen) {
toast.error(
`Prompt exceeds the ${maxLen} character limit for this model`,
);
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
return;
}
@@ -645,20 +621,24 @@ export const PromptBoxVideo = ({
window.__storeTaskEnqueueMeta?.({
prompt,
refImageUrls: referenceImages?.map((img) => img.url).filter(Boolean),
refImageUrls: referenceImages
?.map((img) => img.url)
.filter(Boolean),
modelType: (selectedModel as any)?.tauriId || String(selectedModel),
timestamp: Date.now(),
});
const subscriberIds: string[] = [];
const enqueuePromises: Promise<unknown>[] = [];
const enqueuePromises: Promise<void>[] = [];
for (let i = 0; i < count; i++) {
const subscriberId = crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
subscriberIds.push(subscriberId);
enqueuePromises.push(EnqueueImageToVideo(buildRequest(subscriberId)));
enqueuePromises.push(
EnqueueImageToVideo(buildRequest(subscriberId)) as Promise<void>,
);
}
try {
@@ -924,9 +904,7 @@ export const PromptBoxVideo = ({
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<span
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
>
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}>
{prompt.length} / {maxLen}
</span>
</div>
@@ -1003,6 +981,7 @@ export const PromptBoxVideo = ({
</Tooltip>
)}
{selectedModel?.generateWithSound && (
<Tooltip
content={generateWithSound ? "Sound: ON" : "Sound: OFF"}
@@ -1034,6 +1013,7 @@ export const PromptBoxVideo = ({
/>
</Tooltip>
)}
</div>
<div className="flex items-center gap-2">
{modelNeedsAnImageButNoneAreSelected && (
@@ -1063,9 +1043,7 @@ export const PromptBoxVideo = ({
onClick={handleEnqueue}
disabled={!prompt.trim()}
loading={isEnqueueing}
credits={
credits != null ? credits * generationCount : credits
}
credits={credits != null ? credits * generationCount : credits}
>
Generate
</GenerateButton>
@@ -1074,20 +1052,13 @@ export const PromptBoxVideo = ({
</div>
</div>
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
<Tooltip
content={isExpanded ? "Collapse" : "Expand"}
position="top"
className="-mb-2"
>
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
<button
type="button"
onClick={toggleExpand}
className="text-base-fg/30 hover:text-base-fg/90 transition-colors px-3 py-0.5"
>
<FontAwesomeIcon
icon={isExpanded ? faChevronUp : faChevronDown}
className="text-xs"
/>
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
</button>
</Tooltip>
</div>