Compare commits

...
Author SHA1 Message Date
Michael Chung 8d567c47eb Ok fixed some minor issues. 2025-04-28 19:57:20 -04:00
bflatastic 1802b0939e fix object highlight, camera speed, after delete character hide and toggle out of FK mode 2025-04-28 22:53:33 +07:00
bflatastic 36b6aa78f8 fix visible fps lag on higher refresh rate screens 2025-04-28 22:28:04 +07:00
bflatastic 499c07548c bug fix: undefined fetch on page load 2025-04-28 22:07:06 +07:00
Kasisnu 65580ec502 small tweaks to imagen callback 2025-04-28 20:15:30 +05:30
bflatastic 5d5b782760 only update camera when there's change 2025-04-28 21:36:35 +07:00
BFlat 09d0ae263f freecam camera smoothing (#556) 2025-04-28 15:39:59 +07:00
Kasisnu c3509d770f updated sqlx migrations 2025-04-28 13:11:38 +05:30
bflatastic ee1043f92c add button styling fix 2025-04-28 14:30:51 +07:00
ArEnScandMichael Chung e88b6ce39b Feature/UI sign in (#555)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-27 22:42:34 -04:00
Brandon Thomas 0e535e52ad update job state from endpoint 2025-04-27 02:41:00 -04:00
Brandon Thomas aa594b773e upload media files from gpt-image-1 2025-04-27 02:17:53 -04:00
Brandon Thomas ac209f8f4f builder for media file records 2025-04-27 00:19:54 -04:00
Brandon Thomas 66abfd18ba simple image medadata functions 2025-04-26 21:42:38 -04:00
Brandon Thomas 79cf730270 bundled mimetype / file extension detection 2025-04-26 20:44:31 -04:00
Brandon Thomas 698351f2a3 stub endpoint for updating gpt image job statuses 2025-04-26 03:20:45 -04:00
Brandon Thomas fc4eaff7f6 info -> debug logging 2025-04-25 04:30:00 -04:00
Brandon Thomas dadf14dbf4 accept reference images 2025-04-25 03:52:44 -04:00
Brandon Thomas ff695d7af6 more logging 2025-04-25 03:45:12 -04:00
Brandon Thomas a5e9bf2262 more logging 2025-04-25 02:39:23 -04:00
Brandon Thomas b49da80762 stop logging this in production 2025-04-25 02:19:24 -04:00
Brandon Thomas a0db62f0fd don't use this thing 2025-04-24 23:00:22 -04:00
Brandon Thomas ebc0f1ae42 auto-healing credentials in all pathways 2025-04-24 22:56:28 -04:00
Brandon Thomas dc5f5c5906 auto-renewing image gen 2025-04-24 20:20:20 -04:00
Brandon Thomas 98b7042192 much better session behavior on desktop app (large refactor) (#553) 2025-04-24 19:16:45 -04:00
bflatastic 76db001f59 updated button component to support href links 2025-04-24 23:11:16 +07:00
bflatastic 60637394d6 artcraft website: discord CTA and videos section from youtube 2025-04-24 23:02:27 +07:00
BFlatandTanish Manku f3b5a77475 working shared gallery component for both 2d and 3d (#551)
shared gallery component for 2d and 3d editor.
prompt box reference images are working too.

---------

Co-authored-by: Tanish Manku <-h>
2025-04-24 20:40:01 +07:00
Tanish MankuandTanish Manku d3e1d7fab9 Fix api lib (ApiManager) imports (#552)
Co-authored-by: Tanish Manku <-h>
2025-04-24 00:10:55 -07:00
169 changed files with 4907 additions and 1002 deletions
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = \"complete_success\",\n on_success_result_entity_type = ?,\n on_success_result_entity_token = ?,\n failure_reason = NULL,\n internal_debugging_failure_reason = NULL,\n success_execution_millis = ?,\n success_inference_execution_millis = ?,\n retry_at = NULL,\n successfully_completed_at = NOW()\nWHERE token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "2bd7a5b886c9624ae48533ddac409cfca0668f30ab1f7016c412876b5904168b"
}
Generated
+9 -1
View File
@@ -5653,8 +5653,10 @@ dependencies = [
name = "images"
version = "0.0.1"
dependencies = [
"anyhow",
"errors",
"image",
"mimetypes",
"testing",
"workspace-build-acceleration",
]
@@ -6868,7 +6870,9 @@ dependencies = [
name = "mimetypes"
version = "0.0.1"
dependencies = [
"infer 0.16.0",
"anyhow",
"infer 0.19.0",
"testing",
"workspace-build-acceleration",
]
@@ -7844,12 +7848,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 +10639,7 @@ dependencies = [
"log",
"openai_sora_client",
"r2d2_redis",
"reqwest 0.12.15",
"workspace-build-acceleration",
]
@@ -11188,6 +11195,7 @@ dependencies = [
"hostname 0.4.0",
"http_server_common",
"idempotency",
"images",
"infer 0.16.0",
"language-tags",
"lexical-sort",
+3
View File
@@ -155,6 +155,7 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
anyhow = "1.0.97"
base64 = "0.22.1"
chrono = { version = "0.4.40", features = ["serde"] }
cookie = "0.18.1"
hex-literal = "1.0.0"
log = "0.4.27"
once_cell = "1.21.3"
@@ -176,7 +177,9 @@ enums = { path = "crates/_types/enums" }
enums_public = { path = "crates/_types/enums_public" }
errors = { path = "crates/lib/errors" }
filesys = { path = "crates/lib/files/filesys" }
images = { path = "crates/lib/files/images" }
memory_caching = { path = "crates/lib/caching/memory_caching" }
mimetypes = { path = "crates/lib/files/mimetypes" }
openai_sora_client = { path = "crates/lib/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
shared_service_components = { path = "crates/lib/shared_service_components" }
@@ -28,11 +28,12 @@ pub enum MediaFileOriginCategory {
DeviceApi,
/// From Storyteller Studio Engine
#[deprecated(note="This db field should only denote file provenance, not the product.")]
#[serde(rename = "studio")]
StorytellerStudio,
/// From Storyteller Studio Engine
#[deprecated(note = "Use StorytellerStudio instead")]
#[deprecated(note = "DO NOT USE. Originally deprecated in favor of `StorytellerStudio`.")]
StoryEngine,
}
@@ -39,6 +39,10 @@ pub enum MediaFileOriginProductCategory {
#[serde(rename = "vst")]
VideoStyleTransfer,
/// Image Studio
#[serde(rename = "image_studio")]
ImageStudio,
/// Storyteller Studio
#[serde(rename = "studio")]
StorytellerStudio,
@@ -87,6 +91,7 @@ impl MediaFileOriginProductCategory {
Self::FaceFusion => "face_fusion",
Self::FaceMirror => "face_mirror",
Self::VideoStyleTransfer => "vst",
Self::ImageStudio => "image_studio",
Self::StorytellerStudio => "studio",
Self::TextToSpeech => "tts",
Self::VoiceConversion => "voice_conversion",
@@ -105,6 +110,7 @@ impl MediaFileOriginProductCategory {
"face_fusion" => Ok(Self::FaceFusion),
"face_mirror" => Ok(Self::FaceMirror),
"vst" => Ok(Self::VideoStyleTransfer),
"image_studio" => Ok(Self::ImageStudio),
"studio" => Ok(Self::StorytellerStudio),
"tts" => Ok(Self::TextToSpeech),
"voice_conversion" => Ok(Self::VoiceConversion),
@@ -126,6 +132,7 @@ impl MediaFileOriginProductCategory {
Self::FaceFusion,
Self::FaceMirror,
Self::VideoStyleTransfer,
Self::ImageStudio,
Self::StorytellerStudio,
Self::TextToSpeech,
Self::VoiceConversion,
@@ -153,6 +160,7 @@ mod tests {
assert_serialization(MediaFileOriginProductCategory::FaceFusion, "face_fusion");
assert_serialization(MediaFileOriginProductCategory::FaceMirror, "face_mirror");
assert_serialization(MediaFileOriginProductCategory::VideoStyleTransfer, "vst");
assert_serialization(MediaFileOriginProductCategory::ImageStudio, "image_studio");
assert_serialization(MediaFileOriginProductCategory::StorytellerStudio, "studio");
assert_serialization(MediaFileOriginProductCategory::TextToSpeech, "tts");
assert_serialization(MediaFileOriginProductCategory::VoiceConversion, "voice_conversion");
@@ -170,6 +178,7 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::FaceFusion.to_str(), "face_fusion");
assert_eq!(MediaFileOriginProductCategory::FaceMirror.to_str(), "face_mirror");
assert_eq!(MediaFileOriginProductCategory::VideoStyleTransfer.to_str(), "vst");
assert_eq!(MediaFileOriginProductCategory::ImageStudio.to_str(), "image_studio");
assert_eq!(MediaFileOriginProductCategory::StorytellerStudio.to_str(), "studio");
assert_eq!(MediaFileOriginProductCategory::TextToSpeech.to_str(), "tts");
assert_eq!(MediaFileOriginProductCategory::VoiceConversion.to_str(), "voice_conversion");
@@ -187,6 +196,7 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::from_str("face_fusion").unwrap(), MediaFileOriginProductCategory::FaceFusion);
assert_eq!(MediaFileOriginProductCategory::from_str("face_mirror").unwrap(), MediaFileOriginProductCategory::FaceMirror);
assert_eq!(MediaFileOriginProductCategory::from_str("vst").unwrap(), MediaFileOriginProductCategory::VideoStyleTransfer);
assert_eq!(MediaFileOriginProductCategory::from_str("image_studio").unwrap(), MediaFileOriginProductCategory::ImageStudio);
assert_eq!(MediaFileOriginProductCategory::from_str("studio").unwrap(), MediaFileOriginProductCategory::StorytellerStudio);
assert_eq!(MediaFileOriginProductCategory::from_str("tts").unwrap(), MediaFileOriginProductCategory::TextToSpeech);
assert_eq!(MediaFileOriginProductCategory::from_str("voice_conversion").unwrap(), MediaFileOriginProductCategory::VoiceConversion);
@@ -200,12 +210,13 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = MediaFileOriginProductCategory::all_variants();
assert_eq!(variants.len(), 13);
assert_eq!(variants.len(), 14);
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Unknown));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceAnimator));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceFusion));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceMirror));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoStyleTransfer));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::ImageStudio));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::StorytellerStudio));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::TextToSpeech));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VoiceConversion));
@@ -8,10 +8,10 @@ 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;
use openai_sora_client::requests::upload::upload_media_from_file::SoraMediaUploadRequest;
use std::fs::read_to_string;
use std::io::Cursor;
use tauri::{AppHandle, Manager, State};
@@ -43,14 +43,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 +56,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 +71,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,11 +11,14 @@ 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::recipes::image_remix_with_session_auto_renew::{image_remix_with_session_auto_renew, ImageRemixAutoRenewRequest};
use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
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 openai_sora_client::requests::upload::upload_media_from_file::sora_media_upload_from_file;
use serde_derive::Deserialize;
use std::fs::read_to_string;
use std::io::Cursor;
@@ -50,6 +53,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,77 +85,62 @@ 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 no_sentinel = !app_data_root.get_sora_sentinel_file_path().is_file()
|| sora_creds.sentinel.is_none();
if no_sentinel {
info!("Refreshing credentials...");
let mut creds = sora_creds_manager.get_credentials_required()?;
sora_creds = sora_creds_manager.call_sentinel_refresh()
.await
.map_err(|err| {
error!("Failed to refresh: {:?}", err);
err
})?;
let credential_updated = maybe_upgrade_or_renew_session(&mut creds)
.await
.map_err(|err| {
error!("Failed to upgrade or renew session: {:?}", 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)
.await?;
for (i, file_path) in files_to_upload.iter().enumerate() {
info!("Uploading image {} of {}...", (i+1), files_to_upload.len());
sora_media_tokens.push(sora_upload_response.id);
let (response, maybe_new_credentials) =
image_upload_from_file_with_session_auto_renew(ImageUploadFromFileAutoRenewRequest {
file_path,
credentials: &creds,
}).await?;
if let Some(new_creds) = maybe_new_credentials {
info!("Storing updated credentials.");
sora_creds_manager.set_credentials(&new_creds)?;
creds = new_creds;
}
sora_media_tokens.push(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.
let mut response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: request.prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_creds,
}).await;
let (response, maybe_new_creds) =
image_remix_with_session_auto_renew(ImageRemixAutoRenewRequest {
prompt: request.prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &creds,
}).await?;
if let Err(err) = &response {
error!("Error in generating image: {:?}", err);
sora_creds = sora_creds_manager.call_sentinel_refresh()
.await
.map_err(|err| {
error!("Failed to refresh: {:?}", err);
err
})?;
info!("Retrying request...");
response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: request.prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_creds,
}).await;
if let Some(new_creds) = maybe_new_creds {
info!("Storing updated credentials.");
sora_creds_manager.set_credentials(&new_creds)?;
}
let response = response
.map_err(|err| {
error!("Failed to call Sora image generation: {:?}", err);
err
})?;
println!(">> TASK ID: {:?} ", response.task_id);
Ok(())
@@ -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" }
@@ -7,13 +7,18 @@ use errors::AnyhowResult;
pub 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";
#[derive(Clone, Deserialize)]
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub struct SoraCredentials {
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub bearer_token: String,
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub cookie: String,
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub sentinel: Option<String>,
}
impl SoraCredentials {
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub fn authorization_header_value(&self) -> String {
match self.bearer_token.get(0..6) {
Some("bearer") | Some("Bearer") => self.bearer_token.clone(),
@@ -21,6 +26,7 @@ impl SoraCredentials {
}
}
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub fn from_env() -> AnyhowResult<Self> {
let bearer = std::env::var("SORA_BEARER_TOKEN")?;
let cookie = std::env::var("SORA_COOKIE")?;
@@ -29,12 +35,14 @@ impl SoraCredentials {
Ok(Self { bearer_token: bearer, cookie, sentinel })
}
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub fn from_toml_file<P: AsRef<Path>>(filename: P) -> AnyhowResult<Self> {
let contents = std::fs::read_to_string(filename)?;
let credentials = toml::from_str(&contents)?;
Ok(credentials)
}
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub fn add_credential_headers_to_request(&self, request: RequestBuilder) -> RequestBuilder {
let bearer_header = self.authorization_header_value();
@@ -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;
@@ -0,0 +1,139 @@
use anyhow::anyhow;
use log::{info, warn};
use errors::AnyhowResult;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::creds::sora_sentinel::SoraSentinel;
use crate::requests::bearer::generate::generate_bearer_with_cookie;
use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
use crate::requests::image_gen::image_gen_http_request;
use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use crate::requests::sentinel_refresh::generate::token::generate_token;
use crate::sora_error::SoraError;
pub struct ImageRemixAutoRenewRequest<'a> {
pub prompt: String,
pub num_images: NumImages,
pub image_size: ImageSize,
pub sora_media_tokens: Vec<String>,
pub credentials: &'a SoraCredentialSet,
}
/// Image request with retry and session auto-renewal.
pub async fn image_remix_with_session_auto_renew(request: ImageRemixAutoRenewRequest<'_>) -> Result<(SoraImageGenResponse, Option<SoraCredentialSet>), SoraError> {
let result = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: request.prompt.clone(), // NB: Clone because used again
num_images: request.num_images,
image_size: request.image_size,
sora_media_tokens: request.sora_media_tokens.clone(), // NB: Clone because used again
credentials: CredentialMigrationRef::New(request.credentials),
}).await;
let err = match result {
Ok(response) => return Ok((response, None)),
Err(err) => err,
};
warn!("Image generation failed: {:?}", err);
let mut refresh_jwt = false;
let mut refresh_sentinel = false;
match err {
// We'll fail these requests...
image_gen_http_request::SoraError::TooManyConcurrentTasks(_) => {
return Err(SoraError::TooManyConcurrentTasks);
}
image_gen_http_request::SoraError::GenericError(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("image remix failed with GenericError: {:?}", err)))
}
image_gen_http_request::SoraError::NetworkError(err) => {
// TODO: The underlying type should be a reqwest::Error.
return Err(SoraError::OtherBadStatus(anyhow!("network error: {:?}", err)))
}
// We'll retry these requests...
image_gen_http_request::SoraError::SentinelBlock(err) => {
warn!("Image generation failed due to sentinel block error: {:?}", err);
refresh_sentinel = true;
}
image_gen_http_request::SoraError::TokenExpired(err) => {
warn!("Image generation failed due to token expired error: {:?}", err);
refresh_sentinel = true; // TODO: Not sure what this error is, actually.
refresh_jwt = true;
}
image_gen_http_request::SoraError::InvalidJwt(err) => {
warn!("Image generation failed due to invalid jwt error: {:?}", err);
refresh_jwt = true;
}
}
let mut new_bearer = None;
if refresh_jwt {
info!("Generating new JWT bearer token...");
let cookies = request.credentials.cookies.as_str();
let response = generate_bearer_with_cookie(cookies).await;
match response {
Err(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("failed to generate new JWT bearer token: {:?}", err)))
}
Ok(bearer) => {
match SoraJwtBearerToken::new(bearer) {
Err(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("Failed to parse new JWT bearer token: {:?}", err)));
}
Ok(bearer) => new_bearer = Some(bearer),
}
},
}
}
let mut new_sentinel = None;
if refresh_sentinel {
info!("Generating new sentinel...");
let response = generate_token().await;
match response {
Err(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("failed to generate new sentinel: {:?}", err)))
}
Ok(sentinel) => new_sentinel = Some(SoraSentinel::new(sentinel)),
}
}
let mut new_creds = request.credentials.clone();
if let Some(bearer) = new_bearer {
new_creds.jwt_bearer_token = Some(bearer);
}
if let Some(sentinel) = new_sentinel {
new_creds.sora_sentinel = Some(sentinel);
}
// Now try again...
let result = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: request.prompt,
num_images: request.num_images,
image_size: request.image_size,
sora_media_tokens: request.sora_media_tokens,
credentials: CredentialMigrationRef::New(&new_creds),
}).await;
match result {
Ok(response) => Ok((response, Some(new_creds))),
Err(err) => match err {
image_gen_http_request::SoraError::TooManyConcurrentTasks(err) => {
Err(SoraError::TooManyConcurrentTasks)
}
_ => {
warn!("Image remix failed again: {:?}", err);
Err(SoraError::OtherBadStatus(anyhow!("image remix failed again: {:?}", err)))
}
}
}
}
@@ -0,0 +1,56 @@
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::creds::sora_sentinel::SoraSentinel;
use crate::recipes::maybe_refresh_credentials_on_sora_error::maybe_refresh_credentials_on_sora_error;
use crate::requests::bearer::generate::generate_bearer_with_cookie;
use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
use crate::requests::image_gen::image_gen_http_request;
use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use crate::requests::sentinel_refresh::generate::token::generate_token;
use crate::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use crate::sora_error::SoraError;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info, warn};
pub struct ImageUploadFromBytesAutoRenewRequest<'a> {
pub file_bytes: Vec<u8>,
pub filename: String,
pub mime_type: &'a str,
pub credentials: &'a SoraCredentialSet,
}
/// Image upload with retry and session auto-renewal.
pub async fn image_upload_from_bytes_with_session_auto_renew(
request: ImageUploadFromBytesAutoRenewRequest<'_>
) -> Result<(SoraMediaUploadResponse, Option<SoraCredentialSet>), SoraError> {
let result = sora_media_upload_from_bytes(
request.file_bytes.clone(), // FIXME(bt): This is horrible, but the client needs to take ownership. :(
request.filename.clone(), // FIXME: Same
CredentialMigrationRef::New(request.credentials),
).await;
let err = match result {
Ok(response) => return Ok((response, None)),
Err(err) => err,
};
warn!("Image upload failed: {:?}", err);
let new_creds = maybe_refresh_credentials_on_sora_error(request.credentials, err).await?;
// Now try again...
info!("Retrying image upload with new credentials...");
let result = sora_media_upload_from_bytes(
request.file_bytes,
request.filename,
CredentialMigrationRef::New(&new_creds),
).await?;
Ok((result, Some(new_creds)))
}
@@ -0,0 +1,43 @@
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::recipes::maybe_refresh_credentials_on_sora_error::maybe_refresh_credentials_on_sora_error;
use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file;
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use crate::sora_error::SoraError;
use log::{info, warn};
use std::path::Path;
pub struct ImageUploadFromFileAutoRenewRequest<'a, P: AsRef<Path>> {
pub file_path: P,
pub credentials: &'a SoraCredentialSet,
}
/// Image upload with retry and session auto-renewal.
pub async fn image_upload_from_file_with_session_auto_renew<P: AsRef<Path>>(
request: ImageUploadFromFileAutoRenewRequest<'_, P>
) -> Result<(SoraMediaUploadResponse, Option<SoraCredentialSet>), SoraError> {
let result = sora_media_upload_from_file(
request.file_path.as_ref().clone(), // FIXME(bt): This is horrible, but the client needs to take ownership. :(
CredentialMigrationRef::New(request.credentials),
).await;
let err = match result {
Ok(response) => return Ok((response, None)),
Err(err) => err,
};
warn!("Image upload failed: {:?}", err);
let new_creds = maybe_refresh_credentials_on_sora_error(request.credentials, err).await?;
info!("Retrying image upload with new credentials...");
let result = sora_media_upload_from_file(
request.file_path,
CredentialMigrationRef::New(&new_creds),
).await?;
Ok((result, Some(new_creds)))
}
@@ -0,0 +1,49 @@
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::requests::bearer::generate::generate_bearer_with_cookie;
use crate::sora_error::SoraError;
use anyhow::anyhow;
use log::{error, info, warn};
// TODO: Make other APIs work with this.
pub async fn maybe_refresh_credentials_on_sora_error(creds: &SoraCredentialSet, error: SoraError) -> Result<SoraCredentialSet, SoraError> {
match error {
SoraError::NoBearerTokenAvailable => {
warn!("Previous request failed due to missing bearer token.");
}
SoraError::UnauthorizedCookieOrBearerExpired => {
warn!("Previous request failed due to invalid bearer token.");
}
_ => {
error!("Previous request failed with error: {:?}", error);
return Err(SoraError::OtherBadStatus(anyhow!("previous request failed with error: {:?}", error)))
}
}
info!("Generating new JWT bearer token...");
let cookies = creds.cookies.as_str();
let response = generate_bearer_with_cookie(cookies).await;
let new_bearer = match response {
Err(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("failed to generate new JWT bearer token: {:?}", err)))
}
Ok(bearer) => {
match SoraJwtBearerToken::new(bearer) {
Err(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("failed to parse new JWT bearer token: {:?}", err)));
}
Ok(bearer) => bearer,
}
}
};
let mut new_creds = creds.clone();
new_creds.jwt_bearer_token = Some(new_bearer);
Ok(new_creds)
}
@@ -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,6 @@
mod upgrade_or_renew_session;
pub mod image_remix_with_session_auto_renew;
pub mod image_upload_from_bytes_with_session_auto_renew;
pub mod image_upload_from_file_with_session_auto_renew;
pub mod maybe_refresh_credentials_on_sora_error;
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";
@@ -28,13 +28,13 @@ pub enum SoraError {
NetworkError(String),
}
#[derive(Serialize)]
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) enum VideoGenType {
ImageGen,
}
#[derive(Serialize)]
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) enum OperationType {
/// Simple prompt without reference images
@@ -43,13 +43,13 @@ pub (crate) enum OperationType {
Remix,
}
#[derive(Serialize)]
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) enum InpaintItemType {
Image,
}
#[derive(Serialize)]
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) struct InpaintItem {
pub r#type: InpaintItemType,
@@ -69,7 +69,7 @@ pub (crate) struct InpaintItem {
pub crop_bounds: Option<String>,
}
#[derive(Serialize)]
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) struct RawSoraImageGenRequest {
/// eg. "image_gen"
@@ -90,7 +90,7 @@ pub (crate) struct RawSoraImageGenRequest {
pub inpaint_items: Vec<InpaintItem>,
}
#[derive(Deserialize)]
#[derive(Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) struct RawSoraResponse {
/// eg. "task_01jqsz9dsae9tvjygf1abrv3xf"
@@ -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,5 @@
use crate::credentials::SoraCredentials;
use log::info;
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 +8,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 +36,10 @@ pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Resu
}).collect(),
};
let result = image_gen_http_request(args, &request.credentials).await?;
// TODO: Replace this with debug level logging in the future.
info!("Sending remix request: {:?}", args);
let result = image_gen_http_request(args, request.credentials).await?;
Ok(SoraImageGenResponse {
task_id: result.id,
@@ -45,6 +49,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 +79,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 crate::sora_error::SoraError;
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<'_>) -> Result<SoraMediaUploadResponse, SoraError> {
let file_path = PathBuf::from(&file_name);
// TODO: Read file magic bytes first, then fall back to this.
@@ -1,6 +1,6 @@
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 crate::sora_error::SoraError;
use std::path::Path;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
@@ -9,12 +9,7 @@ use tokio::io::AsyncReadExt;
/// There's a better way to implement this.
const INITIAL_BUFFER_SIZE : usize = 1024*1024;
pub struct SoraMediaUploadRequest<'a> {
pub file_path: String,
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<'_>) -> Result<SoraMediaUploadResponse, SoraError> {
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 +37,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 +62,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);
}
@@ -1,8 +1,16 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
/// The single error type this crate should surface to all callers.
#[derive(Debug)]
pub enum SoraError {
/// We haven't received a bearer token yet (this is our application error)
NoBearerTokenAvailable,
/// We're sending too many tasks to Sora.
TooManyConcurrentTasks,
/// Unauthorized, cookie and/or bearer token expired. We'll need to ask for a refreshed login.
///
/// Example message, e.g. from the upload endpoint:
@@ -21,6 +29,12 @@ pub enum SoraError {
/// Reqwest Error
ReqwestError(reqwest::Error),
/// std::io Error that arises from our end, eg. reading from the filesystem.
IoError(io::Error),
/// anyhow::Error arises from our end
AnyhowError(anyhow::Error),
}
impl Error for SoraError {}
@@ -28,21 +42,45 @@ 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::TooManyConcurrentTasks => {
write!(f, "Too many concurrent tasks. Please wait.")
}
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)
}
Self::IoError(err) => {
write!(f, "IO error: {}", err)
}
Self::AnyhowError(err) => {
write!(f, "Anyhow error: {}", err)
}
}
}
}
impl From<reqwest::Error> for SoraError {
fn from(err: reqwest::Error) -> SoraError {
SoraError::ReqwestError(err)
Self::ReqwestError(err)
}
}
impl From<io::Error> for SoraError {
fn from(err: io::Error) -> Self {
Self::IoError(err)
}
}
impl From<anyhow::Error> for SoraError {
fn from(err: anyhow::Error) -> Self {
Self::AnyhowError(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;
@@ -1,6 +1,7 @@
use std::collections::HashSet;
use elasticsearch::{Elasticsearch, SearchParts};
use log::debug;
use once_cell::sync::Lazy;
use serde_json::{json, Number, Value};
@@ -75,7 +76,7 @@ pub async fn search_model_weights(args: SearchArgs<'_>) -> AnyhowResult<Vec<Mode
let json_query = serde_json::to_string(&query)?;
println!("Query: {:#?}", json_query);
debug!("ElasticSearch Query: {:#?}", json_query);
let search_response = args.client
.search(SearchParts::Index(&[MODEL_WEIGHT_INDEX]))
+7 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "images"
edition = "2018"
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
@@ -18,15 +18,16 @@ path = "src/lib.rs"
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
errors = { path = "../../errors" }
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../../workspace-build-acceleration" }
errors = { workspace = true}
mimetypes = { workspace = true }
# External
image = "0.25.1"
image = "0.25.6"
[dev-dependencies]
anyhow = { workspace = true }
testing = { path = "../../testing" }
@@ -0,0 +1,100 @@
use crate::image_info::image_info_error::ImageInfoError;
use image::{GenericImageView, ImageReader};
use mimetypes::mimetype_info::file_extension::FileExtension;
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use std::io::Cursor;
use std::path::Path;
#[derive(Clone, Debug)]
pub struct ImageInfo {
/// The mimetype we determined
mimetype_info: MimetypeInfo,
/// The width of the image in pixels
width: u32,
/// The height of the image in pixels
height: u32,
}
impl ImageInfo {
/// Note: This has to do a full file read, and we throw the parsed image on the floor.
pub fn decode_image_from_bytes(bytes: &[u8]) -> Result<Self, ImageInfoError> {
let image = ImageReader::new(Cursor::new(bytes)).with_guessed_format()?.decode()?;
let (width, height) = image.dimensions();
Ok(ImageInfo {
mimetype_info: MimetypeInfo::get_for_bytes(bytes)
.ok_or_else(|| ImageInfoError::CouldNotDetermineMimetype)?,
width,
height,
})
}
/// Note: This has to do a full file read, and we throw the parsed image on the floor.
pub fn decode_image_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ImageInfoError> {
let image = ImageReader::open(&path)?.decode()?;
let (width, height) = image.dimensions();
Ok(ImageInfo {
mimetype_info: MimetypeInfo::get_for_path(path)?
.ok_or_else(|| ImageInfoError::CouldNotDetermineMimetype)?,
width,
height,
})
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn dimensions(&self) -> (u32, u32) {
(self.width, self.height)
}
pub fn mime_type(&self) -> &str {
self.mimetype_info.mime_type()
}
pub fn file_extension(&self) -> Option<FileExtension> {
self.mimetype_info.file_extension()
}
}
#[cfg(test)]
mod tests {
use crate::image_info::image_info::ImageInfo;
use mimetypes::mimetype_info::file_extension::FileExtension;
use testing::test_file_path::test_file_path;
#[test]
fn jpg_file() -> anyhow::Result<()> {
let path = test_file_path("test_data/image/mochi.jpg")?;
let info = ImageInfo::decode_image_from_path(path)?;
assert_eq!(info.width(), 753);
assert_eq!(info.height(), 1000);
assert_eq!(info.mime_type(), "image/jpeg");
let ext = info.file_extension().expect("should have extension");
assert_eq!(ext, FileExtension::Jpg);
assert_eq!(ext.extension_with_period(), ".jpg");
assert_eq!(ext.extension_without_period(), "jpg");
Ok(())
}
#[test]
fn jpg_bytes() -> anyhow::Result<()> {
let path = test_file_path("test_data/image/juno.jpg")?;
let bytes = std::fs::read(path)?;
let info = ImageInfo::decode_image_from_bytes(&bytes)?;
assert_eq!(info.width(), 1000);
assert_eq!(info.height(), 750);
assert_eq!(info.mime_type(), "image/jpeg");
let ext = info.file_extension().expect("should have extension");
assert_eq!(ext, FileExtension::Jpg);
assert_eq!(ext.extension_with_period(), ".jpg");
assert_eq!(ext.extension_without_period(), "jpg");
Ok(())
}
}
@@ -0,0 +1,39 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
#[derive(Debug)]
pub enum ImageInfoError {
CouldNotDetermineMimetype,
ImageError(image::ImageError),
IoError(io::Error),
}
impl Error for ImageInfoError {}
impl Display for ImageInfoError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ImageInfoError::CouldNotDetermineMimetype => {
write!(f, "ImageInfoError::CouldNotDetermineMimetype")
}
ImageInfoError::ImageError(err) => {
write!(f, "ImageInfoError::ImageError: {}", err)
}
ImageInfoError::IoError(err) => {
write!(f, "ImageInfoError::IoError: {}", err)
}
}
}
}
impl From<image::ImageError> for ImageInfoError {
fn from(err: image::ImageError) -> Self {
ImageInfoError::ImageError(err)
}
}
impl From<io::Error> for ImageInfoError {
fn from(err: io::Error) -> Self {
ImageInfoError::IoError(err)
}
}
@@ -0,0 +1,2 @@
pub mod image_info;
pub mod image_info_error;
+1
View File
@@ -23,6 +23,7 @@
// Re-export image to everyone importing this library
pub use image;
pub mod image_info;
pub mod resize_image_file_preserving_aspect;
pub mod resize_preserving_aspect;
+4 -5
View File
@@ -16,15 +16,14 @@ path = "src/lib.rs"
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
errors = { path = "../../errors" }
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../../workspace-build-acceleration" }
errors = { workspace = true }
# External
log = "0.4.14"
log = { workspace = true }
# Powerful audio decoder and demuxer
#symphonia = { version = "0.5.2", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "vorbis", "wav"] }
+5 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "mimetypes"
edition = "2018"
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
@@ -18,11 +18,11 @@ normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../../workspace-build-acceleration" }
workspace-build-acceleration = { workspace = true }
# External
infer = "0.16.0" # Mimetype detection
infer = "0.19.0" # Mimetype detection
[dev-dependencies]
# None yet
testing = { path = "../../testing" }
anyhow = { workspace = true }
+1
View File
@@ -21,4 +21,5 @@
pub mod mimetype_for_bytes;
pub mod mimetype_for_file;
pub mod mimetype_info;
pub mod mimetype_to_extension;
@@ -0,0 +1,68 @@
/// This is a constrained set of file extensions that:
/// 1. we care about
/// 2. we can detect with magic bytes
/// 3. are not text formats / are binary formats
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
pub enum FileExtension {
Aac,
Flac,
Gif,
Jpg,
Mov,
Mp3,
Mp4,
Ogg,
Opus,
Png,
Wav,
Webm,
Webp,
}
impl FileExtension {
pub fn from_mimetype(mimetype: &str) -> Option<Self> {
match mimetype {
"audio/aac" => Some(FileExtension::Aac),
"audio/flac" => Some(FileExtension::Flac),
"image/gif" => Some(FileExtension::Gif),
"image/jpeg" => Some(FileExtension::Jpg),
"video/quicktime" => Some(FileExtension::Mov),
"audio/mpeg" => Some(FileExtension::Mp3),
"video/mp4" => Some(FileExtension::Mp4),
"audio/ogg" => Some(FileExtension::Ogg),
"audio/opus" => Some(FileExtension::Opus),
"image/png" => Some(FileExtension::Png),
"audio/wav" => Some(FileExtension::Wav),
"video/webm" => Some(FileExtension::Webm),
"image/webp" => Some(FileExtension::Webp),
_ => None,
}
}
pub fn extension_with_period(&self) -> &'static str {
self.with_and_without_period().0
}
pub fn extension_without_period(&self) -> &'static str {
self.with_and_without_period().1
}
fn with_and_without_period(&self) -> (&'static str, &'static str) {
match self {
FileExtension::Aac => (".aac", "aac"),
FileExtension::Flac => (".flac", "flac"),
FileExtension::Gif => (".gif", "gif"),
FileExtension::Jpg => (".jpg", "jpg"),
FileExtension::Mov => (".mov", "mov"),
FileExtension::Mp3 => (".mp3", "mp3"),
FileExtension::Mp4 => (".mp4", "mp4"),
FileExtension::Ogg => (".ogg", "ogg"),
FileExtension::Opus => (".opus", "opus"),
FileExtension::Png => (".png", "png"),
FileExtension::Wav => (".wav", "wav"),
FileExtension::Webm => (".webm", "webm"),
FileExtension::Webp => (".webp", "webp"),
}
}
}
@@ -0,0 +1,80 @@
use crate::mimetype_info::file_extension::FileExtension;
use infer::Type;
use std::io;
use std::path::Path;
#[derive(Clone, Debug)]
pub struct MimetypeInfo {
/// The mimetype we determined
mime_type: String,
/// The extension for the mimetype, if we could determine it.
file_extension: Option<FileExtension>,
}
impl MimetypeInfo {
pub fn get_for_bytes(bytes: &[u8]) -> Option<Self> {
infer::get(bytes)
.map(|t| Self::for_type(t))
}
pub fn get_for_path<P: AsRef<Path>>(path: P) -> io::Result<Option<Self>> {
infer::get_from_path(path)
.map(|maybe_type| maybe_type.map(|t| Self::for_type(t)))
}
fn for_type(r#type: Type) -> Self {
let mime_type = r#type.mime_type().to_string();
MimetypeInfo {
file_extension: FileExtension::from_mimetype(&mime_type),
mime_type,
}
}
pub fn mime_type(&self) -> &str {
&self.mime_type
}
pub fn file_extension(&self) -> Option<FileExtension> {
self.file_extension
}
}
#[cfg(test)]
mod tests {
use crate::mimetype_info::file_extension::FileExtension;
use crate::mimetype_info::mimetype_info::MimetypeInfo;
mod for_bytes {
use super::*;
#[test]
fn png() {
// NB: From "Hanashi Mask.png"
let bytes: Vec<u8> = vec![137, 80, 78, 71, 13, 10, 26, 10];
let t = MimetypeInfo::get_for_bytes(&bytes).expect("should detect bytes");
assert_eq!(t.mime_type(), "image/png");
let ext = t.file_extension.expect("should have extension");
assert_eq!(ext, FileExtension::Png);
assert_eq!(ext.extension_with_period(), ".png");
assert_eq!(ext.extension_without_period(), "png");
}
}
mod for_files {
use testing::test_file_path::test_file_path;
use super::*;
#[test]
fn mp3() -> anyhow::Result<()> {
let path = test_file_path("test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3")?;
let t = MimetypeInfo::get_for_path(path)?.expect("should detect type");
assert_eq!(t.mime_type(), "audio/mpeg");
let ext = t.file_extension.expect("should have extension");
assert_eq!(ext, FileExtension::Mp3);
assert_eq!(ext.extension_with_period(), ".mp3");
assert_eq!(ext.extension_without_period(), "mp3");
Ok(())
}
}
}
@@ -0,0 +1,2 @@
pub mod file_extension;
pub mod mimetype_info;
+3 -3
View File
@@ -18,14 +18,14 @@ normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../../workspace-build-acceleration" }
workspace-build-acceleration = { workspace = true }
# Internal
errors = { path = "../../errors" }
errors = { workspace = true }
# External
ffprobe = "0.4.0"
log = "0.4.20"
log = { workspace = true }
# TODO(bt,2024-08-08): We might drop `mp4` crate because it crashes on reading
# some videos with the message "avcc not found"
mp4 = "0.14.0"
@@ -0,0 +1,57 @@
use std::time::Duration;
use anyhow::anyhow;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
use errors::AnyhowResult;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob;
pub async fn mark_generic_inference_job_successfully_done_by_token(
pool: &MySqlPool,
token: &InferenceJobToken,
maybe_entity_type: Option<InferenceResultType>,
maybe_entity_token: Option<&str>,
total_job_duration: Option<Duration>,
inference_duration: Option<Duration>,
) -> AnyhowResult<()>
{
// NB: MySql's unsigned int (32 bits) can store integers up to 4,294,967,295.
// Given milliseconds, this is ~49.71 days, which should be plenty for us.
let truncated_total_job_execution_millis = total_job_duration
.map(|duration| duration.as_millis() as u32)
.unwrap_or(0);
let truncated_inference_execution_millis = inference_duration
.map(|duration| duration.as_millis() as u32)
.unwrap_or(0);
let query_result = sqlx::query!(
r#"
UPDATE generic_inference_jobs
SET
status = "complete_success",
on_success_result_entity_type = ?,
on_success_result_entity_token = ?,
failure_reason = NULL,
internal_debugging_failure_reason = NULL,
success_execution_millis = ?,
success_inference_execution_millis = ?,
retry_at = NULL,
successfully_completed_at = NOW()
WHERE token = ?
"#,
maybe_entity_type,
maybe_entity_token,
truncated_total_job_execution_millis,
truncated_inference_execution_millis,
token.as_str()
)
.execute(pool)
.await;
match query_result {
Err(err) => Err(anyhow!("error with query: {:?}", err)),
Ok(_r) => Ok(()),
}
}
@@ -7,4 +7,5 @@ pub mod job_status;
pub mod kill_generic_inference_jobs;
pub mod kill_jobs_in_development;
pub mod list_session_jobs;
pub mod mark_generic_inference_job_cancelled_by_user;
pub mod mark_generic_inference_job_cancelled_by_user;
pub mod mark_generic_inference_job_successfully_done_by_token;
@@ -0,0 +1,269 @@
use crate::queries::media_files::create::generic_insert::insert_media_file_generic::{insert_media_file_generic, InsertArgs};
use anyhow::anyhow;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::generic_synthetic_ids::id_category::IdCategory;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use sqlx::MySqlPool;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
use crate::queries::media_files::create::insert_builder::media_file_insert_builder_error::MediaFileInsertBuilderError;
/// Builder for inserting a media files into the database.
/// Resembles an ORM, but can still use statically type checked queries (which is probably
/// really important since we don't have tests on database functionality yet).
#[derive(Clone)]
pub struct MediaFileInsertBuilder {
// TODO: Allow specifying the token externally
// token: MediaFileToken,
// Creator info
maybe_creator_user_token: Option<UserToken>,
maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
creator_ip_address: Option<String>, // NB: Non-nullable field
creator_set_visibility: Visibility, // NB: Non-nullable field
// Important database indices
media_file_class: MediaFileClass, // NB: Non-nullable field
media_file_type: Option<MediaFileType>, // NB: Non-nullable field
is_user_upload: bool,
is_intermediate_system_file: bool,
// Product and other origination information
origin_category: Option<MediaFileOriginCategory>, // NB: Non-nullable field
origin_product_category: MediaFileOriginProductCategory, // NB: Non-nullable field
// maybe_origin_model_type: Option<MediaFileOriginModelType>,
// maybe_origin_model_token: Option<&'a ModelWeightToken>,
// maybe_origin_filename: Option<String>,
// Media info
maybe_mime_type: Option<String>,
file_size_bytes: u64, // NB: Non-nullable field
// maybe_duration_millis: Option<u64>,
// maybe_audio_encoding: Option<&'a str>,
// maybe_video_encoding: Option<&'a str>,
maybe_frame_width: Option<u32>,
maybe_frame_height: Option<u32>,
checksum_sha2: Option<String>, // NB: Non-nullable field
// // User text information
// maybe_title: Option<&'a str>,
// maybe_text_transcript: Option<&'a str>,
// // If generated from a scene, this is the scene media file token.
// maybe_scene_source_media_file_token: Option<&'a MediaFileToken>,
// // If additional prompt details are stored, this is the prompt token.
// maybe_prompt_token: Option<&'a PromptToken>,
// // If batch generated, this is the batch token.
// maybe_batch_token: Option<&'a BatchGenerationToken>,
// Storage details
public_bucket_directory_hash: Option<MediaFileBucketPath>, // NB: Non-nullable field(s)
// Remaining fields...
}
impl MediaFileInsertBuilder {
//pub fn generate_token() -> Self {
// Self::with_token(&MediaFileToken::generate())
//}
pub fn new() -> Self {
MediaFileInsertBuilder {
//token: token.clone(),
maybe_creator_user_token: None,
maybe_creator_anonymous_visitor_token: None,
creator_ip_address: None,
creator_set_visibility: Visibility::Public,
media_file_class: MediaFileClass::Unknown,
media_file_type: None,
is_user_upload: false,
is_intermediate_system_file: false,
origin_category: None,
origin_product_category: MediaFileOriginProductCategory::Unknown,
maybe_mime_type: None,
file_size_bytes: 0,
maybe_frame_width: None,
maybe_frame_height: None,
checksum_sha2: None,
public_bucket_directory_hash: None,
}
}
pub fn creator_user(mut self, user_token: &UserToken) -> Self {
self.maybe_creator_user_token = Some(user_token.clone());
self
}
pub fn maybe_creator_user(mut self, maybe_user: Option<&UserToken>) -> Self {
self.maybe_creator_user_token = maybe_user.map(|token| token.clone());
self
}
pub fn creator_anonymous_visitor(mut self, avt: &AnonymousVisitorTrackingToken) -> Self {
self.maybe_creator_anonymous_visitor_token = Some(avt.clone());
self
}
pub fn maybe_creator_anonymous_visitor(mut self, maybe_avt: Option<&AnonymousVisitorTrackingToken>) -> Self {
self.maybe_creator_anonymous_visitor_token = maybe_avt.map(|token| token.clone());
self
}
pub fn creator_ip_address(mut self, ip_address: &str) -> Self {
self.creator_ip_address = Some(ip_address.to_string());
self
}
pub fn creator_set_visibility(mut self, visibility: Visibility) -> Self {
self.creator_set_visibility = visibility;
self
}
pub fn media_file_class(mut self, media_file_class: MediaFileClass) -> Self {
self.media_file_class = media_file_class;
self
}
pub fn media_file_type(mut self, media_file_type: MediaFileType) -> Self {
self.media_file_type = Some(media_file_type);
self
}
#[allow(clippy::wrong_self_convention)]
pub fn is_user_upload(mut self, is_user_upload: bool) -> Self {
self.is_user_upload = is_user_upload;
self
}
#[allow(clippy::wrong_self_convention)]
pub fn is_intermediate_system_file(mut self, is_intermediate_system_file: bool) -> Self {
self.is_intermediate_system_file = is_intermediate_system_file;
self
}
pub fn media_file_origin_category(mut self, origin_category: MediaFileOriginCategory) -> Self {
self.origin_category = Some(origin_category);
self
}
pub fn media_file_origin_product_category(mut self, origin_category: MediaFileOriginProductCategory) -> Self {
self.origin_product_category = origin_category;
self
}
// TODO: Method for maybe_origin_model_type
// TODO: Method for maybe_origin_model_token
// TODO: Method for maybe_origin_filename
pub fn mime_type(mut self, mime_type: &str) -> Self {
self.maybe_mime_type = Some(mime_type.to_string());
self
}
pub fn file_size_bytes(mut self, file_size_bytes: u64) -> Self {
self.file_size_bytes = file_size_bytes;
self
}
// TODO: maybe_duration_millis
// TODO: maybe_audio_encoding
// TODO: maybe_video_encoding
pub fn frame_width(mut self, frame_width: u32) -> Self {
self.maybe_frame_width = Some(frame_width);
self
}
pub fn frame_height(mut self, frame_height: u32) -> Self {
self.maybe_frame_height = Some(frame_height);
self
}
pub fn checksum_sha2(mut self, checksum_sha2: &str) -> Self {
self.checksum_sha2 = Some(checksum_sha2.to_string());
self
}
// TODO: maybe_title
// TODO: maybe_text_transcript
// TODO: maybe_scene_source_media_file_token
// TODO: maybe_prompt_token
// TODO: maybe_batch_token
pub fn public_bucket_directory_hash(mut self, public_bucket_directory_hash: &MediaFileBucketPath) -> Self {
self.public_bucket_directory_hash = Some(public_bucket_directory_hash.clone());
self
}
// TODO(bt,2025-04-26): Other connector options.
pub async fn insert_pool(self, mysql_pool: &MySqlPool) -> Result<MediaFileToken, MediaFileInsertBuilderError> {
let media_file_type = self.media_file_type
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
"Media file type is required".to_string()))?;
let origin_category = self.origin_category
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
"Origin category is required".to_string()))?;
let checksum_sha2 = self.checksum_sha2
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
"Checksum SHA2 is required".to_string()))?;
let bucket_path = self.public_bucket_directory_hash
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
"Public bucket directory hash is required".to_string()))?;
let result = insert_media_file_generic(InsertArgs {
pool: mysql_pool,
maybe_creator_user_token: self.maybe_creator_user_token.as_ref(),
maybe_creator_anonymous_visitor_token: self.maybe_creator_anonymous_visitor_token.as_ref(),
creator_ip_address: self.creator_ip_address.as_deref().unwrap_or("127.0.0.1"),
creator_set_visibility: self.creator_set_visibility,
media_class: self.media_file_class,
media_type: media_file_type,
is_user_upload: self.is_user_upload,
is_intermediate_system_file: self.is_intermediate_system_file,
origin_category,
origin_product_category: self.origin_product_category,
maybe_origin_model_type: None, // TODO
maybe_origin_model_token: None, // TODO
maybe_origin_filename: None, // TODO
maybe_mime_type: self.maybe_mime_type.as_deref(),
file_size_bytes: self.file_size_bytes,
maybe_duration_millis: None, // TODO
maybe_audio_encoding: None, // TODO
maybe_video_encoding: None, // TODO
maybe_frame_width: self.maybe_frame_width,
maybe_frame_height: self.maybe_frame_height,
checksum_sha2: &checksum_sha2,
maybe_title: None, // TODO
maybe_text_transcript: None, // TODO
maybe_scene_source_media_file_token: None, // TODO
maybe_prompt_token: None, // TODO
maybe_batch_token: None, // TODO
public_bucket_directory_hash: bucket_path.get_object_hash(),
maybe_public_bucket_prefix: bucket_path.get_optional_prefix(),
maybe_public_bucket_extension: bucket_path.get_optional_extension(),
maybe_creator_file_synthetic_id_category: IdCategory::MediaFile, // TODO: Remove this
maybe_creator_category_synthetic_id_category: IdCategory::FileUpload, // TODO: Remove this
maybe_extra_media_info: None, // TODO
is_generated_on_prem: false, // TODO
generated_by_worker: None, // TODO
generated_by_cluster: None, // TODO
maybe_mod_user_token: None, // TODO
}).await;
match result {
Ok(result) => Ok(result.0),
Err(err) => Err(MediaFileInsertBuilderError::ProbablyDatabaseError(err)),
}
}
}
@@ -0,0 +1,27 @@
use std::error::Error;
use std::fmt::{Debug, Display, Formatter};
use errors::AnyhowError;
#[derive(Debug)]
pub enum MediaFileInsertBuilderError {
/// A user-provided field is missing (400 error).
MissingRequiredField(String),
// TODO: We need to refactor to ensure these are actually DB errors (500s).
ProbablyDatabaseError(AnyhowError),
}
impl Error for MediaFileInsertBuilderError {}
impl Display for MediaFileInsertBuilderError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
MediaFileInsertBuilderError::MissingRequiredField(reason) => {
write!(f, "Missing required field: {}", reason)
}
MediaFileInsertBuilderError::ProbablyDatabaseError(reason) => {
write!(f, "Probably database error: {:?}", reason)
}
}
}
}
@@ -0,0 +1,2 @@
pub mod media_file_insert_builder;
mod media_file_insert_builder_error;
@@ -1,4 +1,5 @@
pub mod generic_insert;
pub mod insert_builder;
pub mod insert_media_file_from_face_fusion;
pub mod insert_media_file_from_gptsovits;
pub mod insert_media_file_from_live_portrait;
@@ -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;
@@ -51,11 +51,12 @@ google_sign_in = { path = "../../../lib/api_clients/google_sign_in" }
hashing = { path = "../../../lib/files/hashing" }
http_server_common = { path = "../../../lib/deprecated/http_server_common" }
idempotency = { path = "../../../lib/idempotency" }
images = { workspace = true }
markdown = { path = "../../../lib/markdown" }
media = { path = "../../../lib/files/media" }
memory_caching = { path = "../../../lib/caching/memory_caching" }
migration = { path = "../../../lib/migration" }
mimetypes = { path = "../../../lib/files/mimetypes" }
mimetypes = { workspace = true }
mysql_queries = { path = "../../../lib/mysql_queries" }
openai_sora_client = { path = "../../../lib/api_clients/openai_sora_client" }
password = { path = "../../../lib/password" }
@@ -1,3 +1,4 @@
pub mod upload;
pub mod prompt;
pub mod get_sora_credentials_from_request;
pub mod get_sora_credentials_from_request;
pub mod update_gpt_image_job_status_handler;
@@ -12,16 +12,16 @@ use actix_web::{web, HttpRequest, HttpResponse};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use cloud_storage::bucket_client::BucketClient;
use log::warn;
use log::{debug, error, info};
use log::{debug, error};
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 serde::Deserialize;
use serde::Serialize;
use shared_service_components::sora_redis_credentials::keys::RedisSoraCredentialSubkey;
use shared_service_components::sora_redis_credentials::set_sora_credential_field_in_redis::set_sora_credential_field_in_redis;
use shared_service_components::sora_redis_sentinel_refresh::refresh::refresh_sentinel;
use sqlx::MySqlPool;
use tempdir::TempDir;
use utoipa::ToSchema;
@@ -37,9 +37,10 @@ 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};
use openai_sora_client::requests::upload::upload_media_from_file::sora_media_upload_from_file;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::model_weights::ModelWeightToken;
@@ -83,6 +84,9 @@ pub struct EnqueueStudioImageGenRequest {
pub disable_system_prompt: Option<bool>,
/// Additional images to include (optional). Up to nine images.
pub additional_images: Option<Vec<MediaFileToken>>,
#[deprecated(note="use `additional_images` instead. if both are present, `additional_images` will be used.")]
pub maybe_additional_images: Option<Vec<MediaFileToken>>,
pub maybe_number_of_samples: Option<u32>,
@@ -236,13 +240,26 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
EnqueueImageGenRequestError::ServerError
})?;
let mut files_to_upload = Vec::with_capacity(1 + request.maybe_additional_images.as_ref().map(|v| v.len()).unwrap_or(0));
let additional_images;
// NB: Frontend is calling us with the wrong field name. Let's just accept both.
if let Some(images) = &request.additional_images {
additional_images = images.clone();
} else if let Some(images) = &request.maybe_additional_images {
additional_images = images.clone();
} else {
additional_images = vec![];
}
let mut files_to_upload = Vec::with_capacity(1 + additional_images.len());
files_to_upload.push(scene_media_path.clone());
let additional_images = request.maybe_additional_images.clone().unwrap_or_else(|| vec![]);
debug!("Additional images to download: {}", additional_images.len());
for (i, media_file_token ) in additional_images.iter().enumerate() {
debug!("Downloading additional image {} of {} ...", (i+1), additional_images.len());
for media_file_token in &additional_images {
let media_file_path = query_and_download_media_file(&media_file_token, &work_temp_dir, &public_bucket_client, &server_state.mysql_pool).await.map_err(|err| {
error!("Failed to download additional media file: {:?}", err);
EnqueueImageGenRequestError::ServerError
@@ -267,11 +284,16 @@ 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| {
error!("Failed to upload scene media to Sora: {:?}", err);
EnqueueImageGenRequestError::ServerError
})?;
for (i, file_path) in files_to_upload.iter().enumerate() {
debug!("Uploading file {} of {} to Sora...", (i+1), files_to_upload.len());
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
})?;
debug!("Uploaded media to Sora : {:?}", sora_upload_response);
sora_media_tokens.push(sora_upload_response.id);
@@ -294,12 +316,14 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
let prompt = create_prompt(&request);
debug!("Sending Sora remix request with {} media tokens...", sora_media_tokens.len());
let mut response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: prompt.clone(),
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 +352,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 +371,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) => {
@@ -485,7 +509,7 @@ async fn query_and_download_media_file(media_file_token: &MediaFileToken, downlo
let download_filename = format!("download_file_{}.{}", media_file.token.as_str(), extension);
let download_file_path = download_dir.path().join(download_filename);
info!("Downloading from bucket...");
debug!("Downloading from bucket...");
bucket_client.download_file_to_disk(&media_file_bucket_path.to_full_object_pathbuf(), &download_file_path).await?;
@@ -0,0 +1,296 @@
use std::fmt;
use std::sync::Arc;
use crate::http_server::endpoints::engine::create_scene_handler::CreateSceneError;
use crate::http_server::endpoints::inference_job::get::get_inference_job_status_handler::GetInferenceJobStatusError;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::{Data, Json, Path};
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
use base64::alphabet::STANDARD;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use cloud_storage::bucket_client::BucketClient;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::job_status::JobStatus;
use enums::common::job_status_plus::JobStatusPlus;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use http_server_common::request::get_request_ip::get_request_ip;
use images::image_info::image_info::ImageInfo;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::job::mark_generic_inference_job_completely_failed::mark_generic_inference_job_completely_failed;
use mysql_queries::queries::generic_inference::web::dismiss_finished_jobs_for_user::dismiss_finished_jobs_for_user;
use mysql_queries::queries::generic_inference::web::get_inference_job_status::{get_inference_job_status, get_inference_job_status_from_connection};
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_cancelled_by_user::mark_generic_inference_job_cancelled_by_user;
use mysql_queries::queries::media_files::create::generic_insert::insert_media_file_generic::{insert_media_file_generic, InsertArgs};
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
use mysql_queries::queries::tts::tts_inference_jobs::mark_tts_inference_job_permanently_dead::mark_tts_inference_job_permanently_dead;
use sqlx::{MySqlConnection, MySqlPool};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
#[derive(Deserialize, ToSchema)]
pub struct UpdateGptImageJobStatusRequest {
/// The token of the job we're updating.
pub job_token: InferenceJobToken,
/// How to mark the job.
/// A subset of the job states that are relevant to image completion status.
pub job_status: UpdatedJobStatus,
/// Base64-encoded image data.
/// Only present on success.
/// In the success case, there may be one or more images.
pub images: Option<Vec<String>>,
}
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum UpdatedJobStatus {
Started,
CompleteSuccess,
CompleteFailure,
AttemptFailed,
Dead,
}
#[derive(Serialize, ToSchema)]
pub struct UpdateGptImageJobStatusSuccessResponse {
pub success: bool,
}
#[derive(Debug, ToSchema)]
pub enum UpdateGptImageJobStatusError {
ServerError,
NotFound,
NotAuthorized,
}
impl ResponseError for UpdateGptImageJobStatusError {
fn status_code(&self) -> StatusCode {
match *self {
UpdateGptImageJobStatusError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
UpdateGptImageJobStatusError::NotFound => StatusCode::NOT_FOUND,
UpdateGptImageJobStatusError::NotAuthorized => StatusCode::UNAUTHORIZED,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
Self::ServerError => "server error".to_string(),
Self::NotFound => "not found".to_string(),
Self::NotAuthorized => "unauthorized".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for UpdateGptImageJobStatusError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
/// [INTERNAL ENDPOINT] Update a GPT Image generation job status.
/// This is called by our internal infra, not users. Keep it guarded.
/// We can do secrets-based auth later.
#[utoipa::path(
post,
tag = "Jobs",
path = "/v1/image_studio/update_job_status",
responses(
(status = 200, body = UpdateGptImageJobStatusSuccessResponse),
(status = 500, body = UpdateGptImageJobStatusError),
),
params(
("request" = UpdateGptImageJobStatusRequest, description = "Request"),
)
)]
pub async fn update_gpt_image_job_status_handler(
http_request: HttpRequest,
request: Json<UpdateGptImageJobStatusRequest>,
server_state: web::Data<Arc<ServerState>>) -> Result<Json<UpdateGptImageJobStatusSuccessResponse>, UpdateGptImageJobStatusError>
{
//// TODO(bt,2024-06-16): Reuse connection
//let mut mysql_connection = server_state.mysql_pool.acquire()
// .await
// .map_err(|e| {
// warn!("Could not acquire DB pool: {:?}", e);
// UpdateGptImageJobStatusError::ServerError
// })?;
let inference_job = get_inference_job_status(
&request.job_token,
&server_state.mysql_pool
).await;
let inference_job = match inference_job {
Ok(Some(record)) => record,
Ok(None) => return Err(UpdateGptImageJobStatusError::NotFound),
Err(err) => {
error!("tts job query error: {:?}", err);
return Err(UpdateGptImageJobStatusError::ServerError);
}
};
match inference_job.status {
JobStatusPlus::CompleteSuccess => {
// Job has already been completed. Don't replay the request.
return Ok(Json(UpdateGptImageJobStatusSuccessResponse { success: true }))
},
// TODO: Handle other states as terminal states?
//JobStatusPlus::CompleteFailure => return Err(UpdateGptImageJobStatusError::ServerError),
//JobStatusPlus::AttemptFailed => {}
//JobStatusPlus::Dead => {}
//JobStatusPlus::CancelledByUser => return Err(UpdateGptImageJobStatusError::ServerError),
//JobStatusPlus::CancelledBySystem => {}
_ => {} // Intentional fall through.
}
let target_status = match request.job_status {
UpdatedJobStatus::Started => JobStatusPlus::Started,
UpdatedJobStatus::CompleteSuccess => JobStatusPlus::CompleteSuccess,
UpdatedJobStatus::CompleteFailure => JobStatusPlus::CompleteFailure,
UpdatedJobStatus::AttemptFailed => JobStatusPlus::AttemptFailed,
UpdatedJobStatus::Dead => JobStatusPlus::Dead,
};
if let Some(images) = &request.images {
for image in images.iter() {
let result = upload_and_save_image(
image,
&inference_job,
&server_state.mysql_pool,
&server_state.public_bucket_client,
).await;
let media_token = match result {
Ok(media_token) => media_token,
Err(err) => {
error!("Error uploading image: {:?}", err);
return Err(UpdateGptImageJobStatusError::ServerError);
}
};
}
}
info!("Updating job record for job: {:?}", request.job_token);
update_job_record(&request, &server_state, inference_job, target_status)
.await
.map_err(|err| {
error!("Error updating job: {:?}", err);
UpdateGptImageJobStatusError::ServerError
})?;
info!("Job record updated for job: {:?}", request.job_token);
Ok(Json(UpdateGptImageJobStatusSuccessResponse {
success: true,
}))
}
async fn upload_and_save_image(
base64_image: &str,
inference_job: &GenericInferenceJobStatus,
mysql_pool: &MySqlPool,
public_bucket_client: &BucketClient,
) -> AnyhowResult<MediaFileToken> {
let image_bytes = BASE64_STANDARD.decode(base64_image)?;
// Read file metadata
let file_size_bytes = image_bytes.len();
let file_hash = sha256_hash_bytes(&image_bytes)?;
let image_info = ImageInfo::decode_image_from_bytes(&image_bytes)?;
let mimetype = image_info.mime_type();
// Upload file
const PREFIX : Option<&str> = Some("image_");
let extension_with_period = image_info.file_extension()
.map(|ext| ext.extension_with_period())
.unwrap_or_else(|| ".png");
let media_file_type = match mimetype {
"image/png" => MediaFileType::Png,
"image/jpeg" => MediaFileType::Jpg,
_ => MediaFileType::Png,
};
let bucket_upload_path = MediaFileBucketPath::generate_new(
PREFIX, Some(extension_with_period));
info!("Uploading media to bucket path: {}", bucket_upload_path.get_full_object_path_str());
public_bucket_client.upload_file_with_content_type(
bucket_upload_path.get_full_object_path_str(),
image_bytes.as_ref(),
mimetype
).await?;
let media_token = MediaFileInsertBuilder::new()
.maybe_creator_user(inference_job.user_details.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(inference_job.user_details.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&inference_job.user_details.creator_ip_address)
.public_bucket_directory_hash(&bucket_upload_path)
.media_file_class(MediaFileClass::Image)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::ImageStudio)
.mime_type(mimetype)
.file_size_bytes(file_size_bytes as u64)
.frame_width(image_info.width())
.frame_height(image_info.height())
.checksum_sha2(&file_hash)
.insert_pool(mysql_pool)
.await?;
Ok(media_token)
}
async fn update_job_record(
request: &Json<UpdateGptImageJobStatusRequest>,
server_state: &Data<Arc<ServerState>>,
inference_job: GenericInferenceJobStatus,
target_status: JobStatusPlus,
) -> AnyhowResult<()> {
match target_status {
JobStatusPlus::Pending => {}
JobStatusPlus::Started => {}
JobStatusPlus::CompleteSuccess => {
info!("Matched job status complete_success");
mark_generic_inference_job_successfully_done_by_token(
&server_state.mysql_pool,
&request.job_token,
None,
None,
None,
None,
).await?;
}
// TODO: Handle terminal states
JobStatusPlus::CompleteFailure => {}
JobStatusPlus::AttemptFailed => {}
JobStatusPlus::Dead => {}
JobStatusPlus::CancelledByUser => {}
JobStatusPlus::CancelledBySystem => {}
}
Ok(())
}
@@ -1,4 +1,5 @@
use crate::http_server::endpoints::image_studio::prompt::enqueue_studio_image_generation_handler::enqueue_studio_image_generation_handler;
use crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::update_gpt_image_job_status_handler;
use crate::http_server::endpoints::image_studio::upload::upload_snapshot_media_file_handler::upload_snapshot_media_file_handler;
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
@@ -25,5 +26,9 @@ where
.route(web::post().to(enqueue_studio_image_generation_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/update_job_status")
.route(web::post().to(update_gpt_image_job_status_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
}
+9
View File
@@ -55,4 +55,13 @@ npm install
2.import it first in code
3.nx sync
4.nx build
or
# frontend build
in frontend folder
% nx build -project type
% nx build editor3d
% nx dev editor3d
```
@@ -18,17 +18,11 @@ export const DownloadButton = ({ className = "" }: DownloadButtonProps) => {
!isMacOs &&
navigator.platform.toLowerCase().includes("linux");
const handleDownload = () => {
const downloadLink = isWindows
? DOWNLOAD_LINKS.WINDOWS
: isMacOs
? DOWNLOAD_LINKS.MACOS
: isLinux
? DOWNLOAD_LINKS.LINUX
: null;
if (downloadLink) {
window.open(downloadLink, "_blank");
}
const getDownloadLink = () => {
if (isWindows) return DOWNLOAD_LINKS.WINDOWS;
if (isMacOs) return DOWNLOAD_LINKS.MACOS;
if (isLinux) return DOWNLOAD_LINKS.LINUX;
return null;
};
const getIcon = () => {
@@ -39,12 +33,16 @@ export const DownloadButton = ({ className = "" }: DownloadButtonProps) => {
return undefined;
};
const downloadLink = getDownloadLink();
return (
<Button
className={`rounded-xl px-8 py-4 text-md transition-all duration-300 shadow-lg hover:shadow-blue-500/25 ${className}`}
disabled={isMobile || (!isWindows && !isMacOs && !isLinux)}
disabled={isMobile || !downloadLink}
icon={getIcon()}
onClick={handleDownload}
as="link"
href={downloadLink || "#"}
target="_blank"
>
{isMobile
? "Download on desktop"
@@ -4,6 +4,7 @@ import {
faDiscord,
faYoutube,
} from "@fortawesome/free-brands-svg-icons";
import { SOCIAL_LINKS } from "../../config/links";
const navigation = {
main: [
@@ -13,17 +14,17 @@ const navigation = {
social: [
{
name: "Discord",
href: "https://discord.gg/75svZP2Vje",
href: SOCIAL_LINKS.DISCORD,
icon: (props: any) => <FontAwesomeIcon icon={faDiscord} {...props} />,
},
{
name: "YouTube",
href: "https://www.youtube.com/@OfficialArtCraftStudios",
href: SOCIAL_LINKS.YOUTUBE,
icon: (props: any) => <FontAwesomeIcon icon={faYoutube} {...props} />,
},
{
name: "TikTok",
href: "https://www.tiktok.com/@artcraft.studios",
href: SOCIAL_LINKS.TIKTOK,
icon: (props: any) => <FontAwesomeIcon icon={faTiktok} {...props} />,
},
],
@@ -66,7 +66,7 @@ export default function Navbar() {
</div>
<div className="flex items-center">
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center">
<Button onClick={() => window.open("/download", "_self")}>
<Button as="link" href="/download">
Download
</Button>
</div>
@@ -0,0 +1,5 @@
export const SOCIAL_LINKS = {
DISCORD: "https://discord.gg/75svZP2Vje",
YOUTUBE: "https://www.youtube.com/@OfficialArtCraftStudios",
TIKTOK: "https://www.tiktok.com/@artcraft.studios",
} as const;
@@ -1,60 +1,19 @@
import { Button } from "@storyteller/ui-button";
import { DownloadButton } from "../../components/download-button";
import { faArrowDownToLine } from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { SOCIAL_LINKS } from "../../config/links";
const Landing = () => {
// const features = [
// {
// title: "Lorem ipsum dolor sit",
// description:
// "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt.",
// image: "/images/features/ai-creation.jpg",
// tag: "Core",
// },
// {
// title: "Ut enim ad minim",
// description:
// "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea.",
// image: "/images/features/collaboration.jpg",
// tag: "Workflow",
// },
// {
// title: "Duis aute irure dolor",
// description:
// "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla.",
// image: "/images/features/style-models.jpg",
// tag: "Tools",
// },
// ];
const installationSteps = [
const videos = [
{
step: "STEP 1",
title: "Download the app",
description:
"Download and install our application to start creating amazing AI-powered content.",
button: {
text: "Download App",
className:
"rounded-lg bg-[#2D81FF] px-4 py-2 text-sm font-medium hover:bg-[#438AF6]",
variant: "primary" as const,
},
src: "https://www.youtube.com/embed/pGn-1BKo3nY?si=q4dm0ICb6wF1JW8N",
},
{
step: "STEP 2",
title: "Create an account",
description:
"Sign up for an account to access all features and start your journey.",
button: {
text: "Sign up",
className: "text-sm font-medium",
variant: "secondary" as const,
},
src: "https://www.youtube.com/embed/_FkKf7sECk4?si=Jx38ZGSoyYj0hObr",
},
{
step: "STEP 3",
title: "Start creating",
description: "Begin creating with our AI editor!",
src: "https://www.youtube.com/embed/7x7IZkHiGD8?si=tL8nK4CULigpfHQR",
},
];
@@ -86,10 +45,13 @@ const Landing = () => {
<div className="flex flex-col gap-4 sm:flex-row sm:gap-6">
<DownloadButton />
<Button
icon={faDiscord}
className="rounded-xl px-8 py-4 text-md border-2 border-white/10 hover:border-white/20 bg-white/5 hover:bg-white/10 transition-all duration-300 backdrop-blur-md"
onClick={() => window.open("/download", "_self")}
as="link"
href={SOCIAL_LINKS.DISCORD}
target="_blank"
>
Learn more
Join Discord
</Button>
</div>
@@ -127,7 +89,8 @@ const Landing = () => {
<Button
icon={faArrowDownToLine}
className="rounded-lg px-5 py-3.5 text-md bg-[#2D81FF] hover:bg-[#438AF6]"
onClick={() => window.open("/download", "_self")}
as="link"
href="/download"
>
Download App
</Button>
@@ -152,7 +115,8 @@ const Landing = () => {
<Button
icon={faArrowDownToLine}
className="rounded-lg px-5 py-3.5 text-md bg-[#2D81FF] hover:bg-[#438AF6]"
onClick={() => window.open("/download", "_self")}
as="link"
href="/download"
>
Download App
</Button>
@@ -170,60 +134,66 @@ const Landing = () => {
</div>
</div>
<div className="relative z-10 mx-auto max-w-[1920px] px-4 pt-20 pb-40 sm:px-24 lg:px-32">
{/* Features Section */}
{/* <div className="mb-8 flex items-center justify-between">
<h1 className="text-4xl font-bold">Features</h1>
</div>
<div className="grid gap-6 md:grid-cols-3">
{features.map((item, index) => (
<div
key={index}
className="group cursor-pointer overflow-hidden rounded-xl bg-[#1C1C20] transition hover:bg-[#27272B]"
>
<div className="aspect-video overflow-hidden">
<img
src={item.image}
alt={item.title}
className="h-full w-full object-cover transition group-hover:scale-105"
/>
</div>
<div className="p-6">
<h3 className="mb-2 text-xl font-semibold">{item.title}</h3>
<p className="text-gray-400">{item.description}</p>
</div>
</div>
))}
</div> */}
{/* Installation Steps Section */}
<div>
<h1 className="mb-12 text-5xl font-bold">How to install ArtCraft</h1>
<div className="grid gap-6 md:grid-cols-3">
{installationSteps.map((step, index) => (
<div className="relative z-10 mx-auto max-w-[1920px] px-4 py-24 sm:px-24 lg:px-32">
{/* Videos Section */}
<div className="space-y-12">
<div className="text-center">
<h1 className="mb-4 text-5xl font-bold">
Made using{" "}
<span className="relative after:absolute after:bottom-0 after:left-0 after:w-full after:h-[6px] after:bg-primary/60 after:mb-1">
ArtCraft
</span>
</h1>
<p className="text-lg text-gray-300 max-w-2xl mx-auto">
See examples of artwork created using ArtCraft.
</p>
</div>
<div className="grid gap-8 xl:grid-cols-3">
{videos.map((video, index) => (
<div
key={index}
className="rounded-xl bg-white/10 backdrop-blur-md p-8"
className="group relative rounded-2xl bg-white/10 backdrop-blur-md p-1.5 transition-all duration-300 hover:bg-white/20 hover:scale-[1.02] hover:shadow-2xl hover:shadow-primary/20"
>
<div className="mb-4 w-fit rounded-full bg-white/15 px-3 py-1 text-sm font-medium text-white">
{step.step}
<div className="aspect-video overflow-hidden rounded-xl">
<iframe
width="100%"
height="100%"
src={video.src}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
className="rounded-xl transition-transform duration-300 group-hover:scale-[1.02]"
/>
</div>
<h3 className="mb-4 text-2xl font-semibold">{step.title}</h3>
<p className="mb-6 text-gray-400">{step.description}</p>
{step.button && (
<Button
className={step.button.className}
variant={step.button.variant}
>
{step.button.text}
</Button>
)}
<div className="absolute inset-0 rounded-2xl border border-white/10 group-hover:border-white/20 transition-colors duration-300 pointer-events-none" />
</div>
))}
</div>
</div>
</div>
<div className="relative z-10 mx-auto max-w-[1920px] px-4 pt-20 pb-40 sm:px-24 lg:px-32">
{/* Discord CTA Section */}
<div className="flex flex-col items-center justify-center text-center p-12 py-20 rounded-[40px] bg-primary/10 backdrop-blur-md border-[6px] border-primary/30">
<h2 className="text-5xl font-bold mb-6">Join Our Community</h2>
<p className="text-lg text-gray-400 mb-8 max-w-2xl">
Connect with other creators, share your work, get feedback, and stay
updated with the latest features and updates.
</p>
<Button
icon={faDiscord}
className="rounded-xl px-8 py-4 text-md bg-[#5865F2] hover:bg-[#6a76ff] transition-all duration-300 backdrop-blur-md"
as="link"
href={SOCIAL_LINKS.DISCORD}
target="_blank"
>
Join Discord
</Button>
</div>
</div>
<div></div>
</div>
);
};
+2 -3
View File
@@ -22,7 +22,6 @@
"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",
@@ -52,8 +51,8 @@
"@preact/signals-react-transform": "^0.4.0",
"@tauri-apps/cli": "^2.4.0",
"@types/deep-equal": "^1.0.4",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
@@ -63,6 +63,7 @@ export class UsersApi extends ApiManager {
password: string;
}): Promise<ApiResponse<{ signedSession?: string }>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/login`;
const body = {
username_or_email: usernameOrEmail,
password: password,
@@ -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,16 +21,18 @@ 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}
@@ -59,7 +61,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> */}
</>
);
};
+22 -4
View File
@@ -5,10 +5,11 @@ import { faKey, faUser } from "@fortawesome/pro-solid-svg-icons";
import { Button, Input, LoadingSpinner } from "~/components/ui";
import { authentication } from "~/signals";
import { twMerge } from "tailwind-merge";
import { UsersApi } from "@storyteller/api";
export const Login = () => {
const {
signals: { status: authStatus },
signals: { status: authStatus, userInfo },
fetchers: { login },
enums: { AUTH_STATUS },
} = authentication;
@@ -36,17 +37,33 @@ export const Login = () => {
return "Getting Session...";
}
const handleOnSumbit = (ev: React.FormEvent<HTMLFormElement>) => {
const handleOnSumbit = async (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
if (formRef.current) {
const form = new FormData(formRef.current);
const usernameOrEmail = form.get("usernameOrEmail")?.toString();
const password = form.get("password")?.toString();
if (usernameOrEmail && password && login) {
login({
if (usernameOrEmail && password) {
let api = new UsersApi();
let response = await api.Login({
usernameOrEmail,
password,
});
if (response.success) {
let session = response.data?.signedSession;
if (session) {
localStorage.setItem("session", session);
authStatus.value = AUTH_STATUS.LOGGED_IN;
let response = await api.GetUserProfile(usernameOrEmail);
if (response.success) {
userInfo.value = response.data?.user;
}
}
}
}
}
}; // end handleOnSubmit
@@ -82,6 +99,7 @@ export const Login = () => {
shouldShowLoader && "opacity-0",
)}
>
<Input
label="Username or Email"
icon={faUser}
@@ -14,6 +14,7 @@ import { EditModeRootComponent } from "~/EditModeRootComponent/EditModeRootCompo
import { useRef } from "react";
import { GenerationEngine } from "~/KonvaApp/GenerationEngine";
import { GalleryRootComponent } from "~/GalleryRootComponent";
import { DemoModal } from "@storyteller/ui-demo-modal";
export const isTauri = (): boolean => {
@@ -36,6 +36,7 @@ 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;
@@ -56,6 +57,11 @@ 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 }[]
@@ -153,8 +159,40 @@ export const PromptBox = () => {
fileInputRef.current?.click();
};
const handleLibrarySelect = () => {
console.log("Opening library selector");
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 handleAction = (action: string) => {
@@ -162,8 +200,8 @@ export const PromptBox = () => {
case "upload":
handleUploadClick();
break;
case "library":
handleLibrarySelect();
case "gallery":
handleGallerySelect();
break;
default:
console.log("Unknown action:", action);
@@ -292,7 +330,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();
@@ -324,158 +362,176 @@ 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"
>
<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 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={id}
className="glass relative aspect-square w-20 overflow-hidden rounded-lg"
key={image.id}
className="glass relative aspect-square w-20 rounded-lg"
>
<div className="absolute inset-0">
<img
src={previewUrl}
alt="Uploading preview"
className="h-full w-full object-cover blur-sm"
/>
<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 className="absolute inset-0 flex items-center justify-center bg-black/20">
);
})}
</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>
</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="h-6 w-6 animate-spin text-white"
className="animate-spin text-lg"
/>
</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>
) : (
"Generate"
)}
</Button>
</div>
</div>
</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}
/>
</>
);
};
@@ -1,15 +1,18 @@
import { faGear } from "@fortawesome/pro-solid-svg-icons";
import { faGear, faImages } from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { Activity } from "~/components/ui/Activity/Activity";
// import { paperWrapperStyles } from "~/components/styles";
// import { faPlus, faQuestion } from "@fortawesome/pro-solid-svg-icons";
// import { ToolbarButton } from "~/components/features/ToolbarButton";
import { GalleryModal } from "@storyteller/ui-gallery-modal";
import { Button } from "@storyteller/ui-button";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { useState } from "react";
import { AuthButtons } from "~/components/shared_authentication/AuthButtons";
export const ToolbarTopRight = () => {
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [activeGalleryTab, setActiveGalleryTab] = useState("my-media");
return (
<>
<div
@@ -20,13 +23,32 @@ 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 />
<AuthButtons/>
</div>
<SettingsModal
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}
/>
</>
);
};
@@ -23,22 +23,7 @@ export function ProfileDropdown() {
} = authentication;
if (!userInfo.value) return null;
// Extract user info for better readability
const {
core_info: {
username,
gravatar_hash: emailHash,
display_name,
default_avatar: {
image_index: avatarIndex,
color_index: backgroundColorIndex,
},
},
} = userInfo.value;
const profileUrl = `https://storyteller.ai/profile/${display_name}`;
const profileUrl = `https://storyteller.ai/profile/${userInfo.value.display_name}`;
const menuOptions = [
{
label: "Logout",
@@ -57,10 +42,10 @@ export function ProfileDropdown() {
>
<Gravatar
size={34}
username={username}
email_hash={emailHash}
avatarIndex={avatarIndex}
backgroundIndex={backgroundColorIndex}
username={userInfo.value.display_name}
email_hash={userInfo.value.email_gravatar_hash}
avatarIndex={Number(userInfo.value.core_info.default_avatar.image_index) || 0}
backgroundIndex={Number(userInfo.value.core_info.default_avatar.color_index) || 0}
/>
<FontAwesomeIcon icon={faChevronDown} />
</MenuButton>
@@ -1,11 +1,15 @@
import { Link, LinkProps } from "@remix-run/react";
import React from "react";
import { useNavigate } from "react-router-dom";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
interface LinkButtonProps extends LinkProps {
interface ButtonLinkProps {
icon?: IconDefinition;
variant?: "primary" | "secondary";
className?: string;
to: string;
children: React.ReactNode;
}
export const ButtonLink = ({
@@ -13,9 +17,11 @@ export const ButtonLink = ({
children,
className: propsClassName,
variant = "primary",
to,
...rest
}: LinkButtonProps) => {
//TODO: Duplicated from Button.tsx
}: ButtonLinkProps) => {
const navigate = useNavigate();
function getVariantClassNames(variant: string) {
switch (variant) {
case "secondary": {
@@ -31,14 +37,19 @@ export const ButtonLink = ({
"text-sm font-medium whitespace-nowrap rounded-lg px-3 py-2 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 transition-all duration-150";
const variantClassNames = getVariantClassNames(variant);
const className = twMerge(baseClassName, variantClassNames, propsClassName);
// END TODO
const handleClick = () => {
navigate(to);
};
return (
<Link {...rest}>
<button className={className}>
{icon && <FontAwesomeIcon className="mr-2" icon={icon} size="sm" />}
{children}
</button>
</Link>
<button
className={className}
onClick={handleClick}
{...rest}
>
{icon && <FontAwesomeIcon className="mr-2" icon={icon} size="sm" />}
{children}
</button>
);
};
@@ -0,0 +1,38 @@
import { useSignals } from "@preact/signals-react/runtime";
import { AUTH_STATUS } from "~/signals/authentication/enums";
import { authentication } from "~/signals";
import { ButtonLink } from "~/components/reusable/ButtonLink/ButtonLink";
import { ProfileDropdown } from "../features/ToolbarUserProfile/ProfileDropdown";
// TODO MAKE THIS A SHARED COMPONENT ART CRAFT
export const AuthButtons = () => {
useSignals();
const { status } = authentication.signals;
console.log("status", status.value);
if (status.value === AUTH_STATUS.LOGGED_IN) {
return <ProfileDropdown />;
} else {
return (
<div className="flex items-center gap-3.5">
<span className="text-white/20">|</span>
<div className="flex items-center gap-2">
<ButtonLink
to="/login"
variant="primary"
className="bg-[#5F5F68]/60 backdrop-blur-lg hover:bg-[#5F5F68]/90" // TODO(bt,2025-04-19): Once we have in-page routing, get rid of this.
>
Login
</ButtonLink>
<ButtonLink
to="/signup"
variant="primary"
className="bg-[#5F5F68]/60 backdrop-blur-lg hover:bg-[#5F5F68]/90" // TODO(bt,2025-04-19): Once we have in-page routing, get rid of this.
>
Sign Up
</ButtonLink>
</div>
</div>
);
}
};
@@ -0,0 +1,193 @@
// import { faEnvelope, faKey, faUser } from "@fortawesome/pro-solid-svg-icons";
// // import { GlobalSettingsManager } from "./pages/PageEnigma/GlobalSettingsManager";
// import { Button } from "@storyteller/ui-button";
// import { Input } from "@storyteller/ui-input";
// import { Toaster } from "@storyteller/ui-toaster";
// // import { authentication, signUp } from "~/signals";
// import { FormEvent, useRef, useState, StrictMode } from "react";
// // import { AUTH_STATUS, ToastTypes } from "~/enums";
// import { useSignalEffect, useSignals } from "@preact/signals-react/runtime";
// import { useNavigate, useSearchParams, BrowserRouter } from "react-router-dom";
// import { createRoot } from "react-dom/client";
// // import "./styles/normalize.css";
// // import "./styles/tailwind.css";
// // import "./styles/base.css";
// import "@fortawesome/fontawesome-svg-core/styles.css";
// import { config } from "@fortawesome/fontawesome-svg-core";
// config.autoAddCss = false; /* eslint-disable import/first */
// // TODO MAKE THIS A SHARED COMPONENT ART CRAFT
// const regEx =
// /^(([^<>()[\].,;:\s@"]+(.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i;
// // TODO(bt,2025-04-19): Make these configurable
// const ENV = {
// BASE_API: "https://api.storyteller.ai",
// GOOGLE_API: "https://studio.storyteller.ai",
// FUNNEL_API: "https://studio.storyteller.ai",
// CDN_API: "https://cdn-2.fakeyou.com",
// GRAVATAR_API: "https://studio.storyteller.ai",
// DEPLOY_PRIME_URL: "https://studio.storyteller.ai",
// };
// export default function SignUpScreen() {
// useSignals();
// const navigate = useNavigate();
// const formRef = useRef<HTMLFormElement | null>(null);
// const [usernameError, setUsernameError] = useState("");
// const [emailError, setEmailError] = useState("");
// const [passwordError, setPasswordError] = useState("");
// const [passwordConfirmationError, setPasswordConfirmationError] =
// useState("");
// const [searchParams] = useSearchParams();
// // const { status: authStatus } = authentication;
// const handleOnSubmit = (ev: FormEvent<HTMLFormElement>) => {
// ev.preventDefault();
// if (formRef.current) {
// const form = new FormData(formRef.current);
// const email = form.get("email")?.toString();
// const password = form.get("password")?.toString();
// const passwordConfirmation = form
// .get("password-confirmation")
// ?.toString();
// const username = form.get("username")?.toString();
// setUsernameError("");
// if (!username || username.length < 6) {
// setUsernameError("Username must be at least 6 characters long");
// }
// setEmailError("");
// if (!email || !email.match(regEx)) {
// setEmailError("Email must be a valid email address");
// }
// setPasswordError("");
// if (!password || password.length < 8) {
// setPasswordError("Password must be at least 8 characters long");
// }
// setPasswordConfirmationError("");
// if (!passwordError && passwordConfirmation !== password) {
// setPasswordConfirmationError("Passwords do not match");
// }
// if (
// !usernameError &&
// !passwordError &&
// !emailError &&
// !passwordConfirmationError &&
// signUp
// ) {
// signUp({
// username: username!,
// email: email!,
// password: password!,
// passwordConfirmation: passwordConfirmation!,
// }).then((error) => {
// console.log("Error occured", error);
// if (error) {
// Toaster.toast(ToastTypes.ERROR, error);
// }
// });
// }
// }
// }; // end handleOnSubmit
// useSignalEffect(() => {
// const redirectPath = searchParams.get("redirect");
// if (authStatus.value === AUTH_STATUS.LOGGED_IN) {
// navigate(redirectPath ? redirectPath : "/");
// return;
// }
// if (authStatus.value === AUTH_STATUS.NO_ACCESS) {
// window.open("https://storyteller.ai/", "_self");
// return;
// }
// });
// return (
// <div className="fixed flex h-full w-full flex-col items-center justify-center overflow-auto bg-[#1b1b1f] bg-center bg-repeat">
// <div className="mb-7 flex w-10/12 max-w-2xl items-center justify-center gap-4">
// <img
// src="/resources/images/artcraft-logo-3.png"
// alt="ArtCraft Logo"
// className="h-9 select-none"
// />
// </div>
// <div className="w-full max-w-xl rounded-xl shadow-lg">
// <div className="glass glass-no-hover relative mx-auto w-full rounded-none rounded-t-xl p-6 shadow-none">
// <h1 className="mb-8 text-center text-2xl font-bold">Sign up</h1>
// <form
// ref={formRef}
// onSubmit={handleOnSubmit}
// className="flex flex-col gap-4"
// >
// <Input
// label="Username"
// icon={faUser}
// placeholder="Username"
// name="username"
// errorMessage={usernameError}
// required
// />
// <Input
// type="email"
// label="Email"
// icon={faEnvelope}
// placeholder="Email"
// name="email"
// errorMessage={emailError}
// required
// />
// <Input
// type="password"
// label="Password"
// icon={faKey}
// name="password"
// placeholder="Password"
// autoComplete="current-password"
// errorMessage={passwordError}
// required
// />
// <Input
// type="password"
// label="Password Confirmation"
// icon={faKey}
// placeholder="Password Confirmation"
// name="password-confirmation"
// errorMessage={passwordConfirmationError}
// required
// />
// <Button className="mt-4 py-3">Sign up</Button>
// </form>
// </div>
// <div className="align-items flex w-full max-w-xl rounded-b-xl bg-ui-controls px-6 py-4">
// <div className="flex w-full justify-end gap-1 text-sm">
// <p className="opacity-75">Already have an account?</p>
// <a
// href="/login"
// className="text-brand-primary-400 hover:text-brand-primary-300 font-medium"
// >
// Log in
// </a>
// </div>
// </div>
// </div>
// </div>
// );
// }
// // TODO: Replace environment variables from `root.tsx`
// createRoot(document.getElementById("root")!).render(
// <>
// <StrictMode>
// <BrowserRouter>
// {/* <GlobalSettingsManager env={ENV} /> */}
// <SignUpScreen />
// </BrowserRouter>
// </StrictMode>
// </>,
// );
@@ -1,5 +1,4 @@
import { computed, signal } from "@preact/signals-react";
import { UserInfo } from "@ApiManager/models/Users";
import { ActiveSubscriptions } from "@ApiManager/models/Billing";
import { LoyaltyProgram } from "@ApiManager/enums/Billing";
@@ -1,5 +1,5 @@
import { signals } from "./signals";
import { UsersApi } from "@ApiManager/UsersApi";
import { signals } from "./auth_signals";
import { UsersApi } from "@storyteller/api";
import { BillingApi } from "@ApiManager/BillingApi";
import {
@@ -1,4 +1,4 @@
import { signals } from "./signals";
import { signals } from "./auth_signals";
import * as fetchers from "./fetchers";
import * as enums from "./enums";
@@ -2,7 +2,7 @@ import { AUTH_STATUS } from "./enums";
import { UserInfo } from "~/Classes/ApiManager/models/Users";
import { ActiveSubscriptions } from "@ApiManager/models/Billing";
import { signals } from "./signals";
import { signals } from "./auth_signals";
export const updateAuthStatus = (newStatus: AUTH_STATUS) => {
signals.status.value = newStatus;
+6
View File
@@ -34,11 +34,17 @@
{
"path": "../../libs/components/button/tsconfig.lib.json"
},
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/tauri-utils/tsconfig.lib.json"
},
{
"path": "../../libs/components/demo-modal/tsconfig.lib.json"
},
{
"path": "../../libs/api/tsconfig.lib.json"
}
]
}
+6
View File
@@ -7,12 +7,18 @@
{
"path": "../../libs/components/button"
},
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/tauri-utils"
},
{
"path": "../../libs/components/demo-modal"
},
{
"path": "../../libs/api"
},
{
"path": "./tsconfig.app.json"
},
@@ -116,12 +116,12 @@ export class EngineApi extends ApiManager {
snapshot_media_token: string;
additional_images?: string[];
},
{ success?: boolean; job_token?: string; BadInput?: string }
{ success?: boolean; job_token?: string; error_reason?: string, failure_reason?: string}
>({
endpoint,
body,
});
console.log("postResponse", postResponse);
// Check if the response is successful
const isSuccess = postResponse.success ?? false;
@@ -129,7 +129,7 @@ export class EngineApi extends ApiManager {
const result = {
success: isSuccess,
data: isSuccess ? postResponse.job_token : undefined,
errorMessage: isSuccess ? undefined : postResponse.BadInput,
errorMessage: isSuccess ? undefined : (postResponse.error_reason || postResponse.failure_reason),
};
return result;
@@ -66,7 +66,7 @@ export const PopoverMenu = ({
"text-sm font-medium rounded-lg px-3 py-2 shadow-sm",
"flex gap-2 items-center justify-center outline-none",
"transition-all duration-150",
"bg-[#5F5F68]/60 px-3 text-white backdrop-blur-lg hover:bg-[#5F5F68]/90",
"bg-[#5F5F68]/60 px-3 text-white hover:bg-[#5F5F68]/90",
buttonClassName,
);
@@ -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 { LibraryModal } from "~/pages/PageEnigma/comps/LibraryModal/LibraryModal";
import { GalleryModal } from "@storyteller/ui-gallery-modal";
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)}
/>
<LibraryModal
<GalleryModal
isOpen={isLibraryModalOpen}
onClose={() => setIsLibraryModalOpen(false)}
mode="view"
tabs={[
{ id: "my-media", label: "My media" },
{ id: "uploads", label: "Uploads" },
{ id: "my-media", label: "My generations" },
{ id: "uploads", label: "My uploads" },
]}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
@@ -247,7 +247,7 @@ class Editor {
// Recording params.
this.render_timer = 0;
this.fps_number = 60;
this.cap_fps = 60;
this.cap_fps = 120;
// Timeline settings.
this.can_playback = false;
this.playback_location = 0;
@@ -1,4 +1,10 @@
import { EventDispatcher, Quaternion, Vector3, Vector2 } from "three";
import {
EventDispatcher,
Quaternion,
Vector3,
Vector2,
MathUtils,
} from "three";
import * as THREE from "three";
import { cameras, selectedCameraId } from "~/pages/PageEnigma/signals/camera";
import { isPromptBoxFocused } from "~/pages/PageEnigma/signals/promptBox";
@@ -15,6 +21,11 @@ class FreeCam extends EventDispatcher {
xOffset = 0;
yOffset = 0;
// Smoothing properties
smoothingFactor = 0.2;
currentVelocity = new Vector3();
lastPosition = new Vector3();
lastMousePosition = new Vector2();
mouseVelocity = new Vector2();
tmpQuaternion = new Quaternion();
@@ -46,6 +57,7 @@ class FreeCam extends EventDispatcher {
this.object = object;
this.domElement = domElement;
this.lastPosition.copy(object.position);
this.keydown = this.keydown.bind(this);
this.keyup = this.keyup.bind(this);
@@ -168,6 +180,7 @@ class FreeCam extends EventDispatcher {
this.dragging = true;
this.xOffset = event.clientX;
this.yOffset = event.clientY;
this.resetVelocity();
}
}
@@ -178,6 +191,7 @@ class FreeCam extends EventDispatcher {
if (event.button === 2) {
this.xOffset = event.clientX;
this.yOffset = event.clientY;
this.resetVelocity();
}
this.dragging = false;
}
@@ -191,9 +205,28 @@ class FreeCam extends EventDispatcher {
const mouseY = event.clientY - this.yOffset;
this.xOffset = event.clientX;
this.yOffset = event.clientY;
if (Math.abs(mouseX + mouseY) > 0) {
this.object.translateX(-mouseX * (this.movementSpeed * 0.01));
this.object.translateY(mouseY * (this.movementSpeed * 0.01));
// Store last position before movement
this.lastPosition.copy(this.object.position);
// Apply movement with smoothing
const translationX = -mouseX * (this.movementSpeed * 0.01);
const translationY = mouseY * (this.movementSpeed * 0.01);
this.currentVelocity.x = MathUtils.lerp(
this.currentVelocity.x,
translationX,
this.smoothingFactor,
);
this.currentVelocity.y = MathUtils.lerp(
this.currentVelocity.y,
translationY,
this.smoothingFactor,
);
this.object.translateX(this.currentVelocity.x);
this.object.translateY(this.currentVelocity.y);
}
}
}
@@ -203,7 +236,21 @@ class FreeCam extends EventDispatcher {
return;
}
this.object.translateZ(event.deltaY / 120); // 120 is the lowest mouse wheel rot.
// Store last position before zoom
this.lastPosition.copy(this.object.position);
const zoomDelta = event.deltaY / 120;
this.currentVelocity.z = MathUtils.lerp(
this.currentVelocity.z,
zoomDelta,
this.smoothingFactor,
);
this.object.translateZ(this.currentVelocity.z);
}
resetVelocity() {
this.currentVelocity.set(0, 0, 0);
this.lastPosition.copy(this.object.position);
}
reset() {
@@ -217,25 +264,50 @@ class FreeCam extends EventDispatcher {
this.moveState.yawRight = 0;
this.moveState.down = 0;
this.moveState.up = 0;
this.resetVelocity();
this.updateMovementVector();
}
isStationary() {
return this.moveVector.distanceTo(new Vector3(0, 0, 0)) === 0;
return (
this.moveVector.distanceTo(new Vector3(0, 0, 0)) === 0 &&
this.currentVelocity.distanceTo(new Vector3(0, 0, 0)) < 0.0001
);
}
update(delta: number) {
if (!this.enabled || this.isStationary()) {
if (!this.enabled) {
return;
}
// Skip update if there's no movement and velocity
if (this.isStationary()) {
return;
}
const moveMulti = delta * this.movementSpeed;
this.object.translateX(this.moveVector.x * moveMulti);
this.object.translateY(this.moveVector.y * moveMulti);
this.object.translateZ(this.moveVector.z * moveMulti);
// Apply WASD movement with smoothing
const targetMovement = new Vector3(
this.moveVector.x * moveMulti,
this.moveVector.y * moveMulti,
this.moveVector.z * moveMulti,
);
// Update camera position in signals
// Smooth out the movement
this.currentVelocity.lerp(targetMovement, this.smoothingFactor);
// Apply the smoothed movement
this.object.translateX(this.currentVelocity.x);
this.object.translateY(this.currentVelocity.y);
this.object.translateZ(this.currentVelocity.z);
// Store last position
this.lastPosition.copy(this.object.position);
// Update camera signals
if (selectedCameraId.value) {
const pos = this.object.position;
const rot = this.object.rotation;

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