mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 21:10:58 -05:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
398c56d7eb | ||
|
|
ba21368323 | ||
|
|
0750b9fe10 | ||
|
|
05a26b124e | ||
|
|
a394ee5a96 | ||
|
|
c35ea620b3 |
+3
-47
@@ -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
@@ -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]]
|
||||
|
||||
@@ -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;
|
||||
-18
@@ -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,
|
||||
}
|
||||
-6
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod pager;
|
||||
pub mod pager_builder;
|
||||
pub mod pager_client;
|
||||
@@ -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(¬ification).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()
|
||||
}
|
||||
}
|
||||
@@ -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 (¬ification.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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod pager_error;
|
||||
pub mod pager_service_error;
|
||||
pub mod pager_system_error;
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod notification;
|
||||
pub mod worker;
|
||||
@@ -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!("{}...", ¬ification.summary[..197]);
|
||||
}
|
||||
notification
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod pager_worker_message_queue;
|
||||
pub mod pager_worker;
|
||||
@@ -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
-11
@@ -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
|
||||
|
||||
+1
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
-11
@@ -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;
|
||||
-58
@@ -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,
|
||||
}))
|
||||
}
|
||||
-4
@@ -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;
|
||||
|
||||
-36
@@ -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,
|
||||
}
|
||||
}
|
||||
-30
@@ -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(...); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
-14
@@ -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");
|
||||
}
|
||||
}
|
||||
-153
@@ -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());
|
||||
}
|
||||
-5
@@ -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;
|
||||
|
||||
-7
@@ -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()))
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+7
-12
@@ -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",
|
||||
|
||||
Generated
+85
-215
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user