Compare commits

..
Author SHA1 Message Date
Brandon Thomas 80e428ee60 errors 2026-03-25 17:17:25 -04:00
Brandon Thomas a4e3525080 fix error types 2026-03-25 17:13:41 -04:00
Brandon Thomas 7e8ca70153 errors 2026-03-25 17:10:21 -04:00
Brandon Thomas 9152b61cca error refactor 2026-03-25 17:06:55 -04:00
Brandon Thomas 24ccda5a61 rename 2026-03-25 16:57:19 -04:00
Brandon Thomas cdd88ff6d0 cleanup 2026-03-25 16:53:29 -04:00
Brandon Thomas 930d705217 get rid of pesky type alias 2026-03-25 16:38:56 -04:00
Brandon Thomas d80c3fdfe7 improvement 2026-03-25 16:26:37 -04:00
Brandon Thomas 7d5c741bc0 improvements 2026-03-25 16:24:33 -04:00
Brandon Thomas 521c1ebf22 refactor 2026-03-25 15:11:18 -04:00
Brandon Thomas d50cb38178 refactor 2026-03-25 15:06:45 -04:00
Brandon Thomas 7ce0018e13 wip 2026-03-25 15:06:28 -04:00
Brandon Thomas b423e4d524 error 2026-03-25 14:51:54 -04:00
Brandon Thomas 89896611e7 pager 2026-03-25 14:41:44 -04:00
76 changed files with 314 additions and 1509 deletions
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n j.status as `job_status: enums::common::job_status_plus::JobStatusPlus`,\n j.failure_reason as job_failure_reason,\n wle.credits_delta,\n wle.maybe_linked_refund_ledger_token as `maybe_linked_refund_ledger_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n j.on_success_result_entity_token as `on_success_result_media_token: tokens::tokens::media_files::MediaFileToken`,\n j.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n wle.token as `wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n wle.entry_type as `wallet_ledger_entry_type: enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType`,\n j.maybe_external_third_party as `maybe_external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n j.maybe_external_third_party_id,\n j.created_at,\n j.updated_at\nFROM generic_inference_jobs as j\nLEFT OUTER JOIN users as u\n ON j.maybe_creator_user_token = u.token\nLEFT OUTER JOIN wallet_ledger_entries as wle\n ON wle.maybe_entity_ref = j.token\nWHERE u.token = ?\nORDER BY j.id DESC\n ",
"query": "\nSELECT\n j.status as `job_status: enums::common::job_status_plus::JobStatusPlus`,\n j.failure_reason as job_failure_reason,\n wle.credits_delta,\n wle.maybe_linked_refund_ledger_token as `maybe_linked_refund_ledger_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n j.on_success_result_entity_token as `on_success_result_media_token: tokens::tokens::media_files::MediaFileToken`,\n j.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n wle.token as `wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n wle.entry_type as `wallet_ledger_entry_type: enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType`\nFROM generic_inference_jobs as j\nLEFT OUTER JOIN users as u\n ON j.maybe_creator_user_token = u.token\nLEFT OUTER JOIN wallet_ledger_entries as wle\n ON wle.maybe_entity_ref = j.token\nWHERE u.token = ?\nORDER BY j.id DESC\n ",
"describe": {
"columns": [
{
@@ -82,46 +82,6 @@
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 9,
"name": "maybe_external_third_party_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 10,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 11,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
@@ -135,12 +95,8 @@
true,
false,
true,
true,
true,
true,
false,
false
true
]
},
"hash": "cbdaa45597de14d1eb6dc0f6a95788d56334757bb3d03eda2eb64df3ba2a0144"
"hash": "0757d81ba153b2ad2df25df3dd92b655ef08b95f5f83251bf3573f9be3a9f8d1"
}
Generated
-2
View File
@@ -11263,7 +11263,6 @@ dependencies = [
"mimetypes",
"mysql_queries",
"once_cell",
"pager",
"password",
"primitives",
"prompt_engineering",
@@ -11276,7 +11275,6 @@ dependencies = [
"regex",
"resend-rs",
"reusable_types",
"rootly_client",
"rust-s3",
"rustls 0.23.36",
"seedance2pro_client",
@@ -1 +0,0 @@
pub mod moderation_send_alert;
@@ -1,18 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
pub const MODERATION_SEND_ALERT_PATH: &str = "/v1/moderation/alerts/send";
#[derive(Deserialize, ToSchema)]
pub struct ModerationSendAlertRequest {
/// Optional title for the alert. Defaults to "Test Moderation Alert".
pub title: Option<String>,
/// Optional description for the alert. Defaults to "This is a test moderation alert."
pub description: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModerationSendAlertResponse {
pub success: bool,
}
@@ -1,5 +1,3 @@
use chrono::{DateTime, Utc};
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType;
use enums::common::job_status_plus::JobStatusPlus;
use serde_derive::{Deserialize, Serialize};
@@ -32,8 +30,4 @@ pub struct ListUserJobsEntry {
pub job_token: InferenceJobToken,
pub wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
pub wallet_ledger_entry_type: Option<WalletLedgerEntryType>,
pub maybe_external_third_party: Option<InferenceJobExternalThirdParty>,
pub maybe_external_third_party_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -1,4 +1,3 @@
pub mod alerts;
pub mod jobs;
pub mod user;
pub mod wallet_ledger_entries;
@@ -14,16 +14,4 @@ pub enum CommonResolution {
ThreeK,
/// Models: Nano Banana Pro
FourK,
/// Used by Nano Banana 2 (and others)
HalfK,
/// Used by the Seedance family (and others)
FourEightyP,
/// Used by the Seedance family (and others)
SevenTwentyP,
/// Used by the Seedance family (and others)
TenEightyP,
}
@@ -135,8 +135,6 @@ fn plan_resolution(
// No 3K option; default to 2K
Ok(Some(Nb2Res::TwoK))
}
// HalfK, FourEightyP, SevenTwentyP, TenEightyP — nearest supported is OneK
Some(R::HalfK | R::FourEightyP | R::SevenTwentyP | R::TenEightyP) => Ok(Some(Nb2Res::OneK)),
}
}
@@ -135,8 +135,6 @@ fn plan_resolution(
// No 3K option; default to 2K
Ok(Some(NbpRes::TwoK))
}
// HalfK, FourEightyP, SevenTwentyP, TenEightyP — nearest supported is OneK
Some(R::HalfK | R::FourEightyP | R::SevenTwentyP | R::TenEightyP) => Ok(Some(NbpRes::OneK)),
}
}
@@ -202,11 +202,6 @@ fn plan_resolution(
Some(CommonResolution::FourK) => Ok(Some(FalNbpResolution::FourK)),
// No 3K option; fall back to 2K
Some(CommonResolution::ThreeK) => Ok(Some(FalNbpResolution::TwoK)),
// Fall back to nearest supported resolution
Some(CommonResolution::HalfK) => Ok(Some(FalNbpResolution::OneK)),
Some(CommonResolution::FourEightyP) => Ok(Some(FalNbpResolution::OneK)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(FalNbpResolution::OneK)),
Some(CommonResolution::TenEightyP) => Ok(Some(FalNbpResolution::OneK)),
}
}
@@ -167,10 +167,6 @@ fn plan_resolution(
Some(CommonResolution::TwoK) => Ok(Some(Seedance1p5ProMultiFunctionVideoGenResolution::TenEightyP)),
Some(CommonResolution::ThreeK) => Ok(Some(Seedance1p5ProMultiFunctionVideoGenResolution::TenEightyP)),
Some(CommonResolution::FourK) => Ok(Some(Seedance1p5ProMultiFunctionVideoGenResolution::TenEightyP)),
// HalfK and FourEightyP map to the lowest supported resolution
Some(CommonResolution::HalfK | CommonResolution::FourEightyP) => Ok(Some(Seedance1p5ProMultiFunctionVideoGenResolution::FourEightyP)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(Seedance1p5ProMultiFunctionVideoGenResolution::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(Seedance1p5ProMultiFunctionVideoGenResolution::TenEightyP)),
}
}
@@ -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(),
}
@@ -14,16 +14,4 @@ pub enum CommonResolution {
ThreeK,
/// Models: Nano Banana Pro
FourK,
/// Used by Nano Banana 2 (and others)
HalfK,
/// Used by the Seedance family (and others)
FourEightyP,
/// Used by the Seedance family (and others)
SevenTwentyP,
/// Used by the Seedance family (and others)
TenEightyP,
}
@@ -8,10 +8,5 @@ pub fn resolution_to_artcraft_nano_banana_pro(resolution: CommonResolution) -> N
// NB: ThreeK doesn't exist in Nano Banana Pro, use FourK as nearest higher resolution
CommonResolution::ThreeK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
CommonResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
// Fall back to nearest supported resolution
CommonResolution::HalfK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
CommonResolution::FourEightyP => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
CommonResolution::SevenTwentyP => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
CommonResolution::TenEightyP => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
}
}
@@ -144,9 +144,5 @@ fn convert_desktop_resolution(res: CommonResolution2) -> RouterCommonResolution
CommonResolution2::TwoK => RouterCommonResolution::TwoK,
CommonResolution2::ThreeK => RouterCommonResolution::ThreeK,
CommonResolution2::FourK => RouterCommonResolution::FourK,
CommonResolution2::HalfK => RouterCommonResolution::HalfK,
CommonResolution2::FourEightyP => RouterCommonResolution::FourEightyP,
CommonResolution2::SevenTwentyP => RouterCommonResolution::SevenTwentyP,
CommonResolution2::TenEightyP => RouterCommonResolution::TenEightyP,
}
}
+16 -27
View File
@@ -1,7 +1,7 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use log::{debug, info, warn};
use log::info;
use crate::client::pager_client::{PageSentResult, PagerClient};
use crate::error::pager_error::PagerError;
@@ -15,9 +15,20 @@ use crate::worker::pager_worker_message_queue::PagerWorkerMessageQueue;
/// - **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`.
/// Build an instance via `PagerBuilder`:
/// ```ignore
/// // Without worker:
/// let pager = PagerBuilder::new()
/// .application_name("my-service".to_string())
/// .rootly(api_key)
/// .build()?;
///
#[derive(Clone)]
/// // With worker:
/// let (pager, worker) = PagerBuilder::new()
/// .application_name("my-service".to_string())
/// .rootly(api_key)
/// .build_with_worker()?;
/// ```
pub struct Pager {
client: PagerClient,
queue: Option<Arc<PagerWorkerMessageQueue>>,
@@ -44,43 +55,21 @@ impl Pager {
}
/// 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> {
) -> Result<PageSentResult, PagerError> {
self.client.send_page(&notification).await
}
/// Send a page immediately. Logs errors but never fails.
/// Returns `None` if the pager is NoOp or if sending failed.
pub async fn send_page_immediately_infallible(
&self,
notification: NotificationDetails,
) -> Option<PageSentResult> {
match self.send_page_immediately(notification).await {
Ok(result) => result,
Err(err) => {
warn!("Failure sending page: {:?}", err);
None
}
}
}
/// Enqueue a page to be sent by the background worker thread.
///
/// Returns `Ok(())` immediately if the pager is NoOp.
/// Returns an error if the worker is not configured (use `build_with_worker()`).
/// If the queue is full, the oldest item is dropped.
pub fn enqueue_page(
&self,
notification: NotificationDetails,
) -> Result<(), PagerError> {
if self.client.is_noop() {
debug!("Pager no-op: would have enqueued page: {}", notification.summary);
return Ok(());
}
let queue = self.queue.as_ref()
.ok_or(PagerSystemError::QueueNotConfigured)?;
+23 -30
View File
@@ -1,10 +1,11 @@
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::error::pager_builder_error::PagerBuilderError;
use crate::error::pager_error::PagerError;
use crate::worker::pager_worker_message_queue::{new_shared_queue, new_shared_queue_with_capacity, PagerWorkerMessageQueue};
use crate::worker::pager_worker::PagerWorker;
@@ -13,7 +14,6 @@ pub struct PagerBuilder {
client_config: Option<PagerClientConfig>,
application_name: Option<String>,
environment: Option<String>,
hostname: Option<String>,
queue_capacity: Option<usize>,
}
@@ -23,7 +23,6 @@ impl PagerBuilder {
client_config: None,
application_name: None,
environment: None,
hostname: None,
queue_capacity: None,
}
}
@@ -40,12 +39,6 @@ impl PagerBuilder {
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 {
@@ -72,25 +65,30 @@ impl PagerBuilder {
/// 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)
/// Only `send_page_immediately()` will be available.
/// Calling `enqueue_page()` will return `PagerServiceError::WorkerNotAvailable`.
pub fn build(self) -> Result<Pager, PagerError> {
let client = self.make_client()?;
Ok(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).
///
/// Returns both the `Pager` (for enqueuing) and the `PagerWorker` (to spawn).
/// 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();
/// .build_with_worker()?;
///
/// std::thread::spawn(move || {
/// let rt = tokio::runtime::Runtime::new().unwrap();
/// rt.block_on(worker.run());
/// });
/// ```
pub fn build_with_worker(self) -> (Pager, PagerWorker) {
let client = self.make_client();
pub fn build_with_worker(self) -> Result<(Pager, PagerWorker), PagerError> {
let client = self.make_client()?;
let queue: Arc<PagerWorkerMessageQueue> = match self.queue_capacity {
Some(capacity) => new_shared_queue_with_capacity(capacity),
@@ -101,19 +99,14 @@ impl PagerBuilder {
let shutdown = worker.shutdown_handle();
let pager = Pager::with_queue(client, queue, shutdown);
(pager, worker)
Ok((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
}
};
fn make_client(&self) -> Result<PagerClient, PagerError> {
let client_config = self.client_config.clone()
.ok_or(PagerBuilderError::NoBackendConfigured)?;
PagerClient::new(client_config, self.application_name.clone(), self.environment.clone(), self.hostname.clone())
Ok(PagerClient::new(client_config, self.application_name.clone(), self.environment.clone()))
}
}
@@ -153,12 +146,12 @@ impl RootlyConfigBuilder {
}
/// Shortcut: finish Rootly config and build without a worker.
pub fn build(self) -> Pager {
pub fn build(self) -> Result<Pager, PagerError> {
self.done().build()
}
/// Shortcut: finish Rootly config and build with a worker.
pub fn build_with_worker(self) -> (Pager, PagerWorker) {
pub fn build_with_worker(self) -> Result<(Pager, PagerWorker), PagerError> {
self.done().build_with_worker()
}
}
+12 -41
View File
@@ -1,4 +1,4 @@
use log::{debug, warn};
use log::{error, info};
use rootly_client::creds::rootly_api_key::RootlyApiKey;
use rootly_client::requests::create_alert::create_alert::{
@@ -20,17 +20,11 @@ pub struct PagerClient {
/// 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,
@@ -50,7 +44,7 @@ pub enum PagerClientConfig {
#[derive(Debug, Clone)]
pub struct PageSentResult {
/// A backend-specific identifier for the sent notification.
pub id: Option<String>,
pub id: String,
/// A short human-readable ID (if the backend provides one).
pub short_id: Option<String>,
@@ -61,25 +55,14 @@ impl PagerClient {
client_config: PagerClientConfig,
application_name: Option<String>,
environment: Option<String>,
hostname: Option<String>,
) -> Self {
Self { client_config, application_name, environment, hostname }
Self { client_config, application_name, environment }
}
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> {
/// Send a page immediately.
pub async fn send_page(&self, notification: &NotificationDetails) -> Result<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)
}
PagerClientConfig::Rootly { .. } => self.send_page_via_rootly(notification).await,
}
}
@@ -92,15 +75,13 @@ impl PagerClient {
alert_urgency_id,
notification_target_type,
notification_target_id,
} = &self.client_config else {
return Ok(PageSentResult { id: None, short_id: None });
};
} = &self.client_config;
let source = self.application_name
.clone()
.unwrap_or_else(|| "unknown".to_string());
debug!("Sending page via Rootly (source={}): {}", source, notification.summary);
info!("Sending page via Rootly (source={}): {}", source, notification.summary);
let mut labels: Vec<(String, String)> = Vec::new();
@@ -108,23 +89,13 @@ impl PagerClient {
labels.push(("environment".to_string(), env.clone()));
}
if let Some(h) = &self.hostname {
labels.push(("hostname".to_string(), h.clone()));
}
let labels = if labels.is_empty() { None } else { Some(labels) };
// Enrich the description with hostname if both are present.
let description = match (&notification.description, &self.hostname) {
(Some(desc), Some(h)) => Some(format!("{}\n\nHostname: {}", desc, h)),
(desc, _) => desc.clone(),
};
let result = create_alert(CreateAlertArgs {
api_key: api_key.clone(),
source,
summary: notification.summary.clone(),
description,
description: notification.description.clone(),
status: Some("triggered".to_string()),
service_ids: None,
group_ids: None,
@@ -140,14 +111,14 @@ impl PagerClient {
match result {
Ok(success) => {
debug!("Page sent successfully via Rootly: id={}, short_id={:?}", success.id, success.short_id);
info!("Page sent successfully via Rootly: id={}, short_id={:?}", success.id, success.short_id);
Ok(PageSentResult {
id: Some(success.id),
id: success.id,
short_id: success.short_id,
})
}
Err(err) => {
warn!("Failed to send page via Rootly: {}", err);
error!("Failed to send page via Rootly: {}", err);
Err(PagerError::Service(PagerServiceError::RootlyError(err)))
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod pager_builder_error;
pub mod pager_error;
pub mod pager_service_error;
pub mod pager_system_error;
@@ -0,0 +1,19 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// Errors from building/configuring a Pager instance.
#[derive(Debug)]
pub enum PagerBuilderError {
/// The pager was not configured with a backend.
NoBackendConfigured,
}
impl Error for PagerBuilderError {}
impl Display for PagerBuilderError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoBackendConfigured => write!(f, "No pager backend configured. Call .rootly() or .client_config() on PagerBuilder."),
}
}
}
+11
View File
@@ -1,12 +1,16 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use crate::error::pager_builder_error::PagerBuilderError;
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 {
/// An error during Pager construction/configuration.
Builder(PagerBuilderError),
/// A third-party paging service (Rootly, etc.) returned an error.
Service(PagerServiceError),
@@ -19,12 +23,19 @@ impl Error for PagerError {}
impl Display for PagerError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Builder(err) => write!(f, "PagerBuilderError: {}", err),
Self::Service(err) => write!(f, "PagerServiceError: {}", err),
Self::System(err) => write!(f, "PagerSystemError: {}", err),
}
}
}
impl From<PagerBuilderError> for PagerError {
fn from(err: PagerBuilderError) -> Self {
Self::Builder(err)
}
}
impl From<PagerServiceError> for PagerError {
fn from(err: PagerServiceError) -> Self {
Self::Service(err)
+6 -9
View File
@@ -1,8 +1,8 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use log::{debug, error, info, warn};
use log::{error, info, warn};
use crate::client::pager_client::PagerClient;
use crate::worker::pager_worker_message_queue::PagerWorkerMessageQueue;
@@ -67,7 +67,7 @@ impl PagerWorker {
continue;
}
debug!("Pager worker processing {} queued notification(s).", items.len());
info!("Pager worker processing {} queued notification(s).", items.len());
for notification in &items {
if self.shutdown.load(Ordering::Relaxed) {
@@ -76,15 +76,12 @@ impl PagerWorker {
}
match self.client.send_page(notification).await {
Ok(Some(success)) => {
debug!(
"Pager worker sent page: id={:?}, summary={}",
Ok(success) => {
info!(
"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 '{}': {}",
@@ -1,9 +1,7 @@
use anyhow::anyhow;
use chrono::{DateTime, Utc};
use log::warn;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType;
use enums::common::job_status_plus::JobStatusPlus;
use errors::AnyhowResult;
@@ -21,10 +19,6 @@ pub struct UserJobForModerationResult {
pub job_token: InferenceJobToken,
pub wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
pub wallet_ledger_entry_type: Option<WalletLedgerEntryType>,
pub maybe_external_third_party: Option<InferenceJobExternalThirdParty>,
pub maybe_external_third_party_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub async fn list_user_jobs_for_moderation(
@@ -42,11 +36,7 @@ SELECT
j.on_success_result_entity_token as `on_success_result_media_token: tokens::tokens::media_files::MediaFileToken`,
j.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
wle.token as `wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,
wle.entry_type as `wallet_ledger_entry_type: enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType`,
j.maybe_external_third_party as `maybe_external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,
j.maybe_external_third_party_id,
j.created_at,
j.updated_at
wle.entry_type as `wallet_ledger_entry_type: enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType`
FROM generic_inference_jobs as j
LEFT OUTER JOIN users as u
ON j.maybe_creator_user_token = u.token
@@ -13,8 +13,6 @@ use tokens::tokens::users::UserToken;
use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_bool_default_false, nullable_i8_to_optional_bool};
const ADMIN_ROLE_SLUG: &str = "admin";
#[derive(Clone, Serialize, Deserialize)]
pub struct SessionUserRecord {
pub user_token: UserToken,
@@ -92,7 +90,7 @@ impl SessionUserRecord {
}
pub fn is_mod(&self) -> bool {
self.can_ban_users || self.user_role_slug == ADMIN_ROLE_SLUG
self.can_ban_users
}
}
@@ -9,7 +9,7 @@ use strum::EnumIter;
///
/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, Default, utoipa::ToSchema)]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum InferenceJobExternalThirdParty {
/// Fal jobs
@@ -1,167 +0,0 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
use crate::error::enum_error::EnumError;
/// Maximum serialized string length for database storage.
pub const MAX_LENGTH: usize = 16;
/// Common quality levels for generation.
///
/// NB: Keep the max serialized length to 16 characters.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonQuality {
High,
Medium,
Low,
}
impl_enum_display_and_debug_using_to_str!(CommonQuality);
impl_mysql_enum_coders!(CommonQuality);
impl_mysql_from_row!(CommonQuality);
impl CommonQuality {
pub fn to_str(&self) -> &'static str {
match self {
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
}
}
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"high" => Ok(Self::High),
"medium" => Ok(Self::Medium),
"low" => Ok(Self::Low),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::High,
Self::Medium,
Self::Low,
])
}
}
#[cfg(test)]
mod tests {
use crate::common::generation::common_quality::CommonQuality;
use crate::common::generation::common_quality::MAX_LENGTH;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_serialization() {
assert_serialization(CommonQuality::High, "high");
assert_serialization(CommonQuality::Medium, "medium");
assert_serialization(CommonQuality::Low, "low");
}
#[test]
fn to_str() {
assert_eq!(CommonQuality::High.to_str(), "high");
assert_eq!(CommonQuality::Medium.to_str(), "medium");
assert_eq!(CommonQuality::Low.to_str(), "low");
}
#[test]
fn from_str() {
assert_eq!(CommonQuality::from_str("high").unwrap(), CommonQuality::High);
assert_eq!(CommonQuality::from_str("medium").unwrap(), CommonQuality::Medium);
assert_eq!(CommonQuality::from_str("low").unwrap(), CommonQuality::Low);
}
#[test]
fn from_str_err() {
let result = CommonQuality::from_str("invalid");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "invalid");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = CommonQuality::all_variants();
assert_eq!(variants.len(), 3);
assert_eq!(variants.pop_first(), Some(CommonQuality::High));
assert_eq!(variants.pop_first(), Some(CommonQuality::Medium));
assert_eq!(variants.pop_first(), Some(CommonQuality::Low));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(CommonQuality::all_variants().len(), CommonQuality::iter().len());
}
#[test]
fn round_trip() {
for variant in CommonQuality::all_variants() {
assert_eq!(variant, CommonQuality::from_str(variant.to_str()).unwrap());
assert_eq!(variant, CommonQuality::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, CommonQuality::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
for variant in CommonQuality::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant);
}
for variant in CommonQuality::all_variants() {
let json = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json);
}
}
#[test]
fn serialized_names_must_not_contain_dots() {
for variant in CommonQuality::all_variants() {
let to_str_value = variant.to_str();
assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value);
let json_value = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value);
}
}
#[test]
fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() {
let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap();
for variant in CommonQuality::all_variants() {
let to_str_value = variant.to_str();
assert!(valid_pattern.is_match(to_str_value),
"to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value);
let json_value = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(valid_pattern.is_match(&json_value),
"JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value);
}
}
}
}
@@ -23,17 +23,7 @@ pub enum CommonResolution {
ThreeK,
FourK,
/// Used by Nano Banana 2 (and others)
HalfK,
/// Used by the Seedance family (and others)
FourEightyP,
/// Used by the Seedance family (and others)
SevenTwentyP,
/// Used by the Seedance family (and others)
TenEightyP,
// TODO: Add 480p(?), 720p(?), 1080p, etc. as there are some models that use these
}
impl_enum_display_and_debug_using_to_str!(CommonResolution);
@@ -47,10 +37,6 @@ impl CommonResolution {
Self::TwoK => "two_k",
Self::ThreeK => "three_k",
Self::FourK => "four_k",
Self::HalfK => "half_k",
Self::FourEightyP => "four_eighty_p",
Self::SevenTwentyP => "seven_twenty_p",
Self::TenEightyP => "ten_eighty_p",
}
}
@@ -60,10 +46,6 @@ impl CommonResolution {
"two_k" => Ok(Self::TwoK),
"three_k" => Ok(Self::ThreeK),
"four_k" => Ok(Self::FourK),
"half_k" => Ok(Self::HalfK),
"four_eighty_p" => Ok(Self::FourEightyP),
"seven_twenty_p" => Ok(Self::SevenTwentyP),
"ten_eighty_p" => Ok(Self::TenEightyP),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
@@ -74,10 +56,6 @@ impl CommonResolution {
Self::TwoK,
Self::ThreeK,
Self::FourK,
Self::HalfK,
Self::FourEightyP,
Self::SevenTwentyP,
Self::TenEightyP,
])
}
}
@@ -98,10 +76,6 @@ mod tests {
assert_serialization(CommonResolution::TwoK, "two_k");
assert_serialization(CommonResolution::ThreeK, "three_k");
assert_serialization(CommonResolution::FourK, "four_k");
assert_serialization(CommonResolution::HalfK, "half_k");
assert_serialization(CommonResolution::FourEightyP, "four_eighty_p");
assert_serialization(CommonResolution::SevenTwentyP, "seven_twenty_p");
assert_serialization(CommonResolution::TenEightyP, "ten_eighty_p");
}
#[test]
@@ -110,10 +84,6 @@ mod tests {
assert_eq!(CommonResolution::TwoK.to_str(), "two_k");
assert_eq!(CommonResolution::ThreeK.to_str(), "three_k");
assert_eq!(CommonResolution::FourK.to_str(), "four_k");
assert_eq!(CommonResolution::HalfK.to_str(), "half_k");
assert_eq!(CommonResolution::FourEightyP.to_str(), "four_eighty_p");
assert_eq!(CommonResolution::SevenTwentyP.to_str(), "seven_twenty_p");
assert_eq!(CommonResolution::TenEightyP.to_str(), "ten_eighty_p");
}
#[test]
@@ -122,10 +92,6 @@ mod tests {
assert_eq!(CommonResolution::from_str("two_k").unwrap(), CommonResolution::TwoK);
assert_eq!(CommonResolution::from_str("three_k").unwrap(), CommonResolution::ThreeK);
assert_eq!(CommonResolution::from_str("four_k").unwrap(), CommonResolution::FourK);
assert_eq!(CommonResolution::from_str("half_k").unwrap(), CommonResolution::HalfK);
assert_eq!(CommonResolution::from_str("four_eighty_p").unwrap(), CommonResolution::FourEightyP);
assert_eq!(CommonResolution::from_str("seven_twenty_p").unwrap(), CommonResolution::SevenTwentyP);
assert_eq!(CommonResolution::from_str("ten_eighty_p").unwrap(), CommonResolution::TenEightyP);
}
#[test]
@@ -142,15 +108,11 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonResolution::all_variants();
assert_eq!(variants.len(), 8);
assert_eq!(variants.len(), 4);
assert_eq!(variants.pop_first(), Some(CommonResolution::OneK));
assert_eq!(variants.pop_first(), Some(CommonResolution::TwoK));
assert_eq!(variants.pop_first(), Some(CommonResolution::ThreeK));
assert_eq!(variants.pop_first(), Some(CommonResolution::FourK));
assert_eq!(variants.pop_first(), Some(CommonResolution::HalfK));
assert_eq!(variants.pop_first(), Some(CommonResolution::FourEightyP));
assert_eq!(variants.pop_first(), Some(CommonResolution::SevenTwentyP));
assert_eq!(variants.pop_first(), Some(CommonResolution::TenEightyP));
assert_eq!(variants.pop_first(), None);
}
}
@@ -1,7 +1,6 @@
pub mod common_aspect_ratio;
pub mod common_generation_mode;
pub mod common_image_model;
pub mod common_quality;
pub mod common_model_class;
pub mod common_model_type;
pub mod common_resolution;
@@ -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,
@@ -168,9 +168,5 @@ fn map_resolution(res: CommonResolution) -> RouterResolution {
CommonResolution::TwoK => RouterResolution::TwoK,
CommonResolution::ThreeK => RouterResolution::ThreeK,
CommonResolution::FourK => RouterResolution::FourK,
CommonResolution::HalfK => RouterResolution::HalfK,
CommonResolution::FourEightyP => RouterResolution::FourEightyP,
CommonResolution::SevenTwentyP => RouterResolution::SevenTwentyP,
CommonResolution::TenEightyP => RouterResolution::TenEightyP,
}
}
@@ -178,9 +178,5 @@ fn map_resolution(res: CommonResolution) -> RouterResolution {
CommonResolution::TwoK => RouterResolution::TwoK,
CommonResolution::ThreeK => RouterResolution::ThreeK,
CommonResolution::FourK => RouterResolution::FourK,
CommonResolution::HalfK => RouterResolution::HalfK,
CommonResolution::FourEightyP => RouterResolution::FourEightyP,
CommonResolution::SevenTwentyP => RouterResolution::SevenTwentyP,
CommonResolution::TenEightyP => RouterResolution::TenEightyP,
}
}
@@ -46,7 +46,6 @@ use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url, ExtractExtensions};
use pager::notification::notification_details::NotificationDetails;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
// ======================== Result of a successful generation ========================
@@ -220,11 +219,6 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
}
Err(err) => {
warn!("Regular session fallback also failed for user {:?}: {:?}", user_token, err);
if let Err(page_err) = server_state.pager.enqueue_page(
NotificationDetails::from_error_with_context("Seedance 2.0 generation failed (whitelist + fallback)", &err)
) {
warn!("Failed to enqueue pager alert: {:?}", page_err);
}
refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?;
return Err(CommonWebError::ServerError);
}
@@ -251,11 +245,6 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
Ok(result) => result,
Err(err) => {
warn!("Error calling seedance2pro generate_video: {:?}", err);
if let Err(page_err) = server_state.pager.enqueue_page(
NotificationDetails::from_error_with_context("Seedance 2.0 generation failed", &err)
) {
warn!("Failed to enqueue pager alert: {:?}", page_err);
}
refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?;
return Err(CommonWebError::ServerError);
}
@@ -1 +0,0 @@
pub mod moderation_send_alert_handler;
@@ -1,58 +0,0 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::moderation::alerts::moderation_send_alert::{
ModerationSendAlertRequest,
ModerationSendAlertResponse,
};
use pager::client::pager::Pager;
use pager::notification::notification_details::NotificationDetails;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, UseDatabase};
use crate::state::server_state::ServerState;
/// Send a test alert via the pager system. Moderators only.
#[utoipa::path(
post,
tag = "Moderation",
path = "/v1/moderation/alerts/send",
request_body = ModerationSendAlertRequest,
responses(
(status = 200, description = "Success", body = ModerationSendAlertResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderation_send_alert_handler(
http_request: HttpRequest,
request: Json<ModerationSendAlertRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModerationSendAlertResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let title = request.title.clone()
.unwrap_or_else(|| "Test Moderation Alert".to_string());
let description = request.description.clone()
.unwrap_or_else(|| "This is a test moderation alert.".to_string());
let notification = NotificationDetails::with_summary_and_description(title, description);
server_state.pager
.enqueue_page(notification)
.map_err(|err| {
warn!("moderation_send_alert error: {:?}", err);
CommonWebError::ServerError
})?;
Ok(Json(ModerationSendAlertResponse {
success: true,
}))
}
@@ -56,10 +56,6 @@ pub async fn list_user_jobs_handler(
job_token: row.job_token,
wallet_ledger_entry_token: row.wallet_ledger_entry_token,
wallet_ledger_entry_type: row.wallet_ledger_entry_type,
maybe_external_third_party: row.maybe_external_third_party,
maybe_external_third_party_id: row.maybe_external_third_party_id,
created_at: row.created_at,
updated_at: row.updated_at,
}).collect();
Ok(Json(ListUserJobsResponse {
@@ -1,4 +1,3 @@
pub mod alerts;
pub mod info;
pub mod ip_bans;
pub mod jobs;
@@ -1,36 +0,0 @@
use pager::client::pager::Pager;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use super::enqueue_alert::enqueue_alert;
/// Check `CommonWebError` and alert on server errors.
/// Returns `true` if the error was handled (alerted or intentionally skipped).
pub(super) fn check_common_web_error(
pager: &Pager,
method: &str,
path: &str,
error: &CommonWebError,
) -> bool {
match error {
CommonWebError::ServerError => {
enqueue_alert(
pager,
format!("CommonWebError::ServerError on {} {}", method, path),
format!(
"A CommonWebError::ServerError was returned.\n\n\
Endpoint: {} {}\n\
Error: {:?}\n\
Time: {}",
method, path, error,
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
),
);
true
}
// Don't alert on client errors (400, 401, 404, 402).
CommonWebError::BadInputWithSimpleMessage(_) => true,
CommonWebError::NotFound => true,
CommonWebError::NotAuthorized => true,
CommonWebError::PaymentRequired => true,
}
}
@@ -1,30 +0,0 @@
use pager::client::pager::Pager;
use super::enqueue_alert::enqueue_alert;
/// Fallback alerting based on HTTP status code when no typed error matched.
pub(super) fn check_status_code_fallback(
pager: &Pager,
method: &str,
path: &str,
status_code: u16,
) {
match status_code {
500 => {
enqueue_alert(
pager,
format!("HTTP 500: {} {}", method, path),
format!(
"An untyped HTTP 500 response was returned (no typed error matched).\n\n\
Endpoint: {} {}\n\
Time: {}",
method, path,
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
),
);
}
// Add more status-based rules here:
// 503 => { enqueue_alert(...); }
_ => {}
}
}
@@ -1,14 +0,0 @@
use log::{debug, warn};
use pager::client::pager::Pager;
use pager::notification::notification_details::NotificationDetails;
pub(super) fn enqueue_alert(pager: &Pager, summary: String, description: String) {
let notification = NotificationDetails::with_summary_and_description(summary, description);
if let Err(err) = pager.enqueue_page(notification) {
warn!("Error alerting middleware: failed to enqueue page: {:?}", err);
} else {
debug!("Error alerting middleware: enqueued alert");
}
}
@@ -1,153 +0,0 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::Error;
use pager::client::pager::Pager;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::state::flags::paging_flags::PagingFlags;
use super::check_common_web_error::check_common_web_error;
use super::check_status_code_fallback::check_status_code_fallback;
// ======================== Transform (factory) ========================
/// Middleware that intercepts error responses and enqueues pager alerts.
///
/// Inspects errors in two ways:
/// 1. **Typed error matching** via downcast (e.g. `CommonWebError::ServerError`)
/// 2. **Status code fallback** for untyped 500s that slip through
///
/// Both `PagingFlags.is_paging_enabled` and `PagingFlags.is_paging_for_500s_enabled`
/// must be true for any alerting to fire. Otherwise the middleware is a passthrough.
///
/// To add new error types, create a new `check_*.rs` matcher module and
/// add a downcast branch in `check_ok_response_for_alerts` / `check_err_for_alerts`.
#[derive(Clone)]
pub struct ErrorAlertingMiddleware {
pager: Pager,
paging_flags: PagingFlags,
}
impl ErrorAlertingMiddleware {
pub fn new(pager: Pager, paging_flags: PagingFlags) -> Self {
Self { pager, paging_flags }
}
}
impl<S, B> Transform<S, ServiceRequest> for ErrorAlertingMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = ErrorAlertingService<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(ErrorAlertingService {
service,
pager: self.pager.clone(),
paging_flags: self.paging_flags.clone(),
}))
}
}
// ======================== Service (per-request) ========================
pub struct ErrorAlertingService<S> {
service: S,
pager: Pager,
paging_flags: PagingFlags,
}
impl<S, B> Service<ServiceRequest> for ErrorAlertingService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
actix_service::forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
// Fast path: if paging is disabled, skip all inspection.
if !self.paging_flags.is_paging_enabled || !self.paging_flags.is_paging_for_500s_enabled {
return Box::pin(self.service.call(req));
}
let method = req.method().to_string();
let path = req.path().to_string();
let pager = self.pager.clone();
let fut = self.service.call(req);
Box::pin(async move {
match fut.await {
Ok(res) => {
check_ok_response_for_alerts(&pager, &method, &path, &res);
Ok(res)
}
Err(err) => {
check_err_for_alerts(&pager, &method, &path, &err);
Err(err)
}
}
})
}
}
// ======================== Response inspection ========================
/// Inspect a successful `ServiceResponse` that may contain an error response.
///
/// Actix-web converts `ResponseError` types into HTTP responses and stashes
/// the original error in `response.error()`. We can downcast from there.
fn check_ok_response_for_alerts<B>(
pager: &Pager,
method: &str,
path: &str,
response: &ServiceResponse<B>,
) {
let status = response.status();
// Try to get the original typed error from the response (if it came from ResponseError).
if let Some(err) = response.response().error() {
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
if let Some(common_err) = err.as_error::<CommonWebError>() {
if check_common_web_error(pager, method, path, common_err) {
return;
}
}
}
// --- Status code fallback ---
check_status_code_fallback(pager, method, path, status.as_u16());
}
/// Inspect an `actix_web::Error` returned from the handler or inner middleware.
fn check_err_for_alerts(
pager: &Pager,
method: &str,
path: &str,
err: &Error,
) {
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
if let Some(common_err) = err.as_error::<CommonWebError>() {
if check_common_web_error(pager, method, path, common_err) {
return;
}
}
// --- Status code fallback ---
let status = err.as_response_error().status_code();
check_status_code_fallback(pager, method, path, status.as_u16());
}
@@ -1,5 +0,0 @@
mod check_common_web_error;
mod check_status_code_fallback;
mod enqueue_alert;
pub mod error_alerting_middleware;
@@ -1,2 +1 @@
pub mod error_alerting_middleware;
pub mod pushback_filter_middleware;
@@ -35,7 +35,6 @@ use crate::http_server::endpoints::moderation::user::moderator_list_subscribing_
use crate::http_server::endpoints::moderation::user::moderator_list_users_by_signup_date::moderator_list_users_by_signup_date_handler;
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_stripe_customer_id_handler::moderator_user_lookup_by_stripe_customer_id_handler;
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler;
use crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler;
use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::edit_user_feature_flags_handler;
pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
@@ -50,12 +49,6 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
>,
{
app.service(web::scope("/v1/moderation")
.service(web::scope("/alerts")
.service(web::resource("/send")
.route(web::post().to(moderation_send_alert_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::resource("/user_feature_flags/{username_or_token}")
.route(web::post().to(edit_user_feature_flags_handler))
.route(web::head().to(|| HttpResponse::Ok()))
@@ -75,7 +75,7 @@ pub async fn require_moderator(
}
};
if !user_session.is_mod() {
if !user_session.can_ban_users {
warn!("user is not a moderator: {:?}", user_session.user_token.as_str());
return Err(RequireModeratorError::NotAuthorized);
}
+9 -37
View File
@@ -52,7 +52,7 @@ use elasticsearch::http::transport::Transport;
use elasticsearch::Elasticsearch;
use errors::AnyhowResult;
use fal_client::creds::fal_api_key::FalApiKey;
use log::{info, warn};
use log::info;
use memory_caching::arc_ttl_sieve::ArcTtlSieve;
use memory_caching::single_item_ttl_cache::SingleItemTtlCache;
use mysql_queries::mediators::badge_granter::BadgeGranter;
@@ -72,7 +72,6 @@ use crate::configs::app_startup::redis_rate_limiters::configure_redis_rate_limit
use crate::configs::connect_to_database::connect_to_database;
use crate::configs::static_api_tokens::StaticApiTokenSet;
use crate::http_server::cookies::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
use crate::http_server::middleware::error_alerting_middleware::error_alerting_middleware::ErrorAlertingMiddleware;
use crate::http_server::middleware::pushback_filter_middleware::PushbackFilter;
use crate::http_server::routes::add_routes::add_routes;
use crate::http_server::session::session_checker::SessionChecker;
@@ -80,7 +79,6 @@ use crate::http_server::web_utils::handle_multipart_error::handle_multipart_erro
use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator;
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
use crate::startup::build_pager::build_pager;
use crate::state::server_state::{DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo, ServerState, StaticFeatureFlags, StripeSettings, TrollBans, WorldLabsData};
use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus;
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
@@ -95,7 +93,6 @@ pub mod configs;
pub mod email;
pub mod error;
pub mod http_server;
pub mod startup;
pub mod state;
pub mod threads;
pub mod util;
@@ -331,21 +328,6 @@ async fn main() -> AnyhowResult<()> {
poll_model_token_info_thread(model_token_info_cache2, mysql_pool5).await;
});
let server_environment = ServerEnvironment::from_str(&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?)
.ok_or(anyhow!("invalid server environment"))?;
let server_environment_typed = match server_environment {
ServerEnvironment::Production => server_environment::ServerEnvironment::Production,
ServerEnvironment::Development => server_environment::ServerEnvironment::Development,
};
let (pager, pager_worker, paging_flags) = build_pager(server_environment_typed, &server_hostname);
info!("Spawning pager worker thread.");
tokio_runtime.spawn(async move {
pager_worker.run().await;
});
let stripe_configs = StripeConfig {
checkout: StripeCheckoutConfigs {
@@ -363,6 +345,9 @@ async fn main() -> AnyhowResult<()> {
},
};
let server_environment = ServerEnvironment::from_str(&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?)
.ok_or(anyhow!("invalid server environment"))?;
let service_feature_flags = StaticFeatureFlags {
// Permanent (control plane / safety) flags : messaging
maybe_status_alert_category: easyenv::get_env_string_optional("FF_STATUS_ALERT_CATEGORY"),
@@ -392,9 +377,6 @@ async fn main() -> AnyhowResult<()> {
// Disable voice features
disable_tts: easyenv::get_env_bool_or_default("DISABLE_TTS", false),
disable_voice_conversion: easyenv::get_env_bool_or_default("DISABLE_VOICE_CONVERSION", false),
// Paging
paging: paging_flags,
};
let third_party_url_redirector = ThirdPartyUrlRedirector::new(server_environment);
@@ -409,6 +391,11 @@ async fn main() -> AnyhowResult<()> {
.unwrap_or(String::from("unknown"))
.trim()
.to_string();
let server_environment_typed = match server_environment {
ServerEnvironment::Production => server_environment::ServerEnvironment::Production,
ServerEnvironment::Development => server_environment::ServerEnvironment::Development,
};
let fal_api_key = FalApiKey::new(easyenv::get_env_string_required("FAL_API_KEY")?);
let fal_webhook_url = easyenv::get_env_string_required("FAL_WEBHOOK_URL")?;
@@ -486,7 +473,6 @@ async fn main() -> AnyhowResult<()> {
worldlabs: WorldLabsData {
api_key: worldlabs_api_key,
},
pager,
caches: InMemoryCaches {
durable: DurableInMemoryCaches {
model_token_info: model_token_info_cache,
@@ -629,15 +615,6 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()>
ServerEnvironment::Production => server_environment::ServerEnvironment::Production,
};
let paging_flags_for_middleware = server_state.flags.paging.clone();
let pager_for_middleware = server_state.pager.clone();
let enable_error_alerting = paging_flags_for_middleware.is_paging_enabled
&& paging_flags_for_middleware.is_paging_for_500s_enabled;
if enable_error_alerting {
info!("Error alerting middleware is ENABLED (ENABLE_PAGING=true, ENABLE_PAGING_FOR_500S=true).");
}
let server_state_arc = web::Data::new(Arc::new(server_state));
let disabled_endpoints = read_disabled_endpoints();
@@ -675,7 +652,6 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()>
.app_data(web::Data::new(server_state_arc.stripe.clone().client.clone()))
.app_data(web::Data::new(server_state_arc.third_party_url_redirector))
.app_data(web::Data::new(server_state_arc.google_sign_in_cert.clone()))
.app_data(web::Data::new(server_state_arc.pager.clone()))
.app_data(web::Data::new(old_server_environment))
.app_data(web::Data::new(new_server_environment))
.app_data(web::Data::from(product_lookup)) // NB: Data::from(Arc<T>) for dynamic dispatch
@@ -695,10 +671,6 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()>
.wrap(DefaultHeaders::new()
.header("X-Backend-Hostname", &hostname)
.header("X-Build-Sha", server_state_arc.server_info.build_sha.clone()))
.wrap(middleware::Condition::new(
enable_error_alerting,
ErrorAlertingMiddleware::new(pager_for_middleware.clone(), paging_flags_for_middleware.clone()),
))
.wrap(PushbackFilter::new(&server_state_arc.flags.clone()))
.wrap(DisabledEndpointFilter::new(disabled_endpoints.clone()))
.wrap(BannedIpFilter::new(ip_ban_list))
@@ -1,67 +0,0 @@
use log::{info, warn};
use pager::client::pager::Pager;
use pager::client::pager_builder::PagerBuilder;
use pager::worker::pager_worker::PagerWorker;
use rootly_client::creds::rootly_api_key::RootlyApiKey;
use crate::state::flags::paging_flags::PagingFlags;
pub fn build_pager(
server_environment: server_environment::ServerEnvironment,
hostname: &str,
) -> (Pager, PagerWorker, PagingFlags) {
let paging_flags = PagingFlags::from_env();
info!("Paging flags: {:?}", paging_flags);
let environment = if server_environment.is_deployed_in_production() {
"production"
} else {
"development"
};
let builder = PagerBuilder::new()
.application_name("storyteller-web".to_string())
.environment(environment.to_string())
.hostname(hostname.to_string());
// If paging is globally disabled, use a NoOp pager regardless of API key.
if !paging_flags.is_paging_enabled {
warn!("ENABLE_PAGING is false. Pager will be NoOp.");
let (pager, worker) = builder.build_with_worker();
return (pager, worker, paging_flags);
}
let maybe_api_key = easyenv::get_env_string_optional("ROOTLY_API_KEY");
let (pager, worker) = match maybe_api_key {
Some(api_key) => {
info!("Rootly API key found. Configuring pager with Rootly backend.");
build_rootly_pager(builder, api_key)
}
None => {
warn!("ROOTLY_API_KEY not set. Pager will not send real pages.");
builder.build_with_worker()
}
};
(pager, worker, paging_flags)
}
fn build_rootly_pager(builder: PagerBuilder, api_key: String) -> (Pager, PagerWorker) {
let mut rootly_builder = builder
.rootly(RootlyApiKey::new(api_key));
if let Some(urgency_id) = easyenv::get_env_string_optional("ROOTLY_ALERT_URGENCY_ID") {
rootly_builder = rootly_builder.alert_urgency_id(urgency_id);
}
let target_type = easyenv::get_env_string_optional("ROOTLY_NOTIFICATION_TARGET_TYPE");
let target_id = easyenv::get_env_string_optional("ROOTLY_NOTIFICATION_TARGET_ID");
if let (Some(t_type), Some(t_id)) = (target_type, target_id) {
rootly_builder = rootly_builder.notification_target(t_type, t_id);
}
rootly_builder.build_with_worker()
}
@@ -1 +0,0 @@
pub mod build_pager;
@@ -1 +0,0 @@
pub mod paging_flags;
@@ -1,21 +0,0 @@
/// Flags controlling the pager system.
#[derive(Clone, Debug)]
pub struct PagingFlags {
/// Master switch for paging. If false, no pages are sent.
/// Env var: ENABLE_PAGING (default: false)
pub is_paging_enabled: bool,
/// If true, the error alerting middleware will enqueue pages for HTTP 500s.
/// Requires `is_paging_enabled` to also be true.
/// Env var: ENABLE_PAGING_FOR_500S (default: false)
pub is_paging_for_500s_enabled: bool,
}
impl PagingFlags {
pub fn from_env() -> Self {
Self {
is_paging_enabled: easyenv::get_env_bool_or_default("ENABLE_PAGING", false),
is_paging_for_500s_enabled: easyenv::get_env_bool_or_default("ENABLE_PAGING_FOR_500S", false),
}
}
}
@@ -1,5 +1,4 @@
pub mod cached_queries;
pub mod flags;
pub mod certs;
pub mod memory_cache;
pub mod server_state;
@@ -9,7 +9,6 @@ use elasticsearch::Elasticsearch;
use fal_client::creds::fal_api_key::FalApiKey;
use memory_caching::arc_ttl_sieve::ArcTtlSieve;
use memory_caching::single_item_ttl_cache::SingleItemTtlCache;
use pager::client::pager::Pager;
use mysql_queries::mediators::badge_granter::BadgeGranter;
use mysql_queries::mediators::firehose_publisher::FirehosePublisher;
use mysql_queries::queries::generic_inference::web::get_pending_inference_job_count::InferenceQueueLengthResult;
@@ -33,7 +32,6 @@ use crate::http_server::endpoints::stats::result_transformer::CacheableQueueStat
use crate::http_server::endpoints::tts::list_tts_models::TtsModelRecordForResponse;
use crate::http_server::endpoints::voice_conversion::list_voice_conversion_models_handler::VoiceConversionModel;
use crate::http_server::session::session_checker::SessionChecker;
use crate::state::flags::paging_flags::PagingFlags;
use crate::http_server::web_utils::redis_rate_limiter::RedisRateLimiter;
use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator;
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
@@ -102,8 +100,6 @@ pub struct ServerState {
pub worldlabs: WorldLabsData,
pub pager: Pager,
/// Where to store audio uploads for w2l
pub audio_uploads_bucket_root: String,
@@ -299,9 +295,6 @@ pub struct StaticFeatureFlags {
/// Disable voice conversion endpoints
/// If true, respond with 429.
pub disable_voice_conversion: bool,
/// Paging system flags.
pub paging: PagingFlags,
}
/// Instead of top level service denial, these are bans against entities that instead return
@@ -271,8 +271,8 @@ export default function CreateImage() {
try {
const imageMediaTokens = selectedModel.canUseImagePrompt
? referenceImages
.map((img) => img.mediaToken)
.filter((t) => t.length > 0)
.map((img) => img.mediaToken)
.filter((t) => t.length > 0)
: undefined;
const result = await enqueueImageGeneration({
@@ -476,7 +476,7 @@ export default function CreateImage() {
length: Math.max(
0,
4 -
Math.max(1, Math.min(4, batch.requestedCount)),
Math.max(1, Math.min(4, batch.requestedCount)),
),
}).map((_, i) => (
<div
@@ -556,7 +556,7 @@ export default function CreateImage() {
{/* ── Prompt box (fixed bottom center) ───────────────────── */}
<div
className="animate-fade-in-up fixed bottom-6 left-0 right-0 z-30 mx-auto w-full max-w-[900px] px-4"
className="animate-fade-in-up fixed bottom-6 left-0 right-0 z-30 mx-auto w-full max-w-[730px] px-4"
style={{ animationDelay: "150ms" }}
>
<PromptBox
@@ -219,9 +219,9 @@ export default function CreateVideo() {
(): PopoverItem[] | null =>
selectedModel.durationOptions
? selectedModel.durationOptions.map((d) => ({
label: `${d}s`,
selected: d === (duration ?? selectedModel.defaultDuration),
}))
label: `${d}s`,
selected: d === (duration ?? selectedModel.defaultDuration),
}))
: null,
[selectedModel, duration],
);
@@ -231,9 +231,9 @@ export default function CreateVideo() {
(): PopoverItem[] | null =>
selectedModel.resolutionOptions
? selectedModel.resolutionOptions.map((r) => ({
label: r,
selected: r === (resolution ?? selectedModel.defaultResolution),
}))
label: r,
selected: r === (resolution ?? selectedModel.defaultResolution),
}))
: null,
[selectedModel, resolution],
);
@@ -243,17 +243,17 @@ export default function CreateVideo() {
(): PopoverItem[] | null =>
supportsRefMode
? [
{
label: "Keyframe",
description: "First/Last frame",
selected: inputMode === "keyframe",
},
{
label: "Reference",
description: "Multi-media ref",
selected: inputMode === "reference",
},
]
{
label: "Keyframe",
description: "First/Last frame",
selected: inputMode === "keyframe",
},
{
label: "Reference",
description: "Multi-media ref",
selected: inputMode === "reference",
},
]
: null,
[supportsRefMode, inputMode],
);
@@ -424,8 +424,8 @@ export default function CreateVideo() {
const referenceImageMediaTokens =
isReferenceMode && referenceImages.length > 0
? referenceImages
.map((img) => img.mediaToken)
.filter((t) => t.length > 0)
.map((img) => img.mediaToken)
.filter((t) => t.length > 0)
: undefined;
const referenceVideoMediaTokens =
@@ -680,7 +680,7 @@ export default function CreateVideo() {
{/* ── Prompt box (fixed bottom center) ───────────────────── */}
<div
className="animate-fade-in-up fixed bottom-6 left-0 right-0 z-30 mx-auto w-full max-w-[900px] px-4"
className="animate-fade-in-up fixed bottom-6 left-0 right-0 z-30 mx-auto w-full max-w-[730px] px-4"
style={{ animationDelay: "150ms" }}
>
<PromptBox
@@ -882,7 +882,7 @@ export default function CreateVideo() {
maxSelect={Math.max(
1,
(isReferenceMode ? (selectedModel.maxReferenceImages ?? 3) : 1) -
referenceImages.length,
referenceImages.length,
)}
/>
</div>
@@ -375,53 +375,25 @@ export class MouseControls {
if ((event.ctrlKey || event.metaKey) && !this.isProcessing) {
const keyLower = event.key.toLowerCase();
if (keyLower === "z" && !event.shiftKey) {
// undo
event.preventDefault();
if (keyLower === "z") {
// redo
this.isProcessing = true;
try {
await this.sceneManager?.undo();
} finally {
this.isProcessing = false;
}
} else if (keyLower === "z" && event.shiftKey) {
// redo (Ctrl+Shift+Z / Cmd+Shift+Z)
event.preventDefault();
this.isProcessing = true;
try {
await this.sceneManager?.redo();
} finally {
this.isProcessing = false;
}
} else if (keyLower === "y") {
// redo (Ctrl+Y on Windows/Linux, Cmd+Y on macOS)
event.preventDefault();
this.isProcessing = true;
try {
await this.sceneManager?.redo();
} finally {
this.isProcessing = false;
}
await this.sceneManager?.redo();
this.isProcessing = false;
} else if (keyLower === "c") {
// Copy
event.preventDefault();
event.stopPropagation();
this.isProcessing = true;
try {
await this.sceneManager?.copy();
} finally {
this.isProcessing = false;
}
await this.sceneManager?.copy();
this.isProcessing = false;
} else if (keyLower === "v") {
// Paste
event.preventDefault();
event.stopPropagation();
this.isProcessing = true;
try {
await this.sceneManager?.paste();
} finally {
this.isProcessing = false;
}
await this.sceneManager?.paste();
this.isProcessing = false;
} else if (event.key === "0") {
// Stats Menu
this.enable_stats();
@@ -118,10 +118,10 @@ export class SceneManager implements SceneManagerAPI {
}
public async undo() {
if (this.undoIndex >= this.undoStack.length) {
return;
}
this.undoIndex += 1;
if (this.undoIndex >= this.undoStack.length) {
this.undoIndex = this.undoStack.length;
}
const undoCommand = this.undoStack.at(
this.undoStack.length - this.undoIndex,
);
@@ -131,28 +131,16 @@ export class SceneManager implements SceneManagerAPI {
}
public async redo() {
if (this.undoIndex <= 0) {
return;
}
const undoCommand = this.undoStack.at(
this.undoStack.length - this.undoIndex,
);
await undoCommand?.redo();
this.undoIndex -= 1;
this.updateOutliner(); // In the future we will address this because of its relational issues with editor and the current class.
this.lastSceneState = this.getSceneState();
}
// Drop any redo-future before pushing a fresh command so the new action
// replaces the branch of history the user had undone past.
private dropRedoHistory() {
if (this.undoIndex > 0) {
this.undoStack = this.undoStack.slice(
0,
this.undoStack.length - this.undoIndex,
);
if (this.undoIndex <= -1) {
this.undoIndex = 0;
}
this.updateOutliner(); // In the future we will address this because of its relational issues with editor and the current class.
this.lastSceneState = this.getSceneState();
}
public async create(
@@ -479,7 +467,6 @@ export class SceneManager implements SceneManagerAPI {
}
public async add_creation_undostack(object: THREE.Object3D) {
this.dropRedoHistory();
this.undoStack.push(
new CreationSceneItem(this, {
object_uuid: object.uuid,
@@ -501,7 +488,6 @@ export class SceneManager implements SceneManagerAPI {
const is_transform = this.is_transformation(sceneState);
const is_userdata = this.is_userdata(sceneState);
this.dropRedoHistory();
this.undoStack = this.undoStack.slice(-16);
if (is_deleted != "") {
@@ -572,6 +558,7 @@ export class SceneManager implements SceneManagerAPI {
}
}
}
this.undoIndex = 0;
this.lastSceneState = sceneState;
}
}
@@ -23,7 +23,7 @@ export const isHotkeyDisabled = ()=>{
export const disableHotkeyInput = (level: number)=>{
if(hotkeysStatus.value.disabled === true){
if (level > hotkeysStatus.value.disabledBy){
hotkeysStatus.value = { ...hotkeysStatus.value, disabledBy: level };
hotkeysStatus.value.disabledBy === level;
}
}else{
hotkeysStatus.value = {
@@ -358,7 +358,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 && (
@@ -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 */}
@@ -244,55 +244,6 @@ img {
}
/* Hide native scrollbars while preserving scrollability */
/* Minimal thin scrollbar for prompt textareas */
.promptbox-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.promptbox-scrollbar::-webkit-scrollbar {
width: 4px;
}
.promptbox-scrollbar::-webkit-scrollbar:vertical {
width: 4px;
}
.promptbox-scrollbar::-webkit-scrollbar:horizontal {
height: 4px;
}
.promptbox-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.promptbox-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 9999px;
}
.promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.35);
}
.promptbox-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
.promptbox-scrollbar::-webkit-resizer {
background: transparent;
border: none;
}
.promptbox-resize-wrap {
position: relative;
}
.promptbox-resize-wrap::after {
content: "";
position: absolute;
bottom: 16px;
right: 2px;
width: 10px;
height: 10px;
pointer-events: none;
background:
linear-gradient(135deg,
transparent 40%, rgba(255, 255, 255, 0.5) 40%, rgba(255, 255, 255, 0.5) 45%, transparent 45%,
transparent 55%, rgba(255, 255, 255, 0.5) 55%, rgba(255, 255, 255, 0.5) 60%, transparent 60%,
transparent 70%, rgba(255, 255, 255, 0.5) 70%, rgba(255, 255, 255, 0.5) 75%, transparent 75%);
}
.scrollbar-hidden {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
@@ -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 && (
@@ -9,8 +9,6 @@ import {
faMessageCheck,
faFrame,
faExpand,
faChevronDown,
faChevronUp,
} from "@fortawesome/pro-solid-svg-icons";
import {
faRectangleVertical,
@@ -27,7 +25,6 @@ import { twMerge } from "tailwind-merge";
import { GenerationProvider } from "@storyteller/api-enums";
import { GenerationCountPicker } from "./common/GenerationCountPicker";
import { StoreApi, UseBoundStore } from "zustand";
import { toast } from "@storyteller/ui-toaster";
export type AspectRatio = "wide" | "tall" | "square";
@@ -71,17 +68,6 @@ export const PromptBox2D = ({
useSignals();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = () => {
setIsExpanded((prev) => {
const next = !prev;
if (textareaRef.current) {
textareaRef.current.style.height = next ? "300px" : "auto";
}
return next;
});
};
const [content, setContent] = useState<React.ReactNode>("");
//const { lastRenderedBitmap } = useCanvasSignal();
@@ -192,7 +178,7 @@ export const PromptBox2D = ({
value.slice(0, selectionStart) + pastedText + value.slice(selectionEnd);
setPrompt(next);
requestAnimationFrame(() => {
const pos = Math.min(selectionStart + pastedText.length, next.length);
const pos = selectionStart + pastedText.length;
textareaRef.current?.setSelectionRange(pos, pos);
});
};
@@ -261,15 +247,9 @@ export const PromptBox2D = ({
// console.log("generateResponse", generateResponse);
// };
const maxLen = selectedImageModel?.maxPromptLength ?? 1000;
const handleGenerate = async () => {
const busy = Boolean(isEnqueueing ?? internalEnqueueing);
if (busy || isDisabled || !prompt.trim()) return;
if (prompt.length > maxLen) {
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
return;
}
setInternalEnqueueing(true);
const timeout = setTimeout(() => {
setInternalEnqueueing(false);
@@ -370,10 +350,10 @@ export const PromptBox2D = ({
)}
<div
className={twMerge(
"glass relative 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">
@@ -410,23 +390,18 @@ export const PromptBox2D = ({
</Tooltip>
)}
<div className="promptbox-resize-wrap relative flex-1">
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe your image..."
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none ${isExpanded ? "max-h-[300px]" : "max-h-[5.5em]"}`}
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => { }}
onBlur={() => { }}
/>
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}>
{prompt.length} / {maxLen}
</span>
</div>
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe your image..."
className="text-md mb-2 max-h-[5.5em] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => {}}
onBlur={() => {}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
@@ -508,17 +483,6 @@ export const PromptBox2D = ({
</GenerateButton>
</div>
</div>
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
<button
type="button"
onClick={toggleExpand}
className="text-base-fg/30 hover:text-base-fg/90 transition-colors px-3 py-0.5"
>
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
</button>
</Tooltip>
</div>
</div>
</div>
</>
@@ -8,8 +8,6 @@ import {
faMessageCheck,
faMessageXmark,
faExpand,
faChevronDown,
faChevronUp,
} from "@fortawesome/pro-solid-svg-icons";
import {
faRectangleWide,
@@ -72,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;
}
@@ -116,17 +114,6 @@ export const PromptBox3D = ({
const resolution = usePrompt3DStore((s) => s.resolution);
const setResolution = usePrompt3DStore((s) => s.setResolution);
const [isEnqueueing, setIsEnqueueing] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = () => {
setIsExpanded((prev) => {
const next = !prev;
if (textareaRef.current) {
textareaRef.current.style.height = next ? "300px" : "auto";
}
return next;
});
};
const referenceImages = usePrompt3DStore((s) => s.referenceImages);
const setReferenceImages = usePrompt3DStore((s) => s.setReferenceImages);
const [showImagePrompts, setShowImagePrompts] = useState(false);
@@ -176,7 +163,7 @@ export const PromptBox3D = ({
>(undefined);
useEffect(() => {
if (textareaRef.current && !isExpanded) {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
@@ -251,7 +238,7 @@ export const PromptBox3D = ({
value.slice(0, selectionStart) + pastedText + value.slice(selectionEnd);
setPrompt(next);
requestAnimationFrame(() => {
const pos = Math.min(selectionStart + pastedText.length, next.length);
const pos = selectionStart + pastedText.length;
textareaRef.current?.setSelectionRange(pos, pos);
});
};
@@ -266,13 +253,7 @@ export const PromptBox3D = ({
setIsModalOpen(true);
};
const maxLen = selectedImageModel?.maxPromptLength ?? 1000;
const handleEnqueue = async () => {
if (prompt.length > maxLen) {
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
return;
}
gtagEvent("enqueue_3d");
const isDesktop = IsDesktopApp();
console.log("Is Desktop?", isDesktop);
@@ -567,11 +548,11 @@ export const PromptBox3D = ({
)}
<div
className={twMerge(
"glass relative 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()}
@@ -613,29 +594,24 @@ export const PromptBox3D = ({
</Tooltip>
)}
<div className="promptbox-resize-wrap relative flex-1">
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe your image..."
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none ${isExpanded ? "max-h-[300px]" : "max-h-[5.5em]"}`}
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => {
disableHotkeyInput(DomLevels.INPUT);
isPromptBoxFocused.value = true;
}}
onBlur={() => {
enableHotkeyInput(DomLevels.INPUT);
isPromptBoxFocused.value = false;
}}
/>
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}>
{prompt.length} / {maxLen}
</span>
</div>
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe your image..."
className="text-md mb-2 max-h-[5.5em] min-h-[36px] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => {
disableHotkeyInput(DomLevels.INPUT);
isPromptBoxFocused.value = true;
}}
onBlur={() => {
enableHotkeyInput(DomLevels.INPUT);
isPromptBoxFocused.value = false;
}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
@@ -773,17 +749,6 @@ export const PromptBox3D = ({
</GenerateButton>
</div>
</div>
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
<button
type="button"
onClick={toggleExpand}
className="text-base-fg/30 hover:text-base-fg/90 transition-colors px-3 py-0.5"
>
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
</button>
</Tooltip>
</div>
</div>
<CameraSettingsModal
isOpen={isCameraSettingsOpen}
@@ -15,8 +15,6 @@ import {
faRedo,
faExpand,
faArrowsUpDownLeftRight,
faChevronDown,
faChevronUp,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, ToggleButton, GenerateButton } from "@storyteller/ui-button";
@@ -28,7 +26,6 @@ import { CommonAspectRatio, ImageModel } from "@storyteller/model-list";
import { twMerge } from "tailwind-merge";
import { ImagePromptRow, type UploadImageFn } from "./ImagePromptRow";
import { RefImage, usePromptEditStore } from "./promptStore";
import { toast } from "@storyteller/ui-toaster";
import { GenerationProvider } from "@storyteller/api-enums";
import { AspectRatioPicker } from "./common/AspectRatioPicker";
import { GenerationCountPicker } from "./common/GenerationCountPicker";
@@ -85,17 +82,6 @@ export const PromptBoxEdit = ({
const [prompt, setPrompt] = useState("");
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = () => {
setIsExpanded((prev) => {
const next = !prev;
if (textareaRef.current) {
textareaRef.current.style.height = next ? "300px" : "auto";
}
return next;
});
};
const [internalEnqueueing, setInternalEnqueueing] = useState(false);
const referenceImages = usePromptEditStore((s) => s.referenceImages);
const setReferenceImages = usePromptEditStore((s) => s.setReferenceImages);
@@ -219,7 +205,7 @@ export const PromptBoxEdit = ({
value.slice(0, selectionStart) + pastedText + value.slice(selectionEnd);
setPrompt(next);
requestAnimationFrame(() => {
const pos = Math.min(selectionStart + pastedText.length, next.length);
const pos = selectionStart + pastedText.length;
textareaRef.current?.setSelectionRange(pos, pos);
});
};
@@ -252,15 +238,9 @@ export const PromptBoxEdit = ({
// );
// };
const maxLen = selectedImageModel?.maxPromptLength ?? 1000;
const handleGenerate = async () => {
const busy = Boolean(isEnqueueing ?? internalEnqueueing);
if (busy || isDisabled || !prompt.trim()) return;
if (prompt.length > maxLen) {
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
return;
}
setInternalEnqueueing(true);
const timeout = setTimeout(() => {
setInternalEnqueueing(false);
@@ -282,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",
@@ -306,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 (
<>
@@ -327,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
@@ -372,17 +352,17 @@ export const PromptBoxEdit = ({
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
uploadImage={uploadImage}
onImageClick={() => { }}
onImageClick={() => {}}
className=""
/>
)}
<div
className={twMerge(
"glass relative 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">
@@ -418,23 +398,18 @@ export const PromptBoxEdit = ({
</Button>
</Tooltip>
)}
<div className="promptbox-resize-wrap relative flex-1">
<textarea
ref={textareaRef}
rows={1}
placeholder="Write what you want to change in your image and click generate..."
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none ${isExpanded ? "max-h-[300px]" : "max-h-[5.5em]"}`}
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-white/40"}`}>
{prompt.length} / {maxLen}
</span>
</div>
<textarea
ref={textareaRef}
rows={1}
placeholder="Write what you want to change in your image and click generate..."
className="text-md mb-2 max-h-[5.5em] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none"
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
@@ -526,17 +501,6 @@ export const PromptBoxEdit = ({
</div>
</div>
</div>
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
<button
type="button"
onClick={toggleExpand}
className="text-white/30 hover:text-white/90 transition-colors px-3 py-0.5"
>
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
</button>
</Tooltip>
</div>
</div>
</div>
</>
@@ -16,8 +16,6 @@ import {
faMessageXmark,
faMessageCheck,
faExpand,
faChevronDown,
faChevronUp,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { CommonAspectRatio, ImageModel } from "@storyteller/model-list";
@@ -98,21 +96,6 @@ export const PromptBoxImage = ({
const setGenerationCount = usePromptImageStore((s) => s.setGenerationCount);
const [isEnqueueing, setIsEnqueueing] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = () => {
setIsExpanded((prev) => {
const next = !prev;
if (textareaRef.current) {
if (next) {
textareaRef.current.style.height = `${window.innerHeight - 300}px`;
} else {
textareaRef.current.style.height = "auto";
}
}
return next;
});
};
const referenceImages = usePromptImageStore((s) => s.referenceImages);
const setReferenceImages = usePromptImageStore((s) => s.setReferenceImages);
const [uploadingImages, _setUploadingImages] = useState<
@@ -242,7 +225,7 @@ export const PromptBoxImage = ({
value.slice(0, selectionStart) + pastedText + value.slice(selectionEnd);
setPrompt(next);
requestAnimationFrame(() => {
const pos = Math.min(selectionStart + pastedText.length, next.length);
const pos = selectionStart + pastedText.length;
textareaRef.current?.setSelectionRange(pos, pos);
});
};
@@ -251,17 +234,11 @@ export const PromptBoxImage = ({
setPrompt(e.target.value);
};
const maxLen = selectedModel?.maxPromptLength ?? 1000;
const handleEnqueue = async () => {
if (!prompt.trim()) {
console.warn("Cannot generate image: prompt is empty");
return;
}
if (prompt.length > maxLen) {
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
return;
}
if (!selectedModel) {
console.warn("Cannot generate image: no model selected");
@@ -431,7 +408,7 @@ export const PromptBoxImage = ({
<div
className={twMerge(
"glass relative w-[860px] rounded-xl p-4",
"glass w-[730px] rounded-xl p-4",
isImageRowVisible &&
selectedModel?.canUseImagePrompt &&
"rounded-t-none",
@@ -474,23 +451,18 @@ export const PromptBoxImage = ({
</Tooltip>
)}
<div className="promptbox-resize-wrap relative flex-1">
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe what you want in the image..."
className="promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}>
{prompt.length} / {maxLen}
</span>
</div>
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe what you want in the image..."
className="text-md mb-2 min-h-[2.5em] flex-1 resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
@@ -567,17 +539,6 @@ export const PromptBoxImage = ({
</GenerateButton>
</div>
</div>
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
<button
type="button"
onClick={toggleExpand}
className="text-base-fg/30 hover:text-base-fg/90 transition-colors px-3 py-0.5"
>
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
</button>
</Tooltip>
</div>
</div>
</div>
</>
@@ -17,8 +17,6 @@ import {
faWaveformLines,
faClock,
faTriangleExclamation,
faChevronDown,
faChevronUp,
} from "@fortawesome/pro-solid-svg-icons";
import {
faCircleInfo,
@@ -124,21 +122,6 @@ export const PromptBoxVideo = ({
const setGenerationCount = usePromptVideoStore((s) => s.setGenerationCount);
const [isEnqueueing, setIsEnqueueing] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = () => {
setIsExpanded((prev) => {
const next = !prev;
if (textareaRef.current) {
if (next) {
textareaRef.current.style.height = `${window.innerHeight - 300}px`;
} else {
textareaRef.current.style.height = "auto";
}
}
return next;
});
};
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[],
);
@@ -461,7 +444,7 @@ export const PromptBoxVideo = ({
value.slice(0, selectionStart) + pastedText + value.slice(selectionEnd);
setPrompt(next);
requestAnimationFrame(() => {
const pos = Math.min(selectionStart + pastedText.length, next.length);
const pos = selectionStart + pastedText.length;
textareaRef.current?.setSelectionRange(pos, pos);
});
};
@@ -494,18 +477,12 @@ export const PromptBoxVideo = ({
mentionAnchorRef.current = null;
};
const maxLen = selectedModel?.maxPromptLength ?? 1000;
const handleEnqueue = async () => {
if (!prompt.trim()) {
console.warn("Cannot generate video: prompt is empty");
toast.error("Please enter a prompt to generate video");
return;
}
if (prompt.length > maxLen) {
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
return;
}
if (!selectedModel) {
console.warn("Cannot generate video: no model selected");
@@ -788,7 +765,7 @@ export const PromptBoxVideo = ({
)}
<div
className={twMerge(
"glass relative w-[860px] rounded-xl p-4",
"glass w-[730px] rounded-xl p-4",
isImageRowVisible && "rounded-t-none",
isFocused
? "ring-1 ring-primary border-primary"
@@ -872,7 +849,7 @@ export const PromptBoxVideo = ({
</Button>
</Tooltip> */}
<div className="promptbox-resize-wrap relative flex-1">
<div className="relative flex-1">
{isReferenceMode && hasAnyRefs && (
<div
ref={highlightRef}
@@ -891,7 +868,7 @@ export const PromptBoxVideo = ({
: "Describe what you want to happen in the video..."
}
className={twMerge(
"promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 placeholder-base-fg/60 focus:outline-none",
"text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 placeholder-base-fg/60 focus:outline-none",
isReferenceMode && hasAnyRefs
? "text-transparent caret-base-fg"
: "text-base-fg",
@@ -904,9 +881,6 @@ export const PromptBoxVideo = ({
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}>
{prompt.length} / {maxLen}
</span>
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
@@ -1051,17 +1025,6 @@ export const PromptBoxVideo = ({
</Tooltip>
</div>
</div>
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
<button
type="button"
onClick={toggleExpand}
className="text-base-fg/30 hover:text-base-fg/90 transition-colors px-3 py-0.5"
>
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
</button>
</Tooltip>
</div>
</div>
{selectedModel?.id === "seedance_2p0" && (
<div className="flex items-start gap-2.5 rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3.5 py-2.5 text-xs text-yellow-200">
@@ -94,7 +94,6 @@ export class ImageModel extends Model {
resolutions?: CommonResolution[];
defaultAspectRatio?: CommonAspectRatio;
defaultResolution?: CommonResolution;
maxPromptLength?: number;
}) {
if (args.maxGenerationCount < 1) {
throw new Error("maxGenerationCount must be at least 1");
@@ -50,9 +50,6 @@ export class Model {
// Time in milliseconds for the fake progress bar to reach 100% (UI-only)
readonly progressBarTime: number;
// Maximum character length for the prompt text input
readonly maxPromptLength: number;
protected constructor(args: {
id: string;
tauriId: string;
@@ -66,7 +63,6 @@ export class Model {
providers?: GenerationProvider[];
preferredProvidersByPage?: Partial<Record<string, string[]>>;
progressBarTime?: number;
maxPromptLength?: number;
}) {
this.id = args.id;
this.tauriId = args.tauriId;
@@ -80,7 +76,6 @@ export class Model {
this.providers = args.providers;
this.preferredProvidersByPage = args.preferredProvidersByPage;
this.progressBarTime = args.progressBarTime ?? 20000;
this.maxPromptLength = args.maxPromptLength ?? 1000;
}
getProviders(): GenerationProvider[] {
@@ -23,7 +23,6 @@ export class SplatModel extends Model {
tags?: ModelTag[];
providers?: GenerationProvider[];
progressBarTime?: number;
maxPromptLength?: number;
}) {
super(args);
}
@@ -94,7 +94,6 @@ export class VideoModel extends Model {
defaultResolution?: string;
supportsSystemPrompt?: boolean;
supportsCommonAspectRatio?: boolean;
maxPromptLength?: number;
}) {
super(args);
this.startFrame = args.startFrame;
@@ -27,7 +27,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: false,
maxImagePromptCount: 6,
progressBarTime: 10000,
maxPromptLength: 8000,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.Square,
aspectRatios: [
@@ -55,7 +54,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: false,
maxImagePromptCount: 6,
progressBarTime: 45000,
maxPromptLength: 6000,
canChangeAspectRatio: true,
// TODO: Rust client doesn't support aspect ratios for MidJourney yet.
}),
@@ -73,7 +71,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 35000,
maxPromptLength: 4000,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.Square,
aspectRatios: [
@@ -102,7 +99,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 10000,
maxPromptLength: 4000,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.Square,
aspectRatios: [
@@ -127,7 +123,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 10000,
maxPromptLength: 4000,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.Square,
aspectRatios: [
@@ -153,7 +148,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 10000,
maxPromptLength: 4000,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.Square,
aspectRatios: [
@@ -182,7 +176,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canTextToImage: true,
tags: [ModelTag.InstructiveEdit],
progressBarTime: 25000,
maxPromptLength: 2500,
// canChangeResolution: true,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.Square,
@@ -217,7 +210,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canTextToImage: true,
tags: [ModelTag.InstructiveEdit],
progressBarTime: 25000,
maxPromptLength: 2500,
canChangeResolution: true,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.Square,
@@ -257,7 +249,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canTextToImage: true,
tags: [ModelTag.InstructiveEdit],
progressBarTime: 25000,
maxPromptLength: 2500,
canChangeResolution: true,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.Square,
@@ -287,7 +278,7 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.OpenAi,
providers: [
GenerationProvider.Artcraft,
GenerationProvider.Artcraft,
GenerationProvider.Sora
],
selectorName: "GPT Image 1 (GPT-4o)",
@@ -300,7 +291,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 60000,
maxPromptLength: 1500,
canChangeAspectRatio: true,
}),
new ImageModel({
@@ -319,7 +309,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 60000,
maxPromptLength: 1500,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.Square,
aspectRatios: [
@@ -344,7 +333,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 60000,
maxPromptLength: 4000,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.SquareHd,
aspectRatios: [
@@ -374,7 +362,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 60000,
maxPromptLength: 4000,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.SquareHd,
aspectRatios: [
@@ -404,7 +391,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 60000,
maxPromptLength: 4000,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.SquareHd,
aspectRatios: [
@@ -428,7 +414,6 @@ export const IMAGE_MODELS: ImageModel[] = [
selectorBadges: ["30 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 1,
maxPromptLength: 800,
canTextToImage: false,
canEditImages: false,
canEditAngles: true,
@@ -457,7 +442,6 @@ export const IMAGE_MODELS: ImageModel[] = [
selectorBadges: ["30 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 1,
maxPromptLength: 4000,
canTextToImage: false,
canEditImages: false,
canEditAngles: true,
@@ -486,7 +470,6 @@ export const IMAGE_MODELS: ImageModel[] = [
selectorBadges: ["20 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
maxPromptLength: 4000,
canEditImages: true,
canTextToImage: false,
tags: [ModelTag.InstructiveEdit],
@@ -503,7 +486,6 @@ export const IMAGE_MODELS: ImageModel[] = [
selectorDescription: "Fast inpainting",
selectorBadges: ["30 sec."],
maxGenerationCount: 1, // NB: Fal only allows one image for some reason!
maxPromptLength: 4000,
defaultGenerationCount: 1, // NB: Fal only allows one image for some reason!
canEditImages: true,
usesInpaintingMask: true,
@@ -523,7 +505,6 @@ export const IMAGE_MODELS: ImageModel[] = [
selectorBadges: ["10 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
maxPromptLength: 4000,
canEditImages: true,
usesInpaintingMask: true,
editingIsInpainting: true,
@@ -18,7 +18,6 @@ export const VIDEO_MODELS: VideoModel[] = [
endFrame: false,
requiresImage: true,
progressBarTime: 50000,
maxPromptLength: 4096,
sizeOptions: [
{
tauriValue: "landscape",
@@ -50,7 +49,6 @@ export const VIDEO_MODELS: VideoModel[] = [
endFrame: true,
requiresImage: true,
progressBarTime: 300000,
maxPromptLength: 2500,
generateWithSound: false,
}),
new VideoModel({
@@ -66,7 +64,6 @@ export const VIDEO_MODELS: VideoModel[] = [
endFrame: true,
requiresImage: true,
progressBarTime: 300000,
maxPromptLength: 2500,
generateWithSound: false,
}),
new VideoModel({
@@ -82,7 +79,6 @@ export const VIDEO_MODELS: VideoModel[] = [
endFrame: false,
requiresImage: true,
progressBarTime: 300000,
maxPromptLength: 2500,
generateWithSound: false,
}),
new VideoModel({
@@ -98,7 +94,6 @@ export const VIDEO_MODELS: VideoModel[] = [
endFrame: true,
requiresImage: false,
progressBarTime: 300000,
maxPromptLength: 2500,
generateWithSound: false,
}),
new VideoModel({
@@ -114,7 +109,6 @@ export const VIDEO_MODELS: VideoModel[] = [
endFrame: false,
requiresImage: true,
progressBarTime: 300000,
maxPromptLength: 2500,
generateWithSound: false,
}),
new VideoModel({
@@ -150,7 +144,6 @@ export const VIDEO_MODELS: VideoModel[] = [
durationOptions: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
defaultDuration: 5,
progressBarTime: 300000,
maxPromptLength: 2500,
generateWithSound: true,
}),
new VideoModel({
@@ -186,7 +179,6 @@ export const VIDEO_MODELS: VideoModel[] = [
durationOptions: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
defaultDuration: 5,
progressBarTime: 300000,
maxPromptLength: 2500,
generateWithSound: true,
}),
new VideoModel({
@@ -202,7 +194,6 @@ export const VIDEO_MODELS: VideoModel[] = [
endFrame: false,
requiresImage: true,
progressBarTime: 100000,
maxPromptLength: 3000,
}),
new VideoModel({
id: "seedance_1p5_pro",
@@ -255,7 +246,6 @@ export const VIDEO_MODELS: VideoModel[] = [
supportsSystemPrompt: false,
generateWithSound: true,
progressBarTime: 180000,
maxPromptLength: 3000,
}),
new VideoModel({
id: "seedance_2p0",
@@ -300,16 +290,15 @@ 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",
supportsSystemPrompt: false,
progressBarTime: 10800000, //3 hours for now
maxPromptLength: 3000,
}),
new VideoModel({
id: "sora_2",
@@ -337,7 +326,6 @@ export const VIDEO_MODELS: VideoModel[] = [
},
],
progressBarTime: 100000,
maxPromptLength: 2000,
}),
new VideoModel({
id: "sora_2_pro",
@@ -364,7 +352,6 @@ export const VIDEO_MODELS: VideoModel[] = [
},
],
progressBarTime: 100000,
maxPromptLength: 2000,
}),
new VideoModel({
id: "veo_2",
@@ -380,7 +367,6 @@ export const VIDEO_MODELS: VideoModel[] = [
requiresImage: true,
generateWithSound: false,
progressBarTime: 100000,
maxPromptLength: 5000,
}),
new VideoModel({
id: "veo_3",
@@ -396,7 +382,6 @@ export const VIDEO_MODELS: VideoModel[] = [
requiresImage: true,
generateWithSound: true,
progressBarTime: 100000,
maxPromptLength: 5000,
}),
new VideoModel({
id: "veo_3_fast",
@@ -412,7 +397,6 @@ export const VIDEO_MODELS: VideoModel[] = [
requiresImage: true,
generateWithSound: true,
progressBarTime: 100000,
maxPromptLength: 5000,
}),
new VideoModel({
id: "veo_3p1",
@@ -428,7 +412,6 @@ export const VIDEO_MODELS: VideoModel[] = [
requiresImage: true,
generateWithSound: true,
progressBarTime: 100000,
maxPromptLength: 5000,
}),
new VideoModel({
id: "veo_3p1_fast",
@@ -444,7 +427,6 @@ export const VIDEO_MODELS: VideoModel[] = [
requiresImage: true,
generateWithSound: true,
progressBarTime: 100000,
maxPromptLength: 5000,
}),
];