Compare commits

..
126 changed files with 1095 additions and 2165 deletions
Generated
+3
View File
@@ -7844,12 +7844,14 @@ dependencies = [
"anyhow",
"base64 0.22.1",
"chrono",
"cookie 0.18.1",
"crockford",
"errors",
"filesys",
"hex-literal",
"idempotency",
"log",
"once_cell",
"rand 0.9.0",
"reqwest 0.12.15",
"serde",
@@ -10633,6 +10635,7 @@ dependencies = [
"log",
"openai_sora_client",
"r2d2_redis",
"reqwest 0.12.15",
"workspace-build-acceleration",
]
+1
View File
@@ -155,6 +155,7 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
anyhow = "1.0.97"
base64 = "0.22.1"
chrono = { version = "0.4.40", features = ["serde"] }
cookie = "0.18.1"
hex-literal = "1.0.0"
log = "0.4.27"
once_cell = "1.21.3"
@@ -8,6 +8,7 @@ use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, ImageReader};
use log::{error, info};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::creds::credential_migration::CredentialMigrationRef;
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
@@ -43,14 +44,12 @@ pub async fn generate_image(
sora_creds_holder: &SoraCredentialHolder,
) -> AnyhowResult<()> {
let sora_credentials = read_sora_credentials_from_disk(app_data_root)
let creds = read_sora_credentials_from_disk(app_data_root)
.map_err(|err| {
error!("Failed to read Sora credentials from disk: {:?}", err);
err
})?;
//let sora_credentials = sora_creds_holder.get_credentials()?;
let mut sora_media_tokens = vec![];
if let Some(image) = maybe_image {
@@ -58,7 +57,7 @@ pub async fn generate_image(
let filename = "image.png".to_string();
let response = sora_media_upload_from_bytes(image_bytes, filename, &sora_credentials)
let response = sora_media_upload_from_bytes(image_bytes, filename, CredentialMigrationRef::New(&creds))
.await
.map_err(|err| {
error!("Failed to upload image to Sora: {:?}", err);
@@ -73,7 +72,7 @@ pub async fn generate_image(
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_credentials,
credentials: CredentialMigrationRef::New(&creds),
}).await
.map_err(|err| {
error!("Failed to call Sora image generation: {:?}", err);
@@ -11,9 +11,9 @@ use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, ImageReader};
use log::{debug, error, info};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::creds::credential_migration::CredentialMigrationRef;
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::requests::sentinel_refresh::refresh_sentinel;
use openai_sora_client::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use openai_sora_client::requests::upload::upload_media_from_file::{sora_media_upload_from_file, SoraMediaUploadRequest};
use serde_derive::Deserialize;
@@ -22,6 +22,7 @@ use std::io::Cursor;
use storyteller_client::media_files::get_media_file::get_media_file;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Deserialize)]
@@ -50,6 +51,8 @@ pub async fn sora_image_remix_command(
) -> Result<String, String> {
info!("image_generation_command called; processing image...");
// TODO(bt,2025-04-24): Better error messages to caller
generate_image(request, &app_data_root, &sora_creds_manager)
.await
.map_err(|err| {
@@ -80,38 +83,34 @@ pub async fn generate_image(
let files_to_upload = vec![filename];
// TODO(bt,2025-04-21): Read from in-memory cache instead, but allow for desktop replacement.
let mut sora_creds = read_sora_credentials_from_disk(app_data_root)
.map_err(|err| {
error!("Failed to read Sora credentials from disk: {:?}", err);
err
})?;
let mut creds = sora_creds_manager.get_credentials_required()?;
let no_sentinel = !app_data_root.get_sora_sentinel_file_path().is_file()
|| sora_creds.sentinel.is_none();
let credential_updated = maybe_upgrade_or_renew_session(&mut creds)
.await
.map_err(|err| {
error!("Failed to upgrade or renew session: {:?}", err);
err
})?;
if no_sentinel {
info!("Refreshing credentials...");
sora_creds = sora_creds_manager.call_sentinel_refresh()
.await
.map_err(|err| {
error!("Failed to refresh: {:?}", err);
err
})?;
// TODO: Write to disk.
if credential_updated {
info!("Storing updated credentials");
sora_creds_manager.set_credentials(&creds)?;
}
let mut sora_media_tokens = Vec::with_capacity(files_to_upload.len());
for file_path in files_to_upload {
let sora_upload_response = sora_media_upload_from_file(file_path, &sora_creds)
for (i, file_path) in files_to_upload.iter().enumerate() {
info!("Uploading image {} of {}...", (i+1), files_to_upload.len());
// TODO(bt,2025-04-24): Handle JWT reset error.
let sora_upload_response = sora_media_upload_from_file(file_path, CredentialMigrationRef::New(&creds))
.await?;
sora_media_tokens.push(sora_upload_response.id);
}
info!("Calling image generation...");
// TODO(bt,2025-04-21): Download media tokens.
// Note: This is incredibly inefficient. We should keep a local cache.
// Also, if they've already been uploaded to OpenAI, we shouldn't continue to re-upload.
@@ -121,13 +120,15 @@ pub async fn generate_image(
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_creds,
credentials: CredentialMigrationRef::New(&creds),
}).await;
// TODO(bt,2025-04-24): Handle state management better. Different errors imply different conditions.
if let Err(err) = &response {
error!("Error in generating image: {:?}", err);
sora_creds = sora_creds_manager.call_sentinel_refresh()
creds = sora_creds_manager.call_sentinel_refresh()
.await
.map_err(|err| {
error!("Failed to refresh: {:?}", err);
@@ -141,7 +142,7 @@ pub async fn generate_image(
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_creds,
credentials: CredentialMigrationRef::New(&creds),
}).await;
}
@@ -1,3 +1,4 @@
pub mod read_sora_credentials_from_disk;
pub mod read_sora_credentials_from_disk_with_upgrade;
pub mod sora_credential_holder;
pub mod sora_credential_manager;
@@ -1,36 +1,48 @@
use std::fs::read_to_string;
use errors::AnyhowResult;
use openai_sora_client::credentials::SoraCredentials;
use crate::state::app_dir::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use openai_sora_client::creds::sora_cookies::SoraCookies;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use openai_sora_client::creds::sora_sentinel::SoraSentinel;
use std::fs::read_to_string;
pub fn read_sora_credentials_from_disk(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentials> {
pub fn read_sora_credentials_from_disk(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentialSet> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
let bearer_file = app_data_root.get_sora_bearer_token_file_path();
let sentinel_file = app_data_root.get_sora_sentinel_file_path();
let cookie = read_to_string(cookie_file)?
if !cookie_file.exists() {
return Err(anyhow!("Cookie file does not exist: {:?}", cookie_file));
}
let value = read_to_string(cookie_file)?
.trim()
.to_string();
let bearer = read_to_string(bearer_file)?
.trim()
.to_string();
let cookie = SoraCookies::new(value);
if sentinel_file.exists() && sentinel_file.is_file() {
let sentinel = read_to_string(sentinel_file)?
let mut bearer = None;
let mut sentinel = None;
if bearer_file.exists() {
let value = read_to_string(&bearer_file)?
.trim()
.to_string();
bearer = Some(SoraJwtBearerToken::new(value)?);
}
if sentinel_file.exists() {
let value = read_to_string(&sentinel_file)?
.trim()
.to_string();
return Ok(SoraCredentials {
bearer_token: bearer,
cookie,
sentinel: Some(sentinel),
});
sentinel = Some(SoraSentinel::new(value));
}
Ok(SoraCredentials {
bearer_token: bearer,
Ok(SoraCredentialSet::initialize(
cookie,
sentinel: None,
})
bearer,
sentinel,
))
}
@@ -0,0 +1,92 @@
use crate::state::app_dir::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{info, warn};
use openai_sora_client::creds::sora_cookies::SoraCookies;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use openai_sora_client::creds::sora_sentinel::SoraSentinel;
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use std::fs::{read_to_string, OpenOptions};
use std::io::Write;
pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentialSet> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
let bearer_file = app_data_root.get_sora_bearer_token_file_path();
let sentinel_file = app_data_root.get_sora_sentinel_file_path();
if !cookie_file.exists() {
return Err(anyhow!("Cookie file does not exist: {:?}", cookie_file));
}
let value = read_to_string(cookie_file)?
.trim()
.to_string();
let cookie = SoraCookies::new(value);
let mut bearer = None;
let mut sentinel = None;
if bearer_file.exists() {
let value = read_to_string(&bearer_file)?
.trim()
.to_string();
bearer = Some(SoraJwtBearerToken::new(value)?);
}
if sentinel_file.exists() {
let value = read_to_string(&sentinel_file)?
.trim()
.to_string();
sentinel = Some(SoraSentinel::new(value));
}
let mut credentials = SoraCredentialSet::initialize(
cookie,
bearer,
sentinel,
);
let response = maybe_upgrade_or_renew_session(&mut credentials).await;
if let Err(err) = response {
// NB: Make this portion infallible. Don't die on setup. We can recover downstream.
warn!("Failed to upgrade or renew session: {:?}", err);
}
if !bearer_file.exists() {
if let Some(bearer) = &credentials.jwt_bearer_token {
info!("Persisting bearer token value...");
let value = bearer.token_str();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&bearer_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
}
}
if !sentinel_file.exists() {
if let Some(sentinel) = &credentials.sora_sentinel {
info!("Persisting sentinel value...");
let value = sentinel.get_sentinel();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&sentinel_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
}
}
Ok(credentials)
}
@@ -1,23 +1,22 @@
use std::ops::Deref;
use std::sync::{Arc, RwLock};
use anyhow::anyhow;
use errors::AnyhowResult;
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use std::sync::{Arc, RwLock};
/// Hold credentials for the application.
#[derive(Clone)]
pub struct SoraCredentialHolder {
credentials: Arc<RwLock<Option<SoraCredentials>>>
credentials: Arc<RwLock<Option<SoraCredentialSet>>>,
}
impl SoraCredentialHolder {
pub fn new() -> Self {
Self {
credentials: Arc::new(RwLock::new(None))
credentials: Arc::new(RwLock::new(None)),
}
}
pub fn set_credentials(&self, credentials: &SoraCredentials) -> AnyhowResult<()> {
pub fn set_credentials(&self, credentials: &SoraCredentialSet) -> AnyhowResult<()> {
match self.credentials.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
Ok(mut creds) => {
@@ -37,7 +36,7 @@ impl SoraCredentialHolder {
}
}
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentials>> {
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentialSet>> {
match self.credentials.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(creds) => {
@@ -46,10 +45,10 @@ impl SoraCredentialHolder {
}
}
pub fn get_credentials_required(&self) -> AnyhowResult<SoraCredentials> {
pub fn get_credentials_required(&self) -> AnyhowResult<SoraCredentialSet> {
match self.credentials.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(creds) => match creds.deref() {
Ok(creds) => match &*creds {
None => Err(anyhow!("Credentials not set")),
Some(creds) => Ok(creds.clone()),
}
@@ -4,11 +4,13 @@ use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info, warn};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use openai_sora_client::creds::sora_sentinel::SoraSentinel;
use openai_sora_client::requests::sentinel_refresh::generate::token::generate_token;
use openai_sora_client::requests::sentinel_refresh::refresh_sentinel;
use std::fs::OpenOptions;
use std::io::Write;
use openai_sora_client::creds::sora_cookies::SoraCookies;
use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
#[derive(Clone)]
pub struct SoraCredentialManager {
@@ -34,30 +36,50 @@ impl SoraCredentialManager {
}
}
pub fn set_credentials(&self, creds: &SoraCredentials) -> AnyhowResult<()> {
pub fn set_credentials(&self, creds: &SoraCredentialSet) -> AnyhowResult<()> {
self.holder.set_credentials(creds)
}
pub fn clear_credentials(&self) -> AnyhowResult<()> {
self.holder.clear_credentials()
self.holder.clear_credentials()?;
Ok(())
}
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentials>> {
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentialSet>> {
self.holder.get_credentials()
}
pub fn get_credentials_required(&self) -> AnyhowResult<SoraCredentials> {
pub fn get_credentials_required(&self) -> AnyhowResult<SoraCredentialSet> {
self.holder.get_credentials_required()
}
pub fn reset_from_disk(&self) -> AnyhowResult<SoraCredentials> {
pub fn reset_from_disk(&self) -> AnyhowResult<SoraCredentialSet> {
let creds = read_sora_credentials_from_disk(&self.app_data_root)?;
self.holder.set_credentials(&creds)?;
Ok(creds)
}
pub fn persist_all_to_disk(&self) -> AnyhowResult<()> {
let credentials = self.holder.get_credentials_required()?;
info!("Persisting cookies to disk...");
persist_cookies_to_disk(&credentials.cookies, &self.app_data_root)?;
if let Some(bearer) = &credentials.jwt_bearer_token {
info!("Persisting JWT to disk...");
persist_jwt_bearer_to_disk(bearer, &self.app_data_root)?;
}
if let Some(sentinel) = &credentials.sora_sentinel {
info!("Persisting sentinel to disk...");
persist_sentinel_to_disk(sentinel, &self.app_data_root)?;
}
Ok(())
}
/// Refresh the sentinel token from Sora's API
pub async fn call_sentinel_refresh(&self) -> AnyhowResult<SoraCredentials> {
pub async fn call_sentinel_refresh(&self) -> AnyhowResult<SoraCredentialSet> {
// NB(bt,2025-04-21): Technically we don't need credentials to get a sentinel.
let mut creds = self.holder.get_credentials_required()?;
@@ -72,7 +94,7 @@ impl SoraCredentialManager {
info!("Token obtained.");
creds.sentinel = Some(sentinel.clone());
creds.sora_sentinel = Some(SoraSentinel::new(sentinel.clone()));
self.holder.set_credentials(&creds)?;
@@ -88,3 +110,48 @@ impl SoraCredentialManager {
Ok(creds)
}
}
fn persist_cookies_to_disk(cookies: &SoraCookies, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_sora_cookie_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)?;
file.write_all(cookies.as_bytes())?;
file.flush()?;
Ok(())
}
fn persist_jwt_bearer_to_disk(bearer: &SoraJwtBearerToken, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_sora_bearer_token_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)?;
file.write_all(bearer.as_bytes())?;
file.flush()?;
Ok(())
}
fn persist_sentinel_to_disk(sentinel: &SoraSentinel, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_sora_sentinel_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)?;
file.write_all(sentinel.as_bytes())?;
file.flush()?;
Ok(())
}
@@ -1,3 +1,4 @@
use std::fs;
use crate::state::app_dir::AppDataRoot;
use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
@@ -5,12 +6,17 @@ use crate::utils::sora::initialize_sora_jwt_bearer_token::initialize_sora_jwt_be
use crate::utils::sora_webview_cookies::get_all_sora_cookies_as_string;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info};
use log::{error, info, warn};
use once_cell::sync::Lazy;
use reqwest::Url;
use std::fs::OpenOptions;
use std::fs::{read_to_string, OpenOptions};
use std::io::Write;
use std::ops::Sub;
use chrono::{DateTime, NaiveDateTime, TimeDelta};
use tauri::{AppHandle, Manager, Webview};
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence};
pub const LOGIN_WINDOW_NAME: &str = "login_window";
@@ -34,7 +40,11 @@ pub async fn sora_session_login_thread(
loop {
for (window_name, webview) in app.webviews() {
if window_name == LOGIN_WINDOW_NAME {
let result = check_login_window(&webview, &app_data_root).await;
let result = check_login_window(
&webview,
&app_data_root,
&sora_creds_manager,
).await;
if let Err(err) = result {
error!("Error checking login window: {:?}", err);
}
@@ -45,10 +55,14 @@ pub async fn sora_session_login_thread(
}
}
async fn check_login_window(webview: &Webview, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
async fn check_login_window(
webview: &Webview,
app_data_root: &AppDataRoot,
sora_credential_manager: &SoraCredentialManager,
) -> AnyhowResult<()> {
clear_browsing_data_on_test_domain(webview)?;
//keep_on_task(webview)?;
extract_cookies_to_file(webview, app_data_root)?;
extract_cookies_to_file(webview, app_data_root, sora_credential_manager).await?;
//initialize_sora_jwt_bearer_token(app_data_root).await?; // TODO: This only runs once. We need better management.
Ok(())
}
@@ -92,19 +106,67 @@ fn clear_browsing_data_on_test_domain(webview: &Webview) -> AnyhowResult<()> {
}
// TODO(bt,2025-04-07): Heuristic to detect when logged in. Only write when logged in.
fn extract_cookies_to_file(webview: &Webview, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let cookies = get_all_sora_cookies_as_string(webview)?;
async fn extract_cookies_to_file(
webview: &Webview,
app_data_root: &AppDataRoot,
sora_credential_manager: &SoraCredentialManager,
) -> AnyhowResult<()> {
let new_cookies = get_all_sora_cookies_as_string(webview)?.trim().to_string();
let cookie_file = app_data_root.get_sora_cookie_file_path();
if new_cookies.is_empty() {
return Ok(());
}
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(cookie_file)?;
let session_cookie_presence = has_session_cookie(&new_cookies)
.unwrap_or_else(|err| {
error!("Failed to check for session cookie: {:?}", err);
SessionCookiePresence::MaybePresent
});
file.write_all(cookies.as_bytes())?;
file.flush()?;
info!("Session cookies are: {:?}", session_cookie_presence);
let (should_write_cookies, should_upgrade_session) = match session_cookie_presence {
SessionCookiePresence::Present => (true, true),
SessionCookiePresence::MaybePresent => (true, false),
SessionCookiePresence::Absent => (false, false),
};
if !should_write_cookies {
return Ok(());
}
// TODO(bt): Race conditions ahead.
sora_credential_manager.clear_credentials()?;
let _r = fs::remove_file(app_data_root.get_sora_cookie_file_path());
let _r = fs::remove_file(app_data_root.get_sora_bearer_token_file_path());
let _r = fs::remove_file(app_data_root.get_sora_sentinel_file_path());
let mut new_credentials =
SoraCredentialSet::initialize_with_just_cookies_str(&new_cookies);
if should_upgrade_session {
let _upgraded = maybe_upgrade_or_renew_session(&mut new_credentials).await?;
}
sora_credential_manager.set_credentials(&new_credentials)?;
sora_credential_manager.persist_all_to_disk()?;
Ok(())
}
pub fn read_sora_cookies_from_disk(app_data_root: &AppDataRoot) -> Option<String> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
if !cookie_file.exists() {
return None;
}
match read_to_string(cookie_file) {
Ok(contents) => Some(contents.trim().to_string()),
Err(err) => {
warn!("Failed to read cookie file: {:?}", err);
None
}
}
}
@@ -19,24 +19,26 @@ workspace-build-acceleration = { workspace = true }
# Internal
crockford = { path = "../../data/crockford" }
errors = { path = "../../errors" }
subprocess_common = { path = "../../subprocess_common" }
idempotency = { path = "../../idempotency" }
subprocess_common = { path = "../../subprocess_common" }
# External
anyhow = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true}
cookie = { workspace = true }
hex-literal = { workspace = true }
log = { workspace = true }
once_cell= { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["multipart", "gzip"] }
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
sha3 = { workspace = true }
hex-literal = { workspace = true }
base64 = { workspace = true }
thiserror = { workspace = true }
tokio = { version = "1.38.0", features = ["macros"] }
toml = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true}
rand = { workspace = true }
[dev-dependencies]
testing = { path = "../../testing" }
@@ -0,0 +1,12 @@
use crate::credentials::SoraCredentials;
use crate::creds::sora_credential_set::SoraCredentialSet;
pub enum CredentialMigration {
Legacy(SoraCredentials),
New(SoraCredentialSet),
}
pub enum CredentialMigrationRef<'a> {
Legacy(&'a SoraCredentials),
New(&'a SoraCredentialSet),
}
@@ -1,3 +1,4 @@
pub mod credential_migration;
pub mod sora_cookies;
pub mod sora_credential_set;
pub mod sora_jwt_bearer_token;
@@ -11,7 +11,15 @@ impl SoraCookies {
SoraCookies { cookies }
}
pub fn get_cookies(&self) -> &str {
pub fn as_str(&self) -> &str {
&self.cookies
}
pub fn as_bytes(&self) -> &[u8] {
self.cookies.as_bytes()
}
pub fn to_string(&self) -> String {
self.cookies.clone()
}
}
@@ -1,15 +1,30 @@
use anyhow::anyhow;
use errors::AnyhowResult;
use crate::credentials::SoraCredentials;
use crate::creds::sora_cookies::SoraCookies;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::creds::sora_sentinel::SoraSentinel;
#[derive(Clone)]
pub struct SoraCredentialSet {
pub (crate) cookies: SoraCookies,
pub (crate) jwt_bearer_token: Option<SoraJwtBearerToken>,
pub (crate) sora_sentinel: Option<SoraSentinel>,
pub cookies: SoraCookies,
pub jwt_bearer_token: Option<SoraJwtBearerToken>,
pub sora_sentinel: Option<SoraSentinel>,
}
impl SoraCredentialSet {
pub fn initialize(
cookies: SoraCookies,
bearer: Option<SoraJwtBearerToken>,
sentinel: Option<SoraSentinel>
) -> Self {
Self {
cookies,
jwt_bearer_token: bearer,
sora_sentinel: sentinel,
}
}
pub fn initialize_with_just_cookies(cookies: SoraCookies) -> Self {
SoraCredentialSet {
cookies,
@@ -23,4 +38,38 @@ impl SoraCredentialSet {
SoraCookies::new(cookies.to_string()),
)
}
// TODO(bt,2025-04-23): Just here for migration
pub fn from_legacy_credentials(credentials: &SoraCredentials) -> AnyhowResult<Self> {
let cookies = SoraCookies::new(credentials.cookie.clone());
let jwt_bearer_token = SoraJwtBearerToken::new(credentials.bearer_token.clone())?;
let mut sora_sentinel = None;
if let Some(sentinel) = &credentials.sentinel {
sora_sentinel = Some(SoraSentinel::new(sentinel.clone()));
}
Ok(SoraCredentialSet {
cookies,
jwt_bearer_token: Some(jwt_bearer_token),
sora_sentinel,
})
}
pub fn to_legacy_credentials(&self) -> AnyhowResult<SoraCredentials> {
let bearer_token= self.jwt_bearer_token
.as_ref()
.map(|token| token.token_str().to_string())
.ok_or_else(|| anyhow!("There is no bearer token to convert to a required legacy bearer token"))?;
let sentinel = self.sora_sentinel
.as_ref()
.map(|sentinel| sentinel.get_sentinel().to_string());
Ok(SoraCredentials {
cookie: self.cookies.as_str().to_string(),
bearer_token,
sentinel,
})
}
}
@@ -21,7 +21,18 @@ impl SoraJwtBearerToken {
&self.token
}
pub fn as_bytes(&self) -> &[u8] {
self.token.as_bytes()
}
pub fn jwt_claims(&self) -> &SoraJwtClaims {
&self.jwt_claims
}
pub fn to_authorization_header_value(&self) -> String {
match self.token.get(0..6) {
Some("bearer") | Some("Bearer") => self.token.clone(),
_ => "Bearer ".to_owned() + &self.token,
}
}
}
@@ -3,6 +3,9 @@
#[derive(Clone)]
pub struct SoraSentinel {
sentinel: String,
// TODO(bt,2025-04-23): We may want to store the user agent alongside the sentinel
// as that appears to be important for the validity of the sentinel token.
}
impl SoraSentinel {
@@ -13,4 +16,8 @@ impl SoraSentinel {
pub fn get_sentinel(&self) -> &str {
&self.sentinel
}
pub fn as_bytes(&self) -> &[u8] {
self.sentinel.as_bytes()
}
}
@@ -1,6 +1,6 @@
pub mod credentials;
pub mod creds;
pub mod recipes;
pub mod requests;
pub mod sora_error;
pub mod utils;
mod recipes;
pub mod utils;
@@ -13,7 +13,7 @@ const EXPIRATION_DEADLINE : TimeDelta = TimeDelta::hours(12);
/// Call this at session startup to generate the tokens for the first time.
/// Call this periodically to refresh the JWT as it nears its expiration date.
/// This will not refresh the sentinel token once it's been generated - we can use other flows to accomplish that.
pub async fn upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> AnyhowResult<()> {
pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> AnyhowResult<bool> {
let mut refresh_jwt = !sora_credentials.jwt_bearer_token.is_some();
let refresh_sentinel = !sora_credentials.sora_sentinel.is_some();
@@ -29,8 +29,10 @@ pub async fn upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet)
}
}
let mut credential_updated = false;
if refresh_jwt {
let cookies = sora_credentials.cookies.get_cookies();
let cookies = sora_credentials.cookies.as_str();
info!("Generating new JWT bearer token...");
let token = generate_bearer_with_cookie(cookies).await?;
@@ -38,6 +40,7 @@ pub async fn upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet)
info!("Parsing JWT bearer token.");
let token = SoraJwtBearerToken::new(token)?;
sora_credentials.jwt_bearer_token = Some(token);
credential_updated = true;
}
if refresh_sentinel {
@@ -45,7 +48,8 @@ pub async fn upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet)
let token = generate_token().await?;
let token = SoraSentinel::new(token);
sora_credentials.sora_sentinel = Some(token);
credential_updated = true;
}
Ok(())
Ok(credential_updated)
}
@@ -1 +1,2 @@
mod upgrade_or_renew_session;
pub mod maybe_upgrade_or_renew_session;
pub mod wait_for_image_gen_status;
@@ -0,0 +1,51 @@
use crate::credentials::SoraCredentials;
use crate::requests::image_gen::image_gen_status::TaskStatus;
use crate::requests::image_gen::image_gen_status::{get_image_gen_status, StatusRequest, TaskResponse, VideoGenStatusResponse};
use errors::AnyhowResult;
pub async fn wait_for_image_gen_status(task_id: &String, credentials: &SoraCredentials, retry_limit: Option<u32>) -> AnyhowResult<TaskResponse> {
let status_request = StatusRequest {
limit: None,
before: None,
// before: Some(task_id.clone()),
};
let retry_limit = retry_limit.unwrap_or(10);
for _ in 0..retry_limit {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let status_response = get_image_gen_status(&status_request, credentials).await?;
match find_task_response_by_id(&status_response, task_id.clone()) {
Some(task_response) => match TaskStatus::from_str(&task_response.status) {
TaskStatus::Succeeded => {
return Ok(task_response.clone());
},
TaskStatus::Failed => {
return Err(anyhow::anyhow!("Task failed"));
},
TaskStatus::Unknown(status) => {
println!("Unknown task status: {}", status);
continue;
},
TaskStatus::Queued => {
println!("Task is queued");
continue;
},
TaskStatus::Running => {
println!("Task is running");
continue;
},
},
None => {
return Err(anyhow::anyhow!("Task not found"));
},
}
}
Err(anyhow::anyhow!("Task not found"))
}
fn find_task_response_by_id(status_response: &VideoGenStatusResponse, task_id: String) -> Option<&TaskResponse> {
status_response.task_responses.iter().find(|task| task.id == task_id)
}
@@ -1,5 +1,5 @@
use crate::creds::credential_migration::CredentialMigrationRef;
use serde_derive::{Deserialize, Serialize};
use crate::credentials::SoraCredentials;
use thiserror::Error;
const SORA_IMAGE_GEN_URL: &str = "https://sora.com/backend/video_gen";
@@ -146,20 +146,41 @@ impl std::fmt::Display for RawSoraErrorInner {
}
/// Don't expose the internal request implementation as there are only a few "correct" ways to call the API.
pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest, credentials: &SoraCredentials) -> Result<RawSoraResponse, SoraError> {
pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest, credentials: CredentialMigrationRef<'_>) -> Result<RawSoraResponse, SoraError> {
let client = reqwest::Client::new();
let sentinel = match credentials.sentinel.as_deref() {
None => return Err(SoraError::GenericError("Calls to image gen require a sentinel in the credentials payload.".to_string())),
Some(sentinel) => sentinel,
};
let mut cookie;
let mut authorization_header;
let mut sentinel;
match credentials {
CredentialMigrationRef::Legacy(creds) => {
cookie = creds.cookie.clone();
authorization_header = creds.authorization_header_value();
// TODO(bt,2025-04-23): We're using a Sora payload error in place of application state error. Surface this differently.
sentinel = creds.sentinel.as_ref()
.map(|sentinel| sentinel.to_string())
.ok_or(SoraError::SentinelBlock("Sentinel is required for image generation.".to_string()))?;
}
CredentialMigrationRef::New(creds) => {
cookie = creds.cookies.to_string();
// TODO(bt,2025-04-23): We're using a Sora payload error in place of application state error. Surface this differently.
authorization_header = creds.jwt_bearer_token.as_ref()
.ok_or(SoraError::InvalidJwt("JWT bearer is required for image generation".to_string()))?
.to_authorization_header_value();
// TODO(bt,2025-04-23): We're using a Sora payload error in place of application state error. Surface this differently.
sentinel = creds.sora_sentinel.as_ref()
.map(|sentinel| sentinel.get_sentinel().to_string())
.ok_or(SoraError::SentinelBlock("Sentinel is required for image generation.".to_string()))?;
}
}
let http_request = client.post(SORA_IMAGE_GEN_URL)
.header("User-Agent", USER_AGENT)
.header("Cookie", &credentials.cookie)
.header("Authorization", credentials.authorization_header_value())
.header("Cookie", &cookie)
.header("Authorization", &authorization_header)
.header("Content-Type", "application/json")
.header("OpenAI-Sentinel-Token", sentinel);
.header("OpenAI-Sentinel-Token", &sentinel);
let http_request = http_request.json(&sora_request).build()
.map_err(|e| SoraError::NetworkError(e.to_string()))?;
@@ -220,47 +220,6 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
Ok(response)
}
pub async fn wait_for_image_gen_status(task_id: &String, credentials: &SoraCredentials, retry_limit: Option<u32>) -> AnyhowResult<TaskResponse> {
let status_request = StatusRequest {
limit: None,
before: None,
// before: Some(task_id.clone()),
};
let retry_limit = retry_limit.unwrap_or(10);
for _ in 0..retry_limit {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let status_response = get_image_gen_status(&status_request, credentials).await?;
match find_task_response_by_id(&status_response, task_id.clone()) {
Some(task_response) => match TaskStatus::from_str(&task_response.status) {
TaskStatus::Succeeded => {
return Ok(task_response.clone());
},
TaskStatus::Failed => {
return Err(anyhow::anyhow!("Task failed"));
},
TaskStatus::Unknown(status) => {
println!("Unknown task status: {}", status);
continue;
},
TaskStatus::Queued => {
println!("Task is queued");
continue;
},
TaskStatus::Running => {
println!("Task is running");
continue;
},
},
None => {
return Err(anyhow::anyhow!("Task not found"));
},
}
}
Err(anyhow::anyhow!("Task not found"))
}
pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> AnyhowResult<()> {
let dir = Path::new(dir);
if !dir.exists() {
@@ -288,14 +247,11 @@ pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> A
Ok(())
}
fn find_task_response_by_id(status_response: &VideoGenStatusResponse, task_id: String) -> Option<&TaskResponse> {
status_response.task_responses.iter().find(|task| task.id == task_id)
}
#[cfg(test)]
mod tests {
use crate::credentials::SoraCredentials;
use crate::image_gen::image_gen_status::{get_image_gen_status, save_generations_to_dir, wait_for_image_gen_status, StatusRequest, VideoGenStatusResponse};
use crate::recipes::wait_for_image_gen_status::wait_for_image_gen_status;
use crate::requests::image_gen::image_gen_status::{get_image_gen_status, save_generations_to_dir, StatusRequest, VideoGenStatusResponse};
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
@@ -4,4 +4,3 @@ pub mod common;
pub mod sora_image_gen_remix;
pub mod sora_image_gen_simple;
pub mod image_gen_status;
pub use image_gen_status::{get_image_gen_status, save_generations_to_dir, wait_for_image_gen_status, Generation, StatusRequest, TaskResponse, VideoGenStatusResponse};
@@ -1,4 +1,4 @@
use crate::credentials::SoraCredentials;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
use crate::requests::image_gen::image_gen_http_request::{image_gen_http_request, InpaintItem, InpaintItemType, OperationType, RawSoraImageGenRequest, SoraError, VideoGenType};
@@ -7,7 +7,7 @@ pub struct SoraImageGenRemixRequest<'a> {
pub num_images: NumImages,
pub image_size: ImageSize,
pub sora_media_tokens: Vec<String>,
pub credentials: &'a SoraCredentials,
pub credentials: CredentialMigrationRef<'a>,
}
/// The "remix" commands let you supply additional images as context.
@@ -35,7 +35,7 @@ pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Resu
}).collect(),
};
let result = image_gen_http_request(args, &request.credentials).await?;
let result = image_gen_http_request(args, request.credentials).await?;
Ok(SoraImageGenResponse {
task_id: result.id,
@@ -45,6 +45,7 @@ pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Resu
#[cfg(test)]
mod tests {
use crate::credentials::SoraCredentials;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::image_gen::common::{ImageSize, NumImages};
use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use errors::AnyhowResult;
@@ -74,7 +75,7 @@ mod tests {
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: vec!["media_01jqyhrz4detyvtzwp2p4j63ad".to_string()],
credentials: &creds,
credentials: CredentialMigrationRef::Legacy(&creds),
}).await?;
println!("task_id: {}", response.task_id);
@@ -2,12 +2,13 @@ use crate::credentials::SoraCredentials;
use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
use crate::requests::image_gen::image_gen_http_request::{image_gen_http_request, OperationType, RawSoraImageGenRequest, VideoGenType};
use errors::AnyhowResult;
use crate::creds::credential_migration::CredentialMigrationRef;
pub struct SoraImageGenSimpleRequest<'a> {
pub prompt: String,
pub num_images: NumImages,
pub image_size: ImageSize,
pub credentials: &'a SoraCredentials,
pub credentials: CredentialMigrationRef<'a>,
}
pub async fn sora_image_gen_simple(args: SoraImageGenSimpleRequest<'_>) -> AnyhowResult<SoraImageGenResponse> {
@@ -38,6 +39,7 @@ mod tests {
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
use crate::creds::credential_migration::CredentialMigrationRef;
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
#[tokio::test]
@@ -61,7 +63,7 @@ mod tests {
prompt: "A pirate and a ninja fight in a battle inside a UFO. Fully photo realistic, lifelike, lens flare".to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
credentials: &creds,
credentials: CredentialMigrationRef::Legacy(&creds),
}).await?;
println!("task_id: {}", response.task_id);
@@ -1,29 +1,45 @@
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::sora_error::SoraError;
const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36";
// https://sora.com/backend/notif?limit=100&before=task_01jqwwrkvgeqp8jsf5mqk1jceh
use crate::credentials::SoraCredentials;
pub async fn sora_job_status(
task_id: &str,
credentials: &SoraCredentials,
) -> anyhow::Result<()> {
credentials: CredentialMigrationRef<'_>,
) -> Result<(), SoraError> {
let url = format!("https://sora.com/backend/notif?limit=100&before={}", task_id);
let mut cookie;
let mut authorization_header;
match credentials {
CredentialMigrationRef::Legacy(creds) => {
cookie = creds.cookie.clone();
authorization_header = creds.authorization_header_value();
}
CredentialMigrationRef::New(creds) => {
cookie = creds.cookies.to_string();
// TODO(bt,2025-04-23): We're using a Sora payload error in place of application state error. Surface this differently.
authorization_header = creds.jwt_bearer_token.as_ref()
.ok_or(SoraError::NoBearerTokenAvailable)?
.to_authorization_header_value();
}
}
let client = reqwest::Client::new();
let request = client
.get(url)
.header("Authorization", &credentials.bearer_token)
.header("Authorization", &authorization_header)
.header("User-Agent", USER_AGENT)
.header("Cookie", &credentials.cookie);
.header("Cookie", &cookie);
let response = request.send().await?;
let json_response = &response.text().await?;
println!(" >>> response = {:?}", json_response);
//let response = serde_json::from_str(json_response)?;
Ok(())
@@ -31,11 +47,12 @@ pub async fn sora_job_status(
#[cfg(test)]
mod tests {
use std::fs::read_to_string;
use errors::AnyhowResult;
use testing::test_file_path::test_file_path;
use crate::credentials::SoraCredentials;
use crate::job::sora_job_status::sora_job_status;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::job::sora_job_status::sora_job_status;
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
#[ignore]
#[tokio::test]
@@ -57,7 +74,7 @@ mod tests {
let task_id = "task_01jqwwrkvgeqp8jsf5mqk1jceh";
let result = sora_job_status(task_id, &creds).await?;
let result = sora_job_status(task_id, CredentialMigrationRef::Legacy(&creds)).await?;
Ok(())
}
@@ -1,4 +1 @@
pub use refresh::refresh_sentinel;
pub mod refresh;
pub mod generate;
@@ -1,16 +1,11 @@
use crate::credentials::SoraCredentials;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use errors::AnyhowResult;
use std::path::PathBuf;
pub struct SoraMediaUploadRequest<'a> {
pub file_path: String,
pub credentials: &'a SoraCredentials,
}
/// Upload bytes.
/// The underlying reqwest lib needs to own the bytes, so we can't pass as a reference.
pub async fn sora_media_upload_from_bytes(bytes: Vec<u8>, file_name: String, creds: &SoraCredentials) -> AnyhowResult<SoraMediaUploadResponse> {
pub async fn sora_media_upload_from_bytes(bytes: Vec<u8>, file_name: String, creds: CredentialMigrationRef<'_>) -> AnyhowResult<SoraMediaUploadResponse> {
let file_path = PathBuf::from(&file_name);
// TODO: Read file magic bytes first, then fall back to this.
@@ -1,4 +1,5 @@
use crate::credentials::SoraCredentials;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use errors::AnyhowResult;
use std::path::Path;
@@ -14,7 +15,7 @@ pub struct SoraMediaUploadRequest<'a> {
pub credentials: &'a SoraCredentials,
}
pub async fn sora_media_upload_from_file<P: AsRef<Path>>(file_path: P, creds: &SoraCredentials) -> AnyhowResult<SoraMediaUploadResponse> {
pub async fn sora_media_upload_from_file<P: AsRef<Path>>(file_path: P, creds: CredentialMigrationRef<'_>) -> AnyhowResult<SoraMediaUploadResponse> {
let mut file = File::open(&file_path).await?;
let mut buffer = Vec::with_capacity(INITIAL_BUFFER_SIZE);
file.read_to_end(&mut buffer).await?;
@@ -42,7 +43,8 @@ pub async fn sora_media_upload_from_file<P: AsRef<Path>>(file_path: P, creds: &S
#[cfg(test)]
mod tests {
use crate::credentials::SoraCredentials;
use crate::upload::upload_media_from_file::sora_media_upload_from_file;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file;
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
@@ -66,7 +68,7 @@ mod tests {
let response = sora_media_upload_from_file(
image_path,
&creds,
CredentialMigrationRef::Legacy(&creds),
).await?;
println!("media: {:?}", response);
@@ -1,4 +1,6 @@
use log::info;
use crate::credentials::{SoraCredentials, USER_AGENT};
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::sora_error::SoraError;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
@@ -65,7 +67,7 @@ pub (crate) async fn upload_media_http_request(
file_bytes: Vec<u8>,
filename: String,
mime_type: &str,
credentials: &SoraCredentials,
credentials: CredentialMigrationRef<'_>,
) -> Result<SoraMediaUploadResponse, SoraError> {
// Create multipart form
@@ -75,18 +77,36 @@ pub (crate) async fn upload_media_http_request(
let form = Form::new().part("file", part);
let cookie;
let auth_header;
match credentials {
CredentialMigrationRef::Legacy(creds) => {
cookie = creds.cookie.clone();
auth_header = creds.authorization_header_value();
}
CredentialMigrationRef::New(creds) => {
cookie = creds.cookies.to_string();
auth_header = creds.jwt_bearer_token
.as_ref()
.ok_or(SoraError::NoBearerTokenAvailable)?
.to_authorization_header_value();
}
}
// Make API request
let client = Client::new();
let request_builder = client.post(SORA_UPLOAD_MEDIA_URL)
.multipart(form)
.header("User-Agent", USER_AGENT)
.header("Cookie", &credentials.cookie)
.header("Authorization", credentials.authorization_header_value());
.header("Cookie", &cookie)
.header("Authorization", &auth_header);
let response = request_builder.send().await?;
// Check response status
if !response.status().is_success() {
info!("Error uploading image: {:?}", response.status());
let error = classify_error(response).await;
return Err(error);
}
@@ -3,6 +3,9 @@ use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum SoraError {
/// We haven't received a bearer token yet.
NoBearerTokenAvailable,
/// Unauthorized, cookie and/or bearer token expired. We'll need to ask for a refreshed login.
///
/// Example message, e.g. from the upload endpoint:
@@ -28,13 +31,16 @@ impl Error for SoraError {}
impl Display for SoraError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SoraError::UnauthorizedCookieOrBearerExpired => {
Self::NoBearerTokenAvailable => {
write!(f, "Unauthorized: no bearer token set")
}
Self::UnauthorizedCookieOrBearerExpired => {
write!(f, "Unauthorized: cookie and/or bearer token expired")
}
SoraError::OtherBadStatus(err) => {
Self::OtherBadStatus(err) => {
write!(f, "Other error: {}", err)
}
SoraError::ReqwestError(err) => {
Self::ReqwestError(err) => {
write!(f, "Reqwest error: {}", err)
}
}
@@ -0,0 +1,123 @@
use std::collections::HashSet;
use std::iter::Map;
use cookie::Cookie;
use once_cell::sync::Lazy;
use errors::AnyhowResult;
pub const SESSION_COOKIE_NAME : &str = "__Secure-authjs.session-token";
/// Cookies Sora.com mints that are not the session cookie. We can ignore these.
/// This list is very likely to change over time.
const IRRELEVANT_COOKIE_NAMES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
"cf_clearance",
"oai-did",
"_cfuvid",
"__cflb",
"__cf_bm",
"__Host-authjs.csrf-token",
"__Secure-authjs.callback-url",
"__Secure-authjs.pkce.code_verifier",
"__Secure-authjs.state",
])
});
/// Super naive heuristic for "logged out" total cookie length being ~1972 characters.
const SESSION_COOKIE_LENGTH_HEURISTIC : usize = 2000;
//pub fn has_session_cookie(cookies: &str) -> bool {
// // NB: This is not a valid check for cookies, just a cheap heuristic.
// cookies.contains(SESSION_COOKIE_NAME)
//}
#[derive(Copy,Clone,Eq,PartialEq,Debug)]
pub enum SessionCookiePresence {
Present,
MaybePresent,
Absent,
}
/// OpenAI might change the name of the session cookie, and we don't want to break clients,
/// so we use some heuristics to determine if the cookie might be present.
pub fn has_session_cookie(cookies_header: &str) -> AnyhowResult<SessionCookiePresence> {
let cookies_header = cookies_header.trim();
if (cookies_header.is_empty()) {
return Ok(SessionCookiePresence::Absent);
}
let cookies = Cookie::split_parse(cookies_header);
let mut unknown_cookie_count = 0; // NB: In the event they change their session cookie
for cookie in cookies {
let cookie= cookie?;
let cookie_name = cookie.name();
if cookie_name == SESSION_COOKIE_NAME {
return Ok(SessionCookiePresence::Present);
} else if !IRRELEVANT_COOKIE_NAMES.contains(cookie_name) {
unknown_cookie_count += 1;
}
}
// This is a really stupid heuristic, but we want to be safe.
if cookies_header.len() > SESSION_COOKIE_LENGTH_HEURISTIC {
return Ok(SessionCookiePresence::MaybePresent);
}
// Another stupid heuristic.
if unknown_cookie_count > 3 {
return Ok(SessionCookiePresence::MaybePresent);
}
Ok(SessionCookiePresence::Absent)
}
#[cfg(test)]
mod tests {
use crate::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence};
#[test]
fn test() {
assert_eq!(has_session_cookie("").unwrap(), SessionCookiePresence::Absent);
assert_eq!(has_session_cookie("foo=bar").unwrap(), SessionCookiePresence::Absent);
// Heuristic: unknown cookies.
assert_eq!(has_session_cookie("foo=bar;bin=baz;bat=ban;bash=barn").unwrap(), SessionCookiePresence::MaybePresent);
// Heuristic: cookie length
let cookies = format!("cookie={}", "a".repeat(10000));
assert_eq!(has_session_cookie(&cookies).unwrap(), SessionCookiePresence::MaybePresent);
let cookies = "__Host-authjs.csrf-token=a2783b5f89ee8dcb184f64723c3fdc91868f89840cb842fb20c3bd3\
d6b087d97%7C8e85a84ac3870dfab160d96c45fb8a0430350ae4b4755784c03c3b97a927eb06; __Secure-authjs.c\
allback-url=https%3A%2F%2Fsora.com%2F; __cflb=0H28vtyuPgbsDEUozcoHSWoAnvpSqAh9PUFn8MimKwT; __Se\
cure-authjs.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwia2lkIjoiYWNBYVdTWHBfa\
DZnNkxtUEs1S0RvTjR3NHFxTEsxVlpSTHA4VWdiaFdWNjctMG5pbDdVVjBCN2dSUU9Bem84ZXBjMVFjb1ozNGxwa0c1OEUy\
U2Q4cUEifQ..I4nYqp8dqgnuBwIgzbtoTg.etnsxNRheMCGgAFaTzAxGdqjao68Xf_NCHqv8h5ZBhfDx_b0dg2bzDIfNFF5\
_8VG1dwZkqe83k3JrOejBVKi3wDSI5p_EgU8YKf_oxshusBn09mGZmAPd49_F73DARypF0eHKyUnGfelkFmVW610P0nV3Pn\
KNJhjIHHxAh_9dHnRvQ5WtaYMvSTE5Yc8Ut1VwKs9QmYdTkfcC_gDATQ-dgF8eew5Seh4KALizGcQqYhcENMVzfshSMwNrr\
mZ-RL6X71wTqhKV3XiJnp_s7e6DxRfcb25V-aOD_rsVa1uAxpYzYvc197Cd0psqQ1MpPjGohUSXjwg177FvBmloAxy8-Qbf\
Moz9ofugywgEqQBJIEznddwEOScVqoOhRbYXGpqBY-rU5ChDG4qetbfKNCX2DT0R0lShR1n-UXo-XJJuuFJ-FRPTOGMVJHX\
5CT-k258ybtsMBM7sMQhohpjMnJuSZkloj0L6gZltG21syJy-14X4ykU20lfneVn-yKorTo2JbDMWUvWhdEIVt5-5czXmA7\
csWiqmCgrkSHsw0NescOtLT1VfqW2aAd6X8ShsDzyMjfBC5GW84S1uBUURbr0WkcxLZFb_IsKaAA0xsfUri0rHXi35rabhM\
KgH4mn8RztNsyod-tEwLil1LZS2HmoyB8RAC1xh5HIhDe3RJYaHyCdXJqeZjb1KBC1xDQV2QbQZ91y0JZzBXvg6NTFYJ-wb\
CytyOnEbknHG_AM0p-GvxjckkL1PbasHdiOOKyT49hYCEQF0Zrnmq4DlvO46CpLBvpENC3NlqDJ3d3RD9_-WVn1yjVHI4Yl\
VPFAyrYnfhtRF7WlgBPOnq6mPDF17dSIdLLWH3iQQQFfq-zxULLn0RaHWgeWndOaOLVPLe6fxZ_6-_lFLR-Vi-DiJj8dAM4\
lRTUucyoXv93vqzCxN653V-LOTmg7SFpe2OXFsmXHiufoFQcVm-erpNZ3_hlxOOdYJh8wuQUBUKPhwqunTcIHZIr2QqjmnX\
SSmBPw1-HQtWkEVGA5qKWLwciaX74Z8NtUWnbC4ZmYGTbnirIbsZ0EyYRNDvNjNGMoWrJBCyi_K3uP6g1GT-Zhc123dGZqu\
1REqPiCmGK8l3ofOHEVFFG7k-Yc80RiVI7EQ2i5qN7nJEavDetXuC-nlbTbYqjqEut31T9G-IC3HRkHg7HLEBIOVYKbPLe5\
BNzfAYO48YlBc1CLRFcx5LQMM1X_hS_WKLtYPkGxE7BZnR11Acf904kELPbL1-7-ZXnAYiQodwa9wzS6uLqdy6FCuqOG08E\
K2mCDsIZXw3mjqOlDLXSSLNmu6CH5r3aXcnVaYHJQpSryPfNpNltXdYnrxF2CgWHPAOuleCKHY6nH9aKIH6RsFN7ZhgLZl4\
5NnVzU3dx5PiSuwELiXFbziCXqfcbbUrhfwnJKPMxQqxErqadM6u9b4hB4QTZq41_OEu3S3Y8y2E1aGPZ81IMK4QRb5A4wh\
WR9gBGF1FUud9JB5kfI479rpcwlDtoCxTkaixTsU0DwNzqA0gZvXZbJAqtSjyJIhTU_OBz94YzMsYYbuLSi82dm_a90zV6n\
EnCYjuPuS6F2nUgW7BhNRGzusP7oIiECCJM8US2iWAGcQffhsEiwmB7YxET3gTifxNv6TI96muD0qA0G7zoNJkTQ11jxnWk\
PqDHH1Mk545Ge7hL3YvjjFcJwhypNHxJCicQeuqOuzJtiS8DAzC1T7xS6ZbpFi8vcXmmv439RKYw4FoDfkl3JA8dBLM-Dii\
2UlSX0byjppcjDOVX1G4azYoJawXhAchOFTkh7ZgAmAC-XIQYN3KZIwUpWIbnkTt-8V-SQlf56YW6VNNP7dAYM8rhqC7quC\
QfFicZYH32oTenr6-75Bq6dFGCdF4Ieh4M1eVI8tgaOM9X-kVy5gliQ61gyyPFiVLGkPJQfJT6jWPVAu1JrB6v0mNm7G10m\
qUPptxw38K_75Mvqd4D5ye6JUAii9iCMbZ8YMZHD4ncum-WJ2cDofssiXM_psgB6W94-PxxFZxfzL8MIqKe1ozAA8pJIyVu\
gqm-AYcFJprbW3C6cgTfeZNW1_4k0stDgE3N2jCVLvGMKkiGTYC_SwgzqF3DU8Up0p_jMoJQ9hh3re7fjEGuKvtIfc_jeFq\
c9-q1LsVirGizzhS_VTA5Ltnh4quwaIU3zkI_y3N9xV62ijUectvOPWONl42Qm9NUU4fQZynrUbTd9sSjYRNHfveIMDhbCj\
dDWjzHjMgCeUopSyrLLra1uC0NsJrBoq24QMQ00jHgKWOnqpdFTjPopZ-zDcN0E4FkYB8kKbrz4u4ZIn0fNNnE2bzlU8rbH";
assert_eq!(has_session_cookie(cookies).unwrap(), SessionCookiePresence::Present);
}
}
@@ -1 +1,2 @@
pub mod has_session_cookie;
pub mod lightweight_sora_jwt_parse;
@@ -27,6 +27,7 @@ errors = { workspace = true }
anyhow = { workspace = true }
log = { workspace = true }
r2d2_redis = "0.14.0"
reqwest = { workspace = true, features = ["multipart", "gzip"] }
[dev-dependencies]
# None yet
@@ -1,3 +1,4 @@
//! This library contains code shared between `storyteller-web` and jobs.
pub mod sora_redis_credentials;
pub mod sora_redis_sentinel_refresh;
@@ -0,0 +1 @@
pub mod refresh;
@@ -1,24 +1,19 @@
// Send a POST request to env variable SORA_SENTINEL_REFRESH_URL
/// Send a POST request to env variable SORA_SENTINEL_REFRESH_URL
use errors::AnyhowResult;
use reqwest::Client;
use std::env;
use errors::AnyhowResult;
const SORA_SENTINEL_REFRESH_URL: &str = "http://oai-token-server/refresh_token";
// Refresh the Sora sentinel
// The response body does _not_ contain the new sentinel
// We should read it again from redis or generate a new one here
// For now we refresh in the server handler
/// Refresh the Sora sentinel
/// The response body does _not_ contain the new sentinel
/// We should read it again from redis or generate a new one here
/// For now we refresh in the server handler
pub async fn refresh_sentinel() -> AnyhowResult<String> {
let client = Client::new();
let url = env::var("SORA_SENTINEL_REFRESH_URL").unwrap_or(SORA_SENTINEL_REFRESH_URL.to_string());
let response = client.post(url).send().await?;
let body = response.text().await?;
Ok(body)
}
@@ -28,8 +28,8 @@ use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
use mysql_queries::queries::batch_generations::insert_batch_generation_records::{insert_batch_generation_records, InsertBatchArgs};
use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob;
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::requests::image_gen::image_gen_status::TaskStatus;
use openai_sora_client::requests::image_gen::{wait_for_image_gen_status, Generation};
use openai_sora_client::recipes::wait_for_image_gen_status::wait_for_image_gen_status;
use openai_sora_client::requests::image_gen::image_gen_status::{Generation, TaskStatus};
use r2d2_redis::r2d2::PooledConnection;
use r2d2_redis::redis::Commands;
use r2d2_redis::RedisConnectionManager;
@@ -17,7 +17,7 @@ use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::requests::image_gen::SoraError;
use openai_sora_client::requests::sentinel_refresh::generate::token::generate_token;
use openai_sora_client::requests::sentinel_refresh::refresh_sentinel;
use shared_service_components::sora_redis_sentinel_refresh::refresh::refresh_sentinel;
use serde::Deserialize;
use serde::Serialize;
use shared_service_components::sora_redis_credentials::keys::RedisSoraCredentialSubkey;
@@ -37,6 +37,7 @@ use mysql_queries::payloads::generic_inference_args::generic_inference_args::{Ge
use mysql_queries::payloads::generic_inference_args::inner_payloads::sora_image_gen_args::SoraImageGenArgs;
use mysql_queries::queries::generic_inference::web::insert_generic_inference_job::{insert_generic_inference_job, InsertGenericInferenceArgs};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use openai_sora_client::creds::credential_migration::CredentialMigrationRef;
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::requests::upload::upload_media_from_file::{sora_media_upload_from_file, SoraMediaUploadRequest};
@@ -268,7 +269,7 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
let mut sora_media_tokens = Vec::with_capacity(files_to_upload.len());
for file_path in files_to_upload {
let sora_upload_response = sora_media_upload_from_file(file_path, &sora_credentials).await.map_err(|err| {
let sora_upload_response = sora_media_upload_from_file(file_path, CredentialMigrationRef::Legacy(&sora_credentials)).await.map_err(|err| {
error!("Failed to upload scene media to Sora: {:?}", err);
EnqueueImageGenRequestError::ServerError
})?;
@@ -299,7 +300,7 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_credentials
credentials: CredentialMigrationRef::Legacy(&sora_credentials),
}).await;
debug!("Sora image gen response: {:?}", response);
@@ -328,7 +329,7 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &updated_sora_credentials
credentials: CredentialMigrationRef::Legacy(&updated_sora_credentials)
}).await;
},
Err(e) => {
@@ -347,7 +348,7 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &updated_sora_credentials
credentials: CredentialMigrationRef::Legacy(&updated_sora_credentials)
}).await;
},
Err(e) => {
@@ -1,6 +1,5 @@
import { Button } from "@storyteller/ui-button";
import { DownloadButton } from "../../components/download-button";
import { faArrowDownToLine } from "@fortawesome/pro-solid-svg-icons";
const Landing = () => {
// const features = [
@@ -98,10 +97,10 @@ const Landing = () => {
</div>
{/* Video Sections */}
<div className="relative z-10 mx-auto max-w-[1920px] px-4 py-20 md:px-16 lg:px-12 xl:px-32">
<div className="relative z-10 mx-auto max-w-[1920px] px-4 py-20 sm:px-24 lg:px-32">
{/* First Video Section */}
<div className="grid grid-cols-1 lg:grid-cols-5 items-center gap-16 mb-32 p-5 lg:pr-16 bg-white/10 backdrop-blur-md lg:rounded-[80px] rounded-[40px]">
<div className="lg:col-span-3 rounded-[30px] lg:rounded-[60px] overflow-hidden bg-[#1C1C20]">
<div className="grid grid-cols-1 lg:grid-cols-5 items-center gap-16 mb-32 p-5 pr-16 bg-white/10 backdrop-blur-md rounded-[80px]">
<div className="lg:col-span-3 rounded-[60px] overflow-hidden bg-[#1C1C20]">
<video
muted
autoPlay
@@ -124,18 +123,14 @@ const Landing = () => {
stunning finished pieces.
</p>
</div>
<Button
icon={faArrowDownToLine}
className="rounded-lg px-5 py-3.5 text-md bg-[#2D81FF] hover:bg-[#438AF6]"
onClick={() => window.open("/download", "_self")}
>
Download App
<Button className="rounded-lg px-5 py-3.5 text-md bg-[#2D81FF] hover:bg-[#438AF6]">
Start Creating
</Button>
</div>
</div>
{/* Second Video Section */}
<div className="grid grid-cols-1 lg:grid-cols-5 items-center gap-16 p-5 lg:pl-16 bg-white/10 backdrop-blur-md lg:rounded-[80px] rounded-[40px]">
<div className="grid grid-cols-1 lg:grid-cols-5 items-center gap-16 p-5 pl-16 bg-white/10 backdrop-blur-md rounded-[80px]">
<div className="lg:col-span-2 order-2 lg:order-1 space-y-9">
<div className="space-y-6">
<h2 className="text-5xl font-bold leading-tight">
@@ -149,15 +144,11 @@ const Landing = () => {
throughout your scenes.
</p>
</div>
<Button
icon={faArrowDownToLine}
className="rounded-lg px-5 py-3.5 text-md bg-[#2D81FF] hover:bg-[#438AF6]"
onClick={() => window.open("/download", "_self")}
>
Download App
<Button className="rounded-lg px-5 py-3.5 text-md bg-[#2D81FF] hover:bg-[#438AF6]">
Learn More
</Button>
</div>
<div className="lg:col-span-3 order-1 lg:order-2 rounded-[30px] lg:rounded-[60px] overflow-hidden bg-[#1C1C20]">
<div className="lg:col-span-3 order-1 lg:order-2 rounded-[60px] overflow-hidden bg-[#1C1C20]">
<video
muted
autoPlay
+1 -1
View File
@@ -14,7 +14,7 @@
href="https://fonts.googleapis.com/css2?family=Syne:wght@600&display=swap"
rel="stylesheet"
/>
<title>ArtCraft Canvas</title>
<title>Board | Storyteller.AI</title>
</head>
<body>
+3 -2
View File
@@ -22,6 +22,7 @@
"preview": "vite preview"
},
"dependencies": {
"@devnomic/marquee": "^1.0.3",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/pro-regular-svg-icons": "^6.7.2",
@@ -51,8 +52,8 @@
"@preact/signals-react-transform": "^0.4.0",
"@tauri-apps/cli": "^2.4.0",
"@types/deep-equal": "^1.0.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 B

After

Width:  |  Height:  |  Size: 701 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
// import { Marquee } from "@devnomic/marquee";
import { Marquee } from "@devnomic/marquee";
import "@devnomic/marquee/dist/index.css";
function getRandomImages(count: number) {
@@ -21,18 +21,16 @@ interface ImageColumnProps {
duration?: number;
}
function ImageColumn(
{
// images,
// delay,
// reverse = false,
// duration = 100,
// isLoaded,
}: ImageColumnProps,
) {
function ImageColumn({
images,
delay,
reverse = false,
duration = 100,
isLoaded,
}: ImageColumnProps) {
return (
<div className="flex h-screen flex-col gap-6">
{/* <Marquee
<Marquee
fade={false}
direction="up"
reverse={reverse}
@@ -61,7 +59,7 @@ function ImageColumn(
/>
</div>
))}
</Marquee> */}
</Marquee>
</div>
);
}
@@ -4,7 +4,7 @@ import { Button, LoadingSpinner } from "~/components/ui";
import { useRenderCounter } from "~/hooks/useRenderCounter";
import { GenerationLoadingState, generationSignal } from "~/signals";
import { useRef, useState } from "react";
// import BackgroundGallery from "./BackgroundGallery";
import BackgroundGallery from "./BackgroundGallery";
import { twMerge } from "tailwind-merge";
import { Transition } from "@headlessui/react";
import { GenerationEngine } from "~/KonvaApp/GenerationEngine";
@@ -138,7 +138,7 @@ export const GenerationRootComponent = ({
</div>
</div>
{/* <Transition
<Transition
show={generationState.loadingState === GenerationLoadingState.INIT}
enter="transition-opacity duration-700"
enterFrom="opacity-0"
@@ -149,7 +149,7 @@ export const GenerationRootComponent = ({
as="div"
>
<BackgroundGallery />
</Transition> */}
</Transition>
</>
);
};
@@ -36,7 +36,6 @@ import { PopoverItem } from "~/components/reusable/Popover";
import { Api, ApiManager } from "~/KonvaApp/Api";
import { useJobContext } from "~/components/JobContext";
import { IsDesktopApp } from "@storyteller/tauri-utils";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
interface ReferenceImage {
id: string;
@@ -57,11 +56,6 @@ export const PromptBox = () => {
const [prompt, setPrompt] = useState("");
const [isEnqueueing, setisEnqueueing] = useState(false);
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[],
);
const [activeGalleryTab, setActiveGalleryTab] = useState("my-media");
const [referenceImages, setReferenceImages] = useState<ReferenceImage[]>([]);
const [uploadingImages, setUploadingImages] = useState<
{ id: string; file: File }[]
@@ -159,40 +153,8 @@ export const PromptBox = () => {
fileInputRef.current?.click();
};
const handleGallerySelect = () => {
setIsGalleryModalOpen(true);
};
const handleGalleryClose = () => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleImageSelect = (id: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) {
return prev.filter((imageId) => imageId !== id);
}
if (prev.length >= 4) {
return prev;
}
return [...prev, id];
});
};
const handleGalleryImages = (selectedItems: GalleryItem[]) => {
selectedItems.forEach((item) => {
if (!item.fullImage) return;
const referenceImage: ReferenceImage = {
id: Math.random().toString(36).substring(7),
url: item.fullImage,
file: new File([], "library-image"),
mediaToken: item.id,
};
setReferenceImages((prev) => [...prev, referenceImage]);
});
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
const handleLibrarySelect = () => {
console.log("Opening library selector");
};
const handleAction = (action: string) => {
@@ -200,8 +162,8 @@ export const PromptBox = () => {
case "upload":
handleUploadClick();
break;
case "gallery":
handleGallerySelect();
case "library":
handleLibrarySelect();
break;
default:
console.log("Unknown action:", action);
@@ -330,7 +292,7 @@ export const PromptBox = () => {
);
const isDesktop = IsDesktopApp();
console.log("Is this a desktop app?", isDesktop);
console.log('Is this a desktop app?', isDesktop);
if (isDesktop) {
handleTauriEnqueue();
@@ -362,176 +324,158 @@ export const PromptBox = () => {
};
return (
<>
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
{(referenceImages.length > 0 || uploadingImages.length > 0) && (
<div className="flex w-full gap-2">
{referenceImages.map((image) => (
<div
key={image.id}
className="glass relative aspect-square w-20 rounded-lg"
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
{(referenceImages.length > 0 || uploadingImages.length > 0) && (
<div className="flex w-full gap-2">
{referenceImages.map((image) => (
<div
key={image.id}
className="glass relative aspect-square w-20 rounded-lg"
>
<img
src={image.url}
alt="Reference"
className="h-full w-full rounded-lg object-cover"
/>
<button
onClick={() => handleRemoveReference(image.id)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md transition-colors hover:bg-black"
>
<img
src={image.url}
alt="Reference"
className="h-full w-full rounded-lg object-cover"
/>
<button
onClick={() => handleRemoveReference(image.id)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md transition-colors hover:bg-black"
>
<FontAwesomeIcon icon={faTimes} className="h-2.5 w-2.5" />
</button>
</div>
))}
{uploadingImages.map(({ id, file }) => {
const previewUrl = URL.createObjectURL(file);
return (
<div
key={id}
className="glass relative aspect-square w-20 overflow-hidden rounded-lg"
>
<div className="absolute inset-0">
<img
src={previewUrl}
alt="Uploading preview"
className="h-full w-full object-cover blur-sm"
/>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<FontAwesomeIcon
icon={faSpinnerThird}
className="h-6 w-6 animate-spin text-white"
/>
</div>
</div>
);
})}
</div>
)}
<div className="glass w-[730px] rounded-xl p-4">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple
/>
<div className="flex justify-center gap-2">
<PopoverMenu
mode="button"
panelTitle="Add Image"
items={[
{
label: "Upload from device",
selected: false,
icon: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
action: "upload",
},
{
label: "Choose from library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "gallery",
},
]}
onPanelAction={handleAction}
showIconsInList
buttonClassName="backdrop-blur-none bg-transparent hover:bg-transparent py-1.5 px-0 pr-1 m-0 hover:opacity-50 transition-opacity duration-100 ring-0 border-none focus:ring-0 outline-none"
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
/>
<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-white placeholder-white placeholder:text-white/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">
<Tooltip
content="Aspect ratio"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={aspectRatioList}
onSelect={handleAspectRatioSelect}
mode="toggle"
panelTitle="Aspect Ratio"
showIconsInList
triggerIcon={
<FontAwesomeIcon
icon={getCurrentAspectRatioIcon()}
className="h-4 w-4"
/>
}
/>
</Tooltip>
<Tooltip
content={
useSystemPrompt
? "Use system prompt: ON"
: "Use system prompt: OFF"
}
position="top"
className="z-50"
delay={200}
>
<ToggleButton
isActive={useSystemPrompt}
icon={faMessageXmark}
activeIcon={faMessageCheck}
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
/>
</Tooltip>
<FontAwesomeIcon icon={faTimes} className="h-2.5 w-2.5" />
</button>
</div>
<div className="flex items-center gap-2">
<Button
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
icon={!isEnqueueing ? faSparkles : undefined}
onClick={handleEnqueue}
disabled={isEnqueueing || !prompt.trim()}
))}
{uploadingImages.map(({ id, file }) => {
const previewUrl = URL.createObjectURL(file);
return (
<div
key={id}
className="glass relative aspect-square w-20 overflow-hidden rounded-lg"
>
{isEnqueueing ? (
<div className="absolute inset-0">
<img
src={previewUrl}
alt="Uploading preview"
className="h-full w-full object-cover blur-sm"
/>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg"
className="h-6 w-6 animate-spin text-white"
/>
) : (
"Generate"
)}
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="glass w-[730px] rounded-xl p-4">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple
/>
<div className="flex justify-center gap-2">
<PopoverMenu
mode="button"
panelTitle="Add Image"
items={[
{
label: "Upload from device",
selected: false,
icon: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
action: "upload",
},
{
label: "Choose from library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "library",
disabled: true,
},
]}
onPanelAction={handleAction}
showIconsInList
buttonClassName="backdrop-blur-none bg-transparent hover:bg-transparent py-1.5 px-0 pr-1 m-0 hover:opacity-50 transition-opacity duration-100 ring-0 border-none focus:ring-0 outline-none"
triggerIcon={<FontAwesomeIcon icon={faPlus} className="text-xl" />}
/>
<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-white placeholder-white placeholder:text-white/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">
<Tooltip
content="Aspect ratio"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={aspectRatioList}
onSelect={handleAspectRatioSelect}
mode="toggle"
panelTitle="Aspect Ratio"
showIconsInList
triggerIcon={
<FontAwesomeIcon
icon={getCurrentAspectRatioIcon()}
className="h-4 w-4"
/>
}
/>
</Tooltip>
<Tooltip
content={
useSystemPrompt
? "Use system prompt: ON"
: "Use system prompt: OFF"
}
position="top"
className="z-50"
delay={200}
>
<ToggleButton
isActive={useSystemPrompt}
icon={faMessageXmark}
activeIcon={faMessageCheck}
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
/>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Button
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
icon={!isEnqueueing ? faSparkles : undefined}
onClick={handleEnqueue}
disabled={isEnqueueing || !prompt.trim()}
>
{isEnqueueing ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg"
/>
) : (
"Generate"
)}
</Button>
</div>
</div>
</div>
<GalleryModal
isOpen={isGalleryModalOpen}
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={4}
onUseSelected={handleGalleryImages}
tabs={[
{ id: "my-media", label: "My generations" },
{ id: "uploads", label: "My uploads" },
]}
activeTab={activeGalleryTab}
onTabChange={setActiveGalleryTab}
/>
</>
</div>
);
};
@@ -1,16 +1,15 @@
import { faGear, faImages } from "@fortawesome/pro-solid-svg-icons";
import { faGear } from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { Activity } from "~/components/ui/Activity/Activity";
import { GalleryModal } from "@storyteller/ui-gallery-modal";
// import { paperWrapperStyles } from "~/components/styles";
// import { faPlus, faQuestion } from "@fortawesome/pro-solid-svg-icons";
// import { ToolbarButton } from "~/components/features/ToolbarButton";
import { Button } from "@storyteller/ui-button";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { useState } from "react";
export const ToolbarTopRight = () => {
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [activeGalleryTab, setActiveGalleryTab] = useState("my-media");
return (
<>
<div
@@ -21,13 +20,6 @@ export const ToolbarTopRight = () => {
className="h-[42px] w-[42px] bg-[#5F5F68]/60 backdrop-blur-lg hover:bg-[#5F5F68]/90"
onClick={() => setIsSettingsModalOpen(true)}
/>
<Button
icon={faImages}
className="h-[42px] bg-[#5F5F68]/60 backdrop-blur-lg hover:bg-[#5F5F68]/90"
onClick={() => setIsGalleryModalOpen(true)}
>
My Gallery
</Button>
<Activity />
</div>
@@ -35,18 +27,6 @@ export const ToolbarTopRight = () => {
isOpen={isSettingsModalOpen}
onClose={() => setIsSettingsModalOpen(false)}
/>
<GalleryModal
isOpen={isGalleryModalOpen}
onClose={() => setIsGalleryModalOpen(false)}
mode="view"
tabs={[
{ id: "my-media", label: "My generations" },
{ id: "uploads", label: "My uploads" },
]}
activeTab={activeGalleryTab}
onTabChange={setActiveGalleryTab}
/>
</>
);
};
-3
View File
@@ -34,9 +34,6 @@
{
"path": "../../libs/components/button/tsconfig.lib.json"
},
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/tauri-utils/tsconfig.lib.json"
},
-3
View File
@@ -7,9 +7,6 @@
{
"path": "../../libs/components/button"
},
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/tauri-utils"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 B

After

Width:  |  Height:  |  Size: 701 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -11,7 +11,7 @@ import { AuthButtons } from "./AuthButtons";
import { SceneTitleInput } from "./SceneTitleInput";
import { getCurrentLocationWithoutParams } from "~/utilities";
import { Activity } from "~/pages/PageEnigma/comps/GenerateModals/Activity";
import { GalleryModal } from "@storyteller/ui-gallery-modal";
import { LibraryModal } from "~/pages/PageEnigma/comps/LibraryModal/LibraryModal";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -106,13 +106,13 @@ export const TopBar = ({ pageName }: Props) => {
onClose={() => setIsSettingsModalOpen(false)}
/>
<GalleryModal
<LibraryModal
isOpen={isLibraryModalOpen}
onClose={() => setIsLibraryModalOpen(false)}
mode="view"
tabs={[
{ id: "my-media", label: "My generations" },
{ id: "uploads", label: "My uploads" },
{ id: "my-media", label: "My media" },
{ id: "uploads", label: "Uploads" },
]}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
@@ -38,6 +38,8 @@ import {
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { toEngineActions } from "~/pages/PageEnigma/Queue/toEngineActions";
import { LibraryModal } from "../LibraryModal/LibraryModal";
import type { LibraryItem } from "../LibraryModal/LibraryModal";
import {
cameras,
selectedCameraId,
@@ -59,7 +61,6 @@ import { EngineApi } from "~/Classes/ApiManager/EngineApi";
import { UploaderStates } from "~/enums/UploaderStates";
import { CameraSettingsModal } from "../CameraSettingsModal";
import { twMerge } from "tailwind-merge";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
export const PromptBox = () => {
useSignals();
@@ -91,11 +92,11 @@ export const PromptBox = () => {
},
]);
const [isCameraSettingsOpen, setIsCameraSettingsOpen] = useState(false);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
const [isLibraryModalOpen, setIsLibraryModalOpen] = useState(false);
const [selectedLibraryImages, setSelectedLibraryImages] = useState<string[]>(
[],
);
const [activeGalleryTab, setActiveGalleryTab] = useState("my-media");
const [activeLibraryTab, setActiveLibraryTab] = useState("my-media");
useEffect(() => {
if (textareaRef.current) {
@@ -358,17 +359,17 @@ export const PromptBox = () => {
fileInputRef.current?.click();
};
const handleGallerySelect = () => {
setIsGalleryModalOpen(true);
const handleLibrarySelect = () => {
setIsLibraryModalOpen(true);
};
const handleGalleryClose = () => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
const handleLibraryClose = () => {
setIsLibraryModalOpen(false);
setSelectedLibraryImages([]);
};
const handleImageSelect = (id: string) => {
setSelectedGalleryImages((prev) => {
setSelectedLibraryImages((prev) => {
if (prev.includes(id)) {
return prev.filter((imageId) => imageId !== id);
}
@@ -379,19 +380,19 @@ export const PromptBox = () => {
});
};
const handleGalleryImages = (selectedItems: GalleryItem[]) => {
const handleLibraryImages = (selectedItems: LibraryItem[]) => {
selectedItems.forEach((item) => {
if (!item.fullImage) return;
const referenceImage: ReferenceImage = {
id: Math.random().toString(36).substring(7),
url: item.fullImage,
file: new File([], "gallery-image"),
file: new File([], "library-image"),
mediaToken: item.id,
};
setReferenceImages((prev) => [...prev, referenceImage]);
});
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
setIsLibraryModalOpen(false);
setSelectedLibraryImages([]);
};
const handleAction = (action: string) => {
@@ -399,8 +400,8 @@ export const PromptBox = () => {
case "upload":
handleUploadClick();
break;
case "gallery":
handleGallerySelect();
case "library":
handleLibrarySelect();
break;
default:
console.log("Unknown action:", action);
@@ -580,7 +581,7 @@ export const PromptBox = () => {
label: "Choose from library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "gallery",
action: "library",
},
]}
onPanelAction={handleAction}
@@ -761,20 +762,20 @@ export const PromptBox = () => {
onDeleteCamera={deleteCamera}
/>
</div>
<GalleryModal
isOpen={isGalleryModalOpen}
onClose={handleGalleryClose}
<LibraryModal
isOpen={isLibraryModalOpen}
onClose={handleLibraryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
selectedItemIds={selectedLibraryImages}
onSelectItem={handleImageSelect}
maxSelections={4}
onUseSelected={handleGalleryImages}
onUseSelected={handleLibraryImages}
tabs={[
{ id: "my-media", label: "My generations" },
{ id: "uploads", label: "My uploads" },
{ id: "my-media", label: "My media" },
{ id: "uploads", label: "Uploads" },
]}
activeTab={activeGalleryTab}
onTabChange={setActiveGalleryTab}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
/>
</>
);
-3
View File
@@ -44,9 +44,6 @@
},
{
"path": "../../libs/components/settings-modal"
},
{
"path": "../../libs/components/gallery-modal"
}
]
}
+1 -3
View File
@@ -1,4 +1,4 @@
export * from "./lib/enums/index.js";
export * from './lib/api.js';
export * from "./lib/AnalyticsApi.js";
export * from "./lib/BillingApi.js";
export * from "./lib/CommentsApi.js";
@@ -15,5 +15,3 @@ export * from "./lib/UserBookmarksApi.js";
export * from "./lib/UsersApi.js";
export * from "./lib/VideoApi.js";
export * from "./lib/WeightsApi.js";
export * from "./lib/GetCdnOrigin.js";
export * from "./lib/GalleryModalApi.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { ApiManager, ApiResponse } from "../apis/ApiManager";
export class CommentsApi extends ApiManager {
public CreateComment({
@@ -1,4 +1,4 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { ApiManager, ApiResponse } from "../apis/ApiManager";
export class FeaturedItemsApi extends ApiManager {
public CreateFeaturedItem({
@@ -1,33 +0,0 @@
import { MediaFilesApi } from "./MediaFilesApi.js";
import {
FilterMediaClasses,
FilterMediaType,
FilterEngineCategories,
} from "./enums/QueryFilters.js";
interface ListUserMediaQuery {
username: string;
filter_media_classes?: FilterMediaClasses[];
filter_media_type?: FilterMediaType[];
filter_engine_categories?: FilterEngineCategories[];
user_uploads_only?: boolean;
include_user_uploads?: boolean;
}
export class GalleryModalApi extends MediaFilesApi {
constructor() {
super();
}
public async listUserMediaFiles(query: ListUserMediaQuery) {
return await this.ListUserMediaFiles({
...query,
filter_media_classes: query.filter_media_classes,
filter_media_type: query.filter_media_type,
filter_engine_categories: query.filter_engine_categories,
username: query.username,
user_uploads_only: query.user_uploads_only,
include_user_uploads: query.include_user_uploads,
});
}
}
-45
View File
@@ -1,45 +0,0 @@
// import { GetFrontendEnvironment } from "~/Classes/GetFrontendEnvironment";
/**
* Return the CDN origin (scheme + host + optional port, but no path)
* See: https://web.dev/articles/url-parts
*
* TODO(bt,2025-02-10): Make this easier to configure in development
*/
// class CdnHostHelper {
// private static instance: CdnHostHelper;
// private readonly origin: string;
// public static getInstance(): CdnHostHelper {
// if (CdnHostHelper.instance !== undefined) {
// return CdnHostHelper.instance;
// }
// const isLocalDev = GetFrontendEnvironment().getIsLocalDev();
// let origin;
// if (isLocalDev) {
// //origin = 'https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev';
// origin = "https://cdn-2.fakeyou.com";
// } else {
// origin = "https://cdn-2.fakeyou.com";
// }
// const instance = new CdnHostHelper(origin);
// CdnHostHelper.instance = instance;
// return instance;
// }
// public getOrigin(): string {
// return this.origin;
// }
// private constructor(origin: string) {
// this.origin = origin;
// }
// }
// TODO(bt,2025-02-10): Consolidate all of this logic.
export function GetCdnOrigin(): string {
// return CdnHostHelper.getInstance().getOrigin();
return "https://cdn-2.fakeyou.com";
}
+3 -3
View File
@@ -1,5 +1,5 @@
// libs/api-client/src/JobsApi.ts
import { ApiManager, ApiResponse } from "./ApiManager.js";
import { ApiManager, ApiResponse } from "../../../api/src/lib/ApiManager.js";
import { Job, JobPreview } from "./models/Job.js";
import { JobState } from "./models/JobState.js";
@@ -59,7 +59,7 @@ export class JobsApi extends ApiManager {
public ListRecentJobs(): Promise<ApiResponse<Job[]>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/jobs/session`;
return this.get<{
success: boolean;
jobs: Job[];
@@ -90,4 +90,4 @@ export class JobsApi extends ApiManager {
return { success: false, errorMessage: err.message };
});
}
}
}
+11 -19
View File
@@ -27,8 +27,6 @@ interface ListUserMediaQuery {
filter_media_classes?: FilterMediaClasses[];
filter_media_type?: FilterMediaType[];
filter_engine_categories?: FilterEngineCategories[];
user_uploads_only?: boolean;
include_user_uploads?: boolean;
}
interface SearchFeaturedMediaQuery {
@@ -117,7 +115,7 @@ export class MediaFilesApi extends ApiManager {
}
public async ListMediaFiles(
query: ListMediaQuery
query: ListMediaQuery,
): Promise<ApiResponse<MediaFile[], PaginationInfinite>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/media_files/list`;
const queryWithStrings = {
@@ -151,7 +149,7 @@ export class MediaFilesApi extends ApiManager {
}
public async ListFeaturedMediaFiles(
query: ListMediaQuery
query: ListMediaQuery,
): Promise<ApiResponse<MediaFile[], PaginationInfinite>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/media_files/list_featured`;
const queryWithStrings = {
@@ -185,13 +183,13 @@ export class MediaFilesApi extends ApiManager {
}
public async ListUserMediaFiles(
query: ListUserMediaQuery
query: ListUserMediaQuery,
): Promise<ApiResponse<MediaFile[], Pagination>> {
const userName = query.username;
const endpoint = `${this.ApiTargets.BaseApi}/v1/media_files/list/user/${userName}`;
const queryWithStrings = {
...query,
include_user_uploads: query.include_user_uploads,
include_user_uploads: true,
filter_media_classes: query.filter_media_classes
? query.filter_media_classes.join(",")
: undefined,
@@ -207,17 +205,11 @@ export class MediaFilesApi extends ApiManager {
results: MediaFile[];
pagination?: Pagination;
}>({ endpoint, query: queryWithStrings })
.then((response) => {
let results = response.results ?? [];
if (query.user_uploads_only) {
results = results.filter((file) => file.origin_category === "upload");
}
return {
success: response.success,
data: results,
pagination: response.pagination,
};
})
.then((response) => ({
success: response.success,
data: response.results ?? [],
pagination: response.pagination,
}))
.catch((err) => {
return {
success: false,
@@ -227,7 +219,7 @@ export class MediaFilesApi extends ApiManager {
}
public async SearchFeaturedMediaFiles(
query: SearchFeaturedMediaQuery
query: SearchFeaturedMediaQuery,
): Promise<ApiResponse<MediaFile[], Pagination>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/media_files/search_featured`;
const queryWithStrings = {
@@ -259,7 +251,7 @@ export class MediaFilesApi extends ApiManager {
}
public async SearchUserMediaFiles(
query: SearchFeaturedMediaQuery
query: SearchFeaturedMediaQuery,
): Promise<ApiResponse<MediaFile[], Pagination>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/media_files/search_session`;
const queryWithStrings = {
+1 -1
View File
@@ -1,4 +1,4 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { ApiManager, ApiResponse } from "../apis/ApiManager";
export class ModerationApi extends ApiManager {
public async UpdateFeatureFlagsByToken({
+7
View File
@@ -0,0 +1,7 @@
import { api } from './api.js';
describe('api', () => {
it('should work', () => {
expect(api()).toEqual('api');
})
})
+3
View File
@@ -0,0 +1,3 @@
export function api(): string {
return 'api library built and imported!';
}
-1
View File
@@ -1 +0,0 @@
export * from "./QueryFilters.js";
+18
View File
@@ -0,0 +1,18 @@
// https://storyteller-docs.netlify.app/#/
export * from "./AnalyticsApi";
export * from "./BetaKeysApi";
export * from "./BillingApi";
export * from "./CommentsApi";
export * from "./EngineApi";
export * from "./FeaturedItemsApi";
export * from "./JobsApi";
export * from "./MediaFilesApi";
export * from "./MediaUploadApi";
export * from "./MiscApi";
export * from "./ModerationApi";
export * from "./PromptsApi";
export * from "./TtsApi";
export * from "./UserBookmarksApi";
export * from "./UsersApi";
export * from "./VideoApi";
export * from "./WeightsApi";
+9 -7
View File
@@ -2,19 +2,21 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"lib/*": ["./src/lib/*"]
},
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
"emitDeclarationOnly": true,
"module": "Preserve",
"moduleResolution": "bundler",
"module": "nodenext",
"moduleResolution": "nodenext",
"forceConsistentCasingInFileNames": true,
"types": ["node", "vite/client"]
"types": [
"node",
"vite/client"
]
},
"include": ["src/**/*.ts"],
"include": [
"src/**/*.ts"
],
"references": [],
"exclude": [
"vite.config.ts",
@@ -1,12 +0,0 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -1,7 +0,0 @@
# @storyteller/ui-gallery-modal
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-gallery-modal` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -1,17 +0,0 @@
{
"name": "@storyteller/ui-gallery-modal",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
@@ -1,2 +0,0 @@
export * from './lib/gallery-modal';
@@ -1,7 +0,0 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -1,12 +0,0 @@
import { render } from '@testing-library/react';
import GalleryModal from './gallery-modal';
describe('GalleryModal', () => {
it('should render successfully', () => {
const { baseElement } = render(<GalleryModal />);
expect(baseElement).toBeTruthy();
});
});
@@ -1,352 +0,0 @@
import { Modal } from "@storyteller/ui-modal";
import { LightboxModal } from "@storyteller/ui-lightbox-modal";
import { TabSelector } from "@storyteller/ui-tab-selector";
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { CloseButton } from "@storyteller/ui-close-button";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
GetCdnOrigin,
FilterMediaClasses,
GalleryModalApi,
UsersApi,
} from "@storyteller/api";
import { twMerge } from "tailwind-merge";
export interface GalleryItem {
id: string;
label: string;
thumbnail: string | null;
fullImage?: string | null;
createdAt: string;
}
interface GroupedItems {
[date: string]: GalleryItem[];
}
type ModalMode = "select" | "view";
interface GalleryModalProps {
isOpen: boolean;
onClose: () => void;
mode: ModalMode;
selectedItemIds?: string[];
onSelectItem?: (id: string) => void;
maxSelections?: number;
onUseSelected?: (selectedItems: GalleryItem[]) => void;
tabs: { id: string; label: string }[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
export const GalleryModal = React.memo(
({
isOpen,
onClose,
mode = "view",
selectedItemIds = [],
onSelectItem,
maxSelections = 4,
onUseSelected,
tabs,
activeTab,
onTabChange,
}: GalleryModalProps) => {
const [groupedItems, setGroupedItems] = useState<GroupedItems>({});
const [loading, setLoading] = useState(false);
const [lightboxImage, setLightboxImage] = useState<GalleryItem | null>(
null
);
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
const [failedImageUrls] = useState<Set<string>>(new Set());
const [username, setUsername] = useState<string>("");
const cdnOrigin = GetCdnOrigin();
const imageUrl = lightboxImage?.fullImage || "";
const api = useMemo(() => new GalleryModalApi(), []);
const usersApi = useMemo(() => new UsersApi(), []);
const formatDate = useCallback((date: string) => {
const d = new Date(date);
return d.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
}, []);
const groupItemsByDate = useCallback(
(items: GalleryItem[]) => {
const grouped = items.reduce((acc: GroupedItems, item) => {
const dateKey = formatDate(item.createdAt);
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(item);
return acc;
}, {});
// Sort dates in descending order
return Object.fromEntries(
Object.entries(grouped).sort(
(a, b) =>
new Date(b[1][0].createdAt).getTime() -
new Date(a[1][0].createdAt).getTime()
)
);
},
[formatDate]
);
const handleImageError = useCallback(
(url: string) => {
failedImageUrls.add(url);
},
[failedImageUrls]
);
const getImageUrl = useCallback(
(path: string | undefined | null) => {
if (!path) return null;
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const url = `${cdnOrigin}${normalizedPath}`;
return failedImageUrls.has(url) ? null : url;
},
[cdnOrigin, failedImageUrls]
);
useEffect(() => {
const getUsername = async () => {
const session = await usersApi.GetSession();
if (session.success && session.data?.user) {
setUsername(session.data.user.username);
}
};
getUsername();
}, [usersApi]);
useEffect(() => {
const loadItems = async () => {
if (!username) return;
setLoading(true);
try {
const response = await api.listUserMediaFiles({
filter_media_classes: [FilterMediaClasses.IMAGE],
username: username,
include_user_uploads: activeTab === "uploads",
user_uploads_only: activeTab === "uploads",
});
if (response.success && response.data) {
const newItems = response.data.map((item: any) => ({
id: item.token,
label: item.maybe_title || "Image Generation",
thumbnail: getImageUrl(item.public_bucket_path),
fullImage: getImageUrl(item.public_bucket_path),
createdAt: item.created_at,
}));
setGroupedItems(groupItemsByDate(newItems));
}
} catch (error) {
console.error("Failed to fetch library items:", error);
}
setLoading(false);
};
if (isOpen) {
loadItems();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, isOpen, username]);
// useEffect(() => {
// console.log("LibraryModal render caused by:", {
// isOpen,
// activeTab,
// selectedItemIds,
// });
// }, [isOpen, activeTab, selectedItemIds]);
const handleItemClick = useCallback(
(item: GalleryItem) => {
if (mode === "select" && onSelectItem) {
onSelectItem(item.id);
} else {
setLightboxImage(item);
setIsLightboxVisible(true);
}
},
[mode, onSelectItem]
);
const handleCloseLightbox = useCallback(() => {
setIsLightboxVisible(false);
// Wait for animation to complete before removing the image
setTimeout(() => setLightboxImage(null), 300);
}, []);
const handleDeselectAll = useCallback(() => {
selectedItemIds.forEach((id: any) => onSelectItem?.(id));
}, [selectedItemIds, onSelectItem]);
const handleUseSelected = useCallback(() => {
const selectedItems = Object.values(groupedItems)
.flat()
.filter((item) => selectedItemIds.includes(item.id));
onUseSelected?.(selectedItems);
}, [groupedItems, selectedItemIds, onUseSelected]);
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
className={twMerge(
"h-[620px] max-w-4xl",
mode === "view" && "h-[80vh] max-h-[870px] max-w-7xl"
)}
childPadding={false}
showClose={false}
>
<div className="flex h-full flex-col">
<div className="border-b border-white/10 p-4 py-3">
<div className="grid grid-cols-3 items-center">
<h2 className="text-xl font-semibold">
{mode === "select" ? "Select Images" : "Gallery"}
</h2>
<div className="flex items-center justify-center">
<TabSelector
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
className="w-auto"
/>
</div>
<div className="flex justify-end">
<CloseButton onClick={onClose} />
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-white/60">
<LoadingSpinner className="h-12 w-12" />
</div>
</div>
) : (
<div className="space-y-6 p-4">
{Object.entries(groupedItems).map(([date, dateItems]) => (
<div key={date}>
<h3 className="text-md mb-2 font-medium text-white/60">
{date}
</h3>
<div
className={twMerge(
"grid grid-cols-5 gap-1",
mode === "view" && "grid-cols-5"
)}
>
{dateItems.map((item) => (
<button
key={item.id}
className={twMerge(
"group relative aspect-square overflow-hidden rounded-md border-[3px] transition-all",
mode === "select" &&
selectedItemIds.includes(item.id)
? "border-primary"
: "border-transparent hover:border-white"
)}
onClick={() => handleItemClick(item)}
>
<div className="relative h-full w-full">
{!item.thumbnail ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">
Image not available
</span>
</div>
) : (
<img
src={item.thumbnail}
alt={item.label}
className="h-full w-full object-cover"
onError={() =>
handleImageError(item.thumbnail!)
}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
{mode === "select" &&
selectedItemIds.includes(item.id) && (
<div className="absolute inset-0 bg-white/30" />
)}
</div>
{mode === "select" &&
selectedItemIds.includes(item.id) && (
<div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<FontAwesomeIcon
icon={faCheck}
className="text-sm"
/>
</div>
)}
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
{mode === "select" && (
<div className="flex items-center justify-between border-t border-white/10 bg-black/25 p-4">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-white/80">
{selectedItemIds.length}/{maxSelections} selected
</div>
{selectedItemIds.length > 0 && (
<span className="text-white/10">|</span>
)}
{selectedItemIds.length > 0 && (
<button
onClick={handleDeselectAll}
className="text-sm text-white/60 hover:text-white"
>
Deselect All
</button>
)}
</div>
<Button
onClick={handleUseSelected}
disabled={selectedItemIds.length === 0}
>
Use selected
</Button>
</div>
)}
</div>
</Modal>
<LightboxModal
isOpen={isLightboxVisible}
onClose={handleCloseLightbox}
imageUrl={imageUrl}
imageAlt={lightboxImage?.label || ""}
onImageError={() => imageUrl && handleImageError(imageUrl)}
title={lightboxImage?.label}
createdAt={lightboxImage?.createdAt}
downloadUrl={imageUrl}
/>
</>
);
}
);
GalleryModal.displayName = "GalleryModal";
export default GalleryModal;
@@ -1,34 +0,0 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../../api"
},
{
"path": "../loading-spinner"
},
{
"path": "../close-button"
},
{
"path": "../button"
},
{
"path": "../tab-selector"
},
{
"path": "../lightbox-modal"
},
{
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -1,68 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
],
"references": [
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../loading-spinner/tsconfig.lib.json"
},
{
"path": "../close-button/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../tab-selector/tsconfig.lib.json"
},
{
"path": "../lightbox-modal/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
}
]
}
@@ -1,34 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -1,49 +0,0 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/gallery-modal',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@storyteller/ui-gallery-modal',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: ['react','react-dom','react/jsx-runtime']
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));
@@ -1,12 +0,0 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -1,7 +0,0 @@
# @storyteller/ui-lightbox-modal
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-lightbox-modal` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -1,17 +0,0 @@
{
"name": "@storyteller/ui-lightbox-modal",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
@@ -1,2 +0,0 @@
export * from './lib/lightbox-modal';
@@ -1,7 +0,0 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -1,12 +0,0 @@
import { render } from '@testing-library/react';
import LightboxModal from './lightbox-modal';
describe('LightboxModal', () => {
it('should render successfully', () => {
const { baseElement } = render(<LightboxModal />);
expect(baseElement).toBeTruthy();
});
});
@@ -1,125 +0,0 @@
import { CloseButton } from "@storyteller/ui-close-button";
import { Button } from "@storyteller/ui-button";
import { Transition, TransitionChild } from "@headlessui/react";
import { createPortal } from "react-dom";
import dayjs from "dayjs";
import { faDownToLine } from "@fortawesome/pro-solid-svg-icons";
interface LightboxModalProps {
isOpen: boolean;
onClose: () => void;
imageUrl?: string | null;
imageAlt?: string;
onImageError?: () => void;
title?: string;
createdAt?: string;
additionalInfo?: React.ReactNode;
downloadUrl?: string;
}
export function LightboxModal({
isOpen,
onClose,
imageUrl,
imageAlt = "",
onImageError,
title,
createdAt,
additionalInfo,
downloadUrl,
}: LightboxModalProps) {
return createPortal(
<Transition appear show={isOpen}>
<div className="fixed inset-0 z-[100]">
<TransitionChild
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
className="fixed inset-0 cursor-pointer bg-black/60"
onClick={onClose}
/>
</TransitionChild>
<div
className="fixed inset-0 flex items-center justify-center p-4"
onClick={onClose}
>
<TransitionChild
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="relative h-[90vh] w-[80vw] rounded-xl bg-[#2C2C2C]"
onClick={(e) => e.stopPropagation()}
>
<CloseButton
onClick={onClose}
className="absolute right-4 top-4 z-10"
/>
<div className="grid h-full grid-cols-3 gap-6">
<div className="col-span-2 flex h-full items-center justify-center overflow-hidden rounded-l-xl bg-black/40">
{!imageUrl ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">Image not available</span>
</div>
) : (
<img
src={imageUrl}
alt={imageAlt}
className="h-full w-full object-contain"
onError={onImageError}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
</div>
<div className="flex h-full flex-col py-5 pe-5">
<div className="flex-1 space-y-4">
<div className="text-xl font-medium">
{title || "Image Generation"}
</div>
{createdAt && (
<div className="text-sm text-white/60">
Created:{" "}
{dayjs(createdAt).format("MMM D, YYYY HH:mm:ss")}
</div>
)}
{additionalInfo}
</div>
{downloadUrl && (
<div className="flex justify-end">
<a
href={downloadUrl}
download
onClick={(e) => e.stopPropagation()}
className="no-underline"
>
<Button
icon={faDownToLine}
onClick={(e) => e.stopPropagation()}
>
Download
</Button>
</a>
</div>
)}
</div>
</div>
</div>
</TransitionChild>
</div>
</div>
</Transition>,
document.body
);
}
export default LightboxModal;
@@ -1,19 +0,0 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../button"
},
{
"path": "../close-button"
},
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -1,53 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
],
"references": [
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../close-button/tsconfig.lib.json"
}
]
}
@@ -1,34 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -1,49 +0,0 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/lightbox-modal',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@storyteller/ui-lightbox-modal',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: ['react','react-dom','react/jsx-runtime']
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));
@@ -1,12 +0,0 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

Some files were not shown because too many files have changed in this diff Show More