Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd8d702264 | ||
|
|
fc364c62fc | ||
|
|
897ee8fda1 | ||
|
|
ad967db616 | ||
|
|
055cc80aef | ||
|
|
5deb9ef11a | ||
|
|
1553465850 | ||
|
|
528b1d0d0f | ||
|
|
d2ab68d1a1 | ||
|
|
421ef353a2 | ||
|
|
89a842e609 | ||
|
|
77ad36fbbb | ||
|
|
c31d903619 |
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 596 B After Width: | Height: | Size: 701 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
{
|
||||
"path": "../../libs/components/button"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/tauri-utils"
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 596 B After Width: | Height: | Size: 701 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -44,9 +44,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/settings-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager";
|
||||
import { ApiManager, ApiResponse } from "../apis/ApiManager";
|
||||
|
||||
export class ModerationApi extends ApiManager {
|
||||
public async UpdateFeatureFlagsByToken({
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { api } from './api.js';
|
||||
|
||||
describe('api', () => {
|
||||
it('should work', () => {
|
||||
expect(api()).toEqual('api');
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export function api(): string {
|
||||
return 'api library built and imported!';
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./QueryFilters.js";
|
||||
@@ -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";
|
||||
@@ -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": []
|
||||
}
|
||||