Compare commits

..
Author SHA1 Message Date
Brandon Thomas 398c56d7eb page works 2026-03-25 12:29:38 -04:00
Brandon Thomas ba21368323 don't log these 2026-03-25 10:47:31 -04:00
Brandon Thomas 0750b9fe10 that worked! 2026-03-25 10:46:57 -04:00
Brandon Thomas 05a26b124e seems to work 2026-03-25 10:41:58 -04:00
Brandon Thomas a394ee5a96 create alert api 2026-03-25 02:30:11 -04:00
Brandon Thomas c35ea620b3 add rootly stub 2026-03-25 02:18:58 -04:00
79 changed files with 337 additions and 2510 deletions
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n j.status as `job_status: enums::common::job_status_plus::JobStatusPlus`,\n j.failure_reason as job_failure_reason,\n wle.credits_delta,\n wle.maybe_linked_refund_ledger_token as `maybe_linked_refund_ledger_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n j.on_success_result_entity_token as `on_success_result_media_token: tokens::tokens::media_files::MediaFileToken`,\n j.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n wle.token as `wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n wle.entry_type as `wallet_ledger_entry_type: enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType`,\n j.maybe_external_third_party as `maybe_external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n j.maybe_external_third_party_id,\n j.created_at,\n j.updated_at\nFROM generic_inference_jobs as j\nLEFT OUTER JOIN users as u\n ON j.maybe_creator_user_token = u.token\nLEFT OUTER JOIN wallet_ledger_entries as wle\n ON wle.maybe_entity_ref = j.token\nWHERE u.token = ?\nORDER BY j.id DESC\n ",
"query": "\nSELECT\n j.status as `job_status: enums::common::job_status_plus::JobStatusPlus`,\n j.failure_reason as job_failure_reason,\n wle.credits_delta,\n wle.maybe_linked_refund_ledger_token as `maybe_linked_refund_ledger_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n j.on_success_result_entity_token as `on_success_result_media_token: tokens::tokens::media_files::MediaFileToken`,\n j.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n wle.token as `wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n wle.entry_type as `wallet_ledger_entry_type: enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType`\nFROM generic_inference_jobs as j\nLEFT OUTER JOIN users as u\n ON j.maybe_creator_user_token = u.token\nLEFT OUTER JOIN wallet_ledger_entries as wle\n ON wle.maybe_entity_ref = j.token\nWHERE u.token = ?\nORDER BY j.id DESC\n ",
"describe": {
"columns": [
{
@@ -82,46 +82,6 @@
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 9,
"name": "maybe_external_third_party_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 10,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 11,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
@@ -135,12 +95,8 @@
true,
false,
true,
true,
true,
true,
false,
false
true
]
},
"hash": "cbdaa45597de14d1eb6dc0f6a95788d56334757bb3d03eda2eb64df3ba2a0144"
"hash": "0757d81ba153b2ad2df25df3dd92b655ef08b95f5f83251bf3573f9be3a9f8d1"
}
Generated
+10 -31
View File
@@ -557,9 +557,9 @@ dependencies = [
[[package]]
name = "anstream"
version = "0.6.21"
version = "0.6.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b"
dependencies = [
"anstyle",
"anstyle-parse",
@@ -596,13 +596,12 @@ dependencies = [
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3274,7 +3273,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6303,7 +6302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [
"cfg-if",
"windows-targets 0.48.5",
"windows-targets 0.52.6",
]
[[package]]
@@ -7738,12 +7737,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.0"
@@ -8030,18 +8023,6 @@ dependencies = [
"sha2 0.10.8",
]
[[package]]
name = "pager"
version = "0.0.1"
dependencies = [
"chrono",
"errors",
"log",
"rootly_client",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -9812,7 +9793,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9929,7 +9910,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11263,7 +11244,6 @@ dependencies = [
"mimetypes",
"mysql_queries",
"once_cell",
"pager",
"password",
"primitives",
"prompt_engineering",
@@ -11276,7 +11256,6 @@ dependencies = [
"regex",
"resend-rs",
"reusable_types",
"rootly_client",
"rust-s3",
"rustls 0.23.36",
"seedance2pro_client",
@@ -12189,7 +12168,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
-2
View File
@@ -75,7 +75,6 @@ members = [
"crates/lib/jwt_signer",
"crates/lib/logging",
"crates/lib/markdown",
"crates/lib/pager",
"crates/lib/password",
"crates/lib/server_environment",
"crates/lib/sqlx_mysql_helpers",
@@ -253,7 +252,6 @@ midjourney_client = { path = "crates/api_clients/midjourney_client" }
mimetypes = { path = "crates/lib/files/mimetypes" }
muapi_client = { path = "crates/api_clients/muapi_client" }
mysql_queries = { path = "crates/schema/database/mysql_queries" }
pager = { path = "crates/lib/pager" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
seedance2pro_client = { path = "crates/api_clients/seedance2pro_client" }
@@ -1 +0,0 @@
pub mod moderation_send_alert;
@@ -1,18 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
pub const MODERATION_SEND_ALERT_PATH: &str = "/v1/moderation/alerts/send";
#[derive(Deserialize, ToSchema)]
pub struct ModerationSendAlertRequest {
/// Optional title for the alert. Defaults to "Test Moderation Alert".
pub title: Option<String>,
/// Optional description for the alert. Defaults to "This is a test moderation alert."
pub description: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModerationSendAlertResponse {
pub success: bool,
}
@@ -1,5 +1,3 @@
use chrono::{DateTime, Utc};
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType;
use enums::common::job_status_plus::JobStatusPlus;
use serde_derive::{Deserialize, Serialize};
@@ -32,8 +30,4 @@ pub struct ListUserJobsEntry {
pub job_token: InferenceJobToken,
pub wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
pub wallet_ledger_entry_type: Option<WalletLedgerEntryType>,
pub maybe_external_third_party: Option<InferenceJobExternalThirdParty>,
pub maybe_external_third_party_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -1,4 +1,3 @@
pub mod alerts;
pub mod jobs;
pub mod user;
pub mod wallet_ledger_entries;
@@ -685,55 +685,6 @@ mod tests {
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_pro_keyframe_with_start_frame() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
// Upload a start frame image from our CDN
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(
test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL,
).await?;
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session: &session,
extension: "jpg".to_string(),
host_override: None,
}).await?;
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes: image_bytes,
host_override: None,
}).await?;
println!("Uploaded start frame: {}", upload_result.public_url);
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Pro,
prompt: "The corgi dog watches the lake.".to_string(),
resolution: Resolution::Portrait9x16,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: Some(upload_result.public_url),
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
// --- Seedance 2 Fast tests ---
#[tokio::test]
@@ -35,11 +35,7 @@ impl KinoviHost {
/// Returns the CDN base URL for uploaded/static files (no trailing slash).
pub fn cdn_base_url(&self) -> &str {
match self {
// NB: The API endpoint moved to kinovi.ai but the CDN for uploaded materials
// still uses the legacy seedance2-pro.com domain. The generate_video API
// expects URLs on this domain.
Self::Kinovi => "https://static.seedance2-pro.com",
// Self::Kinovi => "https://static.kinovi.ai",
Self::Kinovi => "https://static.kinovi.ai",
Self::Seedance2Pro => "https://static.seedance2-pro.com",
Self::CustomHost { cdn_host, .. } => cdn_host.as_str(),
}
-28
View File
@@ -1,28 +0,0 @@
[package]
name = "pager"
edition = "2024"
version = "0.0.1"
publish = false
[lib]
name = "pager"
path = "src/lib.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
errors.workspace = true
rootly_client.workspace = true
# External
chrono.workspace = true
log.workspace = true
tokio.workspace = true
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
-3
View File
@@ -1,3 +0,0 @@
pub mod pager;
pub mod pager_builder;
pub mod pager_client;
-107
View File
@@ -1,107 +0,0 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use log::{debug, info, warn};
use crate::client::pager_client::{PageSentResult, PagerClient};
use crate::error::pager_error::PagerError;
use crate::error::pager_system_error::PagerSystemError;
use crate::notification::notification_details::NotificationDetails;
use crate::worker::pager_worker_message_queue::PagerWorkerMessageQueue;
/// The main programmer interface to the pager system.
///
/// Supports two modes:
/// - **Immediate**: `send_page_immediately()` sends inline (blocks until API responds).
/// - **Queued**: `enqueue_page()` pushes to a background worker (non-blocking).
///
/// Build an instance via `PagerBuilder`.
///
#[derive(Clone)]
pub struct Pager {
client: PagerClient,
queue: Option<Arc<PagerWorkerMessageQueue>>,
worker_shutdown: Option<Arc<AtomicBool>>,
}
impl Pager {
/// Create a Pager without a worker (immediate-only mode).
pub(crate) fn new(client: PagerClient) -> Self {
Self { client, queue: None, worker_shutdown: None }
}
/// Create a Pager with a shared queue and shutdown handle (worker mode).
pub(crate) fn with_queue(
client: PagerClient,
queue: Arc<PagerWorkerMessageQueue>,
worker_shutdown: Arc<AtomicBool>,
) -> Self {
Self {
client,
queue: Some(queue),
worker_shutdown: Some(worker_shutdown),
}
}
/// Send a page immediately, blocking until the API responds.
/// Returns `Ok(None)` if the pager is configured as NoOp.
pub async fn send_page_immediately(
&self,
notification: NotificationDetails,
) -> Result<Option<PageSentResult>, PagerError> {
self.client.send_page(&notification).await
}
/// Send a page immediately. Logs errors but never fails.
/// Returns `None` if the pager is NoOp or if sending failed.
pub async fn send_page_immediately_infallible(
&self,
notification: NotificationDetails,
) -> Option<PageSentResult> {
match self.send_page_immediately(notification).await {
Ok(result) => result,
Err(err) => {
warn!("Failure sending page: {:?}", err);
None
}
}
}
/// Enqueue a page to be sent by the background worker thread.
///
/// Returns `Ok(())` immediately if the pager is NoOp.
/// Returns an error if the worker is not configured (use `build_with_worker()`).
/// If the queue is full, the oldest item is dropped.
pub fn enqueue_page(
&self,
notification: NotificationDetails,
) -> Result<(), PagerError> {
if self.client.is_noop() {
debug!("Pager no-op: would have enqueued page: {}", notification.summary);
return Ok(());
}
let queue = self.queue.as_ref()
.ok_or(PagerSystemError::QueueNotConfigured)?;
let dropped = queue.push(notification)?;
if let Some(dropped) = dropped {
log::warn!("Pager queue overflow: dropped '{}'", dropped.summary);
}
Ok(())
}
/// Signal the worker thread to shut down.
pub fn shutdown_worker(&self) {
if let Some(ref shutdown) = self.worker_shutdown {
info!("Pager: signaling worker shutdown.");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
}
if let Some(ref queue) = self.queue {
info!("Pager: notifying threads blocking on queue of shutdown.");
queue.notify_all();
}
}
}
@@ -1,164 +0,0 @@
use std::sync::Arc;
use log::warn;
use rootly_client::creds::rootly_api_key::RootlyApiKey;
use crate::client::pager::Pager;
use crate::client::pager_client::{PagerClient, PagerClientConfig};
use crate::worker::pager_worker_message_queue::{new_shared_queue, new_shared_queue_with_capacity, PagerWorkerMessageQueue};
use crate::worker::pager_worker::PagerWorker;
/// Builder for constructing a `Pager` instance.
pub struct PagerBuilder {
client_config: Option<PagerClientConfig>,
application_name: Option<String>,
environment: Option<String>,
hostname: Option<String>,
queue_capacity: Option<usize>,
}
impl PagerBuilder {
pub fn new() -> Self {
Self {
client_config: None,
application_name: None,
environment: None,
hostname: None,
queue_capacity: None,
}
}
/// Set the application name (used as the "source" tag on alerts).
pub fn application_name(mut self, name: String) -> Self {
self.application_name = Some(name);
self
}
/// Set the environment label (e.g. "production", "staging").
pub fn environment(mut self, environment: String) -> Self {
self.environment = Some(environment);
self
}
/// Set the hostname of the machine sending alerts.
pub fn hostname(mut self, hostname: String) -> Self {
self.hostname = Some(hostname);
self
}
/// Configure the Rootly backend. Returns a sub-builder for Rootly-specific options.
pub fn rootly(self, api_key: RootlyApiKey) -> RootlyConfigBuilder {
RootlyConfigBuilder {
parent: self,
api_key,
alert_urgency_id: None,
notification_target_type: None,
notification_target_id: None,
}
}
/// Set the backend config directly (for advanced use or future backends).
pub fn client_config(mut self, config: PagerClientConfig) -> Self {
self.client_config = Some(config);
self
}
/// Set the maximum capacity for the worker message queue.
/// Only relevant when using `build_with_worker()`.
pub fn queue_capacity(mut self, capacity: usize) -> Self {
self.queue_capacity = Some(capacity);
self
}
/// Build a `Pager` without a background worker.
///
/// If no backend was configured, a NoOp pager is returned.
pub fn build(self) -> Pager {
let client = self.make_client();
Pager::new(client)
}
/// Build a `Pager` with a background worker thread.
///
/// If no backend was configured, a NoOp pager is returned (the worker will idle).
///
/// The caller is responsible for running the worker on a dedicated thread:
/// ```ignore
/// let (pager, worker) = PagerBuilder::new()
/// .application_name("my-service".to_string())
/// .rootly(api_key)
/// .build_with_worker();
/// ```
pub fn build_with_worker(self) -> (Pager, PagerWorker) {
let client = self.make_client();
let queue: Arc<PagerWorkerMessageQueue> = match self.queue_capacity {
Some(capacity) => new_shared_queue_with_capacity(capacity),
None => new_shared_queue(),
};
let worker = PagerWorker::new(queue.clone(), client.clone());
let shutdown = worker.shutdown_handle();
let pager = Pager::with_queue(client, queue, shutdown);
(pager, worker)
}
fn make_client(&self) -> PagerClient {
let client_config = match self.client_config.clone() {
Some(config) => config,
None => {
warn!("No pager backend configured. Using NoOp pager.");
PagerClientConfig::NoOp
}
};
PagerClient::new(client_config, self.application_name.clone(), self.environment.clone(), self.hostname.clone())
}
}
/// Sub-builder for configuring the Rootly backend.
/// Returned by `PagerBuilder::rootly()`.
pub struct RootlyConfigBuilder {
parent: PagerBuilder,
api_key: RootlyApiKey,
alert_urgency_id: Option<String>,
notification_target_type: Option<String>,
notification_target_id: Option<String>,
}
impl RootlyConfigBuilder {
/// Set the alert urgency ID.
pub fn alert_urgency_id(mut self, id: String) -> Self {
self.alert_urgency_id = Some(id);
self
}
/// Set the notification target (who gets paged).
pub fn notification_target(mut self, target_type: String, target_id: String) -> Self {
self.notification_target_type = Some(target_type);
self.notification_target_id = Some(target_id);
self
}
/// Finish Rootly configuration and return to the parent builder.
pub fn done(mut self) -> PagerBuilder {
self.parent.client_config = Some(PagerClientConfig::Rootly {
api_key: self.api_key,
alert_urgency_id: self.alert_urgency_id,
notification_target_type: self.notification_target_type,
notification_target_id: self.notification_target_id,
});
self.parent
}
/// Shortcut: finish Rootly config and build without a worker.
pub fn build(self) -> Pager {
self.done().build()
}
/// Shortcut: finish Rootly config and build with a worker.
pub fn build_with_worker(self) -> (Pager, PagerWorker) {
self.done().build_with_worker()
}
}
-155
View File
@@ -1,155 +0,0 @@
use log::{debug, warn};
use rootly_client::creds::rootly_api_key::RootlyApiKey;
use rootly_client::requests::create_alert::create_alert::{
create_alert, CreateAlertArgs,
};
use crate::error::pager_error::PagerError;
use crate::error::pager_service_error::PagerServiceError;
use crate::notification::notification_details::NotificationDetails;
/// The actual client that sends pages.
#[derive(Clone)]
pub struct PagerClient {
/// The backend-specific configuration.
pub client_config: PagerClientConfig,
/// Application name used as the "source" tag in alerts (e.g. "storyteller-web", "seedance2-pro-job").
pub application_name: Option<String>,
/// Environment label (e.g. "production", "staging"). Inserted as a label on alerts.
pub environment: Option<String>,
/// Hostname of the machine sending alerts. Inserted as a label on alerts.
pub hostname: Option<String>,
}
/// Configuration for the pager client backend.
#[derive(Clone)]
pub enum PagerClientConfig {
/// No-op backend. All calls are silently ignored.
NoOp,
/// Use Rootly as the paging backend.
Rootly {
api_key: RootlyApiKey,
/// Alert urgency ID (e.g. "62fde143-..." maps to "High" in our org).
alert_urgency_id: Option<String>,
/// Notification target type (e.g. "User", "EscalationPolicy").
notification_target_type: Option<String>,
/// Notification target ID (e.g. a user ID or escalation policy ID).
notification_target_id: Option<String>,
},
}
/// Result of a successfully sent page.
#[derive(Debug, Clone)]
pub struct PageSentResult {
/// A backend-specific identifier for the sent notification.
pub id: Option<String>,
/// A short human-readable ID (if the backend provides one).
pub short_id: Option<String>,
}
impl PagerClient {
pub fn new(
client_config: PagerClientConfig,
application_name: Option<String>,
environment: Option<String>,
hostname: Option<String>,
) -> Self {
Self { client_config, application_name, environment, hostname }
}
pub fn is_noop(&self) -> bool {
matches!(self.client_config, PagerClientConfig::NoOp)
}
/// Send a page immediately. Returns `Ok(None)` for NoOp.
pub async fn send_page(&self, notification: &NotificationDetails) -> Result<Option<PageSentResult>, PagerError> {
match &self.client_config {
PagerClientConfig::NoOp => {
debug!("Pager no-op: would have sent page: {}", notification.summary);
Ok(None)
}
PagerClientConfig::Rootly { .. } => {
self.send_page_via_rootly(notification).await.map(Some)
}
}
}
async fn send_page_via_rootly(
&self,
notification: &NotificationDetails,
) -> Result<PageSentResult, PagerError> {
let PagerClientConfig::Rootly {
api_key,
alert_urgency_id,
notification_target_type,
notification_target_id,
} = &self.client_config else {
return Ok(PageSentResult { id: None, short_id: None });
};
let source = self.application_name
.clone()
.unwrap_or_else(|| "unknown".to_string());
debug!("Sending page via Rootly (source={}): {}", source, notification.summary);
let mut labels: Vec<(String, String)> = Vec::new();
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()));
}
let labels = if labels.is_empty() { None } else { Some(labels) };
// Enrich the description with hostname if both are present.
let description = match (&notification.description, &self.hostname) {
(Some(desc), Some(h)) => Some(format!("{}\n\nHostname: {}", desc, h)),
(desc, _) => desc.clone(),
};
let result = create_alert(CreateAlertArgs {
api_key: api_key.clone(),
source,
summary: notification.summary.clone(),
description,
status: Some("triggered".to_string()),
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: None,
external_url: None,
alert_urgency_id: alert_urgency_id.clone(),
notification_target_type: notification_target_type.clone(),
notification_target_id: notification_target_id.clone(),
labels,
deduplication_key: None,
}).await;
match result {
Ok(success) => {
debug!("Page sent successfully via Rootly: id={}, short_id={:?}", success.id, success.short_id);
Ok(PageSentResult {
id: Some(success.id),
short_id: success.short_id,
})
}
Err(err) => {
warn!("Failed to send page via Rootly: {}", err);
Err(PagerError::Service(PagerServiceError::RootlyError(err)))
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod pager_error;
pub mod pager_service_error;
pub mod pager_system_error;
-38
View File
@@ -1,38 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use crate::error::pager_service_error::PagerServiceError;
use crate::error::pager_system_error::PagerSystemError;
/// Top-level error type for the pager library.
#[derive(Debug)]
pub enum PagerError {
/// A third-party paging service (Rootly, etc.) returned an error.
Service(PagerServiceError),
/// Our own pager system broke (mutex, queue, etc.).
System(PagerSystemError),
}
impl Error for PagerError {}
impl Display for PagerError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Service(err) => write!(f, "PagerServiceError: {}", err),
Self::System(err) => write!(f, "PagerSystemError: {}", err),
}
}
}
impl From<PagerServiceError> for PagerError {
fn from(err: PagerServiceError) -> Self {
Self::Service(err)
}
}
impl From<PagerSystemError> for PagerError {
fn from(err: PagerSystemError) -> Self {
Self::System(err)
}
}
@@ -1,27 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use rootly_client::error::rootly_error::RootlyError;
/// Errors from third-party paging services (Rootly, etc.).
#[derive(Debug)]
pub enum PagerServiceError {
/// The underlying Rootly API returned an error.
RootlyError(RootlyError),
}
impl Error for PagerServiceError {}
impl Display for PagerServiceError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::RootlyError(err) => write!(f, "Rootly API error: {}", err),
}
}
}
impl From<RootlyError> for PagerServiceError {
fn from(err: RootlyError) -> Self {
Self::RootlyError(err)
}
}
@@ -1,23 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// Errors from our own pager system at runtime (concurrency, queue, etc.).
#[derive(Debug)]
pub enum PagerSystemError {
/// The message queue was not configured. Use `build_with_worker()` to enable queuing.
QueueNotConfigured,
/// A mutex lock was poisoned (another thread panicked while holding it).
MutexPoisoned(String),
}
impl Error for PagerSystemError {}
impl Display for PagerSystemError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::QueueNotConfigured => write!(f, "Pager message queue not configured. Use build_with_worker() to enable queuing."),
Self::MutexPoisoned(msg) => write!(f, "Pager mutex poisoned: {}", msg),
}
}
}
-4
View File
@@ -1,4 +0,0 @@
pub mod client;
pub mod error;
pub mod notification;
pub mod worker;
-1
View File
@@ -1 +0,0 @@
pub mod notification_details;
@@ -1,101 +0,0 @@
use chrono::{DateTime, Utc};
use std::fmt::{Debug, Display};
/// Details for a pager notification.
#[derive(Clone, Debug)]
pub struct NotificationDetails {
/// Title or summary of the alert.
pub summary: String,
/// Full details for the alert.
pub description: Option<String>,
/// When the event occurred.
pub event_time: DateTime<Utc>,
}
impl NotificationDetails {
/// Create a notification with a summary and description.
pub fn with_summary_and_description(summary: String, description: String) -> Self {
Self {
summary,
description: Some(description),
event_time: Utc::now(),
}
}
/// Create a notification with just a summary.
pub fn with_summary(summary: String) -> Self {
Self {
summary,
description: None,
event_time: Utc::now(),
}
}
/// 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 summary = format!("{}", error);
// Truncate summary to a reasonable length for alert titles.
let summary = if summary.len() > 200 {
format!("{}...", &summary[..197])
} else {
summary
};
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 {
description_parts.push(String::new());
description_parts.push(format!("Debug: {}", debug_repr));
}
// Attempt to walk the error source chain.
// NB: We use the Debug trait here since we can't require std::error::Error
// (that would require E: 'static + Error which is too restrictive for callers).
// Try to capture a backtrace from the current call site.
#[cfg(feature = "backtrace")]
{
let bt = std::backtrace::Backtrace::capture();
if bt.status() == std::backtrace::BacktraceStatus::Captured {
description_parts.push(String::new());
description_parts.push(format!("Backtrace:\n{}", bt));
}
}
let description = description_parts.join("\n");
Self {
summary,
description: Some(description),
event_time,
}
}
/// Create a notification from an error, with a custom summary prefix.
pub fn from_error_with_context<E: Debug + Display>(context: &str, error: &E) -> Self {
let mut notification = Self::from_error(error);
notification.summary = format!("{}: {}", context, notification.summary);
if notification.summary.len() > 200 {
notification.summary = format!("{}...", &notification.summary[..197]);
}
notification
}
}
-2
View File
@@ -1,2 +0,0 @@
pub mod pager_worker_message_queue;
pub mod pager_worker;
-115
View File
@@ -1,115 +0,0 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use log::{debug, error, info, warn};
use crate::client::pager_client::PagerClient;
use crate::worker::pager_worker_message_queue::PagerWorkerMessageQueue;
/// A background worker thread that consumes a message queue and sends pages.
pub struct PagerWorker {
queue: Arc<PagerWorkerMessageQueue>,
client: PagerClient,
shutdown: Arc<AtomicBool>,
}
impl PagerWorker {
pub fn new(
queue: Arc<PagerWorkerMessageQueue>,
client: PagerClient,
) -> Self {
Self {
queue,
client,
shutdown: Arc::new(AtomicBool::new(false)),
}
}
/// Get a handle to the shutdown flag so external code can trigger shutdown.
pub fn shutdown_handle(&self) -> Arc<AtomicBool> {
self.shutdown.clone()
}
/// Signal the worker to stop.
pub fn shutdown(&self) {
info!("Pager worker thread shutdown requested.");
self.shutdown.store(true, Ordering::Relaxed);
// Wake the thread in case it's blocked on wait_and_drain().
self.queue.notify_all();
}
/// Run the main loop. This blocks the current thread until shutdown is signaled.
///
/// Intended to be called from a dedicated thread:
/// ```ignore
/// std::thread::spawn(move || {
/// let rt = tokio::runtime::Runtime::new().unwrap();
/// rt.block_on(worker.run());
/// });
/// ```
pub async fn run(&self) {
info!("Pager worker thread started.");
while !self.shutdown.load(Ordering::Relaxed) {
// Block until items are available (or we're woken up for shutdown).
let items = match self.queue.wait_and_drain() {
Ok(items) => items,
Err(err) => {
error!("Pager worker queue error: {}. Retrying in 5s.", err);
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
};
if items.is_empty() {
// Woken up for shutdown with no items.
continue;
}
debug!("Pager worker processing {} queued notification(s).", items.len());
for notification in &items {
if self.shutdown.load(Ordering::Relaxed) {
warn!("Pager worker shutting down, {} item(s) still in batch.", items.len());
break;
}
match self.client.send_page(notification).await {
Ok(Some(success)) => {
debug!(
"Pager worker sent page: id={:?}, summary={}",
success.id, notification.summary
);
}
Ok(None) => {
// NoOp — already logged by the client.
}
Err(err) => {
error!(
"Pager worker failed to send page for '{}': {}",
notification.summary, err
);
// Don't kill the thread on errors — keep processing.
}
}
}
}
// Drain any remaining items on shutdown.
match self.queue.drain_available() {
Ok(remaining) if !remaining.is_empty() => {
warn!(
"Pager worker shutting down with {} unsent notification(s) in queue.",
remaining.len()
);
}
Err(err) => {
error!("Pager worker could not drain queue on shutdown: {}", err);
}
_ => {}
}
info!("Pager worker thread stopped.");
}
}
@@ -1,105 +0,0 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, Condvar};
use log::warn;
use crate::error::pager_error::PagerError;
use crate::error::pager_system_error::PagerSystemError;
use crate::notification::notification_details::NotificationDetails;
const DEFAULT_MAX_SIZE: usize = 256;
/// A thread-safe bounded ring buffer for pager notifications.
///
/// When the queue is full, the oldest item is dropped to make room.
/// The worker thread blocks on `wait_and_drain()` until items are available.
pub struct PagerWorkerMessageQueue {
inner: Mutex<VecDeque<NotificationDetails>>,
condvar: Condvar,
max_size: usize,
}
impl PagerWorkerMessageQueue {
pub fn new() -> Self {
Self::with_capacity(DEFAULT_MAX_SIZE)
}
pub fn with_capacity(max_size: usize) -> Self {
Self {
inner: Mutex::new(VecDeque::with_capacity(max_size)),
condvar: Condvar::new(),
max_size,
}
}
/// Push a notification onto the queue.
///
/// If the queue is full, the oldest item is dropped and a warning is logged.
/// Returns `Ok(Some(summary))` if an old item was evicted, `Ok(None)` otherwise.
pub fn push(&self, notification: NotificationDetails) -> Result<Option<NotificationDetails>, PagerError> {
let mut queue = self.inner.lock()
.map_err(|e| PagerSystemError::MutexPoisoned(format!("push: {}", e)))?;
let dropped = if queue.len() >= self.max_size {
let old = queue.pop_front();
if let Some(ref n) = old {
warn!("Pager queue full (max={}). Dropped oldest: {}", self.max_size, n.summary);
}
old
} else {
None
};
queue.push_back(notification);
self.condvar.notify_one();
Ok(dropped)
}
/// Block until at least one item is available, then drain all items.
///
/// Returns an error if the mutex is poisoned.
pub fn wait_and_drain(&self) -> Result<Vec<NotificationDetails>, PagerError> {
let mut queue = self.inner.lock()
.map_err(|e| PagerSystemError::MutexPoisoned(format!("wait_and_drain lock: {}", e)))?;
while queue.is_empty() {
queue = self.condvar.wait(queue)
.map_err(|e| PagerSystemError::MutexPoisoned(format!("wait_and_drain wait: {}", e)))?;
}
let items: Vec<NotificationDetails> = queue.drain(..).collect();
Ok(items)
}
/// Non-blocking drain of all currently queued items.
pub fn drain_available(&self) -> Result<Vec<NotificationDetails>, PagerError> {
let mut queue = self.inner.lock()
.map_err(|e| PagerSystemError::MutexPoisoned(format!("drain_available: {}", e)))?;
Ok(queue.drain(..).collect())
}
/// Wake up any thread blocked on `wait_and_drain()`.
///
/// Used during shutdown to unblock the worker thread.
pub fn notify_all(&self) {
self.condvar.notify_all();
}
pub fn len(&self) -> Result<usize, PagerError> {
let queue = self.inner.lock()
.map_err(|e| PagerSystemError::MutexPoisoned(format!("len: {}", e)))?;
Ok(queue.len())
}
pub fn is_empty(&self) -> Result<bool, PagerError> {
Ok(self.len()? == 0)
}
}
pub fn new_shared_queue() -> Arc<PagerWorkerMessageQueue> {
Arc::new(PagerWorkerMessageQueue::new())
}
pub fn new_shared_queue_with_capacity(max_size: usize) -> Arc<PagerWorkerMessageQueue> {
Arc::new(PagerWorkerMessageQueue::with_capacity(max_size))
}
@@ -1,9 +1,7 @@
use anyhow::anyhow;
use chrono::{DateTime, Utc};
use log::warn;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType;
use enums::common::job_status_plus::JobStatusPlus;
use errors::AnyhowResult;
@@ -21,10 +19,6 @@ pub struct UserJobForModerationResult {
pub job_token: InferenceJobToken,
pub wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
pub wallet_ledger_entry_type: Option<WalletLedgerEntryType>,
pub maybe_external_third_party: Option<InferenceJobExternalThirdParty>,
pub maybe_external_third_party_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub async fn list_user_jobs_for_moderation(
@@ -42,11 +36,7 @@ SELECT
j.on_success_result_entity_token as `on_success_result_media_token: tokens::tokens::media_files::MediaFileToken`,
j.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
wle.token as `wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,
wle.entry_type as `wallet_ledger_entry_type: enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType`,
j.maybe_external_third_party as `maybe_external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,
j.maybe_external_third_party_id,
j.created_at,
j.updated_at
wle.entry_type as `wallet_ledger_entry_type: enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType`
FROM generic_inference_jobs as j
LEFT OUTER JOIN users as u
ON j.maybe_creator_user_token = u.token
@@ -13,8 +13,6 @@ use tokens::tokens::users::UserToken;
use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_bool_default_false, nullable_i8_to_optional_bool};
const ADMIN_ROLE_SLUG: &str = "admin";
#[derive(Clone, Serialize, Deserialize)]
pub struct SessionUserRecord {
pub user_token: UserToken,
@@ -92,7 +90,7 @@ impl SessionUserRecord {
}
pub fn is_mod(&self) -> bool {
self.can_ban_users || self.user_role_slug == ADMIN_ROLE_SLUG
self.can_ban_users
}
}
@@ -9,7 +9,7 @@ use strum::EnumIter;
///
/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, Default, utoipa::ToSchema)]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum InferenceJobExternalThirdParty {
/// Fal jobs
@@ -65,9 +65,7 @@ memory_caching = { path = "../../../lib/caching/memory_caching" }
migration = { path = "../../../schema/database/migration" }
mimetypes.workspace = true
mysql_queries.workspace = true
pager.workspace = true
password = { path = "../../../lib/password" }
rootly_client.workspace = true
primitives = { path = "../../../lib/data/primitives" }
prompt_engineering = { path = "../../../lib/deprecated/machine_learning/prompt_engineering" }
redis_caching = { path = "../../../lib/caching/redis_caching" }
@@ -8,10 +8,6 @@ COOKIE_SECURE=false
COOKIE_DOMAIN=.jungle.horse
WEBSITE_HOMEPAGE_REDIRECT=http://jungle.horse
# Paging
ENABLE_PAGING=true
ENABLE_PAGING_FOR_500S=true
# Test the rate limiter bypass (obviously these are dummy values)
AI_STREAMER_USERNAMES="PewDiePie,xQc"
@@ -32,7 +32,6 @@ pub mod docs;
pub mod email;
pub mod error;
pub mod http_server;
pub mod startup;
pub mod state;
pub mod threads;
pub mod util;
@@ -34,7 +34,6 @@ pub mod docs;
pub mod email;
pub mod error;
pub mod http_server;
pub mod startup;
pub mod state;
pub mod threads;
pub mod util;
@@ -197,7 +197,6 @@ use enums::by_table::comments::comment_entity_type::CommentEntityType;
use enums::by_table::featured_items::featured_item_entity_type::FeaturedItemEntityType;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
@@ -279,7 +278,6 @@ use artcraft_api_defs::subscriptions::get_session_subscription::*;
use artcraft_api_defs::media_file::list_batch_generated_media_files::*;
// Handler modules with locally-defined types
use crate::http_server::endpoints::moderation::info::moderator_token_info_handler::*;
use artcraft_api_defs::moderation::alerts::moderation_send_alert::*;
use artcraft_api_defs::moderation::user::list_subscribing_users_by_signup_date::*;
use artcraft_api_defs::moderation::user::list_users_by_signup_date::*;
use artcraft_api_defs::moderation::user::user_lookup::*;
@@ -469,7 +467,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
// TTS
crate::http_server::endpoints::tts::enqueue_infer_f5_tts_handler::enqueue_infer_f5_tts_handler::enqueue_infer_f5_tts_handler,
// Moderation
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler,
crate::http_server::endpoints::moderation::user::moderator_list_subscribing_users_by_signup_date::moderator_list_subscribing_users_by_signup_date_handler,
crate::http_server::endpoints::moderation::user::moderator_list_users_by_signup_date::moderator_list_users_by_signup_date_handler,
@@ -664,8 +661,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
EditUserFeatureFlagsError,
EditUserFeatureFlagsOption,
EditUserFeatureFlagsRequest,
ModerationSendAlertRequest,
ModerationSendAlertResponse,
ModeratorListSubscribingUsersBySignupDateRequest,
ModeratorListSubscribingUsersBySignupDateResponse,
ModeratorListSubscribingUsersBySignupDateEntry,
@@ -681,7 +676,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListUserJobsPathInfo,
ListUserJobsResponse,
ListUserJobsEntry,
InferenceJobExternalThirdParty,
ListUserWalletsPathInfo,
ListUserWalletsResponse,
ListUserWalletsEntry,
@@ -46,7 +46,6 @@ use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url, ExtractExtensions};
use pager::notification::notification_details::NotificationDetails;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
// ======================== Result of a successful generation ========================
@@ -220,11 +219,6 @@ 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(
NotificationDetails::from_error_with_context("Seedance 2.0 generation failed (whitelist + fallback)", &err)
) {
warn!("Failed to enqueue pager alert: {:?}", page_err);
}
refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?;
return Err(CommonWebError::ServerError);
}
@@ -251,11 +245,6 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
Ok(result) => result,
Err(err) => {
warn!("Error calling seedance2pro generate_video: {:?}", err);
if let Err(page_err) = server_state.pager.enqueue_page(
NotificationDetails::from_error_with_context("Seedance 2.0 generation failed", &err)
) {
warn!("Failed to enqueue pager alert: {:?}", page_err);
}
refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?;
return Err(CommonWebError::ServerError);
}
@@ -1 +0,0 @@
pub mod moderation_send_alert_handler;
@@ -1,58 +0,0 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::moderation::alerts::moderation_send_alert::{
ModerationSendAlertRequest,
ModerationSendAlertResponse,
};
use pager::client::pager::Pager;
use pager::notification::notification_details::NotificationDetails;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, UseDatabase};
use crate::state::server_state::ServerState;
/// Send a test alert via the pager system. Moderators only.
#[utoipa::path(
post,
tag = "Moderation",
path = "/v1/moderation/alerts/send",
request_body = ModerationSendAlertRequest,
responses(
(status = 200, description = "Success", body = ModerationSendAlertResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderation_send_alert_handler(
http_request: HttpRequest,
request: Json<ModerationSendAlertRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModerationSendAlertResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let title = request.title.clone()
.unwrap_or_else(|| "Test Moderation Alert".to_string());
let description = request.description.clone()
.unwrap_or_else(|| "This is a test moderation alert.".to_string());
let notification = NotificationDetails::with_summary_and_description(title, description);
server_state.pager
.enqueue_page(notification)
.map_err(|err| {
warn!("moderation_send_alert error: {:?}", err);
CommonWebError::ServerError
})?;
Ok(Json(ModerationSendAlertResponse {
success: true,
}))
}
@@ -56,10 +56,6 @@ pub async fn list_user_jobs_handler(
job_token: row.job_token,
wallet_ledger_entry_token: row.wallet_ledger_entry_token,
wallet_ledger_entry_type: row.wallet_ledger_entry_type,
maybe_external_third_party: row.maybe_external_third_party,
maybe_external_third_party_id: row.maybe_external_third_party_id,
created_at: row.created_at,
updated_at: row.updated_at,
}).collect();
Ok(Json(ListUserJobsResponse {
@@ -1,4 +1,3 @@
pub mod alerts;
pub mod info;
pub mod ip_bans;
pub mod jobs;
@@ -1,36 +0,0 @@
use pager::client::pager::Pager;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use super::enqueue_alert::enqueue_alert;
/// Check `CommonWebError` and alert on server errors.
/// Returns `true` if the error was handled (alerted or intentionally skipped).
pub(super) fn check_common_web_error(
pager: &Pager,
method: &str,
path: &str,
error: &CommonWebError,
) -> bool {
match error {
CommonWebError::ServerError => {
enqueue_alert(
pager,
format!("CommonWebError::ServerError on {} {}", method, path),
format!(
"A CommonWebError::ServerError was returned.\n\n\
Endpoint: {} {}\n\
Error: {:?}\n\
Time: {}",
method, path, error,
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
),
);
true
}
// Don't alert on client errors (400, 401, 404, 402).
CommonWebError::BadInputWithSimpleMessage(_) => true,
CommonWebError::NotFound => true,
CommonWebError::NotAuthorized => true,
CommonWebError::PaymentRequired => true,
}
}
@@ -1,30 +0,0 @@
use pager::client::pager::Pager;
use super::enqueue_alert::enqueue_alert;
/// Fallback alerting based on HTTP status code when no typed error matched.
pub(super) fn check_status_code_fallback(
pager: &Pager,
method: &str,
path: &str,
status_code: u16,
) {
match status_code {
500 => {
enqueue_alert(
pager,
format!("HTTP 500: {} {}", method, path),
format!(
"An untyped HTTP 500 response was returned (no typed error matched).\n\n\
Endpoint: {} {}\n\
Time: {}",
method, path,
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
),
);
}
// Add more status-based rules here:
// 503 => { enqueue_alert(...); }
_ => {}
}
}
@@ -1,14 +0,0 @@
use log::{debug, warn};
use pager::client::pager::Pager;
use pager::notification::notification_details::NotificationDetails;
pub(super) fn enqueue_alert(pager: &Pager, summary: String, description: String) {
let notification = NotificationDetails::with_summary_and_description(summary, description);
if let Err(err) = pager.enqueue_page(notification) {
warn!("Error alerting middleware: failed to enqueue page: {:?}", err);
} else {
debug!("Error alerting middleware: enqueued alert");
}
}
@@ -1,153 +0,0 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::Error;
use pager::client::pager::Pager;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::state::flags::paging_flags::PagingFlags;
use super::check_common_web_error::check_common_web_error;
use super::check_status_code_fallback::check_status_code_fallback;
// ======================== Transform (factory) ========================
/// Middleware that intercepts error responses and enqueues pager alerts.
///
/// Inspects errors in two ways:
/// 1. **Typed error matching** via downcast (e.g. `CommonWebError::ServerError`)
/// 2. **Status code fallback** for untyped 500s that slip through
///
/// Both `PagingFlags.is_paging_enabled` and `PagingFlags.is_paging_for_500s_enabled`
/// must be true for any alerting to fire. Otherwise the middleware is a passthrough.
///
/// To add new error types, create a new `check_*.rs` matcher module and
/// add a downcast branch in `check_ok_response_for_alerts` / `check_err_for_alerts`.
#[derive(Clone)]
pub struct ErrorAlertingMiddleware {
pager: Pager,
paging_flags: PagingFlags,
}
impl ErrorAlertingMiddleware {
pub fn new(pager: Pager, paging_flags: PagingFlags) -> Self {
Self { pager, paging_flags }
}
}
impl<S, B> Transform<S, ServiceRequest> for ErrorAlertingMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = ErrorAlertingService<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(ErrorAlertingService {
service,
pager: self.pager.clone(),
paging_flags: self.paging_flags.clone(),
}))
}
}
// ======================== Service (per-request) ========================
pub struct ErrorAlertingService<S> {
service: S,
pager: Pager,
paging_flags: PagingFlags,
}
impl<S, B> Service<ServiceRequest> for ErrorAlertingService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
actix_service::forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
// Fast path: if paging is disabled, skip all inspection.
if !self.paging_flags.is_paging_enabled || !self.paging_flags.is_paging_for_500s_enabled {
return Box::pin(self.service.call(req));
}
let method = req.method().to_string();
let path = req.path().to_string();
let pager = self.pager.clone();
let fut = self.service.call(req);
Box::pin(async move {
match fut.await {
Ok(res) => {
check_ok_response_for_alerts(&pager, &method, &path, &res);
Ok(res)
}
Err(err) => {
check_err_for_alerts(&pager, &method, &path, &err);
Err(err)
}
}
})
}
}
// ======================== Response inspection ========================
/// Inspect a successful `ServiceResponse` that may contain an error response.
///
/// Actix-web converts `ResponseError` types into HTTP responses and stashes
/// the original error in `response.error()`. We can downcast from there.
fn check_ok_response_for_alerts<B>(
pager: &Pager,
method: &str,
path: &str,
response: &ServiceResponse<B>,
) {
let status = response.status();
// Try to get the original typed error from the response (if it came from ResponseError).
if let Some(err) = response.response().error() {
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
if let Some(common_err) = err.as_error::<CommonWebError>() {
if check_common_web_error(pager, method, path, common_err) {
return;
}
}
}
// --- Status code fallback ---
check_status_code_fallback(pager, method, path, status.as_u16());
}
/// Inspect an `actix_web::Error` returned from the handler or inner middleware.
fn check_err_for_alerts(
pager: &Pager,
method: &str,
path: &str,
err: &Error,
) {
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
if let Some(common_err) = err.as_error::<CommonWebError>() {
if check_common_web_error(pager, method, path, common_err) {
return;
}
}
// --- Status code fallback ---
let status = err.as_response_error().status_code();
check_status_code_fallback(pager, method, path, status.as_u16());
}
@@ -1,5 +0,0 @@
mod check_common_web_error;
mod check_status_code_fallback;
mod enqueue_alert;
pub mod error_alerting_middleware;
@@ -1,2 +1 @@
pub mod error_alerting_middleware;
pub mod pushback_filter_middleware;
@@ -35,7 +35,6 @@ use crate::http_server::endpoints::moderation::user::moderator_list_subscribing_
use crate::http_server::endpoints::moderation::user::moderator_list_users_by_signup_date::moderator_list_users_by_signup_date_handler;
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_stripe_customer_id_handler::moderator_user_lookup_by_stripe_customer_id_handler;
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler;
use crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler;
use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::edit_user_feature_flags_handler;
pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
@@ -50,12 +49,6 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
>,
{
app.service(web::scope("/v1/moderation")
.service(web::scope("/alerts")
.service(web::resource("/send")
.route(web::post().to(moderation_send_alert_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::resource("/user_feature_flags/{username_or_token}")
.route(web::post().to(edit_user_feature_flags_handler))
.route(web::head().to(|| HttpResponse::Ok()))
@@ -75,7 +75,7 @@ pub async fn require_moderator(
}
};
if !user_session.is_mod() {
if !user_session.can_ban_users {
warn!("user is not a moderator: {:?}", user_session.user_token.as_str());
return Err(RequireModeratorError::NotAuthorized);
}
+9 -37
View File
@@ -52,7 +52,7 @@ use elasticsearch::http::transport::Transport;
use elasticsearch::Elasticsearch;
use errors::AnyhowResult;
use fal_client::creds::fal_api_key::FalApiKey;
use log::{info, warn};
use log::info;
use memory_caching::arc_ttl_sieve::ArcTtlSieve;
use memory_caching::single_item_ttl_cache::SingleItemTtlCache;
use mysql_queries::mediators::badge_granter::BadgeGranter;
@@ -72,7 +72,6 @@ use crate::configs::app_startup::redis_rate_limiters::configure_redis_rate_limit
use crate::configs::connect_to_database::connect_to_database;
use crate::configs::static_api_tokens::StaticApiTokenSet;
use crate::http_server::cookies::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
use crate::http_server::middleware::error_alerting_middleware::error_alerting_middleware::ErrorAlertingMiddleware;
use crate::http_server::middleware::pushback_filter_middleware::PushbackFilter;
use crate::http_server::routes::add_routes::add_routes;
use crate::http_server::session::session_checker::SessionChecker;
@@ -80,7 +79,6 @@ use crate::http_server::web_utils::handle_multipart_error::handle_multipart_erro
use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator;
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
use crate::startup::build_pager::build_pager;
use crate::state::server_state::{DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo, ServerState, StaticFeatureFlags, StripeSettings, TrollBans, WorldLabsData};
use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus;
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
@@ -95,7 +93,6 @@ pub mod configs;
pub mod email;
pub mod error;
pub mod http_server;
pub mod startup;
pub mod state;
pub mod threads;
pub mod util;
@@ -331,21 +328,6 @@ async fn main() -> AnyhowResult<()> {
poll_model_token_info_thread(model_token_info_cache2, mysql_pool5).await;
});
let server_environment = ServerEnvironment::from_str(&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?)
.ok_or(anyhow!("invalid server environment"))?;
let server_environment_typed = match server_environment {
ServerEnvironment::Production => server_environment::ServerEnvironment::Production,
ServerEnvironment::Development => server_environment::ServerEnvironment::Development,
};
let (pager, pager_worker, paging_flags) = build_pager(server_environment_typed, &server_hostname);
info!("Spawning pager worker thread.");
tokio_runtime.spawn(async move {
pager_worker.run().await;
});
let stripe_configs = StripeConfig {
checkout: StripeCheckoutConfigs {
@@ -363,6 +345,9 @@ async fn main() -> AnyhowResult<()> {
},
};
let server_environment = ServerEnvironment::from_str(&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?)
.ok_or(anyhow!("invalid server environment"))?;
let service_feature_flags = StaticFeatureFlags {
// Permanent (control plane / safety) flags : messaging
maybe_status_alert_category: easyenv::get_env_string_optional("FF_STATUS_ALERT_CATEGORY"),
@@ -392,9 +377,6 @@ async fn main() -> AnyhowResult<()> {
// Disable voice features
disable_tts: easyenv::get_env_bool_or_default("DISABLE_TTS", false),
disable_voice_conversion: easyenv::get_env_bool_or_default("DISABLE_VOICE_CONVERSION", false),
// Paging
paging: paging_flags,
};
let third_party_url_redirector = ThirdPartyUrlRedirector::new(server_environment);
@@ -409,6 +391,11 @@ async fn main() -> AnyhowResult<()> {
.unwrap_or(String::from("unknown"))
.trim()
.to_string();
let server_environment_typed = match server_environment {
ServerEnvironment::Production => server_environment::ServerEnvironment::Production,
ServerEnvironment::Development => server_environment::ServerEnvironment::Development,
};
let fal_api_key = FalApiKey::new(easyenv::get_env_string_required("FAL_API_KEY")?);
let fal_webhook_url = easyenv::get_env_string_required("FAL_WEBHOOK_URL")?;
@@ -486,7 +473,6 @@ async fn main() -> AnyhowResult<()> {
worldlabs: WorldLabsData {
api_key: worldlabs_api_key,
},
pager,
caches: InMemoryCaches {
durable: DurableInMemoryCaches {
model_token_info: model_token_info_cache,
@@ -629,15 +615,6 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()>
ServerEnvironment::Production => server_environment::ServerEnvironment::Production,
};
let paging_flags_for_middleware = server_state.flags.paging.clone();
let pager_for_middleware = server_state.pager.clone();
let enable_error_alerting = paging_flags_for_middleware.is_paging_enabled
&& paging_flags_for_middleware.is_paging_for_500s_enabled;
if enable_error_alerting {
info!("Error alerting middleware is ENABLED (ENABLE_PAGING=true, ENABLE_PAGING_FOR_500S=true).");
}
let server_state_arc = web::Data::new(Arc::new(server_state));
let disabled_endpoints = read_disabled_endpoints();
@@ -675,7 +652,6 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()>
.app_data(web::Data::new(server_state_arc.stripe.clone().client.clone()))
.app_data(web::Data::new(server_state_arc.third_party_url_redirector))
.app_data(web::Data::new(server_state_arc.google_sign_in_cert.clone()))
.app_data(web::Data::new(server_state_arc.pager.clone()))
.app_data(web::Data::new(old_server_environment))
.app_data(web::Data::new(new_server_environment))
.app_data(web::Data::from(product_lookup)) // NB: Data::from(Arc<T>) for dynamic dispatch
@@ -695,10 +671,6 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()>
.wrap(DefaultHeaders::new()
.header("X-Backend-Hostname", &hostname)
.header("X-Build-Sha", server_state_arc.server_info.build_sha.clone()))
.wrap(middleware::Condition::new(
enable_error_alerting,
ErrorAlertingMiddleware::new(pager_for_middleware.clone(), paging_flags_for_middleware.clone()),
))
.wrap(PushbackFilter::new(&server_state_arc.flags.clone()))
.wrap(DisabledEndpointFilter::new(disabled_endpoints.clone()))
.wrap(BannedIpFilter::new(ip_ban_list))
@@ -1,67 +0,0 @@
use log::{info, warn};
use pager::client::pager::Pager;
use pager::client::pager_builder::PagerBuilder;
use pager::worker::pager_worker::PagerWorker;
use rootly_client::creds::rootly_api_key::RootlyApiKey;
use crate::state::flags::paging_flags::PagingFlags;
pub fn build_pager(
server_environment: server_environment::ServerEnvironment,
hostname: &str,
) -> (Pager, PagerWorker, PagingFlags) {
let paging_flags = PagingFlags::from_env();
info!("Paging flags: {:?}", paging_flags);
let environment = if server_environment.is_deployed_in_production() {
"production"
} else {
"development"
};
let builder = PagerBuilder::new()
.application_name("storyteller-web".to_string())
.environment(environment.to_string())
.hostname(hostname.to_string());
// If paging is globally disabled, use a NoOp pager regardless of API key.
if !paging_flags.is_paging_enabled {
warn!("ENABLE_PAGING is false. Pager will be NoOp.");
let (pager, worker) = builder.build_with_worker();
return (pager, worker, paging_flags);
}
let maybe_api_key = easyenv::get_env_string_optional("ROOTLY_API_KEY");
let (pager, worker) = match maybe_api_key {
Some(api_key) => {
info!("Rootly API key found. Configuring pager with Rootly backend.");
build_rootly_pager(builder, api_key)
}
None => {
warn!("ROOTLY_API_KEY not set. Pager will not send real pages.");
builder.build_with_worker()
}
};
(pager, worker, paging_flags)
}
fn build_rootly_pager(builder: PagerBuilder, api_key: String) -> (Pager, PagerWorker) {
let mut rootly_builder = builder
.rootly(RootlyApiKey::new(api_key));
if let Some(urgency_id) = easyenv::get_env_string_optional("ROOTLY_ALERT_URGENCY_ID") {
rootly_builder = rootly_builder.alert_urgency_id(urgency_id);
}
let target_type = easyenv::get_env_string_optional("ROOTLY_NOTIFICATION_TARGET_TYPE");
let target_id = easyenv::get_env_string_optional("ROOTLY_NOTIFICATION_TARGET_ID");
if let (Some(t_type), Some(t_id)) = (target_type, target_id) {
rootly_builder = rootly_builder.notification_target(t_type, t_id);
}
rootly_builder.build_with_worker()
}
@@ -1 +0,0 @@
pub mod build_pager;
@@ -1 +0,0 @@
pub mod paging_flags;
@@ -1,21 +0,0 @@
/// Flags controlling the pager system.
#[derive(Clone, Debug)]
pub struct PagingFlags {
/// Master switch for paging. If false, no pages are sent.
/// Env var: ENABLE_PAGING (default: false)
pub is_paging_enabled: bool,
/// If true, the error alerting middleware will enqueue pages for HTTP 500s.
/// Requires `is_paging_enabled` to also be true.
/// Env var: ENABLE_PAGING_FOR_500S (default: false)
pub is_paging_for_500s_enabled: bool,
}
impl PagingFlags {
pub fn from_env() -> Self {
Self {
is_paging_enabled: easyenv::get_env_bool_or_default("ENABLE_PAGING", false),
is_paging_for_500s_enabled: easyenv::get_env_bool_or_default("ENABLE_PAGING_FOR_500S", false),
}
}
}
@@ -1,5 +1,4 @@
pub mod cached_queries;
pub mod flags;
pub mod certs;
pub mod memory_cache;
pub mod server_state;
@@ -9,7 +9,6 @@ use elasticsearch::Elasticsearch;
use fal_client::creds::fal_api_key::FalApiKey;
use memory_caching::arc_ttl_sieve::ArcTtlSieve;
use memory_caching::single_item_ttl_cache::SingleItemTtlCache;
use pager::client::pager::Pager;
use mysql_queries::mediators::badge_granter::BadgeGranter;
use mysql_queries::mediators::firehose_publisher::FirehosePublisher;
use mysql_queries::queries::generic_inference::web::get_pending_inference_job_count::InferenceQueueLengthResult;
@@ -33,7 +32,6 @@ use crate::http_server::endpoints::stats::result_transformer::CacheableQueueStat
use crate::http_server::endpoints::tts::list_tts_models::TtsModelRecordForResponse;
use crate::http_server::endpoints::voice_conversion::list_voice_conversion_models_handler::VoiceConversionModel;
use crate::http_server::session::session_checker::SessionChecker;
use crate::state::flags::paging_flags::PagingFlags;
use crate::http_server::web_utils::redis_rate_limiter::RedisRateLimiter;
use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator;
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
@@ -102,8 +100,6 @@ pub struct ServerState {
pub worldlabs: WorldLabsData,
pub pager: Pager,
/// Where to store audio uploads for w2l
pub audio_uploads_bucket_root: String,
@@ -299,9 +295,6 @@ pub struct StaticFeatureFlags {
/// Disable voice conversion endpoints
/// If true, respond with 429.
pub disable_voice_conversion: bool,
/// Paging system flags.
pub paging: PagingFlags,
}
/// Instead of top level service denial, these are bans against entities that instead return
@@ -271,8 +271,8 @@ export default function CreateImage() {
try {
const imageMediaTokens = selectedModel.canUseImagePrompt
? referenceImages
.map((img) => img.mediaToken)
.filter((t) => t.length > 0)
.map((img) => img.mediaToken)
.filter((t) => t.length > 0)
: undefined;
const result = await enqueueImageGeneration({
@@ -476,7 +476,7 @@ export default function CreateImage() {
length: Math.max(
0,
4 -
Math.max(1, Math.min(4, batch.requestedCount)),
Math.max(1, Math.min(4, batch.requestedCount)),
),
}).map((_, i) => (
<div
@@ -556,7 +556,7 @@ export default function CreateImage() {
{/* ── Prompt box (fixed bottom center) ───────────────────── */}
<div
className="animate-fade-in-up fixed bottom-6 left-0 right-0 z-30 mx-auto w-full max-w-[900px] px-4"
className="animate-fade-in-up fixed bottom-6 left-0 right-0 z-30 mx-auto w-full max-w-[730px] px-4"
style={{ animationDelay: "150ms" }}
>
<PromptBox
@@ -219,9 +219,9 @@ export default function CreateVideo() {
(): PopoverItem[] | null =>
selectedModel.durationOptions
? selectedModel.durationOptions.map((d) => ({
label: `${d}s`,
selected: d === (duration ?? selectedModel.defaultDuration),
}))
label: `${d}s`,
selected: d === (duration ?? selectedModel.defaultDuration),
}))
: null,
[selectedModel, duration],
);
@@ -231,9 +231,9 @@ export default function CreateVideo() {
(): PopoverItem[] | null =>
selectedModel.resolutionOptions
? selectedModel.resolutionOptions.map((r) => ({
label: r,
selected: r === (resolution ?? selectedModel.defaultResolution),
}))
label: r,
selected: r === (resolution ?? selectedModel.defaultResolution),
}))
: null,
[selectedModel, resolution],
);
@@ -243,17 +243,17 @@ export default function CreateVideo() {
(): PopoverItem[] | null =>
supportsRefMode
? [
{
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,
[supportsRefMode, inputMode],
);
@@ -424,8 +424,8 @@ export default function CreateVideo() {
const referenceImageMediaTokens =
isReferenceMode && referenceImages.length > 0
? referenceImages
.map((img) => img.mediaToken)
.filter((t) => t.length > 0)
.map((img) => img.mediaToken)
.filter((t) => t.length > 0)
: undefined;
const referenceVideoMediaTokens =
@@ -680,7 +680,7 @@ export default function CreateVideo() {
{/* ── Prompt box (fixed bottom center) ───────────────────── */}
<div
className="animate-fade-in-up fixed bottom-6 left-0 right-0 z-30 mx-auto w-full max-w-[900px] px-4"
className="animate-fade-in-up fixed bottom-6 left-0 right-0 z-30 mx-auto w-full max-w-[730px] px-4"
style={{ animationDelay: "150ms" }}
>
<PromptBox
@@ -882,7 +882,7 @@ export default function CreateVideo() {
maxSelect={Math.max(
1,
(isReferenceMode ? (selectedModel.maxReferenceImages ?? 3) : 1) -
referenceImages.length,
referenceImages.length,
)}
/>
</div>
@@ -406,18 +406,6 @@ export const ImageTo3DExperience = ({
worldStartGeneration("image", note, firstPreview, subscriberId);
setSelectedResultId(subscriberId);
window.__storeTaskEnqueueMeta?.({
prompt: worldPrompt.trim() || undefined,
refImageUrls: worldImages
.map((img) => img.preview)
.filter(Boolean) as string[],
modelType:
(selectedWorldModel as any)?.tauriId ||
(SPLAT_MODELS[0] as any)?.tauriId ||
String(selectedWorldModel ?? SPLAT_MODELS[0]),
timestamp: Date.now(),
});
const result = await EnqueueImageToGaussian({
image_media_tokens: readyTokens,
prompt: worldPrompt.trim() || undefined,
@@ -448,14 +436,6 @@ export const ImageTo3DExperience = ({
);
setSelectedResultId(subscriberId);
window.__storeTaskEnqueueMeta?.({
prompt: snapshotPrompt || undefined,
refImageUrls: snapshotPreview ? [snapshotPreview] : undefined,
modelType:
(selectedModel as any)?.tauriId || String(selectedModel),
timestamp: Date.now(),
});
const result = await EnqueueImageTo3dObject({
image_media_token: uploadedMediaToken || undefined,
model: selectedModel,
@@ -38,13 +38,14 @@ export const loadPreviewOnCanvas = ({
const camera = new THREE.PerspectiveCamera(35, aspectRatio, 0.1, 1000);
camera.position.z = 2;
const gl2ctx = canvas.getContext("webgl2", { preserveDrawingBuffer: true });
const gl2ctx = canvas.getContext("webgl2");
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvas,
context: gl2ctx!,
preserveDrawingBuffer: true,
});
renderer.setSize(width, height);
@@ -126,19 +126,14 @@ export const UploadFilesSplat = ({
useEffect(() => {
if (previewStatus.type === "OK" && canvasRef.current) {
const canvas = canvasRef.current;
// Defer one animation frame so the splat renderer has produced a visible frame
const rafId = requestAnimationFrame(() => {
snapshotCanvasAsThumbnail({
targetNode: canvas,
resultCallback: (snapshotBlob) => {
if (snapshotBlob) {
setThumbnailFile(snapshotBlob);
}
},
});
snapshotCanvasAsThumbnail({
targetNode: canvasRef.current!,
resultCallback: (snapshotBlob) => {
if (snapshotBlob) {
setThumbnailFile(snapshotBlob);
}
},
});
return () => cancelAnimationFrame(rafId);
}
}, [previewStatus]);
@@ -47,8 +47,6 @@ import {
getPlaceholderForMediaClass,
} from "@storyteller/common";
import { coverImageCache } from "~/pages/PageImageTo3DObject/ImageTo3DStore";
import { useCreditsState } from "@storyteller/credits";
import { getMetaForTask, cleanupOldEntries } from "./taskEnqueueMeta";
type InProgressTask = {
id: string;
@@ -59,8 +57,6 @@ type InProgressTask = {
canDismiss?: boolean;
estimatedTimeLeftMs?: number;
modelType?: string;
prompt?: string;
refImageUrls?: string[];
};
type CompletedTask = {
@@ -74,7 +70,6 @@ type CompletedTask = {
mediaTokens?: string[];
mediaFileClass?: TaskMediaFileClass;
batchImageToken?: string;
prompt?: string;
};
type FailedTask = {
@@ -85,8 +80,6 @@ type FailedTask = {
status: string;
failureReason?: string;
failureMessage?: string;
prompt?: string;
refImageUrls?: string[];
};
const formatTimeLeft = (ms: number): string => {
@@ -116,67 +109,16 @@ const InProgressCard = ({
? formatTimeLeft(task.estimatedTimeLeftMs)
: null;
const isSeedance2 = task.modelType === "seedance_2p0";
const hasRefImages =
task.refImageUrls && task.refImageUrls.length > 0;
const thumbnailContent = hasRefImages ? (
<Tooltip
content={task.prompt || ""}
position="bottom"
strategy="fixed"
className="max-w-[320px] text-wrap text-xs"
zIndex={50}
delay={200}
disabled={!task.prompt}
>
<div className="relative h-[72px] w-[72px] shrink-0 overflow-hidden rounded">
<img
src={task.refImageUrls![0]}
alt="Reference"
className="h-full w-full object-cover opacity-50"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
<div className="absolute inset-0 bg-black/30" />
<div className="absolute inset-0 flex items-center justify-center">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-white/80"
size="lg"
/>
</div>
{task.refImageUrls!.length > 1 && (
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80">
+{task.refImageUrls!.length - 1}
</div>
)}
</div>
</Tooltip>
) : (
<Tooltip
content={task.prompt || ""}
position="bottom"
strategy="fixed"
className="max-w-[320px] text-wrap text-xs"
zIndex={50}
delay={200}
disabled={!task.prompt}
>
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-ui-controls">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-base-fg/60"
size="lg"
/>
</div>
</Tooltip>
);
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
{thumbnailContent}
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-ui-controls">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-base-fg/60"
size="lg"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1.5 truncate font-medium text-base-fg/90">
@@ -253,38 +195,28 @@ const CompletedCard = ({
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
>
<Tooltip
content={task.prompt || ""}
position="bottom"
strategy="fixed"
className="max-w-[320px] text-wrap text-xs"
zIndex={50}
delay={200}
disabled={!task.prompt}
>
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded bg-ui-controls">
{task.thumbnailUrl ? (
<img
src={task.thumbnailUrl}
alt={task.title}
onError={(e) => {
e.currentTarget.src = getPlaceholderForMediaClass(
task.mediaFileClass,
);
e.currentTarget.style.opacity = "0.3";
// Set the `data-brokenurl` property for debugging the broken images:
(e.currentTarget as HTMLImageElement).dataset.brokenurl =
task.thumbnailUrl;
}}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-base-fg/40">
Done
</div>
)}
</div>
</Tooltip>
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded bg-ui-controls">
{task.thumbnailUrl ? (
<img
src={task.thumbnailUrl}
alt={task.title}
onError={(e) => {
e.currentTarget.src = getPlaceholderForMediaClass(
task.mediaFileClass,
);
e.currentTarget.style.opacity = "0.3";
// Set the `data-brokenurl` property for debugging the broken images:
(e.currentTarget as HTMLImageElement).dataset.brokenurl =
task.thumbnailUrl;
}}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-base-fg/40">
Done
</div>
)}
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-base-fg/90">
{task.title}
@@ -345,67 +277,16 @@ const FailedCard = ({
onDismiss?: () => void;
}) => {
const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed";
const hasRefImages =
task.refImageUrls && task.refImageUrls.length > 0;
const thumbnailContent = hasRefImages ? (
<Tooltip
content={task.prompt || ""}
position="bottom"
strategy="fixed"
className="max-w-[320px] text-wrap text-xs"
zIndex={50}
delay={200}
disabled={!task.prompt}
>
<div className="relative h-[72px] w-[72px] shrink-0 overflow-hidden rounded">
<img
src={task.refImageUrls![0]}
alt="Reference"
className="h-full w-full object-cover opacity-50"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
<div className="absolute inset-0 bg-red-900/40" />
<div className="absolute inset-0 flex items-center justify-center">
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
<FontAwesomeIcon
icon={faCircleExclamation}
className="text-red-400"
size="lg"
/>
</div>
{task.refImageUrls!.length > 1 && (
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80">
+{task.refImageUrls!.length - 1}
</div>
)}
</div>
</Tooltip>
) : (
<Tooltip
content={task.prompt || ""}
position="bottom"
strategy="fixed"
className="max-w-[320px] text-wrap text-xs"
zIndex={50}
delay={200}
disabled={!task.prompt}
>
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
<FontAwesomeIcon
icon={faCircleExclamation}
className="text-red-400"
size="lg"
/>
</div>
</Tooltip>
);
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
{thumbnailContent}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between text-sm">
<div className="truncate font-medium text-base-fg/90">
@@ -417,42 +298,38 @@ const FailedCard = ({
{task.subtitle}
</div>
)}
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden">
<span className="shrink-0 rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
<div className="mt-1 flex items-center gap-1.5">
<span className="rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
{statusLabel}
</span>
{task.failureReason && (
<div className="min-w-0 overflow-hidden">
<Tooltip
content={task.failureReason}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap bg-danger text-xs"
zIndex={50}
delay={300}
>
<div className="truncate text-[11px] text-red-400/60">
{task.failureReason}
</div>
</Tooltip>
</div>
<Tooltip
content={task.failureReason}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap bg-danger text-xs"
zIndex={50}
delay={300}
>
<span className="truncate text-[11px] text-red-400/60">
{task.failureReason}
</span>
</Tooltip>
)}
</div>
{task.failureMessage && (
<div className="overflow-hidden">
<Tooltip
content={task.failureMessage}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap text-xs"
zIndex={50}
delay={200}
>
<div className="mt-0.5 cursor-default truncate text-[11px] text-base-fg/40">
{task.failureMessage}
</div>
</Tooltip>
</div>
<Tooltip
content={task.failureMessage}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap text-xs"
zIndex={50}
delay={200}
>
<div className="mt-0.5 cursor-default truncate text-[11px] text-base-fg/40">
{task.failureMessage}
</div>
</Tooltip>
)}
</div>
{onDismiss && (
@@ -502,7 +379,7 @@ export const TaskQueue = () => {
primaryActionText: "",
primaryActionIcon: faTrashAlt,
primaryActionBtnClassName: "",
onConfirm: async () => { },
onConfirm: async () => {},
});
const handleClearCompleted = (onSuccess?: () => void) => {
@@ -660,8 +537,8 @@ export const TaskQueue = () => {
if (!duration) {
const actualModel = t.model_type
? ALL_MODELS_LIST.find(
(m) => m.tauriId === t.model_type || m.id === t.model_type,
)
(m) => m.tauriId === t.model_type || m.id === t.model_type,
)
: undefined;
duration =
actualModel?.progressBarTime ??
@@ -677,11 +554,6 @@ export const TaskQueue = () => {
const estimatedTimeLeftMs = Math.max(0, duration - elapsed);
const parts = formatTitleParts(t);
const canDismiss = now - createdMs > 5 * 60 * 1000; // 5 minutes
const meta = getMetaForTask(
t.id,
t.model_type ? String(t.model_type) : undefined,
createdMs,
);
return {
id: t.id,
title: `Generating ${parts.kind || "Task"}...`,
@@ -691,8 +563,6 @@ export const TaskQueue = () => {
canDismiss,
estimatedTimeLeftMs,
modelType: t.model_type ? String(t.model_type) : undefined,
prompt: meta?.prompt,
refImageUrls: meta?.refImageUrls,
};
});
@@ -723,17 +593,10 @@ export const TaskQueue = () => {
? coverImageCache.get(mediaToken)
: undefined;
const meta = getMetaForTask(
t.id,
t.model_type ? String(t.model_type) : undefined,
t.created_at.getTime(),
);
return {
id: t.id,
...formatTitleParts(t),
thumbnailUrl: serverThumbnail || cachedThumbnail || undefined,
prompt: meta?.prompt,
imageUrls: t.completed_item?.primary_media_file?.cdn_url
? [t.completed_item?.primary_media_file?.cdn_url]
: [],
@@ -772,11 +635,6 @@ export const TaskQueue = () => {
fr?.failure_message && fr.failure_type !== "unknown"
? fr.failure_message
: undefined;
const meta = getMetaForTask(
t.id,
t.model_type ? String(t.model_type) : undefined,
t.created_at.getTime(),
);
return {
id: t.id,
title: parts.title || "Task",
@@ -785,8 +643,6 @@ export const TaskQueue = () => {
status: t.task_status,
failureReason: failureReason || fr?.failure_message || undefined,
failureMessage,
prompt: meta?.prompt,
refImageUrls: meta?.refImageUrls,
};
});
@@ -813,7 +669,6 @@ export const TaskQueue = () => {
}
};
cleanupOldEntries();
load();
const id = setInterval(load, 5000);
@@ -839,10 +694,6 @@ export const TaskQueue = () => {
unlistenFailed = listen("generation-failed-event", () => {
if (!cancelled) {
load();
// Refunded credits may take a moment to settle in the database
setTimeout(() => {
useCreditsState.getState().fetchFromServer();
}, 2000);
}
});
})();
@@ -1,210 +0,0 @@
/**
* Hacky and temporary lightweight cache that stores enqueue metadata (prompt, ref image thumbnails)
* so failed/in-progress tasks in the TaskQueue can display them.
*
* Keyed by approximate timestamp + model type because the Rust backend
* does not expose the frontend_subscriber_id on TaskQueueItem responses.
*
* Ref images are converted to small base64 thumbnails at store time so they
* survive page refreshes (blob URLs do not).
*/
const STORAGE_KEY = "task_enqueue_meta";
const MAX_ENTRIES = 100;
const MATCH_WINDOW_MS = 30_000; // 30-second window for timestamp matching
const THUMB_SIZE = 144; // px – 2x the 72px card thumbnail for retina
export interface EnqueueMeta {
prompt?: string;
refImageUrls?: string[];
modelType?: string;
timestamp: number; // Date.now() at enqueue time
}
interface StoredEntry {
id: string;
prompt?: string;
refImageUrls?: string[]; // base64 data-URLs after conversion
modelType?: string;
timestamp: number;
matchedTaskId?: string; // set once matched to a specific task
}
interface TaskMatch {
prompt?: string;
refImageUrls?: string[];
}
// In-memory cache of task_id → matched metadata
const matchedTasks = new Map<string, TaskMatch>();
function readEntries(): StoredEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function writeEntries(entries: StoredEntry[]) {
const trimmed = entries.slice(-MAX_ENTRIES);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed));
} catch {
// localStorage full – drop oldest half and retry
const half = trimmed.slice(Math.floor(trimmed.length / 2));
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(half));
} catch {
// give up
}
}
}
let entryCounter = 0;
/**
* Convert a blob/object URL to a small base64 data-URL thumbnail.
* Returns the original URL if conversion fails (e.g. CORS, non-image).
*/
function toBase64Thumb(url: string): Promise<string> {
return new Promise((resolve) => {
// Already a data URL — keep as-is
if (url.startsWith("data:")) {
resolve(url);
return;
}
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
const canvas = document.createElement("canvas");
const scale = Math.min(THUMB_SIZE / img.width, THUMB_SIZE / img.height, 1);
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) {
resolve(url);
return;
}
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL("image/jpeg", 0.6));
} catch {
resolve(url);
}
};
img.onerror = () => resolve(url);
img.src = url;
});
}
/**
* Call this at enqueue time to store the prompt + ref image URLs.
* Blob URLs are asynchronously converted to base64 thumbnails for persistence.
*/
export function storeEnqueueMeta(meta: EnqueueMeta) {
const entryId = `${Date.now()}_${entryCounter++}`;
// Write immediately with original URLs so same-session lookups work
const entry: StoredEntry = {
id: entryId,
prompt: meta.prompt,
refImageUrls: meta.refImageUrls,
modelType: meta.modelType,
timestamp: meta.timestamp,
};
const entries = readEntries();
entries.push(entry);
writeEntries(entries);
// Then convert blob URLs to base64 in the background for persistence
if (meta.refImageUrls && meta.refImageUrls.length > 0) {
Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => {
const current = readEntries();
const found = current.find((e) => e.id === entryId);
if (found) {
found.refImageUrls = dataUrls;
writeEntries(current);
}
});
}
}
/**
* Try to match a task to stored enqueue metadata.
* Uses timestamp proximity + model type matching.
* Once matched, the entry stores the taskId so it survives page refresh.
*/
export function getMetaForTask(
taskId: string,
modelType: string | undefined,
createdAtMs: number,
): TaskMatch | undefined {
// Check in-memory cache first
const cached = matchedTasks.get(taskId);
if (cached) return cached;
const entries = readEntries();
// First check if this task was already matched in a previous session
const previousMatch = entries.find((e) => e.matchedTaskId === taskId);
if (previousMatch) {
const result: TaskMatch = {
prompt: previousMatch.prompt,
refImageUrls: previousMatch.refImageUrls,
};
matchedTasks.set(taskId, result);
return result;
}
// Otherwise find the best unmatched entry by timestamp + model proximity
let bestIdx = -1;
let bestDiff = Infinity;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.matchedTaskId) continue; // already matched to another task
const entryModel = entry.modelType?.toLowerCase();
const taskModel = modelType?.toLowerCase();
if (entryModel !== taskModel) continue;
const diff = Math.abs(entry.timestamp - createdAtMs);
if (diff < MATCH_WINDOW_MS && diff < bestDiff) {
bestDiff = diff;
bestIdx = i;
}
}
if (bestIdx === -1) return undefined;
// Mark as matched with this taskId (persists across refresh)
entries[bestIdx].matchedTaskId = taskId;
writeEntries(entries);
const matched = entries[bestIdx];
const result: TaskMatch = {
prompt: matched.prompt,
refImageUrls: matched.refImageUrls,
};
matchedTasks.set(taskId, result);
return result;
}
/**
* Clean up old entries (older than 24 hours).
*/
export function cleanupOldEntries() {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const entries = readEntries().filter((e) => e.timestamp > cutoff);
writeEntries(entries);
}
// Expose globally so libs/ packages can call without cross-package imports.
declare global {
interface Window {
__storeTaskEnqueueMeta?: (meta: EnqueueMeta) => void;
}
}
window.__storeTaskEnqueueMeta = storeEnqueueMeta;
@@ -272,12 +272,6 @@ export const Angles = () => {
const subscriberId = uuidv4();
state.startGeneration(subscriberId);
window.__storeTaskEnqueueMeta?.({
prompt: "",
modelType: (selectedModel as any)?.tauriId || "flux_2_lora_angles",
timestamp: Date.now(),
});
try {
await EnqueueEditImage({
model: selectedModel ?? ("flux_2_lora_angles" as any),
@@ -796,16 +796,6 @@ const PageDraw = () => {
return;
}
window.__storeTaskEnqueueMeta?.({
prompt,
refImageUrls: (options?.images || [])
.map((img) => img.url)
.filter(Boolean),
modelType:
(selectedImageModel as any)?.tauriId || String(selectedImageModel),
timestamp: Date.now(),
});
try {
let result;
@@ -390,16 +390,6 @@ const PageEdit = () => {
crypto?.randomUUID?.() ??
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
window.__storeTaskEnqueueMeta?.({
prompt,
refImageUrls: (options?.images || [])
.map((img) => img.url)
.filter(Boolean),
modelType:
(selectedImageModel as any)?.tauriId || String(selectedImageModel),
timestamp: Date.now(),
});
try {
let result;
@@ -42,7 +42,6 @@ import {
} from "~/signals";
import { outlinerState, updateObjectPanel } from "../signals";
import { transformSpace } from "../signals/selectedMode";
import { IGenerationOptions } from "../models/generationOptions";
import { toEngineActions } from "../Queue/toEngineActions";
import { SceneGenereationMetaData } from "../models/sceneGenerationMetadata";
@@ -593,7 +592,8 @@ class Editor {
this.cameraViewControls.enabled = true;
this.control = new TransformControls(this.camera, this.renderer.domElement);
this.control.space = "world"; // Default to world space for translate mode
this.control.space = "local"; // Local transformation mode
// .space = 'world'; // Global mode
this.control.setScaleSnap(0.01);
this.control.setTranslationSnap(0.01);
this.control.setRotationSnap(0.01);
@@ -1538,18 +1538,9 @@ class Editor {
return;
}
this.control.mode = type;
this.control.space = type === "scale" ? "local" : transformSpace.value;
this.transform_interaction = true;
}
toggleTransformSpace() {
if (this.control == undefined || this.control.mode === "scale") {
return;
}
transformSpace.value = transformSpace.value === "world" ? "local" : "world";
this.control.space = transformSpace.value;
}
async stopPlaybackAndUploadVideo() {
await this.videoGeneration.stopPlaybackAndUploadVideo();
}
@@ -20,7 +20,6 @@ import {
selectedMode,
poseMode,
showPoseControls,
transformSpace,
} from "../signals/selectedMode";
import { Euler } from "three";
@@ -342,25 +341,16 @@ export class MouseControls {
} else if (event.key === "t") {
// transform
this.control?.setMode("translate");
if (this.control) this.control.space = transformSpace.value;
selectedMode.value = "move";
return;
} else if (event.key === "x") {
// toggle world/local space (blocked in scale mode)
if (this.control?.mode === "scale") return;
transformSpace.value = transformSpace.value === "world" ? "local" : "world";
if (this.control) this.control.space = transformSpace.value;
return;
} else if (event.key === "r" && !event.ctrlKey) {
// rotate
this.control?.setMode("rotate");
if (this.control) this.control.space = transformSpace.value;
selectedMode.value = "rotate";
return;
} else if (event.key === "g") {
// scale
this.control?.setMode("scale");
if (this.control) this.control.space = transformSpace.value;
selectedMode.value = "scale";
return;
} else if (event.key === "k") {
@@ -97,11 +97,6 @@ const categoryToTabIdMap: Record<FilterEngineCategories, string> = {
[FilterEngineCategories.VIDEO_PLANE]: "all",
};
// Names (lowercase) to pin to the front of the characters list, in order.
const CHARACTER_PRIORITY_ORDER = ["storyboy", "story girl", "knight", "news anchor"];
// Names (lowercase) of featured characters that should appear in Memes instead.
const MEME_OVERRIDES = ["ai trump"];
export const AssetModal = () => {
useSignals();
const [activeLibraryTab, setActiveLibraryTab] = useState("library");
@@ -258,35 +253,8 @@ export const AssetModal = () => {
// { id: "mine", label: "Mine" },
// ];
const assetTabs = useMemo<AssetTab[]>(() => {
const allCharacterCandidates =
activeLibraryTab === "library"
? [...demoCharacterItems.value, ...(featuredCharacters ?? [])]
: (userCharacters ?? []);
// Split out items that belong in Memes
const memeOverrideItems = allCharacterCandidates.filter((item) =>
MEME_OVERRIDES.includes(item.name?.toLowerCase() ?? ""),
);
const characterCandidates = allCharacterCandidates.filter(
(item) => !MEME_OVERRIDES.includes(item.name?.toLowerCase() ?? ""),
);
// Reorder: priority names first (in specified order), then the rest
const priorityCharacters: MediaItem[] = [];
for (const name of CHARACTER_PRIORITY_ORDER) {
const found = characterCandidates.find(
(item) => item.name?.toLowerCase() === name,
);
if (found) priorityCharacters.push(found);
}
const remainingCharacters = characterCandidates.filter(
(item) =>
!CHARACTER_PRIORITY_ORDER.includes(item.name?.toLowerCase() ?? ""),
);
const orderedCharacters = [...priorityCharacters, ...remainingCharacters];
return [
const assetTabs = useMemo<AssetTab[]>(
() => [
{ id: "all", label: "All", icon: faLayerGroup, items: [] },
{
id: "character",
@@ -294,7 +262,10 @@ export const AssetModal = () => {
labelSingle: "Character",
icon: faUser,
engineCategory: FilterEngineCategories.CHARACTER,
items: orderedCharacters,
items:
activeLibraryTab === "library"
? [...demoCharacterItems.value, ...(featuredCharacters ?? [])]
: (userCharacters ?? []),
},
{
id: "objects",
@@ -313,10 +284,7 @@ export const AssetModal = () => {
labelSingle: "Meme",
icon: faFaceGrinStars,
engineCategory: FilterEngineCategories.CHARACTER,
items:
activeLibraryTab === "library"
? [...demoMemeItems.value, ...memeOverrideItems]
: [],
items: activeLibraryTab === "library" ? demoMemeItems.value : [],
},
{
id: "sets",
@@ -358,8 +326,7 @@ export const AssetModal = () => {
// ? (featuredImagePlanes ?? [])
// : (userImagePlanes ?? []),
// },
];
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[
activeLibraryTab,
@@ -22,7 +22,7 @@ import { useTabStore } from "~/pages/Stores/TabState";
// eslint-disable-next-line import/no-unresolved
// import { AssetType } from "~/enums";
import { AssetModal } from "../AssetMenu/AssetModal";
import { selectedMode, transformSpace } from "../../signals/selectedMode";
import { selectedMode } from "../../signals/selectedMode";
import { useSignals } from "@preact/signals-react/runtime";
import { outlinerState } from "../../signals/outliner/outliner";
// eslint-disable-next-line import/no-unresolved
@@ -320,33 +320,6 @@ export const Controls3D = () => {
onOptionChange={handleModeChange}
selectedOption={selectedMode.value}
/>
{selectedMode.value === "scale" ? (
<Tooltip
content="Scale is always in local space"
position="bottom"
delay={300}
>
<button
disabled
className="h-9 rounded-[10px] px-2.5 text-[10px] font-semibold font-mono bg-white/15 uppercase tracking-wide opacity-40 cursor-not-allowed"
>
Local
</button>
</Tooltip>
) : (
<Tooltip
content={`Transform space: ${transformSpace.value} (X to toggle)`}
position="bottom"
delay={300}
>
<button
className="h-9 rounded-[10px] px-2.5 text-[10px] font-semibold font-mono bg-white/15 hover:bg-white/25 transition-colors uppercase tracking-wide"
onClick={() => editorEngine?.toggleTransformSpace()}
>
{transformSpace.value === "world" ? "World" : "Local"}
</button>
</Tooltip>
)}
</div>
</div>
</div>
@@ -3,4 +3,3 @@ import { signal } from "@preact/signals-react";
export const selectedMode = signal("move");
export const showPoseControls = signal(false);
export const poseMode = signal("select");
export const transformSpace = signal<"world" | "local">("world");
@@ -17,14 +17,9 @@ import {
//ProviderSelector,
//PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { ImageModel, getCreatorIcon } from "@storyteller/model-list";
import { ImageModel } from "@storyteller/model-list";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCheck,
faCircleExclamation,
faCopy,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
interface TextToImageProps {
imageMediaId?: string;
imageUrl?: string;
@@ -47,7 +42,6 @@ import {
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { GenerationProvider } from "@storyteller/api-enums";
import Button from "node_modules/@storyteller/ui-button/src/lib/button";
const PAGE_ID: ModelPage = ModelPage.TextToImage;
@@ -60,7 +54,6 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
const dismissBatch = useTextToImageStore((s) => s.dismissBatch);
const resetBatches = useTextToImageStore((s) => s.reset);
const [imageRowVisible, setImageRowVisible] = useState(false);
const [copiedBatchId, setCopiedBatchId] = useState<string | null>(null);
const promptContentRef = useRef<HTMLDivElement>(null);
const [promptHeight, setPromptHeight] = useState<number>(138);
@@ -76,9 +69,9 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
const jobContext: JobContextType = {
jobTokens: [],
addJobToken: () => { },
removeJobToken: () => { },
clearJobTokens: () => { },
addJobToken: () => {},
removeJobToken: () => {},
clearJobTokens: () => {},
};
const hasAnyBatches = batches.length > 0;
@@ -271,10 +264,10 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
length: Math.max(
0,
4 -
Math.max(
1,
Math.min(4, batch.requestedCount ?? 4),
),
Math.max(
1,
Math.min(4, batch.requestedCount ?? 4),
),
),
}).map((_, i) => (
<div
@@ -329,24 +322,9 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
<div className="glass min-h-0 overflow-y-auto rounded-xl px-4 py-3 text-left text-sm text-base-fg/90">
<div>{batch.prompt}</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button
onClick={() => {
navigator.clipboard.writeText(batch.prompt || "");
setCopiedBatchId(batch.id);
setTimeout(() => setCopiedBatchId(null), 2000);
}}
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-white/50 transition-colors bg-transparent hover:bg-white/10 hover:text-white/70"
>
<FontAwesomeIcon
icon={copiedBatchId === batch.id ? faCheck : faCopy}
className="h-3 w-3"
/>
{copiedBatchId === batch.id ? "Copied" : "Copy"}
</Button>
<div className="flex justify-end pt-2">
<Badge
label={batch.modelLabel}
icon={batch.modelCreator ? getCreatorIcon(batch.modelCreator, "h-3.5 w-3.5 icon-auto-contrast") : undefined}
className="px-2 py-1 text-xs opacity-70"
/>
</div>
@@ -358,7 +336,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
)}
<animated.div
className="fixed left-1/2 z-20 -translate-x-1/2"
className="fixed left-1/2 z-20 w-[730px] -translate-x-1/2"
style={promptAnim}
>
{showPromptAtBottom && batches.length > 0 && (
@@ -387,7 +365,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
credits={imageCredits}
onEnqueuePressed={async (prompt, count, subscriberId) => {
const modelLabel = selectedImageModel?.fullName ?? "";
startBatch(prompt, count, modelLabel, subscriberId, selectedImageModel?.creator);
startBatch(prompt, count, modelLabel, subscriberId);
}}
/>
</div>
@@ -1,6 +1,5 @@
import { create } from "zustand";
import type { GeneratedImage } from "@storyteller/tauri-events";
import type { ModelCreator } from "@storyteller/model-list";
export type TextToImageBatch = {
id: string;
@@ -10,7 +9,6 @@ export type TextToImageBatch = {
createdAt: number;
requestedCount: number;
modelLabel: string;
modelCreator?: ModelCreator;
subscriberId: string;
failureReason?: string;
};
@@ -22,7 +20,6 @@ type TextToImageState = {
requestedCount: number,
modelLabel: string,
subscriberId?: string,
modelCreator?: ModelCreator,
) => string;
completeBatch: (
images: GeneratedImage[],
@@ -41,7 +38,6 @@ export const useTextToImageStore = create<TextToImageState>((set, get) => ({
requestedCount: number,
modelLabel: string,
subscriberId?: string,
modelCreator?: ModelCreator,
) => {
const id = subscriberId
? subscriberId
@@ -56,7 +52,6 @@ export const useTextToImageStore = create<TextToImageState>((set, get) => ({
createdAt: Date.now(),
requestedCount,
modelLabel,
modelCreator,
subscriberId: id,
};
set((s) => ({ batches: [...s.batches, batch] }));
@@ -58,9 +58,9 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
const jobContext: JobContextType = {
jobTokens: [],
addJobToken: () => { },
removeJobToken: () => { },
clearJobTokens: () => { },
addJobToken: () => {},
removeJobToken: () => {},
clearJobTokens: () => {},
};
useVideoGenerationCompleteEvent(
@@ -124,10 +124,10 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
className={twMerge(
"relative z-20 mb-52 flex flex-col items-center justify-center text-center drop-shadow-xl",
imageRowVisible &&
(inputMode === "reference" &&
(inputMode === "reference" &&
selectedVideoModel?.supportsReferenceMode
? "mb-[30rem]"
: "mb-80"),
? "mb-[30rem]"
: "mb-80"),
)}
>
<h1 className="text-7xl font-bold text-base-fg">Generate Video</h1>
@@ -182,7 +182,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
)} */}
<animated.div
className="fixed left-1/2 z-20 w-[860px] -translate-x-1/2"
className="fixed left-1/2 z-20 w-[730px] -translate-x-1/2"
style={promptAnim}
>
{/* TODO: Uncomment when backend supports history frontend id */}
@@ -1,22 +1,19 @@
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface BadgeProps {
label: string;
color?: string;
className?: string;
icon?: ReactNode;
}
export const Badge = ({ label, className, icon }: BadgeProps) => {
export const Badge = ({ label, className }: BadgeProps) => {
return (
<div
className={twMerge(
"flex items-center gap-1 rounded-[6px] bg-ui-controls/45 px-[4px] py-[1px] text-[10px] shadow-sm",
"rounded-[6px] bg-ui-controls/45 px-[4px] py-[1px] text-[10px] shadow-sm",
className
)}
>
{icon}
{label}
</div>
);
@@ -751,7 +751,7 @@ export const ImagePromptRow = ({
)}
<div
className={twMerge(
"absolute left-0 glass w-[860px] rounded-t-xl flex",
"absolute left-0 glass w-[730px] rounded-t-xl flex",
showVideoReferenceSection ? "-top-[144px]" : "-top-[72px]",
className,
)}
@@ -880,56 +880,56 @@ export const ImagePromptRow = ({
})}
{referenceImages.length + uploadingImages.length <
maxImagePromptCount && (
<Tooltip
interactive={true}
position="top"
delay={100}
className="bg-ui-controls text-base-fg border border-ui-panel-border p-2 -mb-0.5"
closeOnClick={true}
content={
<div className="flex flex-col gap-1.5">
{allowUpload && (
<Button
variant="primary"
onClick={handleUploadClickStart}
icon={faPlus}
className="w-full"
>
Upload
</Button>
)}
<Tooltip
interactive={true}
position="top"
delay={100}
className="bg-ui-controls text-base-fg border border-ui-panel-border p-2 -mb-0.5"
closeOnClick={true}
content={
<div className="flex flex-col gap-1.5">
{allowUpload && (
<Button
variant="action"
onClick={() => {
setGalleryTarget("start");
setIsGalleryModalOpen(true);
}}
icon={faImages}
className="w-full bg-base-fg/10 hover:bg-base-fg/20"
variant="primary"
onClick={handleUploadClickStart}
icon={faPlus}
className="w-full"
>
Pick from library
Upload
</Button>
</div>
}
>
<Button
variant="action"
className="bg-ui-controls/40 hover:bg-ui-controls/60 aspect-square w-full overflow-hidden rounded-lg w-14 border-dashed border-2 border-black/5 dark:border-white/25 transition-all"
onClick={() => {
if (allowUpload) handleUploadClickStart();
else {
)}
<Button
variant="action"
onClick={() => {
setGalleryTarget("start");
setIsGalleryModalOpen(true);
}
}}
>
<FontAwesomeIcon
icon={faPlus}
className="text-2xl opacity-80 text-base-fg"
/>
</Button>
</Tooltip>
)}
}}
icon={faImages}
className="w-full bg-base-fg/10 hover:bg-base-fg/20"
>
Pick from library
</Button>
</div>
}
>
<Button
variant="action"
className="bg-ui-controls/40 hover:bg-ui-controls/60 aspect-square w-full overflow-hidden rounded-lg w-14 border-dashed border-2 border-black/5 dark:border-white/25 transition-all"
onClick={() => {
if (allowUpload) handleUploadClickStart();
else {
setGalleryTarget("start");
setIsGalleryModalOpen(true);
}
}}
>
<FontAwesomeIcon
icon={faPlus}
className="text-2xl opacity-80 text-base-fg"
/>
</Button>
</Tooltip>
)}
</div>
</div>
{isVideo && showEndFrameSection && (
@@ -350,10 +350,10 @@ export const PromptBox2D = ({
)}
<div
className={twMerge(
"glass w-[860px] rounded-xl p-4",
"glass w-[730px] rounded-xl p-4",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none",
isImageRowVisible &&
"rounded-t-none",
)}
>
<div className="flex justify-center gap-2">
@@ -399,8 +399,8 @@ export const PromptBox2D = ({
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => { }}
onBlur={() => { }}
onFocus={() => {}}
onBlur={() => {}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
@@ -70,11 +70,11 @@ interface PromptBox3DProps {
selectedImageModel?: ImageModel;
selectedProvider?: GenerationProvider;
snapshotCurrentFrame:
| ((shouldDownload?: boolean) => {
base64Snapshot: string;
file: File;
} | null)
| undefined;
| ((shouldDownload?: boolean) => {
base64Snapshot: string;
file: File;
} | null)
| undefined;
credits?: number | null;
}
@@ -395,17 +395,6 @@ export const PromptBox3D = ({
request.common_aspect_ratio = commonAspectRatio;
}
window.__storeTaskEnqueueMeta?.({
prompt,
refImageUrls: referenceImages
?.map((image) => image.url)
.filter(Boolean),
modelType:
(selectedImageModel as any)?.tauriId ||
String(selectedImageModel),
timestamp: Date.now(),
});
const generateResponse = await EnqueueEditImage(request);
console.log("generateResponse", generateResponse);
@@ -548,11 +537,11 @@ export const PromptBox3D = ({
)}
<div
className={twMerge(
"glass w-[860px] rounded-xl p-4",
"glass w-[730px] rounded-xl p-4",
isPromptBoxFocused.value ? "!border !border-primary" : "",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none",
isImageRowVisible &&
"rounded-t-none",
)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
@@ -262,23 +262,10 @@ export const PromptBoxEdit = ({
const modes = isNanoBananaModel
? [
{
value: "marker",
icon: faPen,
text: "Marker",
},
{
value: "eraser",
icon: faEraser,
text: "Eraser",
},
]
: supportsMaskedInpainting
? [
{
value: "edit",
icon: faEdit,
text: "Edit Region",
value: "marker",
icon: faPen,
text: "Marker",
},
{
value: "eraser",
@@ -286,18 +273,31 @@ export const PromptBoxEdit = ({
text: "Eraser",
},
]
: supportsMaskedInpainting
? [
{
value: "edit",
icon: faEdit,
text: "Edit Region",
},
{
value: "eraser",
icon: faEraser,
text: "Eraser",
},
]
: [
{
value: "edit",
icon: faEdit,
text: "Edit Region",
},
{
value: "select",
icon: faMousePointer,
text: "Select",
},
];
{
value: "edit",
icon: faEdit,
text: "Edit Region",
},
{
value: "select",
icon: faMousePointer,
text: "Select",
},
];
return (
<>
@@ -307,8 +307,8 @@ export const PromptBoxEdit = ({
className={twMerge(
"glass w-fit mx-auto rounded-xl px-2 py-2 flex items-center gap-3",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"mb-[72px]",
isImageRowVisible &&
"mb-[72px]",
)}
>
<ButtonIconSelect
@@ -352,17 +352,17 @@ export const PromptBoxEdit = ({
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
uploadImage={uploadImage}
onImageClick={() => { }}
onImageClick={() => {}}
className=""
/>
)}
<div
className={twMerge(
"glass w-[860px] rounded-xl p-4",
"glass w-[730px] rounded-xl p-4",
isFocused && "ring-1 ring-primary border-primary",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none",
isImageRowVisible &&
"rounded-t-none",
)}
>
<div className="flex justify-center gap-2">
@@ -297,15 +297,6 @@ export const PromptBoxImage = ({
.filter((t) => t.length > 0);
}
window.__storeTaskEnqueueMeta?.({
prompt,
refImageUrls: referenceImages
?.map((img) => img.url)
.filter(Boolean),
modelType: (selectedModel as any)?.tauriId || String(selectedModel),
timestamp: Date.now(),
});
const generateResponse = await EnqueueTextToImage(request);
console.debug("PromptBoxImage - generateResponse", generateResponse);
@@ -408,7 +399,7 @@ export const PromptBoxImage = ({
<div
className={twMerge(
"glass w-[860px] rounded-xl p-4",
"glass w-[730px] rounded-xl p-4",
isImageRowVisible &&
selectedModel?.canUseImagePrompt &&
"rounded-t-none",
@@ -596,15 +596,6 @@ export const PromptBoxVideo = ({
return request;
};
window.__storeTaskEnqueueMeta?.({
prompt,
refImageUrls: referenceImages
?.map((img) => img.url)
.filter(Boolean),
modelType: (selectedModel as any)?.tauriId || String(selectedModel),
timestamp: Date.now(),
});
const subscriberIds: string[] = [];
const enqueuePromises: Promise<void>[] = [];
@@ -765,7 +756,7 @@ export const PromptBoxVideo = ({
)}
<div
className={twMerge(
"glass w-[860px] rounded-xl p-4",
"glass w-[730px] rounded-xl p-4",
isImageRowVisible && "rounded-t-none",
isFocused
? "ring-1 ring-primary border-primary"
@@ -290,10 +290,10 @@ export const VIDEO_MODELS: VideoModel[] = [
durationOptions: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
defaultDuration: 5,
supportsReferenceMode: true,
maxReferenceImages: 9,
maxReferenceVideos: 3,
maxReferenceImages: 6,
maxReferenceVideos: 2,
maxVideoRefDuration: 15,
maxReferenceAudios: 3,
maxReferenceAudios: 2,
maxAudioRefDuration: 15,
resolutionOptions: ["480p", "720p"],
defaultResolution: "720p",
+85 -215
View File
File diff suppressed because it is too large Load Diff